"""
Name: tutorial_geotiff.agxPy
Description:

This tutorial demonstrates how to create a terrain from a GeoTIFF Digital Elevation Model (DEM) in Python.
"""

import agx
import agxCollide
import agxIO
import agxOSG
import agxSDK
import agxTerrain

from agxPythonModules.utils.environment import (
    application,
    init_app,
    root,
    simulation,
)

import os
import tempfile


def createTerrainVoxelRenderer(terrain, root):
    renderer = agxOSG.TerrainVoxelRenderer(terrain, root)
    renderer.setRenderVoxelSolidMass(False)
    renderer.setRenderVoxelFluidMass(False)
    renderer.setRenderVoxelBoundingBox(False)
    renderer.setRenderHeightField(True)
    renderer.setRenderVelocityField(False)
    renderer.setRenderSoilParticles(True)
    return renderer


class TutorialLinearHeightDataResampler(agxTerrain.HeightDataResampler):
    """
    Minimal example of a user-defined HeightDataResampler.
    This implementation performs bilinear interpolation without smoothing.
    """

    def getRequiredInputMargin(self):
        """
        No extra input margin is needed since the interpolation only uses local input samples.

        Note: when used with a TerrainPager, this method may be called on a worker thread.
        """
        return 0

    def resample(self, heights, input_desc, output_desc):
        """
        Resample the input height grid to the requested output grid using bilinear interpolation.

        Note: when used with a TerrainPager, this method may be called on a worker thread.
        """
        if input_desc is None or output_desc is None:
            return agx.RealVector()

        input_num_samples_x = input_desc.numSamplesX
        input_num_samples_y = input_desc.numSamplesY
        output_num_samples_x = output_desc.numSamplesX
        output_num_samples_y = output_desc.numSamplesY

        if (
            input_num_samples_x == 0
            or input_num_samples_y == 0
            or output_num_samples_x == 0
            or output_num_samples_y == 0
            or len(heights) != input_num_samples_x * input_num_samples_y
        ):
            return agx.RealVector()

        input_spacing_x = input_desc.sampleSpacing.x()
        input_spacing_y = input_desc.sampleSpacing.y()
        if input_spacing_x <= 0.0 or input_spacing_y <= 0.0:
            return agx.RealVector()

        output_spacing_x = output_desc.sampleSpacing.x()
        output_spacing_y = output_desc.sampleSpacing.y()
        origin_offset_x = output_desc.originOffset.x()
        origin_offset_y = output_desc.originOffset.y()

        input_max_x = input_num_samples_x - 1
        input_max_y = input_num_samples_y - 1

        resampled_heights = agx.RealVector()
        resampled_heights.resize(output_num_samples_x * output_num_samples_y)

        for output_y in range(output_num_samples_y):
            input_y = (
                origin_offset_y + float(output_y) * output_spacing_y
            ) / input_spacing_y
            clamped_input_y = min(max(input_y, 0.0), float(input_max_y))
            y0 = int(clamped_input_y)
            y1 = min(y0 + 1, input_max_y)
            ty = clamped_input_y - float(y0) if y1 > y0 else 0.0

            output_row_offset = output_y * output_num_samples_x
            input_row_offset_0 = y0 * input_num_samples_x
            input_row_offset_1 = y1 * input_num_samples_x

            for output_x in range(output_num_samples_x):
                input_x = (
                    origin_offset_x + float(output_x) * output_spacing_x
                ) / input_spacing_x
                clamped_input_x = min(max(input_x, 0.0), float(input_max_x))
                x0 = int(clamped_input_x)
                x1 = min(x0 + 1, input_max_x)
                tx = clamped_input_x - float(x0) if x1 > x0 else 0.0

                h00 = heights[input_row_offset_0 + x0]
                h10 = heights[input_row_offset_0 + x1]
                h01 = heights[input_row_offset_1 + x0]
                h11 = heights[input_row_offset_1 + x1]

                hx0 = h00 + tx * (h10 - h00)
                hx1 = h01 + tx * (h11 - h01)
                resampled_heights[output_row_offset + output_x] = hx0 + ty * (hx1 - hx0)

        return resampled_heights


