from matplotlib.animation import FuncAnimation
from IPython.display import HTML
@njit
def sdf_plane_slab(p, x_center, half_thick, half_h, half_d):
"""SDF for a bounded thin slab (wall) at x = x_center."""
dx = abs(p[0] - x_center) - half_thick
dy = abs(p[1]) - half_h
dz = abs(p[2]) - half_d
ox = max(dx, 0.0)
oy = max(dy, 0.0)
oz = max(dz, 0.0)
return np.sqrt(ox*ox + oy*oy + oz*oz) + min(max(dx, max(dy, dz)), 0.0)
def build_rope_segments(progress, n_pts=100):
"""Build rope segments for given progress (0..1).
Seg 1 approaches left portal, seg 2 emerges from right portal.
Segments extend PAST the portal disk; the renderer clips them
with a half-space SDF intersection for a clean cut."""
cx_L, cx_R = -2.0, 2.0
overshoot = 0.3 # extend past portal for clean clip
x_start, x_end = -5.0, 5.0
amp = 0.12
x_stop_L = cx_L + overshoot # extends past left portal
x_start_R = cx_R - overshoot # extends past right portal
# segment 1: x_start → x_stop_L
t1 = np.linspace(0.0, 1.0, n_pts)
seg1 = np.zeros((n_pts, 3))
seg1[:, 0] = x_start + (x_stop_L - x_start) * t1
seg1[:, 1] = amp * np.sin(t1 * 4.0 * np.pi)
seg1[:, 2] = amp * np.cos(t1 * 4.0 * np.pi)
# segment 2: x_start_R → x_end (phase-matched: sin/cos(4kπ)=0/1)
t2 = np.linspace(0.0, 1.0, n_pts)
seg2 = np.zeros((n_pts, 3))
seg2[:, 0] = x_start_R + (x_end - x_start_R) * t2
seg2[:, 1] = amp * np.sin(t2 * 4.0 * np.pi)
seg2[:, 2] = amp * np.cos(t2 * 4.0 * np.pi)
left_len = x_stop_L - x_start
right_len = x_end - x_start_R
total = left_len + right_len
fed = progress * total
if fed <= left_len:
n1 = max(0, int(fed / left_len * n_pts))
n2 = 0
else:
n1 = n_pts
n2 = min(n_pts, max(0, int((fed - left_len) / right_len * n_pts)))
return seg1, n1, seg2, n2
@njit
def sdf_rope_seg(p, seg, n_vis, radius, clip_x, clip_sign):
"""SDF for a rope segment (union of spheres), clipped by a half-space.
clip_sign = -1 : visible for p.x <= clip_x (left segment)
clip_sign = +1 : visible for p.x >= clip_x (right segment)
clip_sign = 0 : no clipping"""
if n_vis <= 0:
return 1e10
d_min = 1e10
for i in range(n_vis):
dx = p[0] - seg[i, 0]
dy = p[1] - seg[i, 1]
dz = p[2] - seg[i, 2]
d = np.sqrt(dx*dx + dy*dy + dz*dz) - radius
if d < d_min:
d_min = d
# half-space intersection: cleanly clips at the portal disk
if clip_sign < 0:
d_min = max(d_min, p[0] - clip_x)
elif clip_sign > 0:
d_min = max(d_min, clip_x - p[0])
return d_min
@njit
def estimate_normal_rope_portal(p, torus_R, torus_r, cx_L, cx_R,
seg1, n1, seg2, n2, rope_r,
plane_x, plane_ht, plane_hh, plane_hd):
eps = 1e-4
n = np.empty(3)
for i in range(3):
pp = p.copy(); pp[i] += eps
pm = p.copy(); pm[i] -= eps
dp = min(sdf_torus_rotated(pp, cx_L, torus_R, torus_r),
min(sdf_torus_rotated(pp, cx_R, torus_R, torus_r),
min(sdf_rope_seg(pp, seg1, n1, rope_r, cx_L, -1),
min(sdf_rope_seg(pp, seg2, n2, rope_r, cx_R, 1),
sdf_plane_slab(pp, plane_x, plane_ht,
plane_hh, plane_hd)))))
dm = min(sdf_torus_rotated(pm, cx_L, torus_R, torus_r),
min(sdf_torus_rotated(pm, cx_R, torus_R, torus_r),
min(sdf_rope_seg(pm, seg1, n1, rope_r, cx_L, -1),
min(sdf_rope_seg(pm, seg2, n2, rope_r, cx_R, 1),
sdf_plane_slab(pm, plane_x, plane_ht,
plane_hh, plane_hd)))))
n[i] = dp - dm
length = np.sqrt(n[0]**2 + n[1]**2 + n[2]**2)
if length > 0:
for i in range(3):
n[i] /= length
return n
@njit(parallel=True)
def render_rope_portal(width, height, fov, cam_pos, cam_target,
torus_R, torus_r, cx_L, cx_R,
seg1, n1, seg2, n2, rope_r,
plane_x, plane_ht, plane_hh, plane_hd,
light_dir, max_steps, max_dist, hit_eps):
"""Ray-march portal scene with rope segments and separating plane.
Rays are teleported through torus-hole disks as before."""
image = np.zeros((height, width, 3))
aspect = width / height
half_w = np.tan(fov / 2.0)
dx_tp = cx_R - cx_L
# ── camera basis ────────────────────────────────────────────────
forward = np.empty(3)
for i in range(3):
forward[i] = cam_target[i] - cam_pos[i]
fl = np.sqrt(forward[0]**2 + forward[1]**2 + forward[2]**2)
for i in range(3):
forward[i] /= fl
world_up = np.array([0.0, 1.0, 0.0])
right = np.empty(3)
right[0] = forward[1]*world_up[2] - forward[2]*world_up[1]
right[1] = forward[2]*world_up[0] - forward[0]*world_up[2]
right[2] = forward[0]*world_up[1] - forward[1]*world_up[0]
rl = np.sqrt(right[0]**2 + right[1]**2 + right[2]**2)
for i in range(3):
right[i] /= rl
up = np.empty(3)
up[0] = right[1]*forward[2] - right[2]*forward[1]
up[1] = right[2]*forward[0] - right[0]*forward[2]
up[2] = right[0]*forward[1] - right[1]*forward[0]
# ── colours ─────────────────────────────────────────────────────
left_torus_col = np.array([0.35, 0.55, 0.90])
right_torus_col = np.array([0.90, 0.55, 0.25])
rope_col = np.array([0.15, 0.72, 0.30])
plane_col = np.array([0.70, 0.70, 0.75])
bg_left = np.array([0.88, 0.90, 0.98])
bg_right = np.array([0.98, 0.91, 0.86])
for py in prange(height):
for px in range(width):
u = (2.0 * (px + 0.5) / width - 1.0) * half_w * aspect
v = (2.0 * (py + 0.5) / height - 1.0) * half_w
d = np.empty(3)
for i in range(3):
d[i] = forward[i] + u * right[i] - v * up[i]
dl = np.sqrt(d[0]**2 + d[1]**2 + d[2]**2)
for i in range(3):
d[i] /= dl
pos = cam_pos.copy()
direction = d.copy()
hit = False
hit_id = -1 # 0=L torus, 1=R torus, 2=rope, 3=plane
n_tp = 0
for _ in range(max_steps):
d_tL = sdf_torus_rotated(pos, cx_L, torus_R, torus_r)
d_tR = sdf_torus_rotated(pos, cx_R, torus_R, torus_r)
d_r1 = sdf_rope_seg(pos, seg1, n1, rope_r, cx_L, -1)
d_r2 = sdf_rope_seg(pos, seg2, n2, rope_r, cx_R, 1)
d_pl = sdf_plane_slab(pos, plane_x, plane_ht,
plane_hh, plane_hd)
dist = d_tL
sid = 0
if d_tR < dist:
dist = d_tR; sid = 1
if d_r1 < dist:
dist = d_r1; sid = 2
if d_r2 < dist:
dist = d_r2; sid = 2
if d_pl < dist:
dist = d_pl; sid = 3
if dist < hit_eps:
hit = True
hit_id = sid
break
if dist > max_dist:
break
old_pos = pos.copy()
pos, direction = step_ray_euclidean(pos, direction, dist)
# portal crossing check (inline)
if n_tp < 4:
for portal_x, shift in ((cx_L, dx_tp), (cx_R, -dx_tp)):
x0 = old_pos[0] - portal_x
x1 = pos[0] - portal_x
if x0 * x1 < 0.0:
t_cross = x0 / (x0 - x1)
cy = old_pos[1] + t_cross*(pos[1] - old_pos[1])
cz = old_pos[2] + t_cross*(pos[2] - old_pos[2])
if cy*cy + cz*cz < (torus_R - torus_r)**2:
pos[0] += shift
n_tp += 1
break
# ── shading / compositing ───────────────────────────────
final_col = np.empty(3)
if hit:
normal = estimate_normal_rope_portal(
pos, torus_R, torus_r, cx_L, cx_R,
seg1, n1, seg2, n2, rope_r,
plane_x, plane_ht, plane_hh, plane_hd)
brightness = shade(pos, normal, d, light_dir)
if hit_id == 0:
col = left_torus_col
elif hit_id == 1:
col = right_torus_col
elif hit_id == 2:
col = rope_col
else:
col = plane_col
for c in range(3):
final_col[c] = min(max(col[c] * brightness, 0.0), 1.0)
else:
mid = (cx_L + cx_R) / 2.0
if pos[0] < mid:
bg = bg_left
else:
bg = bg_right
for c in range(3):
final_col[c] = bg[c]
for c in range(3):
image[py, px, c] = final_col[c]
return image
# ── Animate: camera orbits 360°, rope expands then retracts ────────
N_FRAMES = 60
RES = 400
CAM_DIST = 8.0
CAM_H = 3.0
TORUS_R, TORUS_r = 1.0, 0.3
CX_L, CX_R = -2.0, 2.0
# separating plane at midpoint
PLANE_X = 0.0
PLANE_HT = 0.015 # half-thickness (very thin wall)
PLANE_HH = 2.5 # half-height (y extent)
PLANE_HD = 2.5 # half-depth (z extent)
tgt = np.array([0.0, 0.0, 0.0])
frames = []
for fi in range(N_FRAMES):
phase = 2.0 * np.pi * fi / N_FRAMES
# camera orbits a full 360° → seamless
cam = np.array([CAM_DIST * np.sin(phase),
CAM_H,
CAM_DIST * np.cos(phase)])
# light leads the camera by ~30°
light = np.array([CAM_DIST * np.sin(phase + 0.5),
5.0,
CAM_DIST * np.cos(phase + 0.5)])
# rope expands 0→1 then retracts 1→0 (cosine ease)
progress = 0.5 * (1.0 - np.cos(phase))
seg1, n1, seg2, n2 = build_rope_segments(progress, 100)
frame = render_rope_portal(
RES, RES, np.pi / 3.5,
cam, tgt,
TORUS_R, TORUS_r, CX_L, CX_R,
seg1, n1, seg2, n2, 0.07,
PLANE_X, PLANE_HT, PLANE_HH, PLANE_HD,
light, 200, 50.0, 1e-3
)
frames.append(frame)
fig, ax = plt.subplots(figsize=(7, 7), dpi=100)
ax.set_axis_off()
plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
im = ax.imshow(frames[0])
def update(i):
im.set_data(frames[i])
return [im]
anim = FuncAnimation(fig, update, frames=N_FRAMES, interval=100, blit=True)
plt.close(fig)
HTML(anim.to_html5_video())