diff --git a/src/functionObjects/lagrangian/Make/files b/src/functionObjects/lagrangian/Make/files
index e349d4f5454fbc240faf2ceb27ef1a052c0ea176..51e5c3b1128b7d52fb022c2488b0628c5cc3740a 100644
--- a/src/functionObjects/lagrangian/Make/files
+++ b/src/functionObjects/lagrangian/Make/files
@@ -5,5 +5,6 @@ icoUncoupledKinematicCloud/icoUncoupledKinematicCloud.C
 dsmcFields/dsmcFields.C
 
 vtkCloud/vtkCloud.C
+ensightCloud/ensightCloudWriteObject.cxx
 
 LIB = $(FOAM_LIBBIN)/liblagrangianFunctionObjects
diff --git a/src/functionObjects/lagrangian/ensightCloud/ensightCloudWriteObject.H b/src/functionObjects/lagrangian/ensightCloud/ensightCloudWriteObject.H
new file mode 100644
index 0000000000000000000000000000000000000000..ea10e6bcd748ef0258ccea902eac08d0f705690f
--- /dev/null
+++ b/src/functionObjects/lagrangian/ensightCloud/ensightCloudWriteObject.H
@@ -0,0 +1,266 @@
+/*---------------------------------------------------------------------------*\
+  =========                 |
+  \\      /  F ield         | OpenFOAM: The Open Source CFD Toolbox
+   \\    /   O peration     |
+    \\  /    A nd           | www.openfoam.com
+     \\/     M anipulation  |
+-------------------------------------------------------------------------------
+    Copyright (C) 2024 OpenCFD Ltd.
+-------------------------------------------------------------------------------
+License
+    This file is part of OpenFOAM.
+
+    OpenFOAM is free software: you can redistribute it and/or modify it
+    under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    OpenFOAM is distributed in the hope that it will be useful, but WITHOUT
+    ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+    FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+    for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with OpenFOAM.  If not, see <http://www.gnu.org/licenses/>.
+
+Class
+    Foam::functionObjects::ensightCloudWriteObject
+
+Group
+    grpLagrangianFunctionObjects
+
+Description
+    This functionObject writes cloud(s) in ensight format
+
+    Example of function object specification:
+    \verbatim
+    cloudWrite1
+    {
+        type            ensightCloud;
+        libs            (lagrangianFunctionObjects);
+        writeControl    writeTime;
+        writeInterval   1;
+        format          ascii;
+
+        timeFormat      scientific;
+        timePrecision   5;
+
+        cloud           myCloud;
+        fields          (T U rho);
+        width           4;  // file-padding
+
+        selection
+        {
+            stride
+            {
+                // every 10th parcelId
+                action  add;
+                source  stride;
+                stride  10;
+            }
+            Umin
+            {
+                // Remove slow parcels
+                action  subtract;
+                source  field;
+                field   U;
+                accept  (less 1e-3);
+            }
+            diam
+            {
+                // Only particular diameter ranges
+                action  subset;
+                source  field;
+                field   d;
+                accept  (greater 1e-3) and (less 1e-3);
+            }
+        }
+    }
+    \endverbatim
+
+    \heading Basic Usage
+    \table
+        Property     | Description                      | Required | Default
+        type         | Type name: ensightCloud          | yes |
+        clouds       | List of clouds (name or regex)   | no  |
+        cloud        | Cloud name                       | no  |
+        fields       | List of fields (name or regex)   | no  |
+        selection    | Parcel selection control         | no  | empty-dict
+    \endtable
+
+    \heading Output Options
+    \table
+        Property     | Description                      | Required | Default
+        format       | Format as ascii or binary        | no  | binary
+        width        | Mask width for \c data/XXXX      | no  | 8
+        directory    | The output directory name    | no | postProcessing/NAME
+        overwrite    | Remove existing directory        | no  | false
+        consecutive  | Consecutive output numbering     | no  | false
+        width        | Padding width for file name      | no  | 8
+        prune        | Suppress writing of empty clouds | no  | false
+        timeFormat   | Time format (ensight case)       | no  | scientific
+        timePrecision | Time precision (ensight case)   | no  | 5
+        writeControl | Output control                   | recommended | timeStep
+    \endtable
+
+    The output filename and fields are added to the functionObjectProperties
+    information. For the previous example specification:
+
+    \verbatim
+    cloudWrite1
+    {
+        myCloud
+        {
+            file    "<case>/simulation.case";
+            fields  (T U rho);
+        }
+    }
+    \endverbatim
+
+Note
+    The selection dictionary can be used for finer control of the parcel
+    output. It contains a set of (add,subtract,subset,clear,invert)
+    selection actions and sources.
+    Omitting the selection dictionary is the same as specifying the
+    conversion of all parcels (in the selected clouds).
+    More syntax details are to be found in the corresponding
+    Foam::Detail::parcelSelection class.
+
+See also
+    Foam::Detail::parcelSelection
+    Foam::functionObjects::vtkCloud
+    Foam::functionObjects::ensightWrite
+    Foam::functionObjects::fvMeshFunctionObject
+    Foam::functionObjects::timeControl
+
+SourceFiles
+    ensightCloudWriteObject.cxx
+    ensightCloudWriteObjectImpl.cxx
+
+\*---------------------------------------------------------------------------*/
+
+#ifndef functionObjects_ensightCloudWriteObject_H
+#define functionObjects_ensightCloudWriteObject_H
+
+#include "fvMeshFunctionObject.H"
+#include "ensightCase.H"
+#include "globalIndex.H"
+#include "parcelSelectionDetail.H"
+#include "wordRes.H"
+
+// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //
+
+namespace Foam
+{
+namespace functionObjects
+{
+
+/*---------------------------------------------------------------------------*\
+                   Class ensightCloudWriteObject Declaration
+\*---------------------------------------------------------------------------*/
+
+class ensightCloudWriteObject
+:
+    public fvMeshFunctionObject,
+    public Foam::Detail::parcelSelection
+{
+    // Private Data
+
+        //- Ensight output options
+        ensightCase::options caseOpts_;
+
+        //- Output directory
+        fileName outputDir_;
+
+        //- Consecutive output numbering
+        bool consecutive_;
+
+        //- Suppress writing of empty clouds
+        bool pruneEmpty_;
+
+        //- Apply output filter (for the current cloud)
+        bool applyFilter_;
+
+        //- Sizing of selected parcels (including any filtering)
+        globalIndex procAddr_;
+
+        //- Requested names of clouds to process
+        wordRes selectClouds_;
+
+        //- Subset of cloud fields to process
+        wordRes selectFields_;
+
+        //- Ensight case handler
+        autoPtr<ensightCase> ensCase_;
+
+
+    // Private Member Functions
+
+        //- Ensight case handler
+        ensightCase& ensCase() { return *ensCase_; }
+
+        //- Write a cloud to disk (creates parent directory),
+        //- and record on the cloud OutputProperties.
+        //  \param file is the output file name, with extension.
+        bool writeCloud(const word& cloudName);
+
+        //- Write fields of IOField<Type>
+        template<class Type>
+        wordList writeFields
+        (
+            const word& cloudName,
+            const objectRegistry& obrTmp
+        );
+
+
+        //- No copy construct
+        ensightCloudWriteObject(const ensightCloudWriteObject&) = delete;
+
+        //- No copy assignment
+        void operator=(const ensightCloudWriteObject&) = delete;
+
+
+public:
+
+    //- Runtime type information
+    TypeName("ensightCloud");
+
+
+    // Constructors
+
+        //- Construct from Time and dictionary
+        ensightCloudWriteObject
+        (
+            const word& name,
+            const Time& runTime,
+            const dictionary& dict
+        );
+
+
+    //- Destructor
+    virtual ~ensightCloudWriteObject() = default;
+
+
+    // Member Functions
+
+        //- Read the ensightCloud specification
+        virtual bool read(const dictionary& dict);
+
+        //- Execute, currently does nothing
+        virtual bool execute();
+
+        //- Write fields
+        virtual bool write();
+};
+
+
+// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //
+
+} // End namespace functionObjects
+} // End namespace Foam
+
+// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //
+
+#endif
+
+// ************************************************************************* //
diff --git a/src/functionObjects/lagrangian/ensightCloud/ensightCloudWriteObject.cxx b/src/functionObjects/lagrangian/ensightCloud/ensightCloudWriteObject.cxx
new file mode 100644
index 0000000000000000000000000000000000000000..01b6cdb083dd0f66fa80817d51961c3e819ff54a
--- /dev/null
+++ b/src/functionObjects/lagrangian/ensightCloud/ensightCloudWriteObject.cxx
@@ -0,0 +1,425 @@
+/*---------------------------------------------------------------------------*\
+  =========                 |
+  \\      /  F ield         | OpenFOAM: The Open Source CFD Toolbox
+   \\    /   O peration     |
+    \\  /    A nd           | www.openfoam.com
+     \\/     M anipulation  |
+-------------------------------------------------------------------------------
+    Copyright (C) 2024 OpenCFD Ltd.
+-------------------------------------------------------------------------------
+License
+    This file is part of OpenFOAM.
+
+    OpenFOAM is free software: you can redistribute it and/or modify it
+    under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    OpenFOAM is distributed in the hope that it will be useful, but WITHOUT
+    ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+    FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+    for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with OpenFOAM.  If not, see <http://www.gnu.org/licenses/>.
+
+\*---------------------------------------------------------------------------*/
+
+#include "ensightCloudWriteObject.H"
+#include "ensightCells.H"
+#include "Cloud.H"
+#include "dictionary.H"
+#include "fvMesh.H"
+#include "ensightOutputCloud.H"
+#include "addToRunTimeSelectionTable.H"
+#include "pointList.H"
+#include "stringOps.H"
+
+// * * * * * * * * * * * * * * Static Data Members * * * * * * * * * * * * * //
+
+namespace Foam
+{
+namespace functionObjects
+{
+    defineTypeNameAndDebug(ensightCloudWriteObject, 0);
+
+    addToRunTimeSelectionTable
+    (
+        functionObject,
+        ensightCloudWriteObject,
+        dictionary
+    );
+}
+}
+
+
+// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //
+
+// Implementation
+#include "ensightCloudWriteObjectImpl.cxx"
+
+
+// * * * * * * * * * * * * * Private Member Functions  * * * * * * * * * * * //
+
+bool Foam::functionObjects::ensightCloudWriteObject::writeCloud
+(
+    const word& cloudName
+)
+{
+    applyFilter_ = false;
+    procAddr_.clear();
+
+    const auto* cloudPtr = mesh_.cfindObject<cloud>(cloudName);
+    if (!cloudPtr)
+    {
+        return false;
+    }
+
+    const auto& currCloud = *cloudPtr;
+
+    objectRegistry obrTmp
+    (
+        IOobject
+        (
+            "ensight::ensightCloud::" + cloudName,
+            mesh_.time().constant(),
+            mesh_,
+            IOobject::NO_READ,
+            IOobject::NO_WRITE,
+            IOobject::NO_REGISTER
+        )
+    );
+
+    currCloud.writeObjects(obrTmp);
+
+    const auto* pointsPtr = cloud::findIOPosition(obrTmp);
+
+    if (!pointsPtr)
+    {
+        // This should be impossible
+        return false;
+    }
+
+    applyFilter_ = calculateFilter(obrTmp, log);
+    Pstream::reduceOr(applyFilter_);
+
+    // Number of parcels (locally)
+    const label nParcels =
+    (
+        applyFilter_ ? parcelAddr_.count() : pointsPtr->size()
+    );
+
+    // Gather sizes (offsets irrelevant)
+    procAddr_.reset(globalIndex::gatherOnly{}, nParcels);
+
+    bool noCloud(!procAddr_.totalSize());
+    Pstream::broadcast(noCloud);
+
+    if (applyFilter_)
+    {
+        // Report filtered/unfiltered count
+        Log << "After filtering using "
+            << procAddr_.totalSize() << '/'
+            << (returnReduce(pointsPtr->size(), sumOp<label>()))
+            << " parcels" << nl;
+    }
+
+    if (pruneEmpty_ && noCloud)
+    {
+        return false;
+    }
+
+
+    // Copy positions (for simplicity and for filtering).
+    // Store as floatVector, since that is what Ensight will write anyhow
+
+    DynamicList<floatVector> positions;
+    positions.reserve(UPstream::master() ? procAddr_.maxSize() : nParcels);
+
+    {
+        const auto& points = *pointsPtr;
+
+        positions.resize_nocopy(nParcels);
+
+        auto iter = positions.begin();
+
+        if (applyFilter_)
+        {
+            if (std::is_same<float, vector::cmptType>::value)
+            {
+                for (const label idx : parcelAddr_)
+                {
+                    *iter = points[idx];
+                    ++iter;
+                }
+            }
+            else
+            {
+                for (const label idx : parcelAddr_)
+                {
+                    const auto& pos = points[idx];
+
+                    (*iter).x() = narrowFloat(pos.x());
+                    (*iter).y() = narrowFloat(pos.y());
+                    (*iter).z() = narrowFloat(pos.z());
+                    ++iter;
+                }
+            }
+        }
+        else
+        {
+            if (std::is_same<float, vector::cmptType>::value)
+            {
+                for (const auto& pos : points)
+                {
+                    *iter = pos;
+                    ++iter;
+                }
+            }
+            else
+            {
+                for (const auto& pos : points)
+                {
+                    (*iter).x() = narrowFloat(pos.x());
+                    (*iter).y() = narrowFloat(pos.y());
+                    (*iter).z() = narrowFloat(pos.z());
+                    ++iter;
+                }
+            }
+        }
+    }
+
+
+    // Write positions
+    {
+        autoPtr<ensightFile> os = ensCase().newCloud(cloudName);
+
+        ensightOutput::writeCloudPositions
+        (
+            os.ref(),
+            positions,
+            procAddr_
+        );
+    }
+
+    // Prevent any possible conversion of positions as a field
+    obrTmp.filterKeys
+    (
+        [](const word& k)
+        {
+            return k.starts_with("position") || k.starts_with("coordinate");
+        },
+        true  // prune
+    );
+
+
+    // Write fields
+
+    DynamicList<word> written(obrTmp.size() + currCloud.objectRegistry::size());
+
+    written.push_back
+    (
+        writeFields<label>(cloudName, obrTmp)
+    );
+    written.push_back
+    (
+        writeFields<scalar>(cloudName, obrTmp)
+    );
+    written.push_back
+    (
+        writeFields<vector>(cloudName, obrTmp)
+    );
+
+    // Any cloudFunctions results
+    written.push_back
+    (
+        writeFields<scalar>(cloudName, currCloud)
+    );
+
+    // Record information into the state (all processors)
+    //
+    // foName
+    // {
+    //     cloudName
+    //     {
+    //         file   "<case>/postProcessing/name/casename.case";
+    //         fields (U T rho);
+    //     }
+    // }
+
+    const fileName& file = ensCase().path();
+
+    // Case-local file name with "<case>" to make relocatable
+    dictionary propsDict;
+    propsDict.add
+    (
+        "file",
+        time_.relativePath(file, true)
+    );
+    propsDict.add("fields", written);
+
+    setObjectProperty(name(), cloudName, propsDict);
+
+    return true;
+}
+
+
+// * * * * * * * * * * * * * * * * Constructors  * * * * * * * * * * * * * * //
+
+Foam::functionObjects::ensightCloudWriteObject::ensightCloudWriteObject
+(
+    const word& name,
+    const Time& runTime,
+    const dictionary& dict
+)
+:
+    fvMeshFunctionObject(name, runTime, dict),
+    caseOpts_("format", dict, IOstreamOption::BINARY),
+    outputDir_(),
+    consecutive_(false),
+    pruneEmpty_(false),
+    applyFilter_(false),
+    procAddr_()
+{
+    // May still want this?
+    // if (postProcess)
+    // {
+    //     // Disable for post-process mode.
+    //     // Emit as FatalError for the try/catch in the caller.
+    //     FatalError
+    //         << type() << " disabled in post-process mode"
+    //         << exit(FatalError);
+    // }
+
+    read(dict);
+}
+
+
+// * * * * * * * * * * * * * * * Member Functions  * * * * * * * * * * * * * //
+
+bool Foam::functionObjects::ensightCloudWriteObject::read
+(
+    const dictionary& dict
+)
+{
+    fvMeshFunctionObject::read(dict);
+
+    // Case/writer options
+    consecutive_ = dict.getOrDefault("consecutive", false);
+
+    caseOpts_.width(dict.getOrDefault<label>("width", 8));
+    caseOpts_.overwrite(dict.getOrDefault("overwrite", false));
+
+    caseOpts_.timeFormat("timeFormat", dict);
+    caseOpts_.timePrecision("timePrecision", dict);
+
+
+    pruneEmpty_ = dict.getOrDefault("prune", false);
+
+    selectClouds_.clear();
+    dict.readIfPresent("clouds", selectClouds_);
+    selectClouds_.uniq();
+    if (selectClouds_.empty())
+    {
+        word cloudName;
+        if (dict.readIfPresent("cloud", cloudName))
+        {
+            selectClouds_.push_back(std::move(cloudName));
+        }
+    }
+
+    selectFields_.clear();
+    dict.readIfPresent("fields", selectFields_);
+    selectFields_.uniq();
+
+    // Actions to define selection
+    parcelSelect_ = dict.subOrEmptyDict("selection");
+
+
+    // Output directory
+
+    outputDir_.clear();
+    dict.readIfPresent("directory", outputDir_);
+
+    if (outputDir_.size())
+    {
+        // User-defined output directory
+        outputDir_.expand();
+        if (!outputDir_.isAbsolute())
+        {
+            outputDir_ = time_.globalPath()/outputDir_;
+        }
+    }
+    else
+    {
+        // Standard postProcessing/ naming
+        outputDir_ = time_.globalPath()/functionObject::outputPrefix/name();
+    }
+    outputDir_.clean();  // Remove unneeded ".."
+
+    return true;
+}
+
+
+bool Foam::functionObjects::ensightCloudWriteObject::execute()
+{
+    return true;
+}
+
+
+bool Foam::functionObjects::ensightCloudWriteObject::write()
+{
+    const wordList cloudNames
+    (
+        selectClouds_.empty()
+      ? mesh_.sortedNames<cloud>()
+      : mesh_.sortedNames<cloud>(selectClouds_)
+    );
+
+    if (cloudNames.empty())
+    {
+        return true;  // skip - nothing available
+    }
+
+    if (!ensCase_)
+    {
+        ensCase_.reset
+        (
+            new ensightCase(outputDir_, time_.globalCaseName(), caseOpts_)
+        );
+
+        // Generate a (non-moving) dummy geometry
+        // - ParaView ensight-reader needs this, and usually ensight does too
+        autoPtr<ensightGeoFile> os = ensCase().newGeometry(false);
+        ensightCells::writeBox(os.ref(), mesh_.bounds());
+    }
+
+    if (consecutive_)
+    {
+        ensCase().nextTime(time_.value());
+    }
+    else
+    {
+        ensCase().setTime(time_.value(), time_.timeIndex());
+    }
+
+    Log << type() << ' ' << name() << " write" << nl;
+
+    // Each cloud separately
+    for (const word& cloudName : cloudNames)
+    {
+        // writeCloud() includes mkDir (on master)
+
+        if (writeCloud(cloudName))
+        {
+            Log << "    cloud  : " << endl;
+        }
+    }
+
+    ensCase().write();  // Flush case information
+
+    return true;
+}
+
+
+// ************************************************************************* //
diff --git a/src/functionObjects/lagrangian/ensightCloud/ensightCloudWriteObjectImpl.cxx b/src/functionObjects/lagrangian/ensightCloud/ensightCloudWriteObjectImpl.cxx
new file mode 100644
index 0000000000000000000000000000000000000000..01fcf08b14891f9d8347309bd2db1ebf6663e88f
--- /dev/null
+++ b/src/functionObjects/lagrangian/ensightCloud/ensightCloudWriteObjectImpl.cxx
@@ -0,0 +1,106 @@
+/*---------------------------------------------------------------------------*\
+  =========                 |
+  \\      /  F ield         | OpenFOAM: The Open Source CFD Toolbox
+   \\    /   O peration     |
+    \\  /    A nd           | www.openfoam.com
+     \\/     M anipulation  |
+-------------------------------------------------------------------------------
+    Copyright (C) 2024 OpenCFD Ltd.
+-------------------------------------------------------------------------------
+License
+    This file is part of OpenFOAM.
+
+    OpenFOAM is free software: you can redistribute it and/or modify it
+    under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    OpenFOAM is distributed in the hope that it will be useful, but WITHOUT
+    ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+    FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+    for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with OpenFOAM.  If not, see <http://www.gnu.org/licenses/>.
+
+\*---------------------------------------------------------------------------*/
+
+#include "IOField.H"
+#include "ensightOutputCloud.H"
+
+// * * * * * * * * * * * * * Private Member Functions  * * * * * * * * * * * //
+
+template<class Type>
+Foam::wordList Foam::functionObjects::ensightCloudWriteObject::writeFields
+(
+    const word& cloudName,
+    const objectRegistry& obrTmp
+)
+{
+    static_assert
+    (
+        (
+            std::is_same<label, typename pTraits<Type>::cmptType>::value
+         || std::is_floating_point<typename pTraits<Type>::cmptType>::value
+        ),
+        "Label and Floating-point vector space only"
+    );
+
+    // Other integral types (eg, bool etc) would need cast/convert to label.
+    // Similarly for labelVector etc.
+
+
+    // Fields are not always on all processors (eg, multi-component parcels).
+    // Thus need to resolve names between all processors.
+
+    wordList fieldNames =
+    (
+        selectFields_.size()
+      ? obrTmp.names<IOField<Type>>(selectFields_)
+      : obrTmp.names<IOField<Type>>()
+    );
+
+    Pstream::combineReduce(fieldNames, ListOps::uniqueEqOp<word>());
+    Foam::sort(fieldNames);  // Consistent order
+
+    DynamicList<Type> scratch;
+
+    for (const word& fieldName : fieldNames)
+    {
+        const List<Type>* fldPtr = obrTmp.findObject<IOField<Type>>(fieldName);
+        const List<Type>& values = (fldPtr ? *fldPtr : List<Type>::null());
+
+        autoPtr<ensightFile> os =
+            ensCase().newCloudData<Type>(cloudName, fieldName);
+
+        if (applyFilter_)
+        {
+            scratch.resize_nocopy(parcelAddr_.count());
+
+            auto iter = scratch.begin();
+
+            for (const label idx : parcelAddr_)
+            {
+                *iter = values[idx];
+                ++iter;
+            }
+
+            // TBD:
+            // recalculate globalIndex instead of relying on procAddr_ ?
+
+            ensightOutput::writeCloudField(os.ref(), scratch, procAddr_);
+        }
+        else
+        {
+            // TBD:
+            // recalculate globalIndex instead of relying on procAddr_ ?
+
+            ensightOutput::writeCloudField(os.ref(), values, procAddr_);
+        }
+    }
+
+    return fieldNames;
+}
+
+
+// ************************************************************************* //