Skip to content

Commit 7a6736a

Browse files
committed
Make subdivide_to_size crack-free (conformal refinement)
subdivide_to_size subdivided only the faces that had a too-long edge, using a 1->4 split via subdivide(face_index=...). When a refined face shared an edge with an unrefined neighbor, only the refined side gained the edge midpoint, leaving a T-junction. A watertight input therefore became non-watertight (a 'triangle soup', as the old docstring noted). See #1731. Replace the selective per-face subdivision with edge-based conformal refinement: each pass finds the unique edges longer than max_edge and bisects each at a single midpoint vertex shared by both adjacent faces, then splits every face with a template chosen by how many of its three edges are bisected (0/1/2/3). With two edges split, the residual quad is divided along its shorter diagonal. Because a shared edge is bisected identically from both sides, no T-junctions are created, so a watertight mesh stays watertight; faces that are already small are left untouched. This also produces fewer triangles than the old soup for non-uniform meshes (a 10x1x1 box at max_edge=1.0 goes to 656 watertight faces vs the previous 2064 cracked ones). return_index, the max_iter cap and the (vertices, faces[, index]) signature are unchanged. Add a regression test asserting watertightness, absence of T-junctions (every edge used by exactly two faces), the max_edge target, exact area and volume, and return_index correctness on meshes with non-uniform edge lengths; it fails on the previous implementation.
1 parent 791a694 commit 7a6736a

2 files changed

Lines changed: 199 additions & 85 deletions

File tree

tests/test_remesh.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,50 @@ def test_max_iter(self):
241241
r = m.subdivide_to_size(0.01, max_iter=10)
242242
assert r.is_watertight
243243