class ActiveRenderers:
    def __init__(self):
        self.renderers = dict()

    def key(self, tile_id):
        return (tile_id.x(), tile_id.y())

    def addRenderer(self, tile_id, renderer):
        self.renderers[self.key(tile_id)] = renderer

    def getRenderer(self, tile_id):
        return self.renderers.get(self.key(tile_id), None)

    def removeRenderer(self, tile_id):
        key = self.key(tile_id)
        if key in self.renderers:
            del self.renderers[key]


class TerrainRendererInserter(agxTerrain.PythonTerrainCallback):
    def __init__(self, sim, root, active_renderers):
        super().__init__()
        self.sim = sim
        self.root = root
        self.active_renderers = active_renderers

    def onTileEvent(self, tile_id, terrain):
        if terrain is None or self.active_renderers.getRenderer(tile_id) is not None:
            return

        renderer = createTerrainVoxelRenderer(terrain, self.root)
        self.sim.add(renderer)
        self.active_renderers.addRenderer(tile_id, renderer)


class TerrainRendererRemover(agxTerrain.PythonTerrainCallback):
    def __init__(self, sim, active_renderers):
        super().__init__()
        self.sim = sim
        self.active_renderers = active_renderers

    def onTileEvent(self, tile_id, terrain):
        renderer = self.active_renderers.getRenderer(tile_id)
        if renderer is None:
            return

        self.sim.remove(renderer)
        self.active_renderers.removeRenderer(tile_id)


def getGeotiffDirectory():
    return os.path.abspath(
        os.path.join(
            os.path.dirname(__file__),
            "..",
            "..",
            "..",
            "models",
            "geotiff",
            "gore_range",
        )
    )


def initializeContext():
    # By adding the directory containing our GeoTIFF file to the AGX RESOURCE_PATHs,
    # we can make our application portable across machines.
    resource_directory = getGeotiffDirectory()
    agxIO.Environment.instance().getFilePath(
        agxIO.Environment.RESOURCE_PATH
    ).addFilePath(resource_directory)

    return simulation(), application(), root()


def createReader(file_name):
    # Open a specific GeoTIFF file, located in the directory added to RESOURCE_PATHs above.
    # The GeotiffReader will automaticall re-project the DEM data to an
    # azimuthal equidistant projection.
    reader = agxTerrain.GeotiffReader()
    if not reader.open(file_name):
        raise RuntimeError("Failed to open GeoTIFF: {}".format(file_name))

    return reader


def createSampleWindow():
    # Due to the size of the DEM data, we select a subset, or "window" to read.
    # The offsets and numSamples are in pixel space.
    sample_window = agxTerrain.GeotiffSampleWindow()
    sample_window.offsetX = 600
    sample_window.offsetY = 400
    sample_window.numSamplesX = 65
    sample_window.numSamplesY = 65

    return sample_window


def createReadParams():
    # Since the GeotiffDEM data is offset in z, we set a z offset to ensure heights gets located
    # closer to the world origin.
    # We must be careful not to put a too large negative offset here, that would give us negative
    # height values which the HeightField will not like.
    # An alternative to this would be to skip the heigtOffset here and instead move the final Terrain
    # to get the heights closer to the world origin.
    read_params = agxTerrain.GeotiffHeightReadParams()
    read_params.heightOffset = -3000.0

    # This is how we read the actual height data.
    # We also get the num samples (in pixels) back from the readHeights method.
    # Note that the returned heights are in meters, meaning we can use it directly
    # to create a HeightField.
    return read_params


def readHeightData(reader, file_name):
    sample_window = createSampleWindow()
    read_params = createReadParams()
    ok, heights, samples_x, samples_y = reader.readHeights(sample_window, read_params)
    if not ok or len(heights) == 0:
        raise RuntimeError("Failed to read heights from GeoTIFF: {}".format(file_name))

    # Sample spacing is the distance in meters between two samples (pixels) in the data.
    ok, sample_spacing_x, sample_spacing_y = reader.getSampleSpacing()
    if not ok:
        raise RuntimeError(
            "Failed to read sample spacing from GeoTIFF: {}".format(file_name)
        )

    return (
        heights,
        samples_x,
        samples_y,
        sample_spacing_x,
        sample_spacing_y,
    )


