diff options
Diffstat (limited to 'tools/msh_preview_renderer.py')
| -rw-r--r-- | tools/msh_preview_renderer.py | 481 |
1 files changed, 0 insertions, 481 deletions
diff --git a/tools/msh_preview_renderer.py b/tools/msh_preview_renderer.py deleted file mode 100644 index 53b4e63..0000000 --- a/tools/msh_preview_renderer.py +++ /dev/null @@ -1,481 +0,0 @@ -#!/usr/bin/env python3 -""" -Primitive software renderer for NGI MSH models. - -Output format: binary PPM (P6), no external dependencies. -""" - -from __future__ import annotations - -import argparse -import math -import struct -from pathlib import Path -from typing import Any - -import archive_roundtrip_validator as arv - -MAGIC_NRES = b"NRes" - - -def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes: - start = int(entry["data_offset"]) - end = start + int(entry["size"]) - return blob[start:end] - - -def _parse_nres(blob: bytes, source: str) -> dict[str, Any]: - if blob[:4] != MAGIC_NRES: - raise RuntimeError(f"{source}: not an NRes payload") - return arv.parse_nres(blob, source=source) - - -def _by_type(entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]: - out: dict[int, list[dict[str, Any]]] = {} - for row in entries: - out.setdefault(int(row["type_id"]), []).append(row) - return out - - -def _pick_model_payload(archive_path: Path, model_name: str | None) -> tuple[bytes, str]: - root_blob = archive_path.read_bytes() - parsed = _parse_nres(root_blob, str(archive_path)) - - msh_entries = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")] - if msh_entries: - chosen: dict[str, Any] | None = None - if model_name: - model_l = model_name.lower() - for row in msh_entries: - name_l = str(row["name"]).lower() - if name_l == model_l: - chosen = row - break - if chosen is None: - for row in msh_entries: - if str(row["name"]).lower().startswith(model_l): - chosen = row - break - else: - chosen = msh_entries[0] - - if chosen is None: - names = ", ".join(str(row["name"]) for row in msh_entries[:12]) - raise RuntimeError( - f"model '{model_name}' not found in {archive_path}. Available: {names}" - ) - return _entry_payload(root_blob, chosen), str(chosen["name"]) - - # Fallback: treat file itself as a model NRes payload. - by_type = _by_type(parsed["entries"]) - if all(k in by_type for k in (1, 2, 3, 6, 13)): - return root_blob, archive_path.name - - raise RuntimeError( - f"{archive_path} does not contain .msh entries and does not look like a direct model payload" - ) - - -def _get_single(by_type: dict[int, list[dict[str, Any]]], type_id: int, label: str) -> dict[str, Any]: - rows = by_type.get(type_id, []) - if not rows: - raise RuntimeError(f"missing resource type {type_id} ({label})") - return rows[0] - - -def _extract_geometry( - model_blob: bytes, - *, - lod: int, - group: int, - max_faces: int, -) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]], dict[str, int]]: - parsed = _parse_nres(model_blob, "<model>") - by_type = _by_type(parsed["entries"]) - - res1 = _get_single(by_type, 1, "Res1") - res2 = _get_single(by_type, 2, "Res2") - res3 = _get_single(by_type, 3, "Res3") - res6 = _get_single(by_type, 6, "Res6") - res13 = _get_single(by_type, 13, "Res13") - - # Positions - pos_blob = _entry_payload(model_blob, res3) - if len(pos_blob) % 12 != 0: - raise RuntimeError(f"Res3 size is not divisible by 12: {len(pos_blob)}") - vertex_count = len(pos_blob) // 12 - positions = [struct.unpack_from("<3f", pos_blob, i * 12) for i in range(vertex_count)] - - # Indices - idx_blob = _entry_payload(model_blob, res6) - if len(idx_blob) % 2 != 0: - raise RuntimeError(f"Res6 size is not divisible by 2: {len(idx_blob)}") - index_count = len(idx_blob) // 2 - indices = list(struct.unpack_from(f"<{index_count}H", idx_blob, 0)) - - # Batches - batch_blob = _entry_payload(model_blob, res13) - if len(batch_blob) % 20 != 0: - raise RuntimeError(f"Res13 size is not divisible by 20: {len(batch_blob)}") - batch_count = len(batch_blob) // 20 - batches: list[tuple[int, int, int, int]] = [] - for i in range(batch_count): - off = i * 20 - # Keep only fields used by renderer: - # indexCount, indexStart, baseVertex - idx_count = struct.unpack_from("<H", batch_blob, off + 8)[0] - idx_start = struct.unpack_from("<I", batch_blob, off + 10)[0] - base_vertex = struct.unpack_from("<I", batch_blob, off + 16)[0] - batches.append((idx_count, idx_start, base_vertex, i)) - - # Slots - res2_blob = _entry_payload(model_blob, res2) - if len(res2_blob) < 0x8C: - raise RuntimeError("Res2 is too small (< 0x8C)") - slot_blob = res2_blob[0x8C:] - if len(slot_blob) % 68 != 0: - raise RuntimeError(f"Res2 slot area is not divisible by 68: {len(slot_blob)}") - slot_count = len(slot_blob) // 68 - slots: list[tuple[int, int, int, int]] = [] - for i in range(slot_count): - off = i * 68 - tri_start, tri_count, batch_start, slot_batch_count = struct.unpack_from("<4H", slot_blob, off) - slots.append((tri_start, tri_count, batch_start, slot_batch_count)) - - # Nodes / slot matrix - res1_blob = _entry_payload(model_blob, res1) - node_stride = int(res1["attr3"]) - node_count = int(res1["attr1"]) - node_slot_indices: list[int] = [] - if node_stride >= 38 and len(res1_blob) >= node_count * node_stride: - if lod < 0 or lod > 2: - raise RuntimeError(f"lod must be 0..2 (got {lod})") - if group < 0 or group > 4: - raise RuntimeError(f"group must be 0..4 (got {group})") - matrix_index = lod * 5 + group - for n in range(node_count): - off = n * node_stride + 8 + matrix_index * 2 - slot_idx = struct.unpack_from("<H", res1_blob, off)[0] - if slot_idx == 0xFFFF: - continue - if slot_idx >= slot_count: - continue - node_slot_indices.append(slot_idx) - - # Build triangle list. - faces: list[tuple[int, int, int]] = [] - used_batches = 0 - used_slots = 0 - - def append_batch(batch_idx: int) -> None: - nonlocal used_batches - if batch_idx < 0 or batch_idx >= len(batches): - return - idx_count, idx_start, base_vertex, _ = batches[batch_idx] - if idx_count < 3: - return - end = idx_start + idx_count - if end > len(indices): - return - used_batches += 1 - tri_count = idx_count // 3 - for t in range(tri_count): - i0 = indices[idx_start + t * 3 + 0] + base_vertex - i1 = indices[idx_start + t * 3 + 1] + base_vertex - i2 = indices[idx_start + t * 3 + 2] + base_vertex - if i0 >= vertex_count or i1 >= vertex_count or i2 >= vertex_count: - continue - faces.append((i0, i1, i2)) - if len(faces) >= max_faces: - return - - if node_slot_indices: - for slot_idx in node_slot_indices: - if len(faces) >= max_faces: - break - _tri_start, _tri_count, batch_start, slot_batch_count = slots[slot_idx] - used_slots += 1 - for bi in range(batch_start, batch_start + slot_batch_count): - append_batch(bi) - if len(faces) >= max_faces: - break - else: - # Fallback if slot matrix is unavailable: draw all batches. - for bi in range(batch_count): - append_batch(bi) - if len(faces) >= max_faces: - break - - meta = { - "vertex_count": vertex_count, - "index_count": index_count, - "batch_count": batch_count, - "slot_count": slot_count, - "node_count": node_count, - "used_slots": used_slots, - "used_batches": used_batches, - "face_count": len(faces), - } - if not faces: - raise RuntimeError("no faces selected for rendering") - return positions, faces, meta - - -def _write_ppm(path: Path, width: int, height: int, rgb: bytearray) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("wb") as handle: - handle.write(f"P6\n{width} {height}\n255\n".encode("ascii")) - handle.write(rgb) - - -def _render_software( - positions: list[tuple[float, float, float]], - faces: list[tuple[int, int, int]], - *, - width: int, - height: int, - yaw_deg: float, - pitch_deg: float, - wireframe: bool, -) -> bytearray: - xs = [p[0] for p in positions] - ys = [p[1] for p in positions] - zs = [p[2] for p in positions] - cx = (min(xs) + max(xs)) * 0.5 - cy = (min(ys) + max(ys)) * 0.5 - cz = (min(zs) + max(zs)) * 0.5 - span = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs)) - radius = max(span * 0.5, 1e-3) - - yaw = math.radians(yaw_deg) - pitch = math.radians(pitch_deg) - cyaw = math.cos(yaw) - syaw = math.sin(yaw) - cpitch = math.cos(pitch) - spitch = math.sin(pitch) - - camera_dist = radius * 3.2 - scale = min(width, height) * 0.95 - - # Transform all vertices once. - vx: list[float] = [] - vy: list[float] = [] - vz: list[float] = [] - sx: list[float] = [] - sy: list[float] = [] - for x, y, z in positions: - x0 = x - cx - y0 = y - cy - z0 = z - cz - x1 = cyaw * x0 + syaw * z0 - z1 = -syaw * x0 + cyaw * z0 - y2 = cpitch * y0 - spitch * z1 - z2 = spitch * y0 + cpitch * z1 + camera_dist - if z2 < 1e-3: - z2 = 1e-3 - vx.append(x1) - vy.append(y2) - vz.append(z2) - sx.append(width * 0.5 + (x1 / z2) * scale) - sy.append(height * 0.5 - (y2 / z2) * scale) - - rgb = bytearray([16, 18, 24] * (width * height)) - zbuf = [float("inf")] * (width * height) - light_dir = (0.35, 0.45, 1.0) - l_len = math.sqrt(light_dir[0] ** 2 + light_dir[1] ** 2 + light_dir[2] ** 2) - light = (light_dir[0] / l_len, light_dir[1] / l_len, light_dir[2] / l_len) - - def edge(ax: float, ay: float, bx: float, by: float, px: float, py: float) -> float: - return (px - ax) * (by - ay) - (py - ay) * (bx - ax) - - for i0, i1, i2 in faces: - x0 = sx[i0] - y0 = sy[i0] - x1 = sx[i1] - y1 = sy[i1] - x2 = sx[i2] - y2 = sy[i2] - area = edge(x0, y0, x1, y1, x2, y2) - if area == 0.0: - continue - - # Shading from camera-space normal. - ux = vx[i1] - vx[i0] - uy = vy[i1] - vy[i0] - uz = vz[i1] - vz[i0] - wx = vx[i2] - vx[i0] - wy = vy[i2] - vy[i0] - wz = vz[i2] - vz[i0] - nx = uy * wz - uz * wy - ny = uz * wx - ux * wz - nz = ux * wy - uy * wx - n_len = math.sqrt(nx * nx + ny * ny + nz * nz) - if n_len > 0.0: - nx /= n_len - ny /= n_len - nz /= n_len - intensity = nx * light[0] + ny * light[1] + nz * light[2] - if intensity < 0.0: - intensity = 0.0 - shade = int(45 + 200 * intensity) - color = (shade, shade, min(255, shade + 18)) - - minx = int(max(0, math.floor(min(x0, x1, x2)))) - maxx = int(min(width - 1, math.ceil(max(x0, x1, x2)))) - miny = int(max(0, math.floor(min(y0, y1, y2)))) - maxy = int(min(height - 1, math.ceil(max(y0, y1, y2)))) - if minx > maxx or miny > maxy: - continue - - z0 = vz[i0] - z1 = vz[i1] - z2 = vz[i2] - - for py in range(miny, maxy + 1): - fy = py + 0.5 - row = py * width - for px in range(minx, maxx + 1): - fx = px + 0.5 - w0 = edge(x1, y1, x2, y2, fx, fy) - w1 = edge(x2, y2, x0, y0, fx, fy) - w2 = edge(x0, y0, x1, y1, fx, fy) - if area > 0: - if w0 < 0 or w1 < 0 or w2 < 0: - continue - else: - if w0 > 0 or w1 > 0 or w2 > 0: - continue - inv_area = 1.0 / area - bz0 = w0 * inv_area - bz1 = w1 * inv_area - bz2 = w2 * inv_area - depth = bz0 * z0 + bz1 * z1 + bz2 * z2 - idx = row + px - if depth >= zbuf[idx]: - continue - zbuf[idx] = depth - p = idx * 3 - rgb[p + 0] = color[0] - rgb[p + 1] = color[1] - rgb[p + 2] = color[2] - - if wireframe: - def draw_line(xa: float, ya: float, xb: float, yb: float) -> None: - x0i = int(round(xa)) - y0i = int(round(ya)) - x1i = int(round(xb)) - y1i = int(round(yb)) - dx = abs(x1i - x0i) - sx_step = 1 if x0i < x1i else -1 - dy = -abs(y1i - y0i) - sy_step = 1 if y0i < y1i else -1 - err = dx + dy - x = x0i - y = y0i - while True: - if 0 <= x < width and 0 <= y < height: - p = (y * width + x) * 3 - rgb[p + 0] = 240 - rgb[p + 1] = 245 - rgb[p + 2] = 255 - if x == x1i and y == y1i: - break - e2 = 2 * err - if e2 >= dy: - err += dy - x += sx_step - if e2 <= dx: - err += dx - y += sy_step - - for i0, i1, i2 in faces: - draw_line(sx[i0], sy[i0], sx[i1], sy[i1]) - draw_line(sx[i1], sy[i1], sx[i2], sy[i2]) - draw_line(sx[i2], sy[i2], sx[i0], sy[i0]) - - return rgb - - -def cmd_list_models(args: argparse.Namespace) -> int: - archive_path = Path(args.archive).resolve() - blob = archive_path.read_bytes() - parsed = _parse_nres(blob, str(archive_path)) - rows = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")] - print(f"Archive: {archive_path}") - print(f"MSH entries: {len(rows)}") - for row in rows: - print(f"- {row['name']}") - return 0 - - -def cmd_render(args: argparse.Namespace) -> int: - archive_path = Path(args.archive).resolve() - output_path = Path(args.output).resolve() - - model_blob, model_label = _pick_model_payload(archive_path, args.model) - positions, faces, meta = _extract_geometry( - model_blob, - lod=int(args.lod), - group=int(args.group), - max_faces=int(args.max_faces), - ) - rgb = _render_software( - positions, - faces, - width=int(args.width), - height=int(args.height), - yaw_deg=float(args.yaw), - pitch_deg=float(args.pitch), - wireframe=bool(args.wireframe), - ) - _write_ppm(output_path, int(args.width), int(args.height), rgb) - - print(f"Rendered model: {model_label}") - print(f"Output : {output_path}") - print( - "Geometry : " - f"vertices={meta['vertex_count']}, faces={meta['face_count']}, " - f"batches={meta['used_batches']}/{meta['batch_count']}, slots={meta['used_slots']}/{meta['slot_count']}" - ) - print(f"Mode : lod={args.lod}, group={args.group}, wireframe={bool(args.wireframe)}") - return 0 - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Primitive NGI MSH renderer (software, dependency-free)." - ) - sub = parser.add_subparsers(dest="command", required=True) - - list_models = sub.add_parser("list-models", help="List .msh entries in an NRes archive.") - list_models.add_argument("--archive", required=True, help="Path to archive (e.g. animals.rlb).") - list_models.set_defaults(func=cmd_list_models) - - render = sub.add_parser("render", help="Render one model to PPM image.") - render.add_argument("--archive", required=True, help="Path to NRes archive or direct model payload.") - render.add_argument( - "--model", - help="Model entry name (*.msh) inside archive. If omitted, first .msh is used.", - ) - render.add_argument("--output", required=True, help="Output .ppm file path.") - render.add_argument("--lod", type=int, default=0, help="LOD index 0..2 (default: 0).") - render.add_argument("--group", type=int, default=0, help="Group index 0..4 (default: 0).") - render.add_argument("--max-faces", type=int, default=120000, help="Face limit (default: 120000).") - render.add_argument("--width", type=int, default=1280, help="Image width (default: 1280).") - render.add_argument("--height", type=int, default=720, help="Image height (default: 720).") - render.add_argument("--yaw", type=float, default=35.0, help="Yaw angle in degrees (default: 35).") - render.add_argument("--pitch", type=float, default=18.0, help="Pitch angle in degrees (default: 18).") - render.add_argument("--wireframe", action="store_true", help="Draw white wireframe overlay.") - render.set_defaults(func=cmd_render) - - return parser - - -def main() -> int: - parser = build_parser() - args = parser.parse_args() - return int(args.func(args)) - - -if __name__ == "__main__": - raise SystemExit(main()) |
