Skip to content

Isosurface linear topo

Topography-clipped linear isosurface example
import time
import numpy as np
import pandas as pd
from pathlib import Path
from ferreus_rbf import RBFInterpolator
from ferreus_rbf.isosurfacing import build_isosurface, BoundaryClosure
from ferreus_rbf.interpolant_config import (
    RBFKernelType,
    InterpolantSettings,
    FittingAccuracyType,
    FittingAccuracy,
)
from ferreus_rbf.progress import (
    Progress,
    SolverIteration,
    SurfacingProgress,
    DuplicatesRemoved,
    Message,
    ProgressEvent,
)


def on_progress(event: ProgressEvent) -> None:
    if isinstance(event, SolverIteration):
        print(
            f"Iteration: {event.iter:3d}  {event.residual:>.5E}  {(event.progress * 100):.1f}%"
        )
    elif isinstance(event, SurfacingProgress):
        print(
            f"Isovalue: {event.isovalue}  Stage: {event.stage}  {(event.progress * 100):.1f}%"
        )
    elif isinstance(event, DuplicatesRemoved):
        print(f"Removed duplicates: {event.num_duplicates}")
    elif isinstance(event, Message):
        print(event.message)


# Define the input paths for the example signed distance points and topography points
project_root = Path(__file__).resolve().parents[2]
datasets_path = project_root / "datasets"
pointset_path = datasets_path / "albatite_SD_points.csv"
topo_path = datasets_path / "Topo points.csv"

# Import the example points files
point_df = pd.read_csv(pointset_path)
topo_df = pd.read_csv(topo_path)

# Extract the source point coordinates and signed distance values
#
# The dataset contains 3D point coordinates in the first 3 columns, followed by
# signed distance (SD) values in the 4th column. The SD values are 0 on the surface
# boundary, -ve inside the surface and +ve outside the surface.
source_points = point_df[["X", "Y", "Z"]].to_numpy().astype(np.float64)
source_values = point_df["SignedDistance"].to_numpy().astype(np.float64)

# Extract the topography points as a 2D interpolant: XY locations with Z elevation values.
topo_points = topo_df[["X", "Y"]].to_numpy().astype(np.float64)
topo_values = topo_df["Z"].to_numpy().astype(np.float64)

# Define the RBF kernel to use
kernel_type = RBFKernelType.Linear

# Define the desired fitting accuracy
fitting_accuracy = FittingAccuracy(0.01, FittingAccuracyType.Absolute)

# Initialise an InterpolantSettings instance
interpolant_settings = InterpolantSettings(
    kernel_type, fitting_accuracy=fitting_accuracy
)

# Create a callback to receive progress updates from the RBFInterpolator
callback = Progress(callback=on_progress)

# Setup and solve the signed distance and topography RBF systems
rbfi = RBFInterpolator(
    source_points, source_values, interpolant_settings, progress_callback=callback
)
topo_rbfi = RBFInterpolator(
    topo_points, topo_values, interpolant_settings, progress_callback=callback
)

# Define the sampling grid resolution for the surfacer
resolution = 5.0

# Define the signed distance isovalue to surface before topography clipping.
isovalue = 20.0

# Define the isosurfacing extents: [minx, miny, minz, maxx, maxy, maxz].
extents = np.array(
    [
        329105.0,
        7744370.0,
        -320.0,
        329845.0,
        7745275.0,
        435.0,
    ],
    dtype=np.float64,
)

# When setting up the RBF evaluators for isosurfacing we need to add a buffer to
# the evaluator extents so we don't evaluate points outside the evaluator domains.
evaluator_padding = 10.0 * resolution

rbf_source_extents = np.hstack(
    (
        np.min(source_points, axis=0),
        np.max(source_points, axis=0),
    )
)
rbf_evaluator_extents = np.hstack(
    (
        np.minimum(rbf_source_extents[:3], extents[:3]) - evaluator_padding,
        np.maximum(rbf_source_extents[3:], extents[3:]) + evaluator_padding,
    )
)
rbfi.build_evaluator(rbf_evaluator_extents.astype(np.float64))

topo_source_extents = np.hstack(
    (
        np.min(topo_points, axis=0),
        np.max(topo_points, axis=0),
    )
)
topo_surfacing_extents = np.array(
    [extents[0], extents[1], extents[3], extents[4]],
    dtype=np.float64,
)
topo_evaluator_extents = np.hstack(
    (
        np.minimum(topo_source_extents[:2], topo_surfacing_extents[:2])
        - evaluator_padding,
        np.maximum(topo_source_extents[2:], topo_surfacing_extents[2:])
        + evaluator_padding,
    )
)
topo_rbfi.build_evaluator(topo_evaluator_extents.astype(np.float64))


def surface_fn(targets: np.ndarray) -> np.ndarray:
    # The surfacer calls this function with batches of 3D sample locations.
    # It expects one scalar value per sample. The zero contour of those returned
    # values is the surface that will be extracted.
    rbf_values = np.asarray(rbfi.evaluate_targets(targets)).ravel()

    # The topography interpolant is 2D: it maps XY locations to a topographic Z
    # elevation. For every 3D sample point, evaluate the topography directly
    # below or above it using only the sample's X and Y coordinates.
    topo_values = np.asarray(topo_rbfi.evaluate_targets(targets[:, :2])).ravel()

    # Shift the signed-distance RBF so the requested isovalue becomes zero.
    # For example, if isovalue is 20, points where the RBF evaluates to 20 now
    # return 0, values below 20 are negative, and values above 20 are positive.
    rbf_isovalue_values = rbf_values - isovalue

    # Build a second implicit field for the topography clipping plane/surface.
    # This is negative below the topography, zero on it, and positive above it.
    topo_clip_values = targets[:, 2] - topo_values

    # Taking the maximum combines the two implicit fields as an intersection:
    # the result is negative only where both fields are negative. Surfacing the
    # combined field at zero keeps the RBF isosurface only where it is below the
    # topography, and the topography itself closes the clipped portion.
    return np.maximum(rbf_isovalue_values, topo_clip_values)


start_surfacing = time.time()

# surface_fn shifts the RBF by isovalue, so the combined clipped field is surfaced at 0.
mesh = build_isosurface(
    source_points,
    extents,
    resolution,
    0.0,
    surface_fn,
    boundary_closure=BoundaryClosure.ClosePositive,
    progress_callback=callback,
)

end_surfacing = time.time()
print(f"Surfacing took {end_surfacing - start_surfacing} seconds")

# Save the isosurface out to an obj file
surface_name = f"isosurface_linear_topo_{isovalue:g}_{resolution:g}m"
outpath = Path(f"{surface_name}.obj")
mesh.save_obj(str(outpath), surface_name)

Topography-clipped isosurface: Topography-clipped RBF isosurface