def createTerrainFromHeights(heights, samples_x, samples_y, size_x, size_y):
    # Calculate the overall size, in meters.
    # Create a HeightField from the height data.
    height_field = agxCollide.HeightField(samples_x, samples_y, size_x, size_y, heights)

    # Now we can create a Terrain from the HeightField created above.
    terrain = agxTerrain.Terrain.createFromHeightField(height_field, 1.0)
    if terrain is None:
        raise RuntimeError("Failed to create terrain from GeoTIFF height field.")

    return terrain


def basicTerrain():
    """
    Minimal example of creating a Terrain from part of a GeoTIFF DEM.
    """
    sim, app, root = initializeContext()
    app.setEnableDebugRenderer(False)
    file_name = "Gore_Range_Albers_5m.tif"
    reader = createReader(file_name)

    # First we read part of the height data from the GeoTIFF.
    # This uses the GeotiffDEM reader and a GeotiffSampleWindow to
    # select only a subset of the data.
    # Details can be seen in the readHeightData method.
    (
        heights,
        samples_x,
        samples_y,
        sample_spacing_x,
        sample_spacing_y,
    ) = readHeightData(reader, file_name)

    # Overall size in meters of the Terrain.
    size_x = (samples_x - 1) * sample_spacing_x if samples_x > 1 else 0.0
    size_y = (samples_y - 1) * sample_spacing_y if samples_y > 1 else 0.0

    # From this, we can create a Terrain.
    terrain = createTerrainFromHeights(heights, samples_x, samples_y, size_x, size_y)

    sim.add(terrain)

    # We add a renderer for the Terrain so that we can visualize it.
    renderer = createTerrainVoxelRenderer(terrain, root)
    sim.add(renderer)
    app.setEnableDebugRenderer(False)

    # Setup camera.
    eye = agx.Vec3(0.0, -500.0, 300.0)
    center = agx.Vec3(0.0, 0.0, 200.0)
    up = agx.Vec3(0.0, 0.4, 0.92)
    app.setCameraHome(eye, center, up)

    return root


def upsampledTerrain():
    """
    Example of using a resampler to increase the resolution of the
    height grid extracted from a GeoTIFF DEM before creating a Terrain
    from it.
    """
    sim, app, root = initializeContext()
    file_name = "Gore_Range_Albers_5m.tif"
    reader = createReader(file_name)

    # First we read the height data from the GeoTIFF.
    # This uses the GeotiffDEM reader and a GeotiffSampleWindow to
    # select only a subset of the data.
    # Details can be seen in the readHeightData method.
    (
        heights,
        samples_x,
        samples_y,
        sample_spacing_x,
        sample_spacing_y,
    ) = readHeightData(reader, file_name)

    # Overall size in meters of the Terrain.
    size_x = (samples_x - 1) * sample_spacing_x if samples_x > 1 else 0.0
    size_y = (samples_y - 1) * sample_spacing_y if samples_y > 1 else 0.0

    # We create a "reference" Terrain, which is simply using parts
    # of the GeoTIFF DEM data directly.
    reference_terrain = createTerrainFromHeights(
        heights, samples_x, samples_y, size_x, size_y
    )

    sim.add(reference_terrain)

    # Now we can use the GaussianHeightDataResampler to "upsample" or increase the resolution
    # of the Terrain while also applying a mild smoothing of the input height grid.
    # Other HeightDataResamplers exists and can be user defined as well, by derriving from the
    # agxTerrain::HeightDataResampler class.
    # Each resampler needs an InputDescription and an OutputDescription which describes the layout
    # of the input data and the wanted output data.
    # It is then up to the HeightDataResampler to provide new height values according to the
    # given OutputDescription.
    input_desc = agxTerrain.ResampleInputDescription()
    input_desc.numSamplesX = samples_x
    input_desc.numSamplesY = samples_y
    input_desc.sampleSpacing = agx.Vec2(sample_spacing_x, sample_spacing_y)

    # Upsample by factor of 2.
    upsampled_samples_x = samples_x * 2
    upsampled_samples_y = samples_y * 2

    output_desc = agxTerrain.ResampleOutputDescription()
    output_desc.numSamplesX = upsampled_samples_x
    output_desc.numSamplesY = upsampled_samples_y
    output_desc.sampleSpacing = agx.Vec2(
        size_x / float(upsampled_samples_x - 1),
        size_y / float(upsampled_samples_y - 1),
    )
    output_desc.originOffset = agx.Vec2(0.0, 0.0)

    resampler = agxTerrain.GaussianHeightDataResampler()
    upsampled_heights = resampler.resample(heights, input_desc, output_desc)

    # We can now create a terrain from the upsampled height data.
    upsampled_terrain = createTerrainFromHeights(
        upsampled_heights,
        upsampled_samples_x,
        upsampled_samples_y,
        size_x,
        size_y,
    )

    # We place the upsampled Terrain to the side of the reference terrain
    # for easy visual comparison.
    upsampled_terrain.setPosition(agx.Vec3(400.0, 0.0, 0.0))
    sim.add(upsampled_terrain)

    # For easier visualization of the height grid, we use debug rendering.
    app.setEnableDebugRenderer(True)

    # Setup camera.
    eye = agx.Vec3(200.0, -750.0, 400.0)
    center = agx.Vec3(200.0, 0.0, 200.0)
    up = agx.Vec3(0.0, 0.4, 0.92)
    app.setCameraHome(eye, center, up)

    return root


