Dot Simulation

Quick Start

This is a basic simulation of what happens when idealised inkjet drops land on a surface and spread to an equilibrium contact angle - leaving out drop-to-drop interactions. The implications for the visual appearance and the printed optical density are significant.


Thanks to Neil Chilton at Printed Electronics Ltd in the UK who first showed me why these issues are important to real-world printing.

Dot Size

V Drop pl
θ °
Ref OD
Ref H @ OD μm
Print DPI
D Drop μm
D Dot μm
H Dot μm
S Dot μm
Overlap - Grid
Overlap - Diagonal
Single %
Multiple %
Open %
//One universal basic required here to get things going once loaded
window.onload = function () {
    //restoreDefaultValues(); //Un-comment this if you want to start with defaults

//Main() is hard wired as THE place to start calculating when inputs change
//It does no calculations itself, it merely sets them up, sends off variables, gets results and, if necessary, plots them.
function Main() {
    //Save settings every time you calculate, so they're always ready on a reload

    //Send all the inputs as a structured object
    //If you need to convert to, say, SI units, do it here!
    const inputs = {
        V: sliders.SlideV.value / 1e15, //pl to m3
        theta: sliders.Slidetheta.value * Math.PI / 180, //Deg to rad
        DPI: sliders.SlideDPI.value,
        OD: sliders.SlideOD.value,
        HatOD: sliders.SlideHatOD.value * 1e-6, //μm to m

    //Send inputs off to CalcIt where the names are instantly available
    //Get all the resonses as an object, result
    const result = CalcIt(inputs);

    document.getElementById('DDrop').value = result.DDrop;
    document.getElementById('DDot').value = result.DDot;
    document.getElementById('HDot').value = result.HDot;
    document.getElementById('SDot').value = result.SDot;
    document.getElementById('OverlapG').value = result.OverlapG;
    document.getElementById('OverlapD').value = result.OverlapD;
    document.getElementById('PercS').value = result.PercS;
    document.getElementById('PercD').value = result.PercD;
    document.getElementById('PercO').value = result.PercO;
    document.getElementById('OD').value = result.OD;

    //You might have some other stuff to do here, but for most apps that's it for Main!

//Here's the app calculation
//The inputs are just the names provided - their order in the curly brackets is unimportant!
//By convention the input values are provided with the correct units within Main
function CalcIt({ V, theta, DPI, OD, HatOD }) {
    const cost = Math.cos(theta), sint = Math.sin(theta)
    //The basic calculations
    DDrop = Math.pow(V * 6 / Math.PI, 0.333)
    const r = 3 / Math.PI * Math.pow(V / ((2 + cost) * Math.pow(1 - cost, 2)), 0.333)
    const HDot = r * (1 - cost)
    const DDot = 2 * Math.sqrt(HDot * (2 * r - HDot))
    const SDot = 1e-3 / (DPI / 25.4)
    const OverlapG = DDot / SDot
    const OverlapD = DDot / (SDot * Math.sqrt(2))

    //Graphics stuff
    const dpr = window.devicePixelRatio || 1;
    const theCanvas = document.querySelector('canvas')
    const ctx = theCanvas.getContext('2d')//setupCanvas(theCanvas, dpr);
    //const w = Math.floor(theCanvas.width / dpr), h = Math.floor(theCanvas.height / dpr)
    const w = theCanvas.width, h = theCanvas.height
    //We need to convert from metres to pixels for the graphics calculations
    const mtopix = SDot / h
    const offs = (r - HDot) / mtopix

    //Set up the rectangle
    ctx.fillStyle = "white"
    ctx.strokeStyle = "white"
    ctx.lineWidth = 0
    ctx.rect(0, 0, w, h);
    ctx.strokeWidth = 0

    const ws = (w - h) / 2
    let i, i1, j, j1, y, hv
    const rc = h * r / SDot //Ratio of radius to spacing which is defined as h
    const rc2 = rc * rc
    const HatODpx = HatOD * h / SDot

    let ODv = Array(h).fill(0).map(() => Array(h))
    for (i = 0; i < h; i++) {
        for (j = 0; j < h; j++) {
            ODv[i][j] = 0
    let dCount = 0, overlap = false
    //The code could be more efficient, but it's fast & simple to understand
    for (i = 0; i < h; i++) {
        i1 = h - i
        for (j = 0; j < h; j++) {
            j1 = h - j
            overlap = false
            if (i * i + j * j <= rc2) {
                y = Math.sqrt(rc2 - i * i - j * j)
                hv = y - offs
                if (hv > 0) {
                    ODv[i][j] = Math.max(hv / HatODpx, 0)
            if (i1 * i1 + j * j <= rc2) {
                y = Math.sqrt(rc2 - i1 * i1 - j * j)
                hv = y - offs
                if (hv > 0) {
                    if (ODv[i][j] > 0) overlap = true
                    ODv[i][j] += Math.max(hv / HatODpx, 0)
            if (i1 * i1 + j1 * j1 <= rc2) {
                y = Math.sqrt(rc2 - i1 * i1 - j1 * j1)
                hv = y - offs
                if (hv > 0) {
                    if (ODv[i][j] > 0) overlap = true
                    ODv[i][j] += Math.max(hv / HatODpx, 0)
            if (i * i + j1 * j1 <= rc2) {
                y = Math.sqrt(rc2 - i * i - j1 * j1)
                hv = y - offs
                if (hv > 0) {
                    if (ODv[i][j] > 0) overlap = true
                    ODv[i][j] += Math.max(hv / HatODpx, 0)
            if (overlap) dCount++
    let tAbs = 0, oCount = 0
    let id = ctx.getImageData(0, 0, w, h);
    let pixels =;
    for (i = 0; i < h; i++) {
        for (j = 0; j < h; j++) {
            pix = Math.max(Math.floor(255 * (1 - ODv[i][j])), 0)
            k = 4 * (i + ws + j * w)
            pixels[k] = pix; pixels[k + 1] = pix; pixels[k + 2] = pix
            if (ODv[i][j] == 0) oCount++
            //Convert OD to absorbed fraction
            tAbs += 1 - 1 / Math.pow(10, OD * ODv[i][j])
    ctx.putImageData(id, 0, 0)
    const count = h * h
    tAbs /= count
    //Now convert absorbed fraction average to OD
    OD = -Math.log10(1 - tAbs)
    PercS = 100 * (count - dCount - oCount) / count
    PercD = 100 * dCount / count
    PercO = 100 * oCount / count
    //Return the values
    return {
        DDrop: (DDrop * 1e6).toFixed(1),
        DDot: (DDot * 1e6).toFixed(1),
        HDot: (HDot * 1e6).toFixed(1),
        SDot: (SDot * 1e6).toFixed(1),
        OverlapG: OverlapG.toFixed(2),
        OverlapD: OverlapD.toFixed(2),
        PercS: PercS.toFixed(1),
        PercD: PercD.toFixed(1),
        PercO: PercO.toFixed(1),
        OD: OD.toFixed(2),

Small open areas give large reductions in OD

Suppose you had a thick ink coverage that should give an optical density (OD) of 3, absorbing 99.9% of the light. But 5% of the area has no ink because of problems with the dots. The effective light absorption is 94.9% which translates to `OD=-log_(10)(1-0.949)` which is 1.3. So small gaps between dots matter. It also means that if you had used 2/3 the ink so the total absorption was OD=2, i.e. 99% of the light, the resulting OD would `OD=-log_(10)(1-0.94)` which is 1.22. So 1/3 of your ink is providing 0.07 extra OD. If the 2/3 ink had been used to cover up the holes and decreased the total thickness by 5% then the OD would be 1.9. So, getting the most OD for your ink requires knowledge of where it's going, and fixing any obvious unprinted areas.

Spherical caps

You know the drop volume V Drop you are producing (the app, for convenience calculates the drop diameter D Drop) and you can use an estimate of the contact angle of the drop θ to calculate, via tedious spherical cap geometry (see Wiki Spherical Cap), the dot diameter D Dot and maximum height H Dot. The app shows 4 quarter dots spaced according to (scaled by) the input Print DPI. For the same size drop, if you increase Print DPI the relative size of the printed dot increases

The thickness, and therefore the OD of the dots, is highest at their centres and falls to zero at their edge. If you decrease the contact angle θ then the dot gets larger so H Dot decreases and the OD decreases. So we start to see some trade-offs, better spreading reduces the absorption at any give position in the dot. Eventually the spreading dots start to overlap, giving larger OD at the overlaps. [Note that we are just exploring the core geometry, we are ignoring dot-on-dot spreading].

Covering up the open areas

Although increasing dot diameter decreases the OD in the dot, it is also decreasing the amount of open area, so the overall OD generally increases. Once you have overlapped significantly at the centre, you get diminishing returns because ink-on-ink is less effective than ink-on-white. Doubling the thickness of a layer OD=1 gives OD=2 but you've gone from 90% to 99% absorption - not a big return for an extra layer of ink. Doubling the thickness of OD=0.5 to OD=1, takes you from 68% to 90% absorption - a bigger effect for less ink.

Calculating the OD

You must first provide a Ref OD which is the OD of a reference film of a constant height Ref H. You could, for example, make a drawdown of thickness H and measure the OD. Given this, then the OD at all parts of the dots is calculated based on the thickness calculated from spherical cap geometry and from any overlaps.

The output tells you what fraction the dot diameter is of the grid spacing (Overlap - Grid) and of the diagonal spacing (Overlap - Diagonal) which is the bigger challenge. The output also provides the % covered by isolated, Single layers by Multiple layers and by Open areas. At each point the thickness, and therefore the OD is known so we calculated the % light absorbed. By summing these values and taking the average we know the average absorption which can be converted to the effective OD of the print.

Drop volume effects

Although there are plenty of simplifying assumptions, you will find that the app is insightful. There are two ways to increase the OD of the final print.

  1. Increase the DPI at a fixed drop volume
  2. Increase the drop volume at a fixed DPI

Each approach has its advantages and disadvantages. The app lets you explore them so you can better think through your print strategy.