Load a mesh

Load an OBJ file or build a Mesh from tensors, control recentering and scale, choose a fill mode, read topology diagnostics, and use CUDA meshes.

Mesh is an explicit triangle mesh. You build it from vertex and face arrays, or load it from a Wavefront OBJ file. It is the geometry type for scanned rooms, CAD exports and anything trainable at the vertex level.

Load an OBJ file#

Mesh.from_obj(path, ...) reads v and f records, triangulates polygons as fans and ignores everything else (normals, texture coordinates, groups, materials). This example writes a unit cube first so that it runs on its own:

import pathlib
import tempfile
 
from witwin.core import Mesh
 
cube_obj = """
v 0 0 0
v 1 0 0
v 1 1 0
v 0 1 0
v 0 0 1
v 1 0 1
v 1 1 1
v 0 1 1
f 1 3 2
f 1 4 3
f 5 6 7
f 5 7 8
f 1 5 8
f 1 8 4
f 2 3 7
f 2 7 6
f 1 2 6
f 1 6 5
f 4 8 7
f 4 7 3
"""
path = pathlib.Path(tempfile.mkdtemp()) / "cube.obj"
path.write_text(cube_obj)
 
cube = Mesh.from_obj(path, position=(0.0, 0.0, 1.0))
print(cube.vertex_count, cube.face_count, cube.is_watertight)
print(cube.bounds_world)
8 12 True
tensor([[-0.5000, -0.5000,  0.5000],
        [ 0.5000,  0.5000,  1.5000]])

The cube was authored from 0 to 1 but is centred on position, because recenter=True by default shifts the bounding-box centre to the local origin. Pass recenter=False to keep the authored coordinates.

from_obj raises FileNotFoundError for a missing file and ValueError for a file without vertices or triangles. It always runs topology diagnostics.

Build a mesh from arrays#

Pass vertices (N, 3) and faces (M, 3). Faces must be integers (int32 or int64).

import torch
 
vertices = torch.tensor([[0.0, 0.0, 0.0], [4.0, 0.0, 0.0], [4.0, 0.0, 3.0], [0.0, 0.0, 3.0]])
faces = torch.tensor([[0, 1, 2], [0, 2, 3]])
wall = Mesh(vertices, faces, recenter=False, fill_mode="surface")
print(wall.vertices is vertices, wall.is_watertight)
True None

A tensor is stored as is (same object, dtype and device). is_watertight is None here because a tensor-authored mesh with an explicit fill mode skips topology diagnostics; see Read topology diagnostics. Python lists and NumPy arrays become float32 vertices and int64 faces.

Transform a mesh#

position, rotation and scale place the mesh. scale is a number or a 3-vector. The world vertices are (local * scale) @ R.T + position:

scaled = Mesh(vertices, faces, scale=(0.5, 1.0, 1.0), position=(1.0, 2.0, 0.0), recenter=False, fill_mode="surface")
print(scaled.world_vertices[1])
tensor([3., 2., 0.])

world_vertices, bounds_world (shape (2, 3): minimum row, then maximum row) and to_mesh() all keep autograd. to_mesh(device=...) returns the mesh on another device.

Choose a fill mode#

fill_mode tells distance queries whether the mesh encloses a volume:

fill_modeMeaningSigned distance
"solid"The mesh is a closed surface around a volume.Unsigned distance with a winding-number sign.
"surface"The mesh is a thin shell, such as a wall or a sheet.Unsigned distance minus a band half-width.
"auto" (default)Decide from the topology diagnostics."solid" when the mesh is watertight, "surface" otherwise.

For "surface", the band is surface_thickness if you give it, otherwise a quarter of the smallest grid spacing (or 1 mm without a grid). surface_thickness may be a tensor that you train.

x = torch.tensor([0.5, 2.0])                    # 1-D axes: Mesh builds the ij grid for you
y = torch.tensor([0.0])
z = torch.tensor([1.5])
thin = Mesh(vertices, faces, recenter=False, fill_mode="surface", surface_thickness=0.1)
print(thin.signed_distance(x, y, z).flatten())
tensor([-0.1000, -0.1000])

Both points lie on the wall plane, so they are 0.1 m inside the band.

Read topology diagnostics#

When diagnostics run, the mesh reports what it found:

print(cube.boundary_edge_count, cube.non_manifold_edge_count, cube.degenerate_face_count,
      cube.inconsistent_edge_orientation_count, cube.signed_volume)
0 0 0 0 1.0

A mesh is watertight (is_watertight) when it has no boundary edges, no non-manifold edges, no degenerate faces, no inconsistently wound edges and a non-zero enclosed volume. A negative signed_volume means the faces are wound inward.

Diagnostics run when:

  • the vertices and faces are not tensors, or
  • you pass topology_diagnostics=True, or
  • fill_mode="auto" and both tensors are on the CPU.

Otherwise the counts and is_watertight are None.

Use CUDA meshes#

Put vertices and faces on the GPU before you build the mesh. Core never moves them and never copies CUDA geometry to the host, so it cannot run diagnostics on it. That means fill_mode="auto" cannot decide; choose the mode yourself:

if torch.cuda.is_available():
    gpu_wall = Mesh(vertices.cuda(), faces.cuda(), recenter=False, fill_mode="surface")
    origin = torch.zeros(1, device="cuda")
    print(gpu_wall.signed_distance(origin, origin, origin).flatten())
tensor([-0.0010], device='cuda:0')

With fill_mode="auto", a CUDA mesh can still be put in a scene and meshed, but signed_distance raises RuntimeError (Mesh topology is unknown; ...).

Edit vertices in place#

You may update vertex tensors in place (for example in an optimizer step). Two rules apply:

  • If diagnostics ran on a tensor-authored mesh, an in-place change to vertices or faces makes them stale. The next signed_distance raises RuntimeError (Mesh topology diagnostics are stale ...). Build a new Mesh, or use fill_mode="solid" or "surface" with topology_diagnostics=False.
  • A mesh caches query data for non-trainable geometry. The cache key includes each tensor's modification counter, so in-place edits invalidate it. clear_sdf_cache() empties it by hand.

has_trainable_geometry() returns True when any of position, rotation, scale, vertices or surface_thickness requires a gradient. Trainable meshes are never cached.