def pagedTerrain():
    """
    Example where we bring all together; use heights from a GeoTIFF DEM
    file in a TerrainDataSource, and use that with a Terrain Pager.
    The TerrainDataSource has a HeightDataResampler that does runtime
    resampling to increase the height grid resolution.
    """
    sim, app, root = initializeContext()
    file_name = "Gore_Range_Albers_5m.tif"

    reader = createReader(file_name)

    # We create a GeotiffTerrainDataSource which will lazy-load
    # tiles from the GeoTIFF DEM data, upsample the height grid
    # using the GaussianHeightDataResampler before passing the
    # heights to a Terrain Pager.
    terrain_data_source = agxTerrain.GeotiffTerrainDataSource()
    terrain_data_source.setReader(reader)
    terrain_data_source.setReadParameters(agxTerrain.GeotiffHeightReadParams())
    terrain_data_source.setResampler(agxTerrain.GaussianHeightDataResampler())

    ok, sample_spacing_x, sample_spacing_y = reader.getSampleSpacing()
    if not ok:
        raise RuntimeError(
            "Failed to read sample spacing from GeoTIFF: {}".format(file_name)
        )

    # Here we set up the size and overlap of the Terrain Pager tiles.
    # Note that we decrease the element_size by a factor of 0.5 to effectively
    # double the grid resolution using the GaussianHeightDataResampler provided
    # above.
    tile_resolution = 300
    tile_overlap = 2
    element_size = sample_spacing_x * 0.5
    maximum_depth = 2.0

    # Since the height data has a large z offset, we set the
    # reference point to a large negative z value to get the
    # final Terrain tiles to appear closer to the world origin.
    template_terrain = agxTerrain.Terrain(5, 5, 1.0, 1.0)
    terrain_pager = agxTerrain.TerrainPager(
        tile_resolution,
        tile_overlap,
        element_size,
        maximum_depth,
        agx.Vec3(0.0, 0.0, -4000.0),
        agx.Quat(),
        template_terrain,
    )

    # We pass the GeotiffTerrainDataSource we configured above
    # to the Terrain Pager.
    terrain_pager.setTerrainDataSource(terrain_data_source)

    # Here we set up a body that the Terrain Pager will track.
    # This will ensure tiles are loaded in and out when the body
    # moves in the world.
    tracker_body = agx.RigidBody()
    tracker_body.setName("terrainPagerTracker")
    tracker_body.add(agxCollide.Geometry(agxCollide.Sphere(100.0)))
    tracker_body.getMassProperties().setMass(100.0)
    tracker_body.setPosition(2050.0, 0.0, 150.0)
    sim.add(tracker_body)
    agxOSG.createVisual(tracker_body, root)

    hinge_frame = agx.HingeFrame()
    hinge_frame.setAxis(agx.Vec3(0.0, 0.0, 1.0))
    hinge_frame.setCenter(agx.Vec3(0.0, 0.0, tracker_body.getPosition().z()))
    hinge = agx.Hinge(hinge_frame, tracker_body)
    hinge.getMotor1D().setEnable(True)
    hinge.getMotor1D().setSpeed(0.25)
    sim.add(hinge)

    # Register the body with the Terrain Pager so that it can
    # track the body.
    terrain_pager.add(tracker_body, 450.0, 700.0)
    sim.add(terrain_pager)

    # To enable Terrain Rendering, we set up a listener that will
    # create a Renderer for each loaded-in tile in runtime.
    active_renderers = ActiveRenderers()

    global pager_insert_renderer_event_handler
    global pager_remove_renderer_event_handler
    pager_insert_renderer_event_handler = TerrainRendererInserter(
        sim, root, active_renderers
    )
    pager_remove_renderer_event_handler = TerrainRendererRemover(sim, active_renderers)
    terrain_pager.tileLoadEvent.addPythonCallback(pager_insert_renderer_event_handler)
    terrain_pager.tileUnloadEvent.addPythonCallback(pager_remove_renderer_event_handler)

    app.setEnableDebugRenderer(False)

    eye = agx.Vec3(2050.0, -1200.0, 650.0)
    center = agx.Vec3(2050.0, 0.0, 50.0)
    up = agx.Vec3(0.0, 0.0, 1.0)
    app.setCameraHome(eye, center, up)

    return root


