Download remesh.py from Upsampler/remesh: direct link, hf CLI and curl.
- Browser
- Download file 22.5 kB
-
https://huggingface.co/spaces/Upsampler/remesh/resolve/main/remesh.py
- Command line
-
hf download hf://spaces/Upsampler/remesh/remesh.py
-
curl -L -o remesh.py https://huggingface.co/spaces/Upsampler/remesh/resolve/main/remesh.py
22.5 kB
| """ | |
| Remesh a model: new clean topology (quads or even triangles) at a target face | |
| count, fresh UVs, and the original's look baked onto it. | |
| solid.py one sealed surface around the input, whatever its state, | |
| with parts thinner than a face widened to one face | |
| QuadriFlow quad remeshing of that surface (Instant Meshes if it fails) | |
| topology.py the result closed up: no holes, shards or tangled edges | |
| xatlas UVs for the new mesh | |
| bake per texel, the nearest point of the original: base colour, | |
| metallic/roughness, and a normal map carrying the detail the | |
| new mesh is too coarse to hold, in the tangents stored in the | |
| file so every renderer shades it the way it was baked | |
| """ | |
| import os | |
| import time | |
| import numpy as np | |
| import trimesh | |
| from PIL import Image | |
| from scipy import ndimage | |
| import native | |
| from glb import write_glb, write_obj | |
| from solid import grid_surface, near, solid_grid, thicken | |
| from topology import close_up, triangles, trouble | |
| def sniff(path): | |
| """The format from the file itself, for uploads that lost their name on the way.""" | |
| with open(path, "rb") as f: | |
| head = f.read(512) | |
| if head[:4] == b"glTF": | |
| return "glb" | |
| if head.startswith(b"ply"): | |
| return "ply" | |
| if head.lstrip().startswith(b"{"): | |
| return "gltf" | |
| text = head.decode("latin-1").lstrip() | |
| if text.startswith("solid") and b"facet" in head: | |
| return "stl" | |
| if any(line.split()[:1] in (["v"], ["vn"], ["vt"], ["f"], ["o"], ["g"], ["mtllib"], ["#"]) for line in text.splitlines() if line.strip()): | |
| return "obj" | |
| return "stl" # binary STL has no magic; it is the one format left | |
| # STL and PLY come from CAD, slicers and scanners, which are Z-up; glTF and | |
| # OBJ are Y-up. The results are a GLB and an OBJ, so a Z-up model is turned | |
| # upright on the way in: (x, y, z) -> (x, z, -y), the quarter turn about X that | |
| # the site's viewer and converters make (src/lib/mesh/axes.ts). | |
| Z_UP = ("stl", "ply") | |
| Z_UP_TO_Y_UP = np.array([[1, 0, 0, 0], [0, 0, 1, 0], [0, -1, 0, 0], [0, 0, 0, 1]], dtype=np.float64) | |
| def load(path): | |
| extension = os.path.splitext(path)[1].lower().lstrip(".") | |
| known = extension in ("glb", "gltf", "obj", "stl", "ply") | |
| kind = extension if known else sniff(path) | |
| scene = trimesh.load(path, force="scene") if known else trimesh.load(path, file_type=kind, force="scene") | |
| if len(scene.geometry) == 0: | |
| raise ValueError("The file has no mesh in it.") | |
| mesh = scene.to_geometry() if len(scene.geometry) == 1 else scene.dump(concatenate=True) | |
| if kind in Z_UP: | |
| mesh.apply_transform(Z_UP_TO_Y_UP) | |
| return mesh | |
| # Auto: quad counts tried in turn, and how far the new surface may stray from | |
| # the sealed one (99th percentile, as a share of the model's largest side). | |
| # At 0.4% the shape, faces included, reads as the same object; measured on generated | |
| # and scanned models, the count that takes ranges from a few thousand (a barrel) | |
| # to tens of thousands (a race car's thin spoilers), so no single default fits. | |
| AUTO_LADDER = [2000, 3500, 6000, 10000, 15000, 25000, 40000] | |
| AUTO_TOLERANCE = 0.004 | |
| # A few small holes are filled cleanly; more corners than this on open or | |
| # tangled edges means a thin part came apart, and it is remeshed widened. | |
| TROUBLE = 24 | |
| class Sealed: | |
| """The model filled into voxels, and sealed surfaces made from it. | |
| Every surface is one closed piece whatever the input was, and sheets down | |
| to a voxel thick are kept (the smoothing would otherwise erase them). | |
| """ | |
| def __init__(self, mesh): | |
| self.grid, self.h, self.lo = solid_grid(np.asarray(mesh.vertices, dtype=np.float64), np.asarray(mesh.faces)) | |
| self.grid = thicken(self.grid, 1.0) | |
| self.v, self.f = grid_surface(self.grid, self.h, self.lo) | |
| # The reference the result is measured against. | |
| reference = trimesh.Trimesh(self.v, self.f, process=False) | |
| self.area = float(reference.area) | |
| self.size = float((reference.bounds[1] - reference.bounds[0]).max()) | |
| # Seeded, so a model gets the same count every time it is run. | |
| self.points, _ = trimesh.sample.sample_surface(reference, 10000, seed=0) | |
| def widened(self, quads, spots): | |
| """The sealed surface with thin parts near `spots` made one face of `quads` thick. | |
| No remesher can lay faces on both sides of a sheet thinner than its | |
| edges: the sides snap together and leave holes and shards. Widening | |
| only where that happened leaves sharp edges elsewhere as they are. | |
| """ | |
| edge = np.sqrt(self.area / max(quads, 1)) | |
| where = near(self.grid.shape, self.h, self.lo, spots, 3 * edge) | |
| return grid_surface(thicken(self.grid, max(1.5, 0.5 * edge / self.h), where), self.h, self.lo) | |
| def deviation(self, v, tris): | |
| """99th percentile distance from the sealed model to (v, tris), as a share of the largest side.""" | |
| return float(np.percentile(point_distances(self.points, v, tris), 99)) / self.size | |
| def quad_mesh(sealed_v, sealed_f, quads): | |
| """About `quads` quads over a sealed surface: QuadriFlow, another seed, then Instant Meshes.""" | |
| for seed in (0, 1): | |
| try: | |
| return native.quadriflow_remesh(sealed_v, sealed_f, quads, seed=seed) | |
| except RuntimeError as error: | |
| print(f"QuadriFlow, seed {seed}: {error}", flush=True) | |
| return native.instant_meshes(sealed_v, sealed_f, quads) | |
| def build(sealed, quads, as_quads): | |
| """One new surface of about `quads` quads: (vertices, polygons, triangles). | |
| The result is closed and edge-manifold: tangled faces and loose shards the | |
| remesher leaves are removed and every hole is filled. As triangles, each | |
| quad is split along its shorter diagonal. | |
| """ | |
| v, polys = quad_mesh(sealed.v, sealed.f, quads) | |
| spots = trouble(v, polys) | |
| print(f"{quads} quads: {len(spots)} trouble spots", flush=True) | |
| if len(spots) > TROUBLE: | |
| # Thin parts came out broken: widen them to one face there and run again, | |
| # and keep whichever run, closed up, stays nearer the model. | |
| again_v, again_polys = quad_mesh(*sealed.widened(quads, spots), quads) | |
| first, second = close_up(v, polys), close_up(again_v, again_polys) | |
| pick = min((first, second), key=lambda c: sealed.deviation(c[0], triangles(c[1], c[0]))) | |
| v, polys = pick[0], pick[1] | |
| v, polys, _ = close_up(v, polys) | |
| tris = triangles(polys, v) | |
| # Normals out: the enclosed volume must come out positive. | |
| a, b, c = v[tris[:, 0]], v[tris[:, 1]], v[tris[:, 2]] | |
| if np.einsum("ij,ij->i", a, np.cross(b, c)).sum() < 0: | |
| polys, tris = polys[:, ::-1].copy(), tris[:, ::-1].copy() | |
| if not as_quads: | |
| polys = tris | |
| return v, polys, tris | |
| def new_surface(mesh, target_faces, quads=True, note=None): | |
| """Clean topology at about `target_faces`, from a sealed rebuild of the input. | |
| QuadriFlow needs a manifold input and generated meshes rarely are, so it | |
| remeshes the sealed surface instead. The look is baked from the original | |
| afterwards, so nothing of it is lost here. | |
| A target of 0 is Auto: the fewest faces on AUTO_LADDER whose surface stays | |
| within AUTO_TOLERANCE of the sealed one. Returns the surface and, for Auto, | |
| what was found: {"deviation": share of the size, "held": within tolerance}. | |
| """ | |
| sealed = Sealed(mesh) | |
| # Triangles come from splitting quads, two to a quad. | |
| per_quad = 1 if quads else 2 | |
| if target_faces <= 0: | |
| return auto_surface(sealed, quads, note) | |
| want = max(50, int(target_faces / per_quad)) | |
| # QuadriFlow lands a few percent under the count it is given. | |
| ask = int(want * 1.04) | |
| for _ in range(2): | |
| v, polys, tris = build(sealed, ask, quads) | |
| got = len(polys) / per_quad | |
| if abs(got - want) / want < 0.12: | |
| break | |
| ask = max(50, int(ask * want / max(got, 1))) | |
| return v, polys, tris, None | |
| def auto_surface(sealed, quads, note=None): | |
| """The fewest quads on AUTO_LADDER that keep the shape. | |
| Each try is a full remesh, so the ladder is not walked from the bottom: | |
| the first try is in the middle, and how far it strays says where the | |
| count lies (the error shrinks about as the count grows to the power 0.75). | |
| From there it steps down while the shape holds, or up until it does. | |
| """ | |
| # Never more quads than the sealed surface has triangles over four: that would add, not clean. | |
| most = max(AUTO_LADDER[0], len(sealed.f) // 4) | |
| ladder = [count for count in AUTO_LADDER if count <= most] or AUTO_LADDER[:1] | |
| tried = {} | |
| def attempt(i): | |
| if i not in tried: | |
| started = time.perf_counter() | |
| if note: | |
| # Each try can take a while: say which one is running. | |
| note(f"Trying {ladder[i]:,} {'quads' if quads else 'triangles'}") | |
| v, polys, tris = build(sealed, ladder[i], quads) | |
| tried[i] = (sealed.deviation(v, tris), v, polys, tris) | |
| print(f"Auto: {ladder[i]} quads, {tried[i][0]:.4f} of the size, {time.perf_counter() - started:.0f} s", flush=True) | |
| return tried[i][0] <= AUTO_TOLERANCE | |
| i = min(range(len(ladder)), key=lambda k: abs(ladder[k] - 6000)) | |
| if attempt(i): | |
| while i > 0 and attempt(i - 1): | |
| i -= 1 | |
| else: | |
| guess = ladder[i] * (tried[i][0] / AUTO_TOLERANCE) ** (1 / 0.75) | |
| i = next((k for k in range(i + 1, len(ladder)) if ladder[k] >= 0.9 * guess), len(ladder) - 1) | |
| if attempt(i): | |
| # A jump may overshoot: take the step below if it holds too. | |
| while i > 0 and i - 1 not in tried and attempt(i - 1): | |
| i -= 1 | |
| else: | |
| while i + 1 < len(ladder) and not attempt(i + 1): | |
| i += 1 | |
| i = min(i + 1, len(ladder) - 1) | |
| deviation, v, polys, tris = tried[i] | |
| return v, polys, tris, {"deviation": round(deviation, 4), "held": deviation <= AUTO_TOLERANCE} | |
| def point_distances(points, v, tris): | |
| """Distance from each point to the nearest point of the mesh (v, tris).""" | |
| distance, _, _ = native.closest_points_on_mesh(points, v, tris) | |
| return distance | |
| def vertex_tangents(positions, normals, uvs, faces): | |
| """Per-vertex tangents with handedness, averaged over the triangles around each vertex.""" | |
| p0, p1, p2 = (positions[faces[:, k]] for k in range(3)) | |
| u0, u1, u2 = (uvs[faces[:, k]] for k in range(3)) | |
| e1, e2, d1, d2 = p1 - p0, p2 - p0, u1 - u0, u2 - u0 | |
| det = d1[:, 0] * d2[:, 1] - d2[:, 0] * d1[:, 1] | |
| r = np.where(np.abs(det) < 1e-14, 0.0, 1.0 / np.where(det == 0, 1, det))[:, None] | |
| t_face = (e1 * d2[:, 1:2] - e2 * d1[:, 1:2]) * r | |
| b_face = (e2 * d1[:, 0:1] - e1 * d2[:, 0:1]) * r | |
| t = np.zeros_like(positions) | |
| b = np.zeros_like(positions) | |
| for k in range(3): | |
| np.add.at(t, faces[:, k], t_face) | |
| np.add.at(b, faces[:, k], b_face) | |
| t -= normals * np.sum(t * normals, axis=1, keepdims=True) | |
| length = np.linalg.norm(t, axis=1, keepdims=True) | |
| # A vertex whose UVs give no direction gets any vector perpendicular to its normal. | |
| fallback = np.cross(normals, np.where(np.abs(normals[:, :1]) < 0.9, [[1.0, 0, 0]], [[0, 1.0, 0]])) | |
| fallback /= np.maximum(np.linalg.norm(fallback, axis=1, keepdims=True), 1e-12) | |
| t = np.where(length > 1e-12, t / np.maximum(length, 1e-12), fallback) | |
| w = np.where(np.sum(np.cross(normals, t) * b, axis=1) < 0, -1.0, 1.0) | |
| return np.concatenate([t, w[:, None]], axis=1) | |
| # Candidate texels tested at a time: every texel in each triangle's bounding box | |
| # passes through a dozen arrays, so a few large triangles on a 4096 px map | |
| # would otherwise be gigabytes in one go. | |
| RASTER_TEXELS = 1_500_000 | |
| def rasterize(uvs, faces, size, budget=RASTER_TEXELS): | |
| """Every texel centre inside a UV triangle, as (row, col, triangle, barycentrics).""" | |
| px = np.stack([uvs[:, 0] * size, (1 - uvs[:, 1]) * size], axis=1) | |
| pa, pb, pc = px[faces[:, 0]], px[faces[:, 1]], px[faces[:, 2]] | |
| lo = np.clip(np.floor(np.minimum(np.minimum(pa, pb), pc)), 0, size - 1).astype(np.int64) | |
| hi = np.clip(np.ceil(np.maximum(np.maximum(pa, pb), pc)), 0, size - 1).astype(np.int64) | |
| wx, wy = hi[:, 0] - lo[:, 0] + 1, hi[:, 1] - lo[:, 1] + 1 | |
| ends = np.cumsum(wx * wy) | |
| out = ([], [], [], []) | |
| start = 0 | |
| while start < len(faces): | |
| # As many triangles as fit the budget, and always at least one. | |
| before = int(ends[start - 1]) if start else 0 | |
| stop = max(start + 1, int(np.searchsorted(ends, before + budget, side="right"))) | |
| part = slice(start, stop) | |
| a, b, c, low, width = pa[part], pb[part], pc[part], lo[part], wx[part] | |
| # intp: np.repeat takes nothing wider, and under Pyodide (32-bit) that is int32. | |
| counts = (width * wy[part]).astype(np.intp) | |
| tri = np.repeat(np.arange(stop - start), counts) | |
| k = np.arange(int(counts.sum())) - np.repeat(np.cumsum(counts) - counts, counts) | |
| x = low[tri, 0] + k % width[tri] | |
| y = low[tri, 1] + k // width[tri] | |
| p = np.stack([x + 0.5, y + 0.5], axis=1) | |
| v0, v1, v2 = b[tri] - a[tri], c[tri] - a[tri], p - a[tri] | |
| d = v0[:, 0] * v1[:, 1] - v1[:, 0] * v0[:, 1] | |
| ok = np.abs(d) > 1e-12 | |
| d = np.where(ok, d, 1) | |
| w1 = (v2[:, 0] * v1[:, 1] - v1[:, 0] * v2[:, 1]) / d | |
| w2 = (v0[:, 0] * v2[:, 1] - v2[:, 0] * v0[:, 1]) / d | |
| w0 = 1 - w1 - w2 | |
| inside = ok & (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4) | |
| # Compact types: at 4096 px there are over ten million texels. | |
| out[0].append(y[inside].astype(np.int32)) | |
| out[1].append(x[inside].astype(np.int32)) | |
| out[2].append((tri[inside] + start).astype(np.int32)) | |
| out[3].append(np.stack([w0[inside], w1[inside], w2[inside]], axis=1).astype(np.float32)) | |
| start = stop | |
| return tuple(np.concatenate(part) for part in out) | |
| def sample(image, uvs): | |
| """Bilinear lookup; uvs with v up.""" | |
| h, w = image.shape[:2] | |
| x = np.clip(uvs[:, 0] % 1.0 * w - 0.5, 0, w - 1) | |
| y = np.clip((1 - uvs[:, 1] % 1.0) * h - 0.5, 0, h - 1) | |
| x0, y0 = np.floor(x).astype(int), np.floor(y).astype(int) | |
| x1, y1 = np.minimum(x0 + 1, w - 1), np.minimum(y0 + 1, h - 1) | |
| fx, fy = (x - x0)[:, None], (y - y0)[:, None] | |
| fx, fy = fx.astype(np.float32), fy.astype(np.float32) | |
| px = lambda yy, xx: image[yy, xx].astype(np.float32) # noqa: E731 - the image stays 8-bit; only the texels asked for are widened | |
| return ((px(y0, x0) * (1 - fx) + px(y0, x1) * fx) * (1 - fy) + (px(y1, x0) * (1 - fx) + px(y1, x1) * fx) * fy) / 255 | |
| def seam_fill(mask, reach=16): | |
| """For every texel within `reach` of a baked one, the index of the nearest baked texel. | |
| Filling the empty space around UV islands with their edge colour keeps | |
| texture filtering at a seam from mixing in the background. One distance | |
| transform serves every map. | |
| """ | |
| distance, (iy, ix) = ndimage.distance_transform_edt(~mask, return_indices=True) | |
| near = ~mask & (distance <= reach) | |
| return near, iy[near], ix[near] | |
| def source_look(high): | |
| """What the original looks like: colour and metallic/roughness as lookups by (face, barycentrics).""" | |
| visual = high.visual | |
| material = getattr(visual, "material", None) | |
| uv = getattr(visual, "uv", None) | |
| color_image = getattr(material, "baseColorTexture", None) if material is not None else None | |
| if color_image is None and material is not None: | |
| color_image = getattr(material, "image", None) | |
| mr_image = getattr(material, "metallicRoughnessTexture", None) if material is not None else None | |
| factor = np.array(getattr(material, "baseColorFactor", None) if material is not None and getattr(material, "baseColorFactor", None) is not None else [255, 255, 255, 255], dtype=np.float64) | |
| factor = factor / 255 if factor.max() > 1 else factor | |
| color_px = np.asarray(color_image.convert("RGBA")) if color_image is not None and uv is not None else None | |
| mr_px = np.asarray(mr_image.convert("RGB")) if mr_image is not None and uv is not None else None | |
| vertex_colors = None | |
| if color_px is None and hasattr(visual, "vertex_colors") and getattr(visual, "kind", None) == "vertex": | |
| vertex_colors = np.asarray(visual.vertex_colors, dtype=np.float64) / 255 | |
| def lookup(face, bc): | |
| corners = high.faces[face] | |
| # A model may carry a metallic/roughness texture without a colour one. | |
| at = np.einsum("nk,nkd->nd", bc, uv[corners]) if color_px is not None or mr_px is not None else None | |
| if color_px is not None: | |
| color = sample(color_px, at) * factor | |
| elif vertex_colors is not None: | |
| color = np.einsum("nk,nkd->nd", bc, vertex_colors[corners]) | |
| else: | |
| color = np.tile(factor, (len(face), 1)) | |
| return color, sample(mr_px, at) if mr_px is not None else None | |
| return lookup, mr_px is not None | |
| # Texels baked at a time. Each one passes through some fifty numbers on the way | |
| # (its point, frame, nearest source point, colour), so all of a 4096 px map at | |
| # once is several gigabytes: more than a browser tab's Python can have at all. | |
| BAKE_CHUNK = 400_000 | |
| def to_bytes(values): | |
| return (np.clip(values, 0, 1) * 255 + 0.5).astype(np.uint8) | |
| def bake(positions, normals, tangents, uvs, faces, high, size, step=None): | |
| rows, cols, tri, bary = rasterize(uvs, faces, size) | |
| lookup, has_mr = source_look(high) | |
| high_v = np.asarray(high.vertices, dtype=np.float64) | |
| high_f = np.asarray(high.faces) | |
| high_normals = np.asarray(high.vertex_normals) | |
| # The maps as they are stored, 8 bits a channel, filled chunk by chunk. | |
| color_map = np.zeros((size, size, 4), dtype=np.uint8) | |
| color_map[..., 3] = 255 | |
| normal_map = np.empty((size, size, 3), dtype=np.uint8) | |
| normal_map[:] = (128, 128, 255) | |
| mr_map = None | |
| if has_mr: | |
| mr_map = np.zeros((size, size, 3), dtype=np.uint8) | |
| mr_map[..., 1] = 255 | |
| for start in range(0, len(tri), BAKE_CHUNK): | |
| # Said per chunk: a large map takes a while, and silence reads as stuck. | |
| if step: | |
| step(0.5 + 0.4 * start / len(tri), "Baking textures from the original") | |
| part = slice(start, start + BAKE_CHUNK) | |
| y, x, b = rows[part], cols[part], bary[part].astype(np.float64) | |
| corners = faces[tri[part]] | |
| point = np.einsum("nk,nkd->nd", b, positions[corners]) | |
| n = np.einsum("nk,nkd->nd", b, normals[corners]) | |
| n /= np.linalg.norm(n, axis=1, keepdims=True) + 1e-12 | |
| t = np.einsum("nk,nkd->nd", b, tangents[corners, :3]) | |
| t -= n * np.sum(t * n, axis=1, keepdims=True) | |
| t /= np.linalg.norm(t, axis=1, keepdims=True) + 1e-12 | |
| bt = np.cross(n, t) * tangents[corners[:, 0], 3:4] | |
| # The original surface point nearest to each texel. | |
| _, face, bc = native.closest_points_on_mesh(point, high_v, high_f) | |
| color, mr = lookup(face, bc) | |
| high_n = np.einsum("nk,nkd->nd", bc, high_normals[high_f[face]]) | |
| high_n /= np.linalg.norm(high_n, axis=1, keepdims=True) + 1e-12 | |
| # Generated meshes carry patches wound the wrong way, whose normals point | |
| # inward. Seen from outside, a surface cannot face away from the new one. | |
| high_n *= np.where(np.sum(high_n * n, axis=1, keepdims=True) < 0, -1.0, 1.0) | |
| ts = np.stack([np.sum(high_n * t, 1), np.sum(high_n * bt, 1), np.sum(high_n * n, 1)], axis=1) | |
| ts[:, 2] = np.maximum(ts[:, 2], 0.05) | |
| ts /= np.linalg.norm(ts, axis=1, keepdims=True) | |
| color_map[y, x] = to_bytes(color) | |
| normal_map[y, x] = to_bytes(ts * 0.5 + 0.5) | |
| if mr_map is not None: | |
| mr_map[y, x] = to_bytes(mr) | |
| mask = np.zeros((size, size), dtype=bool) | |
| mask[rows, cols] = True | |
| near, from_y, from_x = seam_fill(mask) | |
| maps = {"color": color_map, "normal": normal_map} | |
| if mr_map is not None: | |
| maps["metallicRoughness"] = mr_map | |
| for img in maps.values(): | |
| img[near] = img[from_y, from_x] | |
| return maps, mask.mean() | |
| def to_image(array, mode): | |
| return Image.fromarray(array, mode) | |
| def remesh(path, glb_out, obj_out=None, target_faces=10000, quads=True, texture_size=2048, progress=None): | |
| step = progress or (lambda fraction, text: None) | |
| timings = {} | |
| clock = time.perf_counter() | |
| def lap(name): | |
| nonlocal clock | |
| now = time.perf_counter() | |
| timings[name] = round(now - clock, 1) | |
| clock = now | |
| step(0.05, "Reading the model") | |
| high = load(path) | |
| lap("load") | |
| step(0.15, "Building the new topology") | |
| v, polys, tris, auto = new_surface(high, target_faces, quads, lambda text: step(0.15, text)) | |
| welded_normals = trimesh.Trimesh(v, tris, process=False).vertex_normals | |
| lap("remesh") | |
| step(0.35, "Unwrapping UVs") | |
| vmap, faces, uvs = native.parametrize(v, tris) | |
| faces = faces.astype(np.int64) | |
| uvs = uvs.astype(np.float64) | |
| positions, normals = v[vmap], np.asarray(welded_normals)[vmap] | |
| tangents = vertex_tangents(positions, normals, uvs, faces) | |
| lap("uv") | |
| step(0.5, "Baking textures from the original") | |
| maps, coverage = bake(positions, normals, tangents, uvs, faces, high, texture_size, step) | |
| lap("bake") | |
| step(0.9, "Writing the files") | |
| write_glb( | |
| glb_out, positions, normals, tangents, uvs, faces, | |
| { | |
| "color": to_image(maps["color"], "RGBA"), | |
| "metallicRoughness": to_image(maps["metallicRoughness"], "RGB") if "metallicRoughness" in maps else None, | |
| "normal": to_image(maps["normal"], "RGB"), | |
| }, | |
| ) | |
| if obj_out: | |
| write_obj(obj_out, v, polys) | |
| lap("write") | |
| return { | |
| "faces": int(len(polys)), | |
| "quads": int((polys[:, -1] != polys[:, 0]).sum()) if polys.shape[1] == 4 else 0, | |
| "triangles": int(len(faces)), | |
| "vertices": int(len(v)), | |
| "uv_coverage": round(float(coverage), 3), | |
| "timings": timings, | |
| **({"auto": auto} if auto else {}), | |
| } | |
| if __name__ == "__main__": | |
| import sys | |
| src, dst = sys.argv[1], sys.argv[2] | |
| target = int(sys.argv[3]) if len(sys.argv) > 3 else 0 | |
| quads = (sys.argv[4] if len(sys.argv) > 4 else "quad") == "quad" | |
| size = int(sys.argv[5]) if len(sys.argv) > 5 else 2048 | |
| print(remesh(src, dst, dst.replace(".glb", ".obj"), target, quads, size)) | |