Volver al índice

docs/render-climbing-demo.py

"""Present native wall climbing with visible stone and measured support contacts.

The Java CLI owns every joint and movement. The camera follows the body while
the wall moves downward at the exact CLI climb distance rate (10 model units/s).
"""
from __future__ import annotations

import argparse
import hashlib
import importlib.util
import json
import time
from pathlib import Path

import numpy as np
from PIL import Image, ImageDraw


# INPUTS: reuse the original stone adapter and the unchanged native renderer.
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "docs"
SCRIPT = Path(__file__).resolve()
MATERIAL_SCRIPT = OUT / "render-corridor-demo.py"
spec = importlib.util.spec_from_file_location("climbing_stone", MATERIAL_SCRIPT)
stone = importlib.util.module_from_spec(spec)
spec.loader.exec_module(stone)
p = stone.p
FPS, DURATION, SIZE = 20, 6, (480, 440)
SPEED_UNITS = 10
WALL_Z = -8
STEM = "parasito-climb-pared"
CLASSES = ROOT / "build/parasite-preview-classes"


def sha(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


# WALL: original block geometry; no Minecraft or third-party stone assets.
def wall_sample(sample, seconds):
    shift = (seconds * SPEED_UNITS) % 16
    boxes = [{"bounds": [x, row * 16 + shift, WALL_Z - 16, 16, 16, 16], "uv": [0, 128]}
             for x in (-32, -16, 0, 16) for row in range(-5, 5)]
    bone = {"name": "climbing_stone", "parent": None, "pivot": [0, 0, 0], "boxes": boxes}
    result = dict(sample)
    result["bones"] = [*sample["bones"], bone]
    result["pose"] = dict(sample["pose"])
    result["pose"][bone["name"]] = {"position": [0, 0, 0], "rotation": [0, 0, 0], "scale": [1, 1, 1], "visible": True}
    return result


# CONTACT AUDIT: real visible vertices, not painted markers or invented grasps.
def native_geometry(sample):
    # Resolve each generic ModelPart transform once, shared by all four supports.
    bones = {bone["name"]: bone for bone in sample["bones"]}
    matrices, visible, vertices = {}, {}, {}

    def resolve(name):
        if name in matrices:
            return matrices[name]
        bone, pose = bones[name], sample["pose"][name]
        own = p.matrix(pose["position"], pose["rotation"], pose["scale"])
        parent = bone["parent"]
        matrices[name] = resolve(parent) @ own if parent else own
        visible[name] = pose["visible"] and (visible[parent] if parent else True)
        return matrices[name]

    for name, bone in bones.items():
        world = resolve(name)
        if not visible[name] or not bone["boxes"]:
            continue
        points = []
        for box in bone["boxes"]:
            x, y, z, w, h, d = box["bounds"]
            points.extend((x + (w if corner & 1 else 0), y + (h if corner & 2 else 0),
                           z + (d if corner & 4 else 0), 1) for corner in range(8))
        vertices[name] = (world @ np.asarray(points).T).T[:, :3]
    return matrices, vertices


def support_measurements(samples, stage=4):
    stride = 5 + 7 * stage / 4
    previous = {}
    gaps, drift, returns = [], [], []
    dimensions = []
    for index, sample in enumerate(samples):
        distance = index / FPS * SPEED_UNITS
        dimensions.append(sample["dimensions_blocks"])
        matrices, visible_vertices = native_geometry(sample)
        for side, offset in (("l", 0), ("r", .5)):
            for kind in ("arm", "leg"):
                limb = kind + "_" + side
                phase = (distance / stride + offset + (0 if kind == "arm" else .5)) % 1
                names = {bone["name"] for bone in sample["bones"]
                         if bone["name"] == limb + "_hand" or kind == "arm" and
                         (bone["name"].startswith(limb + "_finger") or bone["name"].startswith(limb + "_thumb"))}
                vertices = np.concatenate([visible_vertices[name] for name in names if name in visible_vertices])
                nearest = vertices[np.argmin(vertices[:, 2])]
                gap = (float(nearest[2]) - WALL_Z) / 16
                # The wrist/ankle pivot is unambiguous even when tied corners swap order.
                contact_transform = matrices[limb + "_hand"]
                anchor = float(contact_transform[1, 3]) - distance
                if phase < .72:
                    gaps.append(abs(gap))
                    if limb in previous and previous[limb][0] and phase >= previous[limb][1]:
                        drift.append(abs(anchor - previous[limb][2]) / 16)
                else:
                    returns.append(gap)
                previous[limb] = (phase < .72, phase, anchor)
        body = np.concatenate(list(visible_vertices.values()))
        if float(body[:, 2].min()) < WALL_Z - .002:
            raise ValueError("Native geometry penetrates the wall at frame" + str(index))
    result = {"frames": len(samples), "stage": stage, "support_fraction": .72,
              "stride_model_units": stride, "wall_plane_z_model_units": WALL_Z,
              "maximum_support_gap_blocks": max(gaps), "maximum_planted_drift_blocks": max(drift),
              "maximum_return_separation_blocks": max(returns),
              "dimensions_min_blocks": np.min(dimensions, axis=0).tolist(),
              "dimensions_max_blocks": np.max(dimensions, axis=0).tolist()}
    if max(result["maximum_support_gap_blocks"], result["maximum_planted_drift_blocks"]) > .002:
        raise ValueError("Native wall supports failed: " + json.dumps(result))
    return result


# CAMERA: fixed view from behind and to the side, keeping hands and soles visible.
def draw_frame(sample, seconds, framing):
    image = Image.new("RGBA", SIZE, (13, 15, 19, 255))
    image.alpha_composite(p.render(wall_sample(sample, seconds), SIZE, framing))
    draw = ImageDraw.Draw(image)
    draw.rounded_rectangle((17, 16, 228, 47), radius=6, fill=(12, 14, 18, 235))
    draw.text((28, 20), "TREPAR · APOYOS EN PARED", font=p.font(14, True), fill=(216, 205, 195))
    return image.convert("RGB")


# EXPORT: independent supplemental media; all standard clips remain untouched.
def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--contact-only", action="store_true")
    parser.add_argument("--expected-rig-sha256", required=True)
    args = parser.parse_args()
    if sha(p.RIG) != args.expected_rig_sha256.lower():
        raise RuntimeError("The approved native source hash does not match")
    if (CLASSES / "ar/com/companeros/horror/client/ParasiteRig.class").stat().st_mtime < p.RIG.stat().st_mtime:
        raise RuntimeError("Native exporter classes are stale")
    inputs = (p.RIG, p.MODEL, OUT / "render-parasite-preview.py", SCRIPT, MATERIAL_SCRIPT, p.ASSETS / "parasite.png")
    sources = {str(path.relative_to(ROOT)).replace("\\", "/"): sha(path) for path in inputs}
    samples = p.rig_sequence(CLASSES, "climb", 4, DURATION)
    contacts = support_measurements(samples)
    framing = p.framing(samples, *SIZE, yaw=128, pitch=8)
    stone.install_stone_material()
    indices = (0, 6, 12, 18)
    sheet = Image.new("RGB", (SIZE[0] * 2, SIZE[1] * 2), (13, 15, 19))
    for index, number in enumerate(indices):
        sheet.paste(draw_frame(samples[number], number / FPS, framing), ((index % 2) * SIZE[0], (index // 2) * SIZE[1]))
    sheet.save(OUT / (STEM + "-contact.png"))
    draw_frame(samples[12], 12 / FPS, framing).save(OUT / (STEM + ".png"))
    print("Wall contacts verified: " + json.dumps(contacts), flush=True)
    if args.contact_only:
        return
    started = time.monotonic()

    def frames():
        for index, sample in enumerate(samples):
            yield draw_frame(sample, index / FPS, framing)
            if (index + 1) % FPS == 0:
                print(f"Rendered{index + 1}/{len(samples)} frames ({time.monotonic() - started:.1f}s)", flush=True)

    p.write_video(frames(), OUT / (STEM + ".mp4"))
    for relative, expected in sources.items():
        if sha(ROOT / relative) != expected:
            raise RuntimeError("Input changed during export: " + relative)
    metadata = {"mode": "climb", "stage": 4, "fps": FPS, "duration_seconds": DURATION,
                "frame_count": len(samples), "size": list(SIZE), "source_hashes": sources,
                "native_samples_sha256": p.sample_digest(samples), "wall_contacts": contacts,
                "wall_motion_model_units_per_second": SPEED_UNITS, "wall_motion_direction": "+Y",
                "camera": {"yaw_degrees": 128, "pitch_degrees": 8},
                "output_hashes": {name: sha(OUT / name) for name in
                                  (STEM + ".mp4", STEM + ".png", STEM + "-contact.png")}}
    (OUT / (STEM + "-origen.json")).write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
    print("Wall climbing demonstration complete", flush=True)


if __name__ == "__main__":
    main()