def customResampler():
    """
    Example showing how to write a custom HeightDataResampler in Python and use it
    with a GeotiffTerrainDataSource and a Terrain Pager.

    The custom resampler (TutorialLinearHeightDataResampler, defined at the top of
    this file) is a Python subclass of agxTerrain.HeightDataResampler. It performs
    bilinear interpolation without smoothing, in contrast to the built-in
    GaussianHeightDataResampler used in pagedTerrain.

    The key difference from pagedTerrain is that a Python-defined resampler is called
    on the TerrainPager's background worker thread rather than on the main thread.
    This requires one additional setup step compared to previous Terran Pager tutorials;
    calling enablePythonThreadSafety() on the TerrainPager before adding it to the
    simulation, explained in the comment below.
    """
    sim, app, root = initializeContext()

    file_name = "Gore_Range_Albers_5m.tif"
    reader = createReader(file_name)

    terrain_data_source = agxTerrain.GeotiffTerrainDataSource()
    terrain_data_source.setReader(reader)
    terrain_data_source.setReadParameters(agxTerrain.GeotiffHeightReadParams())

    # Here we create our custom resampler and register it with the data source.
    # TutorialLinearHeightDataResampler is a Python subclass of
    # agxTerrain.HeightDataResampler defined at the top of this file.
    # It overrides two methods:
    #   - getRequiredInputMargin(): how many extra samples around the tile border
    #     the resampler needs as input context.
    #   - resample(): the actual resampling logic, called once per tile load.
    #
    # The resampler is stored in a global variable to prevent Python's garbage
    # collector from destroying the Python wrapper while C++ still holds a
    # reference to the underlying object. Without this, the virtual dispatch
    # into Python would call into a dead object and crash.
    global tutorial_linear_height_data_resampler
    tutorial_linear_height_data_resampler = TutorialLinearHeightDataResampler()
    terrain_data_source.setResampler(tutorial_linear_height_data_resampler)

    ok, sample_spacing_x, sample_spacing_y = reader.getSampleSpacing()
    if not ok:
        raise RuntimeError(
            "Failed to read sample spacing from GeoTIFF: {}".format(file_name)
        )

    # These parameters are the same as in pagedTerrain.
    tile_resolution = 100
    tile_overlap = 2
    element_size = sample_spacing_x * 0.5
    maximum_depth = 2.0

    template_terrain = agxTerrain.Terrain(5, 5, 1.0, 1.0)
    terrain_pager = agxTerrain.TerrainPager(
        tile_resolution,
        tile_overlap,
        element_size,
        maximum_depth,
        agx.Vec3(0.0, 0.0, -4000.0),
        agx.Quat(),
        template_terrain,
    )

    terrain_pager.setTerrainDataSource(terrain_data_source)

    # Set up a body for the TerrainPager to track, identical to pagedTerrain.
    tracker_body = agx.RigidBody()
    tracker_body.setName("terrainPagerTrackerLinear")
    tracker_body.add(agxCollide.Geometry(agxCollide.Sphere(30.0)))
    tracker_body.getMassProperties().setMass(100.0)
    tracker_body.setPosition(1050.0, 0.0, 100.0)
    sim.add(tracker_body)
    agxOSG.createVisual(tracker_body, root)

    hinge_frame = agx.HingeFrame()
    hinge_frame.setAxis(agx.Vec3(0.0, 0.0, 1.0))
    hinge_frame.setCenter(agx.Vec3(0.0, 0.0, tracker_body.getPosition().z()))
    hinge = agx.Hinge(hinge_frame, tracker_body)
    hinge.getMotor1D().setEnable(True)
    hinge.getMotor1D().setSpeed(0.3)
    sim.add(hinge)

    terrain_pager.add(tracker_body, 200.0, 300.0)

    # This is the second setup step required when using a Python resampler.
    # The TerrainPager's background thread needs to temporarily acquire
    # Python's interpreter lock while it calls into our resampler. Meanwhile,
    # the main thread must release that lock when it blocks waiting for a tile
    # to finish loading. enablePythonThreadSafety() configures the pager to
    # coordinate this correctly on a per-instance basis.
    # This call must come before sim.add(terrain_pager).
    terrain_pager.enablePythonThreadSafety()
    sim.add(terrain_pager)

    # Subscribe to tile load and unload events to add and remove renderers as
    # tiles page in and out. These callbacks are invoked on the main thread
    # after a tile has been fully prepared by the worker thread.
    # We store the handlers in globals to prevent them from being garbage collected.
    active_renderers = ActiveRenderers()

    global pager_insert_renderer_event_handler_linear
    global pager_remove_renderer_event_handler_linear
    pager_insert_renderer_event_handler_linear = TerrainRendererInserter(
        sim, root, active_renderers
    )
    pager_remove_renderer_event_handler_linear = TerrainRendererRemover(
        sim, active_renderers
    )
    terrain_pager.tileLoadEvent.addPythonCallback(
        pager_insert_renderer_event_handler_linear
    )
    terrain_pager.tileUnloadEvent.addPythonCallback(
        pager_remove_renderer_event_handler_linear
    )

    app.setEnableDebugRenderer(False)

    eye = agx.Vec3(450.0, -1800.0, 1000.0)
    center = agx.Vec3(750.0, 0.0, 50.0)
    up = agx.Vec3(0.0, 0.0, 1.0)
    app.setCameraHome(eye, center, up)

    return root