244+
def test_subdivide_to_size_crack_free(self):
245+
# subdivide_to_size must split edges shared between a refined and an
246+
# unrefined face on both sides, so a watertight mesh stays watertight
247+
# (no T-junctions). This only shows up when faces have non-uniform edge
248+
# lengths so that some faces are refined and their neighbors are not:
249+
# an axis-aligned box that is long in one dimension is the simplest case.
250+
# See https://github.com/mikedh/trimesh/issues/1731
251+
meshes = [
252+
g.trimesh.creation.box(extents=[10.0, 1.0, 1.0]),
253+
g.trimesh.creation.cylinder(radius=1.0, height=8.0),
254+
g.trimesh.creation.icosphere(subdivisions=1),
255+
]
256+
for m in meshes:
257+
assert m.is_watertight
258+
max_edge = 0.5
259+
v, f, idx = g.trimesh.remesh.subdivide_to_size(
260+
m.vertices, m.faces, max_edge=max_edge, return_index=True
261+
)
262+
s = g.trimesh.Trimesh(vertices=v, faces=f, process=False)
263+
264+
# the whole point of the fix: no cracks are introduced
265+
assert s.is_watertight
266+
assert s.is_winding_consistent
267+
# in a watertight mesh every edge is shared by exactly two faces;
268+
# a T-junction would leave an edge used by only one face
269+
assert len(s.edges) == (len(s.edges_unique) * 2)
270+
271+
# the size goal is still met and geometry is unchanged
272+
edge_len = (
273+
g.np.diff(s.vertices[s.edges_unique], axis=1).reshape((-1, 3)) ** 2
274+
).sum(axis=1) ** 0.5
275+
assert (edge_len <= max_edge + 1e-8).all()
276+
assert g.np.isclose(m.area, s.area)
277+
assert g.np.isclose(m.volume, s.volume)
278+
279+
# return_index: one parent per new face, lying on that original face
280+
assert len(idx) == len(f)
281+
assert idx.max() < len(m.faces)
282+
bary = g.trimesh.triangles.points_to_barycentric(
283+
m.triangles[idx], s.triangles.mean(axis=1)
284+
)
285+
assert bary.max() < 1 + 1e-3
286+
assert bary.min() > -1e-3
287+
244288
def test_idx_simple(self):
245289
vertices = g.np.array([0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0]).reshape((-1, 3)) * 10
246290
faces = g.np.array(

trimesh/remesh.py

Lines changed: 155 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -114,15 +114,153 @@ def subdivide(
114114
return new_vertices, new_faces
115115

116116

117+
def _subdivide_to_size_pass(vertices, faces, index, max_edge):
118+
"""
119+
Run a single crack-free refinement pass.
120+
121+
Every edge longer than `max_edge` is bisected at a *single* midpoint vertex
122+
that is shared by both faces adjacent to the edge, so neighboring faces stay
123+
in sync and no T-junctions (cracks) are introduced. Each face is then split
124+
with a template chosen by how many of its three edges are being bisected
125+
(0, 1, 2, or 3); when two edges are split the residual quad is divided along
126+
its shorter diagonal.
127+
128+
Parameters
129+
------------
130+
vertices : (n, 3) float
131+
Vertices in space
132+
faces : (m, 3) int
133+
Indices of vertices which make up triangles
134+
index : (m,) int
135+
Original face index carried by each current face
136+
max_edge : float
137+
Maximum allowed edge length
138+
139+
Returns
140+
------------
141+
vertices : (n + k, 3) float
142+
Vertices with one shared midpoint appended per bisected edge
143+
faces : (p, 3) int
144+
Refined faces
145+
index : (p,) int
146+
Original face index for each refined face
147+
changed : bool
148+
False if no edge exceeded `max_edge` (nothing to do)
149+
"""
150+
faces = np.asarray(faces, dtype=np.int64)
151+
vertices = np.asarray(vertices, dtype=np.float64)
152+
153+
# the unique edges of the mesh and the length of each
154+
edges = np.sort(faces_to_edges(faces), axis=1)
155+
unique, inverse = grouping.unique_rows(edges)
156+
unique_edges = edges[unique]
157+
lengths = ((vertices[unique_edges[:, 0]] - vertices[unique_edges[:, 1]]) ** 2).sum(
158+
axis=1
159+
) ** 0.5
160+
long_edge = lengths > max_edge
161+
162+
# every edge is already short enough: done
163+
if not long_edge.any():
164+
return vertices, faces, index, False
165+
166+
# assign one shared midpoint vertex to each long edge (-1 for edges left intact)
167+
midpoint_id = np.full(len(unique), -1, dtype=np.int64)
168+
midpoint_id[long_edge] = np.arange(int(long_edge.sum())) + len(vertices)
169+
midpoints = vertices[unique_edges[long_edge]].mean(axis=1)
170+
new_vertices = np.vstack((vertices, midpoints))
171+
172+
# midpoint id for each of the 3 edges of every face, columns (v0v1, v1v2, v2v0)
173+
face_mid = midpoint_id[inverse].reshape((-1, 3))
174+
v0, v1, v2 = faces.T
175+
m0, m1, m2 = face_mid.T
176+
split = face_mid >= 0
177+
n_split = split.sum(axis=1)
178+
179+
new_faces = []
180+
new_index = []
181+
182+
def emit(mask, *triangles):
183+
# `triangles` is a flat sequence of masked column arrays, 3 columns per
184+
# output face; reshape interleaves a face's children, so index uses repeat
185+
rows = np.nonzero(mask)[0]
186+
if len(rows) == 0:
187+
return
188+
block = np.column_stack(triangles).reshape((-1, 3))
189+
new_faces.append(block)
190+
new_index.append(np.repeat(index[rows], len(triangles) // 3))
191+
192+
def quad(mask, p, q, r, s):
193+
# split quad (p, q, r, s) (wound CCW) into two triangles along its shorter
194+
# diagonal; the two triangle blocks are stacked, so index uses tile
195+
rows = np.nonzero(mask)[0]
196+
if len(rows) == 0:
197+
return
198+
shorter_pr = (
199+
((new_vertices[p] - new_vertices[r]) ** 2).sum(axis=1)
200+
<= ((new_vertices[q] - new_vertices[s]) ** 2).sum(axis=1)
201+
)[:, None]
202+
t1 = np.where(shorter_pr, np.column_stack((p, q, r)), np.column_stack((p, q, s)))
203+
t2 = np.where(shorter_pr, np.column_stack((p, r, s)), np.column_stack((q, r, s)))
204+
new_faces.append(np.vstack((t1, t2)))
205+
new_index.append(np.tile(index[rows], 2))
206+
207+
# 0 marked edges: the face is already fine, keep it unchanged
208+
keep = n_split == 0
209+
emit(keep, v0[keep], v1[keep], v2[keep])
210+
211+
# 3 marked edges: regular 1 -> 4 split
212+
m = n_split == 3
213+
emit(
214+
m,
215+
v0[m],
216+
m0[m],
217+
m2[m],
218+
m0[m],
219+
v1[m],
220+
m1[m],
221+
m2[m],
222+
m1[m],
223+
v2[m],
224+
m0[m],
225+
m1[m],
226+
m2[m],
227+
)
228+
229+
# 1 marked edge: bisect from the edge midpoint to the opposite vertex
230+
m = (n_split == 1) & split[:, 0]
231+
emit(m, v0[m], m0[m], v2[m], m0[m], v1[m], v2[m])
232+
m = (n_split == 1) & split[:, 1]
233+
emit(m, v0[m], v1[m], m1[m], v0[m], m1[m], v2[m])
234+
m = (n_split == 1) & split[:, 2]
235+
emit(m, v0[m], v1[m], m2[m], m2[m], v1[m], v2[m])
236+
237+
# 2 marked edges: a corner triangle plus a quad split along its shorter diagonal
238+
m = (n_split == 2) & split[:, 0] & split[:, 1] # edges v0v1, v1v2
239+
emit(m, m0[m], v1[m], m1[m])
240+
quad(m, v0[m], m0[m], m1[m], v2[m])
241+
242+
m = (n_split == 2) & split[:, 1] & split[:, 2] # edges v1v2, v2v0
243+
emit(m, m2[m], m1[m], v2[m])
244+
quad(m, v0[m], v1[m], m1[m], m2[m])
245+
246+
m = (n_split == 2) & split[:, 0] & split[:, 2] # edges v0v1, v2v0
247+
emit(m, v0[m], m0[m], m2[m])
248+
quad(m, m0[m], v1[m], v2[m], m2[m])
249+
250+
new_faces = np.vstack(new_faces).astype(np.int64)
251+
new_index = np.concatenate(new_index)
252+
return new_vertices, new_faces, new_index, True
253+
254+
117255
def subdivide_to_size(vertices, faces, max_edge, max_iter=10, return_index=False):
118256
"""
119257
Subdivide a mesh until every edge is shorter than a
120258
specified length.
121259
122-
Every edge longer than `max_edge` is bisected at a single shared
123-
midpoint, so the two faces on either side stay in sync and a
124-
watertight input stays watertight no T-junctions (cracks) are
125-
introduced. Faces already small enough are left untouched.
260+
Unlike calling `subdivide` with a subset of faces, this splits edges shared
261+
between a refined and an unrefined face on *both* sides, so a watertight input
262+
stays watertight (no T-junctions / cracks are introduced). Only edges longer
263+
than `max_edge` are bisected, so faces that are already small are left intact.
126264
127265
Parameters
128266
------------
@@ -148,97 +286,29 @@ def subdivide_to_size(vertices, faces, max_edge, max_iter=10, return_index=False
148286
original face for each new face.
149287
"""
150288
# copy inputs and make sure dtype is correct
151-
vertices = np.array(vertices, dtype=np.float64, copy=True)
152-
faces = np.array(faces, dtype=np.int64, copy=True)
289+
current_vertices = np.array(vertices, dtype=np.float64, copy=True)
290+
current_faces = np.array(faces, dtype=np.int64, copy=True)
291+
153292
# map each current face back to its original face index
154-
index = np.arange(len(faces))
293+
current_index = np.arange(len(faces))
155294

295+
# loop through iteration cap, bisecting all long edges conformally each pass
156296
for i in range(max_iter + 1):
157-
# the unique edges of the current mesh and the length of each
158-
edges = np.sort(faces_to_edges(faces), axis=1)
159-
unique, inverse = grouping.unique_rows(edges)
160-
edges_unique = edges[unique]
161-
# length uses xyz only — callers may hstack extra columns (e.g. uv)
162-
lengths = (
163-
(vertices[edges_unique[:, 0], :3] - vertices[edges_unique[:, 1], :3]) ** 2
164-
).sum(axis=1) ** 0.5
165-
long_edge = lengths > max_edge
166-
167-
# every edge is short enough so we're done
168-
if not long_edge.any():
297+
current_vertices, current_faces, current_index, changed = _subdivide_to_size_pass(
298+
current_vertices, current_faces, current_index, max_edge
299+
)
300+
# every edge met the target so we're done
301+
if not changed:
169302
break
170303
# check max_iter before refining again
171304
if i >= max_iter:
172305
raise ValueError("max_iter exceeded!")
173306

174-
# one shared midpoint vertex per long edge, -1 where left intact
175-
midpoint = np.full(len(unique), -1, dtype=np.int64)
176-
midpoint[long_edge] = np.arange(int(long_edge.sum())) + len(vertices)
177-
vertices = np.vstack((vertices, vertices[edges_unique[long_edge]].mean(axis=1)))
178-
179-
# midpoint id for each face's 3 edges, columns (v0v1, v1v2, v2v0)
180-
face_mid = midpoint[inverse].reshape((-1, 3))
181-
split = face_mid >= 0
182-
# number of edges marked for bisection on each face: 0, 1, 2, or 3
183-
count = split.sum(axis=1)
184-
185-
# 0 marked edges — face passes through unchanged
186-
keep = count == 0
187-
faces_keep = faces[keep]
188-
index_keep = index[keep]
189-
190-
# 1 marked edge — rotate so the split edge is (a, b) so a single
191-
# template handles all three cases: fan the midpoint to the
192-
# opposite vertex as [a, p, c], [p, b, c]
193-
one = count == 1
194-
j = np.argmax(split[one], axis=1)
195-
r = np.arange(len(j))
196-
f1, mid1 = faces[one], face_mid[one]
197-
a, b, c = f1[r, j], f1[r, (j + 1) % 3], f1[r, (j + 2) % 3]
198-
p = mid1[r, j]
199-
faces_one = np.column_stack((a, p, c, p, b, c)).reshape((-1, 3))
200-
index_one = np.repeat(index[one], 2)
201-
202-
# 2 marked edges — rotate so the unsplit edge is (c, a), again so
203-
# one template covers all three cases: a corner triangle [p, b, q]
204-
# plus the quad (a, p, q, c) cut along its shorter diagonal
205-
two = count == 2
206-
j = (np.argmin(split[two], axis=1) + 1) % 3
207-
r = np.arange(len(j))
208-
f2, mid2 = faces[two], face_mid[two]
209-
a, b, c = f2[r, j], f2[r, (j + 1) % 3], f2[r, (j + 2) % 3]
210-
p, q = mid2[r, j], mid2[r, (j + 1) % 3]
211-
# the shorter of the two quad diagonals: a-q versus p-c (xyz only)
212-
use_aq = (
213-
((vertices[a, :3] - vertices[q, :3]) ** 2).sum(axis=1)
214-
<= ((vertices[p, :3] - vertices[c, :3]) ** 2).sum(axis=1)
215-
)[:, None]
216-
corner = np.column_stack((p, b, q))
217-
quad_a = np.where(use_aq, np.column_stack((a, p, q)), np.column_stack((a, p, c)))
218-
quad_b = np.where(use_aq, np.column_stack((a, q, c)), np.column_stack((p, q, c)))
219-
faces_two = np.vstack((corner, quad_a, quad_b))
220-
index_two = np.tile(index[two], 3)
221-
222-
# 3 marked edges — the regular 1 -> 4 split, same winding as subdivide
223-
three = count == 3
224-
f3, mid3 = faces[three], face_mid[three]
225-
v0, v1, v2 = f3.T
226-
m0, m1, m2 = mid3.T
227-
faces_three = np.column_stack(
228-
(v0, m0, m2, m0, v1, m1, m2, m1, v2, m0, m1, m2)
229-
).reshape((-1, 3))
230-
index_three = np.repeat(index[three], 4)
231-
232-
faces = np.vstack((faces_keep, faces_one, faces_two, faces_three)).astype(
233-
np.int64
234-
)
235-
index = np.concatenate((index_keep, index_one, index_two, index_three))
236-
237307
if return_index:
238-
assert len(index) == len(faces)
239-
return vertices, faces, index
308+
assert len(current_index) == len(current_faces)
309+
return current_vertices, current_faces, current_index
240310

241-
return vertices, faces
311+
return current_vertices, current_faces
242312

243313

244314
def subdivide_loop(vertices, faces, iterations=None):

0 commit comments

Comments
 (0)