Skip to content

Add wp.DualContouring and wp.IsoSurfaceBase interface for swappable isosurface extraction #1614

Description

@christophercrouzet

Summary

Add a new wp.DualContouring module for extracting polygonal meshes from 3D scalar fields, alongside the existing wp.MarchingCubes. Introduce a shared abstract base class wp.IsoSurfaceBase so callers can swap extraction backends without changing their surrounding code — the same pattern Newton uses with ViewerBase to support multiple viewer backends behind one API.

Motivation

  • Same performance as wp.MarchingCubes but with a more organic topology.

Proposed design: wp.IsoSurfaceBase

Model this after Newton's ViewerBase:

  • ViewerBase holds shared state (layers, camera, model caches) and defines the public surface area (set_model, log_state, begin_frame, …).
  • Concrete backends (UsdViewer, GL viewer, …) subclass it and implement the backend-specific hooks (end_frame, log_mesh, …).
  • Callers depend on ViewerBase, not a specific backend.

Apply the same idea to isosurface extraction:

from abc import ABC, abstractmethod

class IsoSurfaceBase(ABC):
    """Shared context for isosurface mesh extraction from a regular 3D scalar field."""

    nx: int
    ny: int
    nz: int
    domain_bounds_lower_corner: wp.vec3f | tuple | None
    domain_bounds_upper_corner: wp.vec3f | tuple | None

    verts: wp.array(dtype=wp.vec3f) | None   # populated by surface()
    indices: wp.array(dtype=wp.int32) | None  # flat triangle list (MC convention)

    def __init__(self, nx, ny, nz,
                 domain_bounds_lower_corner=None,
                 domain_bounds_upper_corner=None): ...

    def resize(self, nx: int, ny: int, nz: int) -> None: ...

    @abstractmethod
    def surface(self, field: wp.array3d(dtype=wp.float32), threshold: float) -> None:
        """Extract the iso-surface; populate verts and indices."""
        ...

    @classmethod
    @abstractmethod
    def extract(cls, field, threshold=0.0,
                domain_bounds_lower_corner=None,
                domain_bounds_upper_corner=None,
                **kwargs) -> tuple[wp.array, wp.array, ...]:
        """Stateless one-shot extraction."""
        ...

Shared contract (all backends)

Concern Contract
Input wp.array3d(dtype=wp.float32) on a regular grid; shape (nx, ny, nz) must match the context
Threshold Scalar iso-value (typically 0.0 for SDF zero level-set)
Domain scaling Same domain_bounds_lower_corner / domain_bounds_upper_corner semantics as today’s MarchingCubes
Stateful use context = Backend(nx, ny, nz); context.surface(field, threshold); verts, tris = context.verts, context.indices
Stateless use Backend.extract(field, threshold, ...)
Triangle output Flat int32 indices: each group of 3 ints references verts (compatible with wp.render, wp.Mesh, etc.)
Empty surface Return length-0 arrays, never None
Errors ValueError for wrong shape/empty field; TypeError for non-float32 dtype

Backend-specific extensions

Subclasses may expose additional outputs or tuning knobs without breaking the base contract:

  • MarchingCubes(IsoSurfaceBase) — keeps existing lookup-table class attributes (CUBE_CORNER_OFFSETS, CASE_TO_TRI_RANGE, …) and the current deprecation path for max_verts / max_tris / device.
  • DualContouring(IsoSurfaceBase) — adds native quad connectivity (quads: wp.array(dtype=wp.int32, ndim=2) with shape (num_quads, 4)). Triangle indices remain the (0,1,2)+(0,2,3) triangulation of each quad so triangle-only consumers need no changes.

Callers that only need triangles can type-hint against IsoSurfaceBase; callers that need quads use DualContouring directly.

Implementation scope

1. Refactor MarchingCubes onto IsoSurfaceBase

  • Extract shared init / resize / attribute plumbing into warp/_src/iso_surface.py (or similar).
  • Make MarchingCubes a concrete subclass; preserve the public API and deprecations unchanged.
  • Rename static extract_surface_marching_cubesMarchingCubes.extract (keep old name as a deprecated alias if needed).

2. Add DualContouring

  • New module warp/_src/dual_contouring.py.
  • Export as wp.DualContouring from warp/__init__.py.

3. Docs, tests, example

  • docs/api_reference/warp.rst — document IsoSurfaceBase, DualContouring (alongside MarchingCubes).
  • warp/tests/geometry/test_dual_contouring.py — analytic SDF ground truth; model on test_marching_cubes.py.
  • warp/examples/core/example_dual_contouring.py — port the marching-cubes example scene.
  • CHANGELOG entry under [Unreleased] / Added.

Acceptance criteria

  • wp.IsoSurfaceBase exists and documents the shared stateful/stateless contract.

  • wp.MarchingCubes subclasses IsoSurfaceBase with no breaking changes to existing call sites.

  • wp.DualContouring subclasses IsoSurfaceBase and accepts the same grid input + bounds as MarchingCubes.

  • A caller can swap backends with a one-line change:

    Extractor = wp.MarchingCubes  # or wp.DualContouring
    iso = Extractor(nx, ny, nz)
    iso.surface(field, threshold=0.0)
    render(iso.verts, iso.indices)
  • Tests pass on CPU and CUDA (TestDualContouring, existing TestMarchingCubes unchanged).

  • Example registered in test_examples.py.

Non-goals (initially)

  • Differentiable / autodiff-through-topology support (same as MarchingCubes today).
  • Replacing or removing MarchingCubes — both algorithms remain first-class.

References

Metadata

Metadata

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions