"""Show native crawl3 bracing inside a visible one-by-three stone corridor. Only the environment and camera belong to this script. Geometry, animation and skin come from the existing Java exporter and preview renderer, unchanged. """ 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: import the established renderer without editing any shared asset. ROOT = Path(__file__).resolve().parents[1] OUT = ROOT / "docs" CLASSES = ROOT / "build/parasite-preview-classes" SCRIPT = Path(__file__).resolve() RENDERER = OUT / "render-parasite-preview.py" spec = importlib.util.spec_from_file_location("corridor_preview", RENDERER) p = importlib.util.module_from_spec(spec) spec.loader.exec_module(p) FPS, DURATION, SIZE = 20, 12, (480, 440) STEM = "parasito-crawl3-paredes" def sha(path): return hashlib.sha256(path.read_bytes()).hexdigest() # MATERIAL: deterministic, original stone pixels; the host atlas stays intact. def install_stone_material(): original = p.texture_pixels textures = {} rng = np.random.default_rng(134) for name in ("parasite.png", "parasite_initial.png", "parasite_friendly.png"): atlas = np.zeros((256, 128, 4), dtype=np.uint8) atlas[:128] = original(name) noise = rng.integers(-10, 11, (128, 128)) gray = 91 + noise yy, xx = np.indices(gray.shape) gray[(xx % 16 == 0) | (yy % 16 == 0)] = 42 gray[(xx % 16 == 1) | (yy % 16 == 1)] = 112 atlas[128:, :, :3] = np.stack((gray, gray + 1, gray + 3), axis=-1) atlas[128:, :, 3] = 255 textures[name] = atlas # In-memory adapter only: the original module and atlas files are untouched. p.texture_pixels = lambda name: textures[name] # ENVIRONMENT: inner walls are x=-8/+8 units; three blocks from floor to roof. def corridor_sample(sample, seconds): shift = (seconds * 12) % 16 boxes = [] for row in range(-3, 5): z = row * 16 + shift for level in range(3): boxes.append({"bounds": [-24, -24 + level * 16, z, 16, 16, 16], "uv": [0, 128]}) for column in range(3): boxes.append({"bounds": [-24 + column * 16, 24, z, 16, 16, 16], "uv": [0, 128]}) bone = {"name": "demo_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 # CAMERA: a fixed cutaway makes both hands and their actual wall planes visible. FRAME = (p.camera_matrix(12, 10), 6.7, np.array([SIZE[0] / 2, 220])) def project(points): cam, scale, offset = FRAME return [(float(x), float(y)) for x, y in ((cam @ np.asarray(points).T).T[:, :2] * scale + offset)] def frame(sample, seconds): image = Image.new("RGBA", SIZE, (13, 15, 19, 255)) image.alpha_composite(p.render(corridor_sample(sample, seconds), SIZE, FRAME)) # Near wall is deliberately a transparent cutaway, never a hidden collision. layer = Image.new("RGBA", SIZE) draw = ImageDraw.Draw(layer) draw.polygon(project([(8, -24, -32), (8, 24, -32), (8, 24, 64), (8, -24, 64)]), fill=(133, 143, 149, 23)) shift = (seconds * 12) % 16 for y in (-24, -8, 8, 24): draw.line(project([(8, y, -32), (8, y, 64)]), fill=(164, 172, 178, 88), width=1) for row in range(-3, 5): z = row * 16 + shift draw.line(project([(8, -24, z), (8, 24, z)]), fill=(164, 172, 178, 88), width=1) # The roof edge states the passage height while leaving the face visible. draw.line(project([(-8, -24, -32), (8, -24, -32)]), fill=(150, 150, 154, 190), width=2) image.alpha_composite(layer) draw = ImageDraw.Draw(image) draw.rounded_rectangle((17, 16, 226, 47), radius=6, fill=(12, 14, 18, 235)) draw.text((28, 20), "PASO 1×3 · APOYO LATERAL", font=p.font(14, True), fill=(216, 205, 195)) return image.convert("RGB") # CONTACT: measure real palm/finger vertices against their respective walls. def wall_contact(samples): gaps = {"left_wall": [], "right_wall": []} heights = {"left_wall": [], "right_wall": []} for sample in samples: for side, wall, label in (("r", -8, "left_wall"), ("l", 8, "right_wall")): names = {bone["name"] for bone in sample["bones"] if bone["name"].startswith("arm_" + side + "_") and any(part in bone["name"] for part in ("hand", "finger", "thumb"))} vertices = np.concatenate([vertices for box in p.mesh(sample, names) for vertices, _ in p.cube_faces(box)]) closest = vertices[np.argmin(abs(vertices[:, 0] - wall))] gaps[label].append(float(abs(closest[0] - wall) / 16)) heights[label].append(float((24 - closest[1]) / 16)) result = {label: {"minimum_gap_blocks": min(values), "maximum_gap_blocks": max(values), "contact_height_range_blocks": [min(heights[label]), max(heights[label])]} for label, values in gaps.items()} # Do not visually claim bracing if the actual fingers are visibly detached. if max(max(values) for values in gaps.values()) > .025: raise ValueError("Native crawl3 hands are too far from the side walls: " + json.dumps(result)) return result # EXPORT: contact sheet first; the twelve-second video leaves existing clips intact. def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--contact-only", action="store_true") args = parser.parse_args() if CLASSES.joinpath("ar/com/companeros/horror/client/ParasiteRig.class").stat().st_mtime < p.RIG.stat().st_mtime: raise RuntimeError("Compile the current native rig before rendering this demonstration") sources = {str(path.relative_to(ROOT)).replace("\\", "/"): sha(path) for path in (p.RIG, p.MODEL, RENDERER, SCRIPT, p.ASSETS / "parasite.png")} samples = p.rig_sequence(CLASSES, "crawl3", 4, DURATION) contacts = wall_contact(samples) install_stone_material() selected = [0, 13, 26, 39, 52, 65] sheet = Image.new("RGB", (SIZE[0] * 3, SIZE[1] * 2), (13, 15, 19)) for index, number in enumerate(selected): sheet.paste(frame(samples[number], number / FPS), ((index % 3) * SIZE[0], (index // 3) * SIZE[1])) sheet.save(OUT / (STEM + "-contact.png")) frame(samples[26], 26 / FPS).save(OUT / (STEM + ".png")) print("Contact sheet ready; measured wall contacts: " + json.dumps(contacts), flush=True) if args.contact_only: return started = time.monotonic() def frames(): for index, sample in enumerate(samples): yield frame(sample, index / FPS) if (index + 1) % 20 == 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("Source changed during the demonstration: " + relative) metadata = {"mode": "crawl3", "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, "corridor_inner_width_blocks": 1, "corridor_height_blocks": 3, "camera": {"yaw_degrees": 12, "pitch_degrees": 10, "near_wall": "transparent_cutaway"}, "environment_motion_blocks_per_second": .75, "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("Demonstration complete", flush=True) if __name__ == "__main__": main()