VisualLearningIdea / vli /simplify.py
kwabs22
Claude Opus 5.5
Light unlit scenes with daylight, colour assets from thumbnails, hint at the next tier
98ca16a
Raw History Blame Contribute Delete
8.11 kB
"""The simplification ladder: how far a mesh can be reduced and still read as its word.
Rungs run from the full asset down to an oriented box. The low rungs are the
ones that can be materialized (fewer faces = fewer papercraft pieces).
"""
import numpy as np
import trimesh
FACE_LEVELS = [20000, 5000, 1000, 250, 60]
def base_color(mesh):
"""Mean RGBA of a mesh's colours, or neutral grey."""
vis = getattr(mesh, "visual", None)
# an uncoloured mesh still reports trimesh's default dark grey, so ask whether colours are defined
if isinstance(vis, trimesh.visual.ColorVisuals) and vis.defined:
try:
cols = vis.vertex_colors if vis.kind == "vertex" else vis.face_colors
if cols is not None and len(cols):
return np.asarray(cols, dtype=np.float64).mean(axis=0).astype(np.uint8)
except Exception:
pass
return np.array([180, 180, 180, 255], dtype=np.uint8)
def _welded(mesh):
"""Geometry only, with coincident vertices merged, in fresh writable arrays.
Asset files split vertices at UV and normal seams; the decimator can't collapse
edges that aren't shared, so an unwelded mesh stalls far above its target.
fast-simplification also rejects read-only arrays (cached views), hence the copies.
"""
m = trimesh.Trimesh(np.array(mesh.vertices, dtype=np.float64), np.array(mesh.faces, dtype=np.int64))
return trimesh.Trimesh(np.array(m.vertices, dtype=np.float64), np.array(m.faces, dtype=np.int64),
process=False)
def _clustered(mesh, cell):
"""Vertex clustering: snap vertices to a grid of `cell` metres and merge each cell."""
v, f = np.asarray(mesh.vertices, dtype=np.float64), np.asarray(mesh.faces, dtype=np.int64)
inv = np.unique(np.floor((v - v.min(axis=0)) / cell).astype(np.int64), axis=0, return_inverse=True)[1].ravel()
nv = np.zeros((inv.max() + 1, 3))
np.add.at(nv, inv, v)
nv /= np.bincount(inv)[:, None]
nf = inv[f]
nf = nf[(nf[:, 0] != nf[:, 1]) & (nf[:, 1] != nf[:, 2]) & (nf[:, 0] != nf[:, 2])]
return trimesh.Trimesh(nv, nf, process=False)
def _cluster_to(mesh, faces):
"""The finest grid that gets to `faces` or fewer (log-scale bisection on the cell size)."""
ext = float(mesh.extents.max()) or 1.0
lo, hi, best = ext / 2000, ext / 2, None
for _ in range(20):
cell = (lo * hi) ** 0.5
m = _clustered(mesh, cell)
if len(m.faces) > faces:
lo = cell
else:
hi, best = cell, m
return best
def decimate(mesh, faces):
"""Reduce toward `faces`, re-welding between passes while that still helps.
Quadric decimation stalls on some CAD meshes (long sliver triangles: a SimReady
lamp stopped at ~21k faces whatever the settings). Then vertex clustering takes
over: cruder, but it always reduces and keeps thin parts that quadric drops.
"""
color = base_color(mesh)
out = _welded(mesh)
while len(out.faces) > faces:
nxt = _welded(out.simplify_quadric_decimation(face_count=faces))
if len(nxt.faces) > 0.95 * len(out.faces):
break
out = nxt
if len(out.faces) > faces:
out = _cluster_to(out, faces) or out
out.visual = trimesh.visual.ColorVisuals(out, face_colors=np.tile(color, (len(out.faces), 1)))
return out
def _recolor(mesh, color):
mesh.visual = trimesh.visual.ColorVisuals(mesh, face_colors=np.tile(color, (len(mesh.faces), 1)))
return mesh
def _ray_hits(center, dirs, a, b):
"""Farthest hit of each 2D ray (center + t*dir) on segments a->b; nan where a ray misses."""
e, w = b - a, a - center
denom = dirs[:, None, 0] * e[None, :, 1] - dirs[:, None, 1] * e[None, :, 0]
with np.errstate(divide="ignore", invalid="ignore"):
t = (w[:, 0] * e[:, 1] - w[:, 1] * e[:, 0])[None] / denom
s = (w[:, 0] * dirs[:, None, 1] - w[:, 1] * dirs[:, None, 0]) / denom
far = np.where((np.abs(denom) > 1e-12) & (t >= 0) & (s >= 0) & (s <= 1), t, -np.inf).max(axis=1)
return np.where(np.isfinite(far), far, np.nan)
def rings(mesh, count=8, samples=16):
"""Planar circumferences: `count` horizontal sections, each sampled at `samples` angles.
Each ring is the section's outer outline seen from its own centre: the farthest
crossing along `samples` evenly spaced rays. Neighbouring rings join into bands,
so every band is one paper strip and every vertex is (ring, angle). Cruder than
decimation, but each number can be checked by hand. Returns (mesh, profile).
"""
lo, hi = mesh.bounds[:, 1]
inset = 0.01 * (hi - lo)
ang = np.linspace(0, 2 * np.pi, samples, endpoint=False)
dirs = np.c_[np.cos(ang), np.sin(ang)] # in the (x, z) plane
profile = []
for y in np.linspace(lo + inset, hi - inset, count):
seg = trimesh.intersections.mesh_plane(mesh, [0, 1, 0], [0, y, 0])
if len(seg) == 0:
continue # a gap between parts at this height
a, b = seg[:, 0][:, [0, 2]], seg[:, 1][:, [0, 2]]
pts = np.r_[a, b]
center = (pts.min(axis=0) + pts.max(axis=0)) / 2
r = _ray_hits(center, dirs, a, b)
hit = ~np.isnan(r)
if not hit.any():
continue
r = np.interp(ang, ang[hit], r[hit], period=2 * np.pi) # fill rays that found nothing
profile.append({"y": round(float(y), 3), "center": [round(float(c), 3) for c in center],
"radii": [round(float(x), 3) for x in r]})
if len(profile) < 2:
return None, profile
verts = [[p["center"][0] + rr * dx, p["y"], p["center"][1] + rr * dz]
for p in profile for rr, (dx, dz) in zip(p["radii"], dirs)]
n, faces = len(profile), []
for i in range(n - 1):
for j in range(samples):
v00, v01 = i * samples + j, i * samples + (j + 1) % samples
faces += [[v00, v00 + samples, v01 + samples], [v00, v01 + samples, v01]]
for k, p in ((0, profile[0]), (n - 1, profile[-1])): # flat caps: fan to the ring centre
c = len(verts)
verts.append([p["center"][0], p["y"], p["center"][1]])
faces += [[c, k * samples + (j + 1) % samples, k * samples + j] for j in range(samples)]
out = trimesh.Trimesh(np.array(verts), np.array(faces), process=False)
if out.volume < 0:
out.invert()
return out, profile
def _collapsed(rung, original, prev):
"""A rung that shrank in space, not just in faces: under half the original's size,
or under half the previous decimated rung's area. The original is left out of the
area test, since welding away double-sided faces can halve area legitimately.
"""
if rung.extents.max() < 0.5 * original.extents.max():
return True
return prev is not None and rung.area < 0.5 * prev.area
def ladder(mesh, ring_count=8, ring_samples=16):
color = base_color(mesh)
rungs = [("original", mesh)]
for n in FACE_LEVELS:
prev = len(rungs[-1][1].faces)
if n >= 0.8 * prev:
continue
rung = decimate(mesh, n)
if len(rung.faces) > 0.8 * prev:
break # decimation stalled: lower levels would repeat this mesh
if _collapsed(rung, mesh, rungs[-1][1] if len(rungs) > 1 else None):
break # a degenerate blob would pass for a rung; lower levels only get worse
rungs.append((f"{len(rung.faces)} faces", rung))
ringed, profile = rings(mesh, ring_count, ring_samples)
if ringed is not None:
ringed.metadata["rings"] = profile
rungs.append((f"rings {len(profile)}x{ring_samples}", _recolor(ringed, color)))
rungs.append(("convex hull", _recolor(mesh.convex_hull, color)))
box = mesh.bounding_box_oriented
rungs.append(("bounding box", _recolor(trimesh.Trimesh(box.vertices, box.faces), color)))
return rungs
def stats(mesh):
return {"faces": int(len(mesh.faces)), "vertices": int(len(mesh.vertices)),
"watertight": bool(mesh.is_watertight), "convex": bool(mesh.is_convex),
"surface_area_m2": round(float(mesh.area), 4)}