def mosaicTerrain():
    """
    Example of reading multiple GeoTIFF DEM files as a single seamless mosaic terrain,
    using a TerrainPager.

    The key new concept here is opening several GeoTIFF files at once by passing a
    list of file names to GeotiffReader.open(). The reader treats all provided files
    as tiles of a single mosaic: they collectively cover a larger geographic area than
    any individual file. Partial overlap between tiles is fully supported — the reader
    resolves overlapping height values automatically.

    Because the combined mosaic can be very large, we use a TerrainPager
    to load only the tiles near a moving tracker body, keeping memory usage
    bounded regardless of the total mosaic size.
    """
    sim, app, root = initializeContext()
    app.setEnableDebugRenderer(False)

    # Add the pgda directory to RESOURCE_PATH so the mosaic files can be
    # referred to by name alone, consistent with the single-file tutorials above.
    pgda_directory = os.path.abspath(
        os.path.join(
            os.path.dirname(__file__),
            "..",
            "..",
            "..",
            "models",
            "geotiff",
            "pgda",
        )
    )
    agxIO.Environment.instance().getFilePath(
        agxIO.Environment.RESOURCE_PATH
    ).addFilePath(pgda_directory)

    # The key step: open multiple GeoTIFF files as a single mosaic by passing a
    # list of file names. The two lunar terrain tiles used here partially overlap
    # but together cover a much larger area than either tile on its own.
    # GeotiffReader merges them into one single dataset behind the scenes.
    file_names = agx.StringVector()
    file_names.append("moon_LM2.tif")
    file_names.append("moon_LM3.tif")

    reader = agxTerrain.GeotiffReader()
    if not reader.open(file_names):
        raise RuntimeError("Failed to open GeoTIFF mosaic files.")

    # The raw lunar DEM stores elevations relative to a datum that places many
    # values well below zero. We add a positive height offset so that all returned
    # heights remain non-negative, which is required by the HeightField.
    # (Contrast with the negative offset used for the Gore Range data in earlier
    # tutorials, where the raw elevations were already large positive numbers.)
    # To manually inspect the minimum and maximum heights of a GeoTIFF DEM, see
    # the GeotiffUtils.printGeotiffFileInfo helper function.
    read_params = agxTerrain.GeotiffHeightReadParams()
    read_params.heightOffset = 3920.0

    # The GeotiffTerrainDataSource uses the multi-file reader and supplies height
    # data to the TerrainPager one tile at a time. Setting it up is identical to
    # the single-file case in earlier tutorials.
    terrain_data_source = agxTerrain.GeotiffTerrainDataSource()
    terrain_data_source.setReader(reader)
    terrain_data_source.setReadParameters(read_params)

    # Retrieve the native DEM sample spacing (meters per pixel).
    ok, sample_spacing_x, _ = reader.getSampleSpacing()
    if not ok:
        raise RuntimeError("Failed to read sample spacing from GeoTIFF mosaic.")

    # Set up the TerrainPager. We set element_size to the native sample spacing so
    # each terrain grid cell maps directly to one DEM pixel — no up- or
    # down-sampling is applied in the tile plane. The reference position carries a
    # large negative z to offset the remaining elevation bias after the height_offset
    # above has been applied.
    tile_resolution = 129
    tile_overlap = 2
    element_size = sample_spacing_x
    maximum_depth = 2.0

    template_terrain = agxTerrain.Terrain(5, 5, 1.0, 1.0)
    terrain_pager = agxTerrain.TerrainPager(
        tile_resolution,
        tile_overlap,
        element_size,
        maximum_depth,
        agx.Vec3(0.0, 0.0, -4000.0),
        agx.Quat(),
        template_terrain,
    )
    terrain_pager.setTerrainDataSource(terrain_data_source)

    # Add eight kinematic tracker bodies sweeping in opposite Y directions. As they
    # travel across the mosaic the pager loads tiles from whichever source file
    # covers the current position.
    num_tracker_pairs = 4
    x_spacing = 4300.0

    for i in range(num_tracker_pairs):
        x_offset = float(i) * x_spacing

        for direction in range(2):
            y_sign = 1.0 if direction == 0 else -1.0

            body = agx.RigidBody()
            body.setName("terrainPagerTracker_{}_{}".format(i, direction))
            body.add(agxCollide.Geometry(agxCollide.Sphere(100.0)))
            body.setMotionControl(agx.RigidBody.KINEMATICS)
            body.setPosition(-8000.0 + x_offset, y_sign * 500.0, 200.0)
            body.setVelocity(agx.Vec3(0.0, y_sign * 900.0, 0.0))

            sim.add(body)
            terrain_pager.add(body, 450.0, 700.0)
            agxOSG.createVisual(body, root)

    sim.add(terrain_pager)

    # Create a voxel renderer for each pager tile as it loads at runtime.
    active_renderers = ActiveRenderers()

    global pager_insert_renderer_event_handler_mosaic
    global pager_remove_renderer_event_handler_mosaic
    pager_insert_renderer_event_handler_mosaic = TerrainRendererInserter(
        sim, root, active_renderers
    )
    pager_remove_renderer_event_handler_mosaic = TerrainRendererRemover(
        sim, active_renderers
    )
    terrain_pager.tileLoadEvent.addPythonCallback(
        pager_insert_renderer_event_handler_mosaic
    )
    terrain_pager.tileUnloadEvent.addPythonCallback(
        pager_remove_renderer_event_handler_mosaic
    )

    eye = agx.Vec3(0.0, 15000.0, 30000.0)
    center = agx.Vec3(0.0, 0.0, 50.0)
    up = agx.Vec3(0.0, 0.0, 1.0)
    app.setCameraHome(eye, center, up)

    return root


def buildScene():
    app = application()

    if app.getNumScenes() == 1:
        addRemainingScenes(app)

    return basicTerrain()


def addRemainingScenes(app):
    script_file_name = app.getArguments().getArgumentName(1)
    script_file_name = script_file_name.replace("agxscene:", "")

    def addScene(name):
        scene_key = app.getNumScenes() + 1  # Only works until tutorial 9.
        app.addScene(script_file_name, name, ord(ascii(scene_key)), True)

    addScene("upsampledTerrain")
    addScene("pagedTerrain")
    addScene("customResampler")
    addScene("mosaicTerrain")


init = init_app(
    name=__name__,
    scenes=[
        (buildScene, "1"),
        (upsampledTerrain, "2"),
        (pagedTerrain, "3"),
        (customResampler, "4"),
        (mosaicTerrain, "5"),
    ],
)
