"""Render matching paths, slit integrals, and intensity for 49 physical slits. The continuous-aperture renderer and its films remain separate historical experiments. This renderer uses perforated_tip_to_tail_model in every pane. """ from __future__ import annotations import argparse from datetime import datetime, timezone import json import math from pathlib import Path import shutil import subprocess import perforated_tip_to_tail_model as model import numpy as np from PIL import Image, ImageDraw import imageio_ffmpeg import generate_symmetry_many_slit_paths_phasors_interference as old wave = old.wave ROOT = Path(__file__).resolve().parents[1] OUT = ROOT / "content" / "drafts" / "animations" NAME = "symmetry-tip-to-tail-49-slits" DESKTOP = "symmetry-many-slit-49-slits" CENTERED_PRESENTATION = False DISPLAY_ROTATION = 1.0 + 0.0j FPS, DURATION = old.FPS, old.DURATION SCALE = 2 SAMPLES = (1.8, 3.7, 6.5, 7.5, 8.7, 10.0, 11.85, 13.7, 17.0) B_VALUES = np.linspace(3.2, -3.2, 257) TOTALS = np.array([model.total(float(b)) for b in B_VALUES]) INTENSITIES = abs(TOTALS) ** 2 FREE_INTENSITIES = abs(model.free_amplitude(B_VALUES)) ** 2 IMAX = max(1.0, float(np.max(INTENSITIES)), float(np.max(abs(model.chain(0.0)) ** 2))) * 1.06 CHAIN_RADIUS = max(float(np.max(abs(model.chain(float(b))))) for b in np.linspace(-3.2, 3.2, 1025)) RADIUS = max(CHAIN_RADIUS, max(abs(model.free_amplitude(float(b))) for b in np.linspace(-3.2, 3.2, 1025))) def center_presentation(): """User-requested presentation 'cheat'; leave the physical model intact. The slit mask gives total(0) a finite phase relative to the unobstructed central wave. For this presentation ONLY, choose the completed 49-slit sum at B=0 as phase zero. Apply the SAME unit complex factor to every contribution and partial sum at every B and t. This is a fixed change of display reference, not a phase correction to the physics, and not a B-dependent straightening of the resultant. Arrow magnitudes, tip-to-tail addition, subsequent angular motion, and detector intensities are unchanged. Omit the gray no-screen phasor in this copy: retaining it would show the same relative offset, merely on the other side of the axis. The unadjusted film remains the explicit comparison with unobstructed propagation. The gray INTENSITY curve in pane three is unaffected and remains useful. """ global CENTERED_PRESENTATION, DISPLAY_ROTATION, NAME, DESKTOP, RADIUS CENTERED_PRESENTATION = True DISPLAY_ROTATION = complex(np.exp(-1j * np.angle(model.total(0.0)))) NAME = "symmetry-tip-to-tail-49-slits-centered" DESKTOP = "symmetry-many-slit-49-slits-centered" # Keep one fixed pixel scale for the whole animation, fitted to the # displayed chains. The hidden no-screen phasor need not consume space. # Individual arrows and B-dependent totals retain their actual ratios. RADIUS = CHAIN_RADIUS def text(draw, xy, message, fill=old.INK, font=old.LABEL, anchor=None): wave.draw_text(draw, xy, message, fill=fill, font_obj=font, anchor=anchor) def line(draw, a, b, color, width=1): draw.line(tuple(wave.s(v) for v in (*a, *b)), fill=color, width=wave.s(width)) def arrow(draw, a, b, color, width=2, head=5): length = math.dist(a, b) if length < 0.08: return line(draw, a, b, color, width) head = min(head, 0.42 * length) theta = math.atan2(b[1] - a[1], b[0] - a[0]) vertices = [b] + [(b[0] - head * math.cos(theta + t), b[1] - head * math.sin(theta + t)) for t in (-0.5, 0.5)] draw.polygon([(wave.s(x), wave.s(y)) for x, y in vertices], fill=color) def mapper(bounds): left, top, right, bottom = bounds origin = ((left + right) / 2, (top + bottom) / 2) scale = (min(right - left, bottom - top) / 2 - 20) / RADIUS return lambda value: (origin[0] + value.real * scale, origin[1] - value.imag * scale), scale def phasors(draw, seconds, bounds, compact=False): b, count, active, fraction, complete, *_ = old.animation_state(seconds) terms = DISPLAY_ROTATION * model.contributions(float(b)) values = DISPLAY_ROTATION * model.chain(b) point, scale = mapper(bounds) origin = point(0j) left, top, right, bottom = bounds line(draw, (left, origin[1]), (right, origin[1]), wave.rgba(old.MUTED, .25)) line(draw, (origin[0], top), (origin[0], bottom), wave.rgba(old.MUTED, .25)) text(draw, (right, origin[1] - 24), "Re", old.MUTED, old.SMALL, "ra") text(draw, (origin[0] + 8, top), "Im", old.MUTED, old.SMALL) if not CENTERED_PRESENTATION: reference_end = point(DISPLAY_ROTATION * model.free_amplitude(b)) wave.dashed_line(draw, origin, reference_end, wave.rgba(old.MUTED, .57), width=2, dash=5, gap=4) # A hollow marker keeps the reference distinguishable when nearly aligned. wave.circle(draw, reference_end, 4.2, old.PANEL, outline=wave.rgba(old.MUTED, .75), width=1) for index in range(count): arrow(draw, point(values[index]), point(values[index + 1]), wave.rgba(old.BLUE, .86), width=2, head=3.6 if compact else 5) current = values[count] if active is not None: current = values[active] + fraction * terms[active] arrow(draw, point(values[active]), point(current), old.GOLD, width=3 if compact else 4, head=6) if count or active is not None: if complete: arrow(draw, origin, point(current), old.GREEN, width=4 if compact else 5, head=9) else: wave.dashed_line(draw, origin, point(current), wave.rgba(old.GREEN, .43), width=2, dash=5, gap=4) wave.circle(draw, point(current), 2.6, old.GREEN if complete else old.GOLD) wave.circle(draw, origin, 2.2, old.MUTED) return b, current, complete def standalone(seconds): image = Image.new("RGB", (960 * SCALE, 840 * SCALE), old.BG) draw = ImageDraw.Draw(image, "RGBA") wave.panel(draw, (24, 20, 936, 820)) text(draw, (48, 39), "Tip-to-tail · normalized propagation", font=old.TITLE) text(draw, (48, 83), "49 slit contributions · lengths carry amplitude", old.MUTED, old.SUBTITLE) b, count, active, _, complete, *_ = old.animation_state(seconds) status = "Complete sum" if complete else ("Ready" if active is None else f"Adding slit {active + 1} / 49") text(draw, (48, 116), status, old.GREEN if complete else old.GOLD) text(draw, (912, 116), f"B = {b:+.2f}", old.MUTED, anchor="ra") phasors(draw, seconds, (72, 153, 888, 692)) if CENTERED_PRESENTATION: text(draw, (48, 711), "Phase zero: the complete sum at central B. Reference and scale stay fixed.", old.MUTED, old.SMALL) text(draw, (48, 770), "Blue: contributions", old.BLUE) text(draw, (480, 770), "Gold: adding one", old.GOLD, anchor="ma") text(draw, (912, 770), "Green: sum", old.GREEN, anchor="ra") else: text(draw, (48, 711), "Phase zero: unobstructed A → central B. Reference and scale stay fixed.", old.MUTED, old.SMALL) text(draw, (48, 746), "Blue: slit contributions", old.BLUE) text(draw, (912, 746), "Gray: without the screen", old.MUTED, anchor="ra") text(draw, (48, 777), "Gold: adding one contribution", old.GOLD) text(draw, (912, 777), "Green: their sum", old.GREEN, anchor="ra") return image.convert("RGB").resize((960, 840), Image.Resampling.LANCZOS) def route_panel(draw, seconds): b, count, active, fraction, complete, *_ = old.animation_state(seconds) wave.panel(draw, old.ROUTE_PANEL) text(draw, (58, 145), "routes through 49 slits", font=old.PANE_TITLE) text(draw, (58, 172), "one finite slit · one contribution", old.MUTED, old.SMALL) for y in model.CENTERS[:count]: old.draw_route(draw, float(y), b, wave.rgba(old.BLUE, .13), 2) # Draw the same opaque complement used by the wave calculation. Slit # widths are physical here, not circles painted over a continuous mask. x = old.ROUTE_SCREEN_X for lower, upper in model.opaque_intervals(-old.DETECTOR_HALF_HEIGHT, old.DETECTOR_HALF_HEIGHT): draw.rectangle((wave.s(x-3), wave.s(old.route_y(upper)), wave.s(x+3), wave.s(old.route_y(lower))), fill=old.INK) old.draw_detector_line(draw, b) if complete: # The unconstrained straight route can hit an opaque bar. Highlight # the nearest actual slit-center route, as in the original diagram. y = float(model.CENTERS[np.argmin(abs(model.CENTERS - old.stationary_opening_y(b)))]) old.draw_route(draw, y, b, wave.rgba(old.GOLD, .9), 3) elif active is not None: y = float(model.CENTERS[active]) old.draw_route(draw, y, b, old.GOLD, 3) wave.circle(draw, (x, old.route_y(y)), 1.2, old.GOLD) wave.circle(draw, old.ROUTE_A, 7.5, old.INK) text(draw, (old.ROUTE_A[0] - 12, old.ROUTE_A[1]), "A", font=old.LABEL_BOLD, anchor="rm") text(draw, (x, 591), "49 finite slits", old.MUTED, anchor="ma") text(draw, (old.ROUTE_DETECTOR_X, 591), "detector", old.MUTED, anchor="ma") def detector_panel(draw, seconds, b, current, complete): wave.panel(draw, old.DETECTOR_PANEL) text(draw, (1052, 145), "intensity at detector", font=old.PANE_TITLE) text(draw, (1052, 172), "I = |sum|²", old.MUTED, old.SMALL) x0, x1 = 1065, 1218 x_at = lambda value: x0 + (x1 - x0) * max(0, min(1, float(value) / IMAX)) line(draw, (x0, 190), (x0, 568), wave.rgba(old.MUTED, .5)) for j in range(0, len(B_VALUES) - 3, 7): a = (x_at(FREE_INTENSITIES[j]), old.detector_y(float(B_VALUES[j]))) c = (x_at(FREE_INTENSITIES[j + 3]), old.detector_y(float(B_VALUES[j + 3]))) line(draw, a, c, wave.rgba(old.MUTED, .45), 1) _, _, _, _, _, reveal, scanning, _ = old.animation_state(seconds) final_hold = seconds >= old.SETTLE_END if scanning or final_hold: visited = len(B_VALUES) if final_hold else max(2, round(reveal * len(B_VALUES))) pts = [(wave.s(x_at(v)), wave.s(old.detector_y(float(y)))) for y, v in zip(B_VALUES[:visited], INTENSITIES[:visited])] draw.line(pts, fill=old.BLUE, width=wave.s(2)) value = abs(model.total(float(b)) if complete else current) ** 2 marker = (x_at(value), old.detector_y(b)) line(draw, (x0, marker[1]), marker, old.GOLD, 2) wave.circle(draw, marker, 4, old.GREEN) text(draw, (1140, 591), "gray: no screen", old.MUTED, old.SMALL, "ma") def desktop(seconds): image = Image.new("RGB", (1280 * SCALE, 720 * SCALE), old.BG) draw = ImageDraw.Draw(image, "RGBA") text(draw, (42, 27), "Many paths · one consistent wave amplitude", font=old.TITLE) text(draw, (42, 68), "Complete propagation phases and weights · the same sum drives the detector", old.MUTED, old.SUBTITLE) route_panel(draw, seconds) wave.panel(draw, old.PHASOR_PANEL) text(draw, (625, 145), "complex contributions at B", font=old.PANE_TITLE) text(draw, (625, 172), "fixed phase reference and scale", old.MUTED, old.SMALL) b, current, complete = phasors(draw, seconds, (625, 202, 991, 557), True) text(draw, (810, 591), "green: sum" if CENTERED_PRESENTATION else "green: sum · gray: no screen", old.MUTED, old.SMALL, "ma") detector_panel(draw, seconds, b, current, complete) if CENTERED_PRESENTATION: text(draw, (42, 650), "Phase zero: the complete sum at central B. The reference stays fixed as B moves.", old.MUTED, old.SUBTITLE) text(draw, (42, 682), "2D scalar diffraction · 49 finite slits with opaque gaps · lengths carry amplitude", old.MUTED, old.SMALL) else: text(draw, (42, 650), "Phase zero: the unobstructed field at central B. Moving B retains the travel phase.", old.MUTED, old.SUBTITLE) text(draw, (42, 682), "2D scalar diffraction · 49 finite slits with opaque gaps · mask phase is retained", old.MUTED, old.SMALL) return image.convert("RGB").resize((1280, 720), Image.Resampling.LANCZOS) def visual_checks(): minimum_margin = float("inf") for bounds in ((72, 153, 888, 692), (625, 202, 991, 557)): point, _ = mapper(bounds) left, top, right, bottom = bounds for frame_index in range(round(DURATION * FPS)): b = old.animation_state(frame_index / FPS)[0] values = model.chain(b) if CENTERED_PRESENTATION else np.r_[model.chain(b), model.free_amplitude(b)] for value in DISPLAY_ROTATION * values: x, y = point(value) minimum_margin = min(minimum_margin, x - left, right - x, y - top, bottom - y) assert minimum_margin > 18 return {"minimum_plot_margin_pixels": minimum_margin, "same_scale_all_frames": True} def previews(): for name, render_frame, size in ((NAME, standalone, (480, 420)), (DESKTOP, desktop, (640, 360))): sheet = Image.new("RGB", (size[0] * 3, (size[1] + 26) * 3), old.BG) label = ImageDraw.Draw(sheet) for i, seconds in enumerate(SAMPLES): picture = render_frame(seconds) picture.save(OUT / f"{name}-check-{seconds:g}.png") x, y = i % 3 * size[0], i // 3 * (size[1] + 26) sheet.paste(picture.resize(size, Image.Resampling.LANCZOS), (x, y)) label.text((x + 16, y + size[1] + 4), f"{seconds:g} s", fill=old.INK) sheet.save(OUT / f"{name}-contact-sheet.png") def render(name, render_frame, size): video = OUT / f"{name}.mp4" command = [imageio_ffmpeg.get_ffmpeg_exe(), "-y", "-v", "error", "-nostats", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{size[0]}x{size[1]}", "-r", str(FPS), "-i", "-", "-an", "-c:v", "libx264", "-threads", "2", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(video)] process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) for index in range(round(DURATION * FPS)): process.stdin.write(render_frame(index / FPS).tobytes()) if index % (4 * FPS) == 0: print(f"{name}: {index / FPS:g}/{DURATION:g} seconds", flush=True) process.stdin.close() error = process.stderr.read().decode("utf-8", errors="replace") if process.wait(): raise RuntimeError(error) count, seconds = imageio_ffmpeg.count_frames_and_secs(str(video)) assert count == round(DURATION * FPS) and abs(seconds - DURATION) < .001 decoded = subprocess.run([imageio_ffmpeg.get_ffmpeg_exe(), "-v", "error", "-i", str(video), "-f", "null", "-"], capture_output=True, check=True) assert not decoded.stderr return {"file": video.name, "frames": count, "seconds": seconds, "bytes": video.stat().st_size, "full_decode": "passed"} def render_canonical_desktop(): """Regenerate the named desktop asset with the approved centered treatment. The old generator's main() delegates here; its legacy geometry helpers remain importable by the historical reel and standalone experiments. Save the previous media before rendering, render and validate a separate version, then promote that version. A failed render cannot replace the current canonical video with a partial MP4. """ center_presentation() canonical = old.NAME stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") versions = OUT / "iterations" versions.mkdir(parents=True, exist_ok=True) archived = [] for suffix in (".mp4", "-contact-sheet.png", "-final.png", "-validation.json"): current = OUT / f"{canonical}{suffix}" if current.exists(): backup = versions / f"{canonical}-{stamp}-previous{suffix}" shutil.copy2(current, backup) archived.append(backup.relative_to(ROOT).as_posix()) prefix = f"iterations/{canonical}-{stamp}-centered" video = render(prefix, desktop, (1280, 720)) sheet = Image.new("RGB", (1920, 1158), old.BG) draw = ImageDraw.Draw(sheet) for index, seconds in enumerate(SAMPLES): x, y = index % 3 * 640, index // 3 * 386 sheet.paste(desktop(seconds).resize((640, 360), Image.Resampling.LANCZOS), (x, y)) draw.text((x + 16, y + 364), f"{seconds:g} s", fill=old.INK) sheet.save(OUT / f"{prefix}-contact-sheet.png") desktop(DURATION - 1 / FPS).save(OUT / f"{prefix}-final.png") report = { "canonical_video": f"{canonical}.mp4", "versioned_video": f"{prefix}.mp4", "video_checks": video, "fixed_display_rotation_degrees": float(np.angle(DISPLAY_ROTATION, deg=True)), "layout": visual_checks(), "previous_assets": archived, } for suffix in (".mp4", "-contact-sheet.png", "-final.png"): shutil.copy2(OUT / f"{prefix}{suffix}", OUT / f"{canonical}{suffix}") for name in (canonical, prefix): (OUT / f"{name}-validation.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") print(json.dumps(report, indent=2), flush=True) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--check", action="store_true") parser.add_argument("--preview", action="store_true") parser.add_argument("--render", action="store_true") parser.add_argument("--center-phase", action="store_true", help="presentation copy: set the complete central sum's phase to zero with one fixed display rotation") args = parser.parse_args() if args.center_phase: center_presentation() OUT.mkdir(parents=True, exist_ok=True) report = {} if args.center_phase: report["display"] = { "fixed_rotation_degrees": float(np.angle(DISPLAY_ROTATION, deg=True)), "center_sum_after_rotation": [float((DISPLAY_ROTATION * model.total(0.0)).real), float((DISPLAY_ROTATION * model.total(0.0)).imag)], "gray_phase_comparison_omitted": True, "underlying_physics_validation": "symmetry-tip-to-tail-49-slits-centered-validation.json", } report["layout"] = visual_checks() if args.check: report["physics"] = model.checks() report["layout"] = visual_checks() print(json.dumps(report, indent=2), flush=True) (OUT / f"{NAME}-validation.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") if args.preview: previews() if args.render: report["videos"] = [render(NAME, standalone, (960, 840)), render(DESKTOP, desktop, (1280, 720))] if report: (OUT / f"{NAME}-validation.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") if not any((args.check, args.preview, args.render)): parser.print_help() if __name__ == "__main__": main()