From 083271a2bf2de585d75f91f67eb0b9cb65a5a4f5 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Sat, 4 Apr 2026 18:17:03 +0530 Subject: [PATCH 01/33] Load OpenGL class only if OpenGL renderer is used Now, if OpenGL renderer is not used then OpenGL classes are also not loaded. For OpenGL rednerer, there is no change in logic. Just wanted to ensure clear separation from OpenGL code for other renderers. --- manim/mobject/opengl/opengl_compatibility.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/manim/mobject/opengl/opengl_compatibility.py b/manim/mobject/opengl/opengl_compatibility.py index 761cd32918..c607c4cb64 100644 --- a/manim/mobject/opengl/opengl_compatibility.py +++ b/manim/mobject/opengl/opengl_compatibility.py @@ -4,10 +4,6 @@ from typing import Any from manim import config -from manim.mobject.opengl.opengl_mobject import OpenGLMobject -from manim.mobject.opengl.opengl_point_cloud_mobject import OpenGLPMobject -from manim.mobject.opengl.opengl_three_dimensions import OpenGLSurface -from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject from ...constants import RendererType @@ -26,6 +22,11 @@ def __new__( mcls, name: str, bases: tuple[type, ...], namespace: dict[str, Any] ) -> type: if config.renderer == RendererType.OPENGL: + from manim.mobject.opengl.opengl_mobject import OpenGLMobject + from manim.mobject.opengl.opengl_point_cloud_mobject import OpenGLPMobject + from manim.mobject.opengl.opengl_three_dimensions import OpenGLSurface + from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject + # Must check class names to prevent # cyclic importing. base_names_to_opengl: dict[str, type] = { From 69317ef6e8c7dece5b8859ec9cfd0a0a7e6e5761 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Sat, 4 Apr 2026 19:24:44 +0530 Subject: [PATCH 02/33] Added wgpu-py as a dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index eaceea4bbd..915ba00894 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ dependencies = [ "tqdm>=4.21.0", "typing-extensions>=4.12.0", "watchdog>=2.0.0", + "wgpu>=0.31.0", ] From ef9d044a6820c17ba097f8ab21bfff68e1152f3c Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Sat, 4 Apr 2026 19:32:31 +0530 Subject: [PATCH 03/33] Structural interface shared by all Manim renderers. Using ``typing.Protocol`` (rather than an ABC) means the existing ``CairoRenderer`` and ``OpenGLRenderer`` satisfy the interface automatically without any inheritance changes. --- manim/renderer/base_renderer.py | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 manim/renderer/base_renderer.py diff --git a/manim/renderer/base_renderer.py b/manim/renderer/base_renderer.py new file mode 100644 index 0000000000..b300ddce10 --- /dev/null +++ b/manim/renderer/base_renderer.py @@ -0,0 +1,55 @@ +"""Structural interface shared by all Manim renderers. + +Using ``typing.Protocol`` (rather than an ABC) means the existing +``CairoRenderer`` and ``OpenGLRenderer`` satisfy the interface automatically +without any inheritance changes. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, runtime_checkable + +import numpy as np +from typing import Protocol + +if TYPE_CHECKING: + from PIL import Image + + from manim.scene.scene import Scene + + +@runtime_checkable +class RendererProtocol(Protocol): + """Protocol that every Manim renderer must satisfy. + + ``scene.py`` accesses these attributes and methods on the renderer object. + Declaring them here makes the contract explicit and enables static type + checking without breaking existing renderer classes. + """ + + camera: Any + skip_animations: bool + num_plays: int + time: float + file_writer: Any + window: Any + animation_start_time: float + static_image: Any + + def init_scene(self, scene: Scene) -> None: ... + + def play(self, scene: Scene, *args: Any, **kwargs: Any) -> None: ... + + def render( + self, scene: Scene, frame_offset: float, moving_mobjects: list + ) -> None: ... + + def update_frame(self, scene: Scene) -> None: ... + + def scene_finished(self, scene: Scene) -> None: ... + + def clear_screen(self) -> None: ... + + def get_image(self) -> Image.Image: ... + + def get_frame(self) -> np.ndarray: ... From b80ee61529f02d410633aca86da4d7dbab6bfb99 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Sat, 4 Apr 2026 21:04:32 +0530 Subject: [PATCH 04/33] Basic WebGPU renderer for fill, stroke and surface All Protocol methods of WebGPURender (as defined in manim/renderer/base_renderer.py) is defined. Camera (both 2D and 3D prospective) and Loop-Blinn fill anti-aliasing for Cubic Bezeir Curves are implemented. Phong Shader is implemented for rendering surfaces. --- manim/constants.py | 1 + manim/renderer/webgpu/__init__.py | 5 + manim/renderer/webgpu/shaders/surface.wgsl | 66 ++ .../webgpu/shaders/vmobject_fill.wgsl | 63 ++ .../webgpu/shaders/vmobject_stroke.wgsl | 264 ++++++ manim/renderer/webgpu/webgpu_renderer.py | 825 ++++++++++++++++++ .../webgpu/webgpu_vmobject_rendering.py | 594 +++++++++++++ manim/scene/scene.py | 15 +- manim/scene/scene_file_writer.py | 7 +- 9 files changed, 1832 insertions(+), 8 deletions(-) create mode 100644 manim/renderer/webgpu/__init__.py create mode 100644 manim/renderer/webgpu/shaders/surface.wgsl create mode 100644 manim/renderer/webgpu/shaders/vmobject_fill.wgsl create mode 100644 manim/renderer/webgpu/shaders/vmobject_stroke.wgsl create mode 100644 manim/renderer/webgpu/webgpu_renderer.py create mode 100644 manim/renderer/webgpu/webgpu_vmobject_rendering.py diff --git a/manim/constants.py b/manim/constants.py index ccf99a0293..9774e98acf 100644 --- a/manim/constants.py +++ b/manim/constants.py @@ -273,6 +273,7 @@ class RendererType(Enum): CAIRO = "cairo" #: A renderer based on the cairo backend. OPENGL = "opengl" #: An OpenGL-based renderer. + WEBGPU = "webgpu" #: A WebGPU-based renderer (wgpu-py). class LineJointType(Enum): diff --git a/manim/renderer/webgpu/__init__.py b/manim/renderer/webgpu/__init__.py new file mode 100644 index 0000000000..e431e91996 --- /dev/null +++ b/manim/renderer/webgpu/__init__.py @@ -0,0 +1,5 @@ +"""WebGPU rendering backend for Manim (wgpu-py).""" + +from .webgpu_renderer import WebGPURenderer + +__all__ = ["WebGPURenderer"] diff --git a/manim/renderer/webgpu/shaders/surface.wgsl b/manim/renderer/webgpu/shaders/surface.wgsl new file mode 100644 index 0000000000..fbc0061b5d --- /dev/null +++ b/manim/renderer/webgpu/shaders/surface.wgsl @@ -0,0 +1,66 @@ +// WebGPU surface shader for Manim — Phase 3. +// +// Renders flat-shaded triangulated Surface faces (shade_in_3d=True VMobjects). +// Each vertex carries its world-space position, the face normal, and the fill +// colour. A Phong diffuse + ambient lighting model is applied in the fragment +// shader using the light_pos uniform. +// +// Depth test is enabled so that 3-D surfaces occlude each other correctly. +// +// Uniform layout (group 0, binding 0) — shared with fill/stroke: +// offset 0 — projection mat4x4 (64 bytes) +// offset 64 — view mat4x4 (64 bytes) +// offset 128 — light_pos vec3 (12 bytes, padded to 16) +// +// Vertex attributes: +// location 0 — in_vert vec3 world-space position +// location 1 — in_normal vec3 world-space face normal +// location 2 — in_color vec4 RGBA fill colour + +struct Uniforms { + projection : mat4x4, + view : mat4x4, + light_pos : vec3, + _pad : f32, +}; +@group(0) @binding(0) var u : Uniforms; + +struct VertexInput { + @location(0) in_vert : vec3, + @location(1) in_normal : vec3, + @location(2) in_color : vec4, +}; + +struct VertexOutput { + @builtin(position) clip_position : vec4, + @location(0) v_color : vec4, + @location(1) v_normal : vec3, + @location(2) v_world_pos : vec3, +}; + +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + var out: VertexOutput; + let world_pos = vec4(in.in_vert, 1.0); + out.clip_position = u.projection * u.view * world_pos; + out.v_world_pos = in.in_vert; + out.v_normal = in.in_normal; + out.v_color = in.in_color; + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let ambient_strength = 0.3; + let diffuse_strength = 0.7; + + let norm = normalize(in.v_normal); + let light_dir = normalize(u.light_pos - in.v_world_pos); + + // Two-sided lighting: use abs so back-faces aren't fully dark. + let diff = abs(dot(norm, light_dir)); + + let lighting = ambient_strength + diffuse_strength * diff; + let lit_rgb = clamp(in.v_color.rgb * lighting, vec3(0.0), vec3(1.0)); + return vec4(lit_rgb, in.v_color.a); +} diff --git a/manim/renderer/webgpu/shaders/vmobject_fill.wgsl b/manim/renderer/webgpu/shaders/vmobject_fill.wgsl new file mode 100644 index 0000000000..337baf8f14 --- /dev/null +++ b/manim/renderer/webgpu/shaders/vmobject_fill.wgsl @@ -0,0 +1,63 @@ +// WebGPU fill shader for VMobject — Phase 2. +// +// Uses the Loop-Blinn quadratic bezier test for smooth, anti-aliased fill +// boundaries. The CPU produces three kinds of triangles: +// +// texture_mode = +1 concave bezier region — fill where u²−v ≥ 0 +// texture_mode = −1 convex bezier region — fill where u²−v ≤ 0 +// texture_mode = 0 flat interior — always fill +// +// Uniform layout (group 0, binding 0) — 144 bytes total: +// offset 0 — projection mat4x4 (64 bytes) +// offset 64 — view mat4x4 (64 bytes) +// offset 128 — light_pos vec3 (12 bytes, padded to 16) +// +// Vertex attributes: +// location 0 — in_vert vec3 world-space position +// location 1 — in_color vec4 RGBA fill colour +// location 2 — texture_coords vec2 Loop-Blinn UV (u, v) +// location 3 — texture_mode f32 0 / +1 / −1 (stored as float) + +struct Uniforms { + projection : mat4x4, + view : mat4x4, + light_pos : vec3, + _pad : f32, +}; +@group(0) @binding(0) var u : Uniforms; + +struct VertexInput { + @location(0) in_vert : vec3, + @location(1) in_color : vec4, + @location(2) texture_coords : vec2, + @location(3) texture_mode : f32, +}; + +struct VertexOutput { + @builtin(position) clip_position : vec4, + @location(0) v_color : vec4, + @location(1) v_texture_coords : vec2, + @location(2) @interpolate(flat) v_texture_mode : i32, +}; + +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + var out: VertexOutput; + out.clip_position = u.projection * u.view * vec4(in.in_vert, 1.0); + out.v_color = in.in_color; + out.v_texture_coords = in.texture_coords; + out.v_texture_mode = i32(in.texture_mode); + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let uv = in.v_texture_coords; + let curve_func = uv.x * uv.x - uv.y; + // texture_mode == 0 → always keep (interior) + // sign(texture_mode) * curve_func >= 0 → keep (bezier edge region) + if (f32(in.v_texture_mode) * curve_func >= 0.0) { + return in.v_color; + } + discard; +} diff --git a/manim/renderer/webgpu/shaders/vmobject_stroke.wgsl b/manim/renderer/webgpu/shaders/vmobject_stroke.wgsl new file mode 100644 index 0000000000..b396c016f2 --- /dev/null +++ b/manim/renderer/webgpu/shaders/vmobject_stroke.wgsl @@ -0,0 +1,264 @@ +// WebGPU stroke shader for VMobject — Phase 2/3 (true cubic bezier). +// +// All four cubic bezier control points (b0, h0, h1, b3) are passed from the +// CPU without approximation. The vertex shader builds a tight bounding quad +// from the cubic AABB, and the fragment shader computes the exact unsigned +// distance to the cubic bezier via Newton's-method minimisation, discarding +// fragments outside the stroke half-width. +// +// Uniform layout (group 0, binding 0): +// offset 0 — projection mat4x4 (64 bytes) +// offset 64 — view mat4x4 (64 bytes) +// offset 128 — light_pos vec3 (12 bytes, padded to 16) +// +// Vertex attributes: +// location 0 — current_curve_0 vec3 start anchor (b0) +// location 1 — current_curve_1 vec3 first handle (h0) +// location 2 — current_curve_2 vec3 second handle (h1) +// location 3 — current_curve_3 vec3 end anchor (b3) +// location 4 — tile_coordinate vec2 quad corner ∈ [0,1] +// location 5 — in_color vec4 RGBA stroke colour +// location 6 — in_width f32 stroke width (Manim units) + +struct Uniforms { + projection : mat4x4, + view : mat4x4, + light_pos : vec3, + _pad : f32, +}; +@group(0) @binding(0) var u : Uniforms; + +// ---- Cubic bezier helpers ------------------------------------------------ + +fn cubic_eval( + p0: vec2, p1: vec2, p2: vec2, p3: vec2, t: f32 +) -> vec2 { + let s = 1.0 - t; + return s*s*s*p0 + 3.0*s*s*t*p1 + 3.0*s*t*t*p2 + t*t*t*p3; +} + +fn cubic_deriv1( + p0: vec2, p1: vec2, p2: vec2, p3: vec2, t: f32 +) -> vec2 { + let s = 1.0 - t; + return 3.0 * (s*s*(p1 - p0) + 2.0*s*t*(p2 - p1) + t*t*(p3 - p2)); +} + +fn cubic_deriv2( + p0: vec2, p1: vec2, p2: vec2, p3: vec2, t: f32 +) -> vec2 { + return 6.0 * ((1.0 - t)*(p2 - 2.0*p1 + p0) + t*(p3 - 2.0*p2 + p1)); +} + +// Unsigned distance from pos to the cubic bezier. Uses coarse sampling to +// seed Newton's-method minimisation of f(t) = |B(t) − pos|². +fn ud_cubic_bezier( + p0: vec2, p1: vec2, p2: vec2, p3: vec2, + pos: vec2, +) -> f32 { + // Coarse sampling: 9 equally-spaced t values. + var best_t : f32 = 0.0; + var best_d2 : f32 = 1e18; + for (var i = 0u; i <= 8u; i = i + 1u) { + let t = f32(i) * (1.0 / 8.0); + let pt = cubic_eval(p0, p1, p2, p3, t); + let d2 = dot(pt - pos, pt - pos); + if (d2 < best_d2) { best_d2 = d2; best_t = t; } + } + // Newton refinement. + for (var k = 0u; k < 4u; k = k + 1u) { + let t = clamp(best_t, 0.0, 1.0); + let pt = cubic_eval(p0, p1, p2, p3, t); + let dp = cubic_deriv1(p0, p1, p2, p3, t); + let d2p = cubic_deriv2(p0, p1, p2, p3, t); + let diff = pt - pos; + let denom = dot(dp, dp) + dot(diff, d2p); + if (abs(denom) > 1e-10) { best_t = t - dot(diff, dp) / denom; } + } + let closest = cubic_eval(p0, p1, p2, p3, clamp(best_t, 0.0, 1.0)); + return length(closest - pos); +} + +// ---- Bounding-box helpers ------------------------------------------------ + +fn cubic_eval_1d(p0: f32, p1: f32, p2: f32, p3: f32, t: f32) -> f32 { + let s = 1.0 - t; + return s*s*s*p0 + 3.0*s*s*t*p1 + 3.0*s*t*t*p2 + t*t*t*p3; +} + +// Axis-aligned bounding box of a 2-D cubic bezier. Returns vec4(min_xy, max_xy). +// Roots of the quadratic derivative give potential extremes between the endpoints. +fn bbox_cubic( + p0: vec2, p1: vec2, p2: vec2, p3: vec2 +) -> vec4 { + var mi = min(p0, p3); + var ma = max(p0, p3); + + // B'(t)/3 = a(1-t)^2 + 2b(1-t)t + ct^2 → At^2 + Bt + C = 0 + // where A = a-2b+c, B = 2(b-a), C = a, a=(p1-p0), b=(p2-p1), c=(p3-p2) + let a_v = p1 - p0; + let b_v = p2 - p1; + let c_v = p3 - p2; + let A = a_v - 2.0*b_v + c_v; + let B = 2.0 * (b_v - a_v); + let C = a_v; + + // x component + if (abs(A.x) > 1e-8) { + let disc = B.x*B.x - 4.0*A.x*C.x; + if (disc >= 0.0) { + let sq = sqrt(disc); + let t1 = (-B.x + sq) / (2.0*A.x); + let t2 = (-B.x - sq) / (2.0*A.x); + if (t1 > 0.0 && t1 < 1.0) { + let v = cubic_eval_1d(p0.x, p1.x, p2.x, p3.x, t1); + mi.x = min(mi.x, v); ma.x = max(ma.x, v); + } + if (t2 > 0.0 && t2 < 1.0) { + let v = cubic_eval_1d(p0.x, p1.x, p2.x, p3.x, t2); + mi.x = min(mi.x, v); ma.x = max(ma.x, v); + } + } + } else if (abs(B.x) > 1e-8) { + let t = -C.x / B.x; + if (t > 0.0 && t < 1.0) { + let v = cubic_eval_1d(p0.x, p1.x, p2.x, p3.x, t); + mi.x = min(mi.x, v); ma.x = max(ma.x, v); + } + } + + // y component + if (abs(A.y) > 1e-8) { + let disc = B.y*B.y - 4.0*A.y*C.y; + if (disc >= 0.0) { + let sq = sqrt(disc); + let t1 = (-B.y + sq) / (2.0*A.y); + let t2 = (-B.y - sq) / (2.0*A.y); + if (t1 > 0.0 && t1 < 1.0) { + let v = cubic_eval_1d(p0.y, p1.y, p2.y, p3.y, t1); + mi.y = min(mi.y, v); ma.y = max(ma.y, v); + } + if (t2 > 0.0 && t2 < 1.0) { + let v = cubic_eval_1d(p0.y, p1.y, p2.y, p3.y, t2); + mi.y = min(mi.y, v); ma.y = max(ma.y, v); + } + } + } else if (abs(B.y) > 1e-8) { + let t = -C.y / B.y; + if (t > 0.0 && t < 1.0) { + let v = cubic_eval_1d(p0.y, p1.y, p2.y, p3.y, t); + mi.y = min(mi.y, v); ma.y = max(ma.y, v); + } + } + + return vec4(mi, ma); +} + +fn to_uv(x_unit: vec3, y_unit: vec3, point: vec3) -> vec2 { + return vec2(dot(point, x_unit), dot(point, y_unit)); +} + +fn from_uv( + translation: vec3, x_unit: vec3, y_unit: vec3, p: vec2 +) -> vec3 { + return p.x * x_unit + p.y * y_unit + translation; +} + +// ---- Vertex I/O ----------------------------------------------------------- + +struct VertexInput { + @location(0) current_curve_0 : vec3, + @location(1) current_curve_1 : vec3, + @location(2) current_curve_2 : vec3, + @location(3) current_curve_3 : vec3, + @location(4) tile_coordinate : vec2, + @location(5) in_color : vec4, + @location(6) in_width : f32, +}; + +struct VertexOutput { + @builtin(position) clip_position : vec4, + @location(0) v_thickness : f32, + @location(1) uv_point : vec2, + @location(2) uv_curve_0 : vec2, + @location(3) uv_curve_1 : vec2, + @location(4) uv_curve_2 : vec2, + @location(5) uv_curve_3 : vec2, + @location(6) v_color : vec4, +}; + +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + let thickness_multiplier = 0.004; + var out: VertexOutput; + out.v_color = in.in_color; + out.v_thickness = thickness_multiplier * in.in_width; + + // For 2-D scenes the scene-normal is always +Z. + let manim_unit_normal = vec3(0.0, 0.0, 1.0); + + // Tile x-axis follows the chord (b3 − b0); fall back to h0 direction. + let chord = in.current_curve_3 - in.current_curve_0; + let chord_len = length(chord.xy); + + var tile_x_unit : vec3; + if (chord_len > 1e-6) { + tile_x_unit = vec3(normalize(chord.xy), 0.0); + } else { + let alt = in.current_curve_1 - in.current_curve_0; + let alt_len = length(alt.xy); + if (alt_len > 1e-6) { + tile_x_unit = vec3(normalize(alt.xy), 0.0); + } else { + // Truly degenerate — collapse to a point, nothing to draw. + out.clip_position = u.projection * u.view * vec4(in.current_curve_0, 1.0); + out.uv_point = vec2(0.0); + out.uv_curve_0 = vec2(0.0); + out.uv_curve_1 = vec2(0.0); + out.uv_curve_2 = vec2(0.0); + out.uv_curve_3 = vec2(0.0); + return out; + } + } + let tile_y_unit = cross(manim_unit_normal, tile_x_unit); + + // Project all four cubic control points into the local tile UV space. + let uv0 = to_uv(tile_x_unit, tile_y_unit, in.current_curve_0); + let uv1 = to_uv(tile_x_unit, tile_y_unit, in.current_curve_1); + let uv2 = to_uv(tile_x_unit, tile_y_unit, in.current_curve_2); + let uv3 = to_uv(tile_x_unit, tile_y_unit, in.current_curve_3); + out.uv_curve_0 = uv0; + out.uv_curve_1 = uv1; + out.uv_curve_2 = uv2; + out.uv_curve_3 = uv3; + + // Tight bounding quad: cubic AABB padded by the stroke thickness. + let t = out.v_thickness; + let uv_bb = bbox_cubic(uv0, uv1, uv2, uv3); + let uv_min = uv_bb.xy - vec2(t); + let uv_max = uv_bb.zw + vec2(t); + + // tile_coordinate ∈ [0,1]² → lerp within [uv_min, uv_max]. + let uv_tile = mix(uv_min, uv_max, in.tile_coordinate); + + let tile_translation = manim_unit_normal * dot(in.current_curve_0, manim_unit_normal); + let tile_point = from_uv(tile_translation, tile_x_unit, tile_y_unit, uv_tile); + + out.clip_position = u.projection * u.view * vec4(tile_point, 1.0); + out.uv_point = uv_tile; + return out; +} + +// ---- Fragment shader ------------------------------------------------------- + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let dist = ud_cubic_bezier( + in.uv_curve_0, in.uv_curve_1, in.uv_curve_2, in.uv_curve_3, + in.uv_point, + ); + if (dist < in.v_thickness) { + return in.v_color; + } + discard; +} diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py new file mode 100644 index 0000000000..7cadd1861d --- /dev/null +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -0,0 +1,825 @@ +"""WebGPU renderer for Manim — Phase 1. + +Phase 1 scope +------------- +* Headless rendering (no preview window). +* VMobject fill only. +* ``config.save_last_frame = True`` → saves a PNG. +* ``config.write_to_movie = True`` → writes video frames. + +Design +------ +Reads geometry directly from Cairo ``VMobject``. No dependency on OpenGL +classes (``OpenGLCamera``, ``OpenGLVMobject``, ``moderngl``). + +Camera +------ +A simple orthographic projection matrix maps Manim's frame coordinate system +(centre at origin, width = config.frame_width, height = config.frame_height) +to WebGPU NDC (x, y ∈ [-1, 1], z ∈ [0, 1]). +""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import numpy as np +from PIL import Image + +from manim import config, logger +from manim.constants import OUT, PI, RIGHT +from manim.mobject.types.vectorized_mobject import VMobject +from manim.scene.scene_file_writer import SceneFileWriter +from manim.utils.color import color_to_rgba +from manim.utils.exceptions import EndSceneEarlyException +from manim.utils.simple_functions import clip +from manim.utils.space_ops import ( + quaternion_from_angle_axis, + quaternion_mult, + rotation_matrix_transpose_from_quaternion, +) + +from .webgpu_vmobject_rendering import ( + FILL_VERTEX_LAYOUT, + STROKE_VERTEX_LAYOUT, + SURFACE_VERTEX_LAYOUT, + render_webgpu_mobject, +) + +if TYPE_CHECKING: + import wgpu as wgpu_t + + from manim.scene.scene import Scene + +try: + import wgpu +except ImportError as exc: + msg = ( + "wgpu-py is required for the WebGPU renderer. " + "Install it with: pip install wgpu" + ) + raise ImportError(msg) from exc + + +# --------------------------------------------------------------------------- +# Camera — feature-parity with OpenGLCamera for 2-D + 3-D scenes. +# --------------------------------------------------------------------------- + + +class WebGPUCamera: + """Camera for the WebGPU renderer. + + Matches the attribute / method surface of ``OpenGLCamera`` so that + scene code that inspects ``renderer.camera`` works without changes. + + Projection + ---------- + * 2-D scenes (default): orthographic, z mapped to the WebGPU [0, 1] NDC + range. + * 3-D scenes (Phase 3): perspective projection driven by ``focal_distance`` + and the Euler-angle view matrix. + + Parameters + ---------- + frame_shape + (width, height) of the rendered frame. Defaults to + ``(config.frame_width, config.frame_height)``. + center_point + World-space origin of the camera frame. Defaults to the origin. + euler_angles + (theta, phi, gamma) camera orientation angles in radians. + Defaults to (0, 0, 0) — looking straight down the −Z axis. + focal_distance + Perspective focal distance expressed as a multiple of ``frame_height``. + Only used when ``orthographic=False``. + light_source_position + World-space position of the key light. Defaults to (−10, 10, 10). + orthographic + Use orthographic (True) or perspective (False) projection. + Default is True (matching Manim's default 2-D look). + minimum_polar_angle / maximum_polar_angle + Clamp range for the phi Euler angle during interactive orbit. + """ + + near: float = -100.0 + far: float = 100.0 + use_z_index: bool = True + + def __init__( + self, + frame_shape: tuple[float, float] | None = None, + center_point: np.ndarray | None = None, + euler_angles: np.ndarray | None = None, + focal_distance: float = 2.0, + light_source_position: np.ndarray | None = None, + orthographic: bool = True, + minimum_polar_angle: float = -PI / 2, + maximum_polar_angle: float = PI / 2, + ) -> None: + self.use_z_index = True + self.frame_rate: int = config.get("frame_rate", 60) + self.orthographic = orthographic + self.minimum_polar_angle = minimum_polar_angle + self.maximum_polar_angle = maximum_polar_angle + self.focal_distance = focal_distance + + self.frame_shape: tuple[float, float] = ( + frame_shape + if frame_shape is not None + else (float(config["frame_width"]), float(config["frame_height"])) + ) + self.center_point: np.ndarray = ( + np.asarray(center_point, dtype=float) + if center_point is not None + else np.zeros(3) + ) + self.light_source_position: np.ndarray = np.asarray( + light_source_position if light_source_position is not None else [-10, 10, 10], + dtype=float, + ) + self.euler_angles: np.ndarray = np.asarray( + euler_angles if euler_angles is not None else [0.0, 0.0, 0.0], + dtype=float, + ) + self.refresh_rotation_matrix() + + # ------------------------------------------------------------------ + # Frame geometry helpers (mirrors OpenGLCamera) + # ------------------------------------------------------------------ + + def get_width(self) -> float: + """Width of the camera frame in scene units.""" + return self.frame_shape[0] + + def get_height(self) -> float: + """Height of the camera frame in scene units.""" + return self.frame_shape[1] + + def get_shape(self) -> tuple[float, float]: + """(width, height) of the camera frame in scene units.""" + return self.frame_shape + + def get_center(self) -> np.ndarray: + """World-space centre of the camera frame.""" + return self.center_point.copy() + + def get_focal_distance(self) -> float: + """Perspective focal distance in scene units.""" + return self.focal_distance * self.get_height() + + # ------------------------------------------------------------------ + # Camera reset + # ------------------------------------------------------------------ + + def to_default_state(self) -> WebGPUCamera: + """Reset frame size, position, and orientation to config defaults.""" + self.frame_shape = ( + float(config["frame_width"]), + float(config["frame_height"]), + ) + self.center_point = np.zeros(3) + self.euler_angles = np.zeros(3) + self.refresh_rotation_matrix() + return self + + # ------------------------------------------------------------------ + # Rotation — matches OpenGLCamera.set/increment_* interface + # ------------------------------------------------------------------ + + def refresh_rotation_matrix(self) -> None: + """Recompute ``inverse_rotation_matrix`` from current Euler angles.""" + theta, phi, gamma = self.euler_angles + quat = quaternion_mult( + quaternion_from_angle_axis(theta, OUT, axis_normalized=True), + quaternion_from_angle_axis(phi, RIGHT, axis_normalized=True), + quaternion_from_angle_axis(gamma, OUT, axis_normalized=True), + ) + self.inverse_rotation_matrix: np.ndarray = np.array( + rotation_matrix_transpose_from_quaternion(np.asarray(quat, dtype=float)), + dtype=float, + ) + + def set_euler_angles( + self, + theta: float | None = None, + phi: float | None = None, + gamma: float | None = None, + ) -> WebGPUCamera: + if theta is not None: + self.euler_angles[0] = theta + if phi is not None: + self.euler_angles[1] = phi + if gamma is not None: + self.euler_angles[2] = gamma + self.refresh_rotation_matrix() + return self + + def set_theta(self, theta: float) -> WebGPUCamera: + return self.set_euler_angles(theta=theta) + + def set_phi(self, phi: float) -> WebGPUCamera: + return self.set_euler_angles(phi=phi) + + def set_gamma(self, gamma: float) -> WebGPUCamera: + return self.set_euler_angles(gamma=gamma) + + def increment_theta(self, dtheta: float) -> WebGPUCamera: + self.euler_angles[0] += dtheta + self.refresh_rotation_matrix() + return self + + def increment_phi(self, dphi: float) -> WebGPUCamera: + self.euler_angles[1] = clip( + self.euler_angles[1] + dphi, + self.minimum_polar_angle, + self.maximum_polar_angle, + ) + self.refresh_rotation_matrix() + return self + + def increment_gamma(self, dgamma: float) -> WebGPUCamera: + self.euler_angles[2] += dgamma + self.refresh_rotation_matrix() + return self + + # ------------------------------------------------------------------ + # View matrix (world → camera space) + # ------------------------------------------------------------------ + + @property + def view_matrix(self) -> np.ndarray: + """4×4 float32 view matrix: rotates and translates world space into + camera space. + + For default 2-D scenes (no rotation, center at origin) this is the + identity matrix, so 2-D rendering is unaffected. + """ + R = np.asarray(self.inverse_rotation_matrix, dtype=np.float32) # 3×3 + c = self.center_point.astype(np.float32) + view = np.eye(4, dtype=np.float32) + view[:3, :3] = R + view[:3, 3] = -(R @ c) + return view + + # ------------------------------------------------------------------ + # Projection matrix (used by the shader uniform upload) + # ------------------------------------------------------------------ + + @property + def projection_matrix(self) -> np.ndarray: + """4×4 float32 projection matrix in WebGPU NDC convention (z ∈ [0, 1]). + + Orthographic when ``self.orthographic`` is True (default). + Perspective otherwise — focal distance drives the field of view. + """ + fw, fh = self.frame_shape + near, far = self.near, self.far + + if self.orthographic: + # Orthographic: map frame to NDC with z ∈ [0, 1]. + return np.array( + [ + [2.0 / fw, 0.0, 0.0, 0.0], + [0.0, 2.0 / fh, 0.0, 0.0], + [0.0, 0.0, 1.0 / (far - near), -near / (far - near)], + [0.0, 0.0, 0.0, 1.0], + ], + dtype=np.float32, + ) + else: + # Perspective: symmetric frustum, z ∈ [0, 1] (WebGPU NDC). + fd = self.get_focal_distance() + return np.array( + [ + [2.0 * fd / fw, 0.0, 0.0, 0.0], + [0.0, 2.0 * fd / fh, 0.0, 0.0], + [0.0, 0.0, far / (far - near), -far * near / (far - near)], + [0.0, 0.0, 1.0, 0.0], + ], + dtype=np.float32, + ) + + +# --------------------------------------------------------------------------- +# Main renderer class +# --------------------------------------------------------------------------- + + +class WebGPURenderer: + """Headless WebGPU renderer (Phase 1: fill rendering to PNG / video).""" + + def __init__( + self, + file_writer_class: type[SceneFileWriter] = SceneFileWriter, + skip_animations: bool = False, + ) -> None: + self._file_writer_class = file_writer_class + self._original_skipping_status = skip_animations + self.skip_animations = skip_animations + + self.animation_start_time: float = 0.0 + self.animation_elapsed_time: float = 0.0 + self.time: float = 0.0 + self.num_plays: int = 0 + self.animations_hashes: list[str | None] = [] + + self.camera: WebGPUCamera = WebGPUCamera() + self.window: None = None + self.static_image: Any = None + self.file_writer: SceneFileWriter | None = None # set by init_scene() + + self.background_color = config["background_color"] + + # Filled by init_scene(): + self._device: wgpu_t.GPUDevice | None = None + self._render_texture: wgpu_t.GPUTexture | None = None + self._render_texture_view: wgpu_t.GPUTextureView | None = None + self._depth_texture: wgpu_t.GPUTexture | None = None + self._depth_texture_view: wgpu_t.GPUTextureView | None = None + self._proj_bgl: wgpu_t.GPUBindGroupLayout | None = None + self._fill_pipeline: wgpu_t.GPURenderPipeline | None = None + self._stroke_pipeline: wgpu_t.GPURenderPipeline | None = None + self._surface_pipeline: wgpu_t.GPURenderPipeline | None = None + + # Per-frame state (set during update_frame, cleared after submit). + self.current_render_pass: wgpu_t.GPURenderPassEncoder | None = None + self.camera_bind_group: wgpu_t.GPUBindGroup | None = None + self.frame_vbos: list[wgpu_t.GPUBuffer] = [] + + # ------------------------------------------------------------------ + # Initialisation + # ------------------------------------------------------------------ + + def init_scene(self, scene: Scene) -> None: + """Create the wgpu device, offscreen texture, and file writer.""" + self.scene = scene + self.partial_movie_files: list[str | None] = [] + self.file_writer: SceneFileWriter = self._file_writer_class( + self, + scene.__class__.__name__, + ) + + self.background_color = config["background_color"] + + adapter = wgpu.gpu.request_adapter_sync(power_preference="high-performance") + self._device = adapter.request_device_sync( + required_features=[], + required_limits={}, + ) + logger.debug("WebGPU adapter: %s", adapter.info) + + width = config.pixel_width + height = config.pixel_height + self._render_texture = self._device.create_texture( + size=(width, height, 1), + format=wgpu.TextureFormat.rgba8unorm, + usage=wgpu.TextureUsage.RENDER_ATTACHMENT | wgpu.TextureUsage.COPY_SRC, + ) + self._render_texture_view = self._render_texture.create_view() + + self._depth_texture = self._device.create_texture( + size=(width, height, 1), + format=wgpu.TextureFormat.depth24plus, + usage=wgpu.TextureUsage.RENDER_ATTACHMENT, + ) + self._depth_texture_view = self._depth_texture.create_view() + + self._proj_bgl, self._fill_pipeline = self._create_fill_pipeline() + self._stroke_pipeline = self._create_stroke_pipeline(self._proj_bgl) + self._surface_pipeline = self._create_surface_pipeline(self._proj_bgl) + + # ------------------------------------------------------------------ + # Pipeline creation + # ------------------------------------------------------------------ + + def _create_fill_pipeline( + self, + ) -> tuple[wgpu_t.GPUBindGroupLayout, wgpu_t.GPURenderPipeline]: + assert self._device is not None + shader_path = Path(__file__).parent / "shaders" / "vmobject_fill.wgsl" + shader_module = self._device.create_shader_module( + code=shader_path.read_text(encoding="utf-8") + ) + + # One uniform buffer: projection (64 B) + view (64 B) + light_pos+pad (16 B) = 144 bytes. + proj_bgl = self._device.create_bind_group_layout( + entries=[ + { + "binding": 0, + "visibility": wgpu.ShaderStage.VERTEX | wgpu.ShaderStage.FRAGMENT, + "buffer": {"type": "uniform"}, + } + ] + ) + + pipeline = self._device.create_render_pipeline( + layout=self._device.create_pipeline_layout( + bind_group_layouts=[proj_bgl] + ), + vertex={ + "module": shader_module, + "entry_point": "vs_main", + "buffers": [FILL_VERTEX_LAYOUT], + }, + fragment={ + "module": shader_module, + "entry_point": "fs_main", + "targets": [ + { + "format": wgpu.TextureFormat.rgba8unorm, + "blend": { + "color": { + "src_factor": "src-alpha", + "dst_factor": "one-minus-src-alpha", + "operation": "add", + }, + "alpha": { + "src_factor": "one", + "dst_factor": "one", + "operation": "add", + }, + }, + } + ], + }, + primitive={"topology": "triangle-list", "cull_mode": "none"}, + depth_stencil={ + "format": wgpu.TextureFormat.depth24plus, + "depth_write_enabled": False, + "depth_compare": "always", + "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_read_mask": 0, + "stencil_write_mask": 0, + }, + multisample={ + "count": 1, + "mask": 0xFFFF_FFFF, + "alpha_to_coverage_enabled": False, + }, + ) + return proj_bgl, pipeline + + def _create_stroke_pipeline( + self, proj_bgl: wgpu_t.GPUBindGroupLayout + ) -> wgpu_t.GPURenderPipeline: + assert self._device is not None + shader_path = Path(__file__).parent / "shaders" / "vmobject_stroke.wgsl" + shader_module = self._device.create_shader_module( + code=shader_path.read_text(encoding="utf-8") + ) + _blend = { + "color": { + "src_factor": "src-alpha", + "dst_factor": "one-minus-src-alpha", + "operation": "add", + }, + "alpha": { + "src_factor": "one", + "dst_factor": "one", + "operation": "add", + }, + } + return self._device.create_render_pipeline( + layout=self._device.create_pipeline_layout( + bind_group_layouts=[proj_bgl] + ), + vertex={ + "module": shader_module, + "entry_point": "vs_main", + "buffers": [STROKE_VERTEX_LAYOUT], + }, + fragment={ + "module": shader_module, + "entry_point": "fs_main", + "targets": [{"format": wgpu.TextureFormat.rgba8unorm, "blend": _blend}], + }, + primitive={"topology": "triangle-list", "cull_mode": "none"}, + depth_stencil={ + "format": wgpu.TextureFormat.depth24plus, + "depth_write_enabled": False, + "depth_compare": "always", + "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_read_mask": 0, + "stencil_write_mask": 0, + }, + multisample={ + "count": 1, + "mask": 0xFFFF_FFFF, + "alpha_to_coverage_enabled": False, + }, + ) + + def _create_surface_pipeline( + self, proj_bgl: wgpu_t.GPUBindGroupLayout + ) -> wgpu_t.GPURenderPipeline: + assert self._device is not None + shader_path = Path(__file__).parent / "shaders" / "surface.wgsl" + shader_module = self._device.create_shader_module( + code=shader_path.read_text(encoding="utf-8") + ) + _blend = { + "color": { + "src_factor": "src-alpha", + "dst_factor": "one-minus-src-alpha", + "operation": "add", + }, + "alpha": { + "src_factor": "one", + "dst_factor": "one", + "operation": "add", + }, + } + return self._device.create_render_pipeline( + layout=self._device.create_pipeline_layout( + bind_group_layouts=[proj_bgl] + ), + vertex={ + "module": shader_module, + "entry_point": "vs_main", + "buffers": [SURFACE_VERTEX_LAYOUT], + }, + fragment={ + "module": shader_module, + "entry_point": "fs_main", + "targets": [{"format": wgpu.TextureFormat.rgba8unorm, "blend": _blend}], + }, + primitive={"topology": "triangle-list", "cull_mode": "none"}, + depth_stencil={ + "format": wgpu.TextureFormat.depth24plus, + "depth_write_enabled": True, + "depth_compare": "less", + "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_read_mask": 0, + "stencil_write_mask": 0, + }, + multisample={ + "count": 1, + "mask": 0xFFFF_FFFF, + "alpha_to_coverage_enabled": False, + }, + ) + + # ------------------------------------------------------------------ + # Camera bind group (rebuilt each frame when projection changes) + # ------------------------------------------------------------------ + + def _build_camera_bind_group(self) -> wgpu_t.GPUBindGroup: + assert self._device is not None + assert self._proj_bgl is not None + + # Pack uniform buffer: projection (64 B) + view (64 B) + light_pos+pad (16 B) = 144 B. + # WGSL mat4x4 is column-major: transpose → flatten before packing. + proj_bytes = self.camera.projection_matrix.T.flatten().tobytes() # 64 bytes + view_bytes = self.camera.view_matrix.T.flatten().tobytes() # 64 bytes + light = np.zeros(4, dtype=np.float32) + light[:3] = self.camera.light_source_position.astype(np.float32) + light_bytes = light.tobytes() # 16 bytes + + uniform_data = proj_bytes + view_bytes + light_bytes # 144 bytes + + proj_buf = self._device.create_buffer_with_data( + data=uniform_data, + usage=wgpu.BufferUsage.UNIFORM, + ) + self.frame_vbos.append(proj_buf) + + return self._device.create_bind_group( + layout=self._proj_bgl, + entries=[ + {"binding": 0, "resource": {"buffer": proj_buf, "offset": 0, "size": 144}} + ], + ) + + # ------------------------------------------------------------------ + # Pipeline / device accessors (used by webgpu_vmobject_rendering) + # ------------------------------------------------------------------ + + @property + def device(self) -> wgpu_t.GPUDevice: + assert self._device is not None, "init_scene() has not been called" + return self._device + + @property + def fill_pipeline(self) -> wgpu_t.GPURenderPipeline: + assert self._fill_pipeline is not None, "init_scene() has not been called" + return self._fill_pipeline + + @property + def stroke_pipeline(self) -> wgpu_t.GPURenderPipeline: + assert self._stroke_pipeline is not None, "init_scene() has not been called" + return self._stroke_pipeline + + @property + def surface_pipeline(self) -> wgpu_t.GPURenderPipeline: + assert self._surface_pipeline is not None, "init_scene() has not been called" + return self._surface_pipeline + + # ------------------------------------------------------------------ + # Frame rendering + # ------------------------------------------------------------------ + + def update_frame(self, scene: Scene) -> None: + """Render one frame into the offscreen texture.""" + assert self._device is not None + assert self._render_texture_view is not None + assert self._depth_texture_view is not None + + bg = self._background_color # (r, g, b, a) floats in [0, 1] + + encoder = self._device.create_command_encoder() + render_pass = encoder.begin_render_pass( + color_attachments=[ + { + "view": self._render_texture_view, + "load_op": "clear", + "store_op": "store", + "clear_value": tuple(float(c) for c in bg), + } + ], + depth_stencil_attachment={ + "view": self._depth_texture_view, + "depth_clear_value": 1.0, + "depth_load_op": "clear", + "depth_store_op": "discard", + }, + ) + + self.current_render_pass = render_pass + self.frame_vbos = [] + + # One camera bind group per frame (projection may change). + self.camera_bind_group = self._build_camera_bind_group() + + for mobject in scene.mobjects: + if isinstance(mobject, VMobject): + # render_webgpu_mobject routes each family member to fill/stroke + # or the Phong surface pipeline based on shade_in_3d per submobject. + render_webgpu_mobject(self, mobject) + + render_pass.end() + self._device.queue.submit([encoder.finish()]) + + self.current_render_pass = None + self.camera_bind_group = None + self.frame_vbos = [] + + self.animation_elapsed_time = time.time() - self.animation_start_time + + # ------------------------------------------------------------------ + # Frame readback + # ------------------------------------------------------------------ + + def _get_raw_frame_data(self) -> bytes: + """Copy the render texture to CPU memory and return tightly-packed RGBA bytes.""" + assert self._device is not None + assert self._render_texture is not None + + width = config.pixel_width + height = config.pixel_height + bpr = width * 4 # bytes per row (unpadded) + + # WebGPU requires bytes_per_row to be a multiple of 256. + aligned_bpr = (bpr + 255) & ~255 + + readback_buf = self._device.create_buffer( + size=aligned_bpr * height, + usage=wgpu.BufferUsage.COPY_DST | wgpu.BufferUsage.MAP_READ, + ) + + encoder = self._device.create_command_encoder() + encoder.copy_texture_to_buffer( + {"texture": self._render_texture, "mip_level": 0, "origin": (0, 0, 0)}, + { + "buffer": readback_buf, + "offset": 0, + "bytes_per_row": aligned_bpr, + "rows_per_image": height, + }, + (width, height, 1), + ) + self._device.queue.submit([encoder.finish()]) + + readback_buf.map_sync(wgpu.MapMode.READ) + raw = bytes(readback_buf.read_mapped()) + readback_buf.unmap() + + if aligned_bpr != bpr: + raw = b"".join( + raw[i * aligned_bpr : i * aligned_bpr + bpr] for i in range(height) + ) + return raw + + def get_image(self) -> Image.Image: + """Return the current frame as a PIL Image (RGBA).""" + raw = self._get_raw_frame_data() + return Image.frombytes( + "RGBA", (config.pixel_width, config.pixel_height), raw + ) + + def get_frame(self) -> np.ndarray: + """Return the current frame as a (height, width, 4) uint8 NumPy array.""" + raw = self._get_raw_frame_data() + return np.frombuffer(raw, dtype=np.uint8).reshape( + (config.pixel_height, config.pixel_width, 4) + ) + + # ------------------------------------------------------------------ + # Scene lifecycle + # ------------------------------------------------------------------ + + def render(self, scene: Scene, frame_offset: float, moving_mobjects: list) -> None: + self.update_frame(scene) + if not self.skip_animations: + self.file_writer.write_frame(self) + + def play(self, scene: Scene, *animations: Any, **kwargs: Any) -> None: + self.animation_start_time = time.time() + self.skip_animations = self._original_skipping_status + self._update_skipping_status() + + self.animations_hashes.append(None) + self.file_writer.add_partial_movie_file(None) + + self.file_writer.begin_animation(not self.skip_animations) + scene.compile_animation_data(*animations, **kwargs) + scene.begin_animations() + + if scene.is_current_animation_frozen_frame(): + self.update_frame(scene) + if not self.skip_animations: + self.file_writer.write_frame( + self, num_frames=int(config.frame_rate * scene.duration) + ) + self.animation_elapsed_time = scene.duration + else: + scene.play_internal() + + self.file_writer.end_animation(not self.skip_animations) + self.time += scene.duration + self.num_plays += 1 + + def scene_finished(self, scene: Scene) -> None: + if self.num_plays > 0: + self.file_writer.finish() + elif self.num_plays == 0 and config.write_to_movie: + config.save_last_frame = True + config.write_to_movie = False + + if self._should_save_last_frame(): + config.save_last_frame = True + self.update_frame(scene) + self.file_writer.save_image(self.get_image()) + + def save_static_frame_data(self, scene: Scene, static_mobjects: Any) -> None: + pass # not implemented in Phase 1 + + def clear_screen(self) -> None: + pass # headless — no window + + # ------------------------------------------------------------------ + # Skipping helpers + # ------------------------------------------------------------------ + + def _update_skipping_status(self) -> None: + if self.file_writer.sections[-1].skip_animations: + self.skip_animations = True + if config["save_last_frame"]: + self.skip_animations = True + if ( + config.from_animation_number > 0 + and self.num_plays < config.from_animation_number + ): + self.skip_animations = True + if ( + config.upto_animation_number >= 0 + and self.num_plays > config.upto_animation_number + ): + self.skip_animations = True + raise EndSceneEarlyException() + + def _should_save_last_frame(self) -> bool: + if config["save_last_frame"]: + return True + if self.scene.interactive_mode: + return False + return self.num_plays == 0 + + # ------------------------------------------------------------------ + # Background colour + # ------------------------------------------------------------------ + + @property + def background_color(self): + return self._background_color + + @background_color.setter + def background_color(self, value) -> None: + self._background_color = color_to_rgba(value, 1.0) + + def get_pixel_shape(self) -> tuple[int, int]: + return (config.pixel_width, config.pixel_height) diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py new file mode 100644 index 0000000000..e1fce7c3e9 --- /dev/null +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -0,0 +1,594 @@ +"""WebGPU draw calls for VMobject fill + stroke + surface rendering — Phase 2/3. + +Fill +---- +Uses the Loop-Blinn quadratic bezier test for anti-aliased fill boundaries. +Each cubic bezier curve is first split into 4 sub-cubics via de Casteljau +subdivision (2 levels), then each sub-cubic is approximated as a quadratic. +The subdivision reduces the approximation error to negligible levels (<0.01 %). +Three kinds of triangles are emitted: + + texture_mode = +1 concave bezier region (Loop-Blinn: keep where u²−v ≥ 0) + texture_mode = −1 convex bezier region (Loop-Blinn: keep where u²−v ≤ 0) + texture_mode = 0 flat interior (always kept) + +Stroke +------ +All four cubic bezier control points (b0, h0, h1, b3) are passed to the GPU. +The fragment shader computes the exact unsigned distance to the cubic bezier +curve via Newton's-method minimisation and discards pixels outside the stroke +half-width — no quadratic approximation is made. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from manim.utils.space_ops import cross2d, earclip_triangulation + +if TYPE_CHECKING: + import wgpu as wgpu_t + + from manim.mobject.types.vectorized_mobject import VMobject + from manim.renderer.webgpu.webgpu_renderer import WebGPURenderer + + +# --------------------------------------------------------------------------- +# Surface vertex layout — must match surface.wgsl locations: +# location 0 → in_vert float32x3 offset 0 (12 bytes) +# location 1 → in_normal float32x3 offset 12 (12 bytes) +# location 2 → in_color float32x4 offset 24 (16 bytes) +# stride: 40 bytes +# --------------------------------------------------------------------------- + +_SURFACE_DTYPE = np.dtype( + [ + ("in_vert", np.float32, (3,)), + ("in_normal", np.float32, (3,)), + ("in_color", np.float32, (4,)), + ] +) +_SURFACE_STRIDE: int = _SURFACE_DTYPE.itemsize # 40 bytes + +_SURFACE_OFFSETS: dict[str, int] = { + name: _SURFACE_DTYPE.fields[name][1] # type: ignore[index] + for name in _SURFACE_DTYPE.names +} + +SURFACE_VERTEX_LAYOUT: dict = { + "array_stride": _SURFACE_STRIDE, + "step_mode": "vertex", + "attributes": [ + {"format": "float32x3", "offset": _SURFACE_OFFSETS["in_vert"], "shader_location": 0}, + {"format": "float32x3", "offset": _SURFACE_OFFSETS["in_normal"], "shader_location": 1}, + {"format": "float32x4", "offset": _SURFACE_OFFSETS["in_color"], "shader_location": 2}, + ], +} + + +# --------------------------------------------------------------------------- +# Fill vertex layout — must match vmobject_fill.wgsl locations: +# location 0 → in_vert float32x3 offset 0 (12 bytes) +# location 1 → in_color float32x4 offset 12 (16 bytes) +# location 2 → texture_coords float32x2 offset 28 ( 8 bytes) +# location 3 → texture_mode float32 offset 36 ( 4 bytes) +# stride: 40 bytes +# --------------------------------------------------------------------------- + +_FILL_DTYPE = np.dtype( + [ + ("in_vert", np.float32, (3,)), + ("in_color", np.float32, (4,)), + ("texture_coords", np.float32, (2,)), + ("texture_mode", np.float32), + ] +) +_FILL_STRIDE: int = _FILL_DTYPE.itemsize # 40 bytes + +_FILL_OFFSETS: dict[str, int] = { + name: _FILL_DTYPE.fields[name][1] # type: ignore[index] + for name in _FILL_DTYPE.names +} + +FILL_VERTEX_LAYOUT: dict = { + "array_stride": _FILL_STRIDE, + "step_mode": "vertex", + "attributes": [ + {"format": "float32x3", "offset": _FILL_OFFSETS["in_vert"], "shader_location": 0}, + {"format": "float32x4", "offset": _FILL_OFFSETS["in_color"], "shader_location": 1}, + {"format": "float32x2", "offset": _FILL_OFFSETS["texture_coords"], "shader_location": 2}, + {"format": "float32", "offset": _FILL_OFFSETS["texture_mode"], "shader_location": 3}, + ], +} + + +# --------------------------------------------------------------------------- +# Stroke vertex layout — must match vmobject_stroke.wgsl locations: +# location 0 → current_curve_0 float32x3 offset 0 (12 bytes) +# location 1 → current_curve_1 float32x3 offset 12 (12 bytes) ← into current_curve +# location 2 → current_curve_2 float32x3 offset 24 (12 bytes) ← into current_curve +# location 3 → current_curve_3 float32x3 offset 36 (12 bytes) ← into current_curve +# location 4 → tile_coordinate float32x2 offset 48 ( 8 bytes) +# location 5 → in_color float32x4 offset 56 (16 bytes) +# location 6 → in_width float32 offset 72 ( 4 bytes) +# stride: 76 bytes +# --------------------------------------------------------------------------- + +_STROKE_DTYPE = np.dtype( + [ + ("current_curve", np.float32, (4, 3)), # 48 bytes at offset 0 (b0, h0, h1, b3) + ("tile_coordinate", np.float32, (2,)), + ("in_color", np.float32, (4,)), + ("in_width", np.float32), + ] +) +_STROKE_STRIDE: int = _STROKE_DTYPE.itemsize # 76 bytes + + +def _stroke_field_offset(name: str) -> int: + return _STROKE_DTYPE.fields[name][1] # type: ignore[index] + + +STROKE_VERTEX_LAYOUT: dict = { + "array_stride": _STROKE_STRIDE, + "step_mode": "vertex", + "attributes": [ + # current_curve is a (4,3) sub-field starting at offset 0. + # Split into four vec3 bindings with explicit byte offsets. + {"format": "float32x3", "offset": _stroke_field_offset("current_curve"), "shader_location": 0}, + {"format": "float32x3", "offset": _stroke_field_offset("current_curve") + 12, "shader_location": 1}, + {"format": "float32x3", "offset": _stroke_field_offset("current_curve") + 24, "shader_location": 2}, + {"format": "float32x3", "offset": _stroke_field_offset("current_curve") + 36, "shader_location": 3}, + {"format": "float32x2", "offset": _stroke_field_offset("tile_coordinate"), "shader_location": 4}, + {"format": "float32x4", "offset": _stroke_field_offset("in_color"), "shader_location": 5}, + {"format": "float32", "offset": _stroke_field_offset("in_width"), "shader_location": 6}, + ], +} + + +# --------------------------------------------------------------------------- +# Public entry points +# --------------------------------------------------------------------------- + + +def render_webgpu_mobject( + renderer: WebGPURenderer, + mobject: VMobject, +) -> None: + """Dispatch every family member of *mobject* to the correct pipeline. + + Each family member is routed independently: + + * ``shade_in_3d=True`` → surface pipeline (Phong-lit, depth-tested). + * otherwise → fill + stroke pipelines (2-D painter's algorithm). + + This correctly handles ``Surface`` objects (a ``VGroup`` whose individual + face pieces carry ``shade_in_3d=True``) as well as mixed scenes where some + sub-mobjects are 3-D and others are flat overlays. + """ + for submob in mobject.family_members_with_points(): + if getattr(submob, "shade_in_3d", False): + _draw_surface_face(renderer, submob) + else: + _draw_vmobject_fill(renderer, submob) + _draw_vmobject_stroke(renderer, submob) + + +def render_webgpu_surface( + renderer: WebGPURenderer, + mobject: VMobject, +) -> None: + """Record surface draw calls for *mobject* and its descendants. + + Only processes VMobjects that have ``shade_in_3d=True`` and non-zero fill + alpha. The geometry is fan-triangulated from each subpath's anchor points + with a per-face normal computed via the cross product. + """ + for submob in mobject.family_members_with_points(): + if getattr(submob, "shade_in_3d", False): + _draw_surface_face(renderer, submob) + + +def render_webgpu_vmobject_fill( + renderer: WebGPURenderer, + mobject: VMobject, +) -> None: + """Record fill draw calls for *mobject* and all its descendants.""" + for submob in mobject.family_members_with_points(): + _draw_vmobject_fill(renderer, submob) + + +def render_webgpu_vmobject_stroke( + renderer: WebGPURenderer, + mobject: VMobject, +) -> None: + """Record stroke draw calls for *mobject* and all its descendants.""" + for submob in mobject.family_members_with_points(): + _draw_vmobject_stroke(renderer, submob) + + +# --------------------------------------------------------------------------- +# Cubic → quadratic subdivision (used by fill triangulation) +# --------------------------------------------------------------------------- + +_CUBIC_SUBDIVISION_LEVELS: int = 2 # 4 quadratic pieces per cubic bezier + + +def _cubic_to_quadratics( + b0s: np.ndarray, + h0s: np.ndarray, + h1s: np.ndarray, + b2s: np.ndarray, + levels: int = _CUBIC_SUBDIVISION_LEVELS, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Subdivide n cubic beziers into n*2^levels quadratic approximations. + + Each cubic (b0, h0, h1, b3) is split by de Casteljau at t=0.5 `levels` + times, then each sub-cubic is approximated by the quadratic whose single + control point is the midpoint of its two handles. + + Returns (qb0s, qmids, qb2s), each shaped (n * 2^levels, 3). + """ + curves = np.stack([b0s, h0s, h1s, b2s], axis=1).astype(np.float64) # (n, 4, 3) + + for _ in range(levels): + n_cur = len(curves) + c0, c1, c2, c3 = curves[:, 0], curves[:, 1], curves[:, 2], curves[:, 3] + m01 = (c0 + c1) * 0.5 + m12 = (c1 + c2) * 0.5 + m23 = (c2 + c3) * 0.5 + m012 = (m01 + m12) * 0.5 + m123 = (m12 + m23) * 0.5 + m0123 = (m012 + m123) * 0.5 + + new_curves = np.empty((n_cur * 2, 4, 3), dtype=np.float64) + new_curves[0::2, 0] = c0; new_curves[0::2, 1] = m01 + new_curves[0::2, 2] = m012; new_curves[0::2, 3] = m0123 + new_curves[1::2, 0] = m0123; new_curves[1::2, 1] = m123 + new_curves[1::2, 2] = m23; new_curves[1::2, 3] = c3 + curves = new_curves + + qb0s = curves[:, 0] + qmids = (curves[:, 1] + curves[:, 2]) * 0.5 # midpoint of sub-handles + qb2s = curves[:, 3] + return qb0s, qmids, qb2s + + +# --------------------------------------------------------------------------- +# Fill draw helper +# --------------------------------------------------------------------------- + + +def _draw_vmobject_fill( + renderer: WebGPURenderer, + vmobject: VMobject, +) -> None: + fill_rgba = vmobject.get_fill_rgbas() + if fill_rgba.shape[0] == 0 or fill_rgba[0, 3] == 0: + return # transparent or no fill + + color = fill_rgba[0].astype(np.float32) + + result = _triangulate_cairo_vmobject(vmobject) + if result is None: + return + verts, tex_coords, tex_modes = result + if len(verts) == 0: + return + + n_verts = len(verts) + attrs = np.empty(n_verts, dtype=_FILL_DTYPE) + attrs["in_vert"] = verts.astype(np.float32) + attrs["in_color"] = color # broadcast: same color for all vertices + attrs["texture_coords"] = tex_coords.astype(np.float32) + attrs["texture_mode"] = tex_modes.astype(np.float32) + + import wgpu # local import so module loads without wgpu installed + + device: wgpu_t.GPUDevice = renderer.device + vbo = device.create_buffer_with_data( + data=attrs.tobytes(), + usage=wgpu.BufferUsage.VERTEX, + ) + renderer.frame_vbos.append(vbo) + + rp = renderer.current_render_pass + rp.set_pipeline(renderer.fill_pipeline) + rp.set_bind_group(0, renderer.camera_bind_group, [], 0, 0) + rp.set_vertex_buffer(0, vbo) + rp.draw(n_verts, 1, 0, 0) + + +# --------------------------------------------------------------------------- +# Fill triangulation: cubic VMobject → Loop-Blinn triangles +# --------------------------------------------------------------------------- + + +def _triangulate_cairo_vmobject( + vmobject: VMobject, +) -> tuple[np.ndarray, np.ndarray, np.ndarray] | None: + """Return (verts, tex_coords, tex_modes) for all fill triangles. + + Converts each cubic bezier to a quadratic approximation, classifies each + curve as concave (+1) or convex (−1), emits bezier boundary triangles with + Loop-Blinn UVs, and earclip-triangulates the flat interior (tex_mode=0). + """ + subpaths = vmobject.get_subpaths() + if not subpaths: + return None + + nppcc = vmobject.n_points_per_cubic_curve # 4 + atol = vmobject.tolerance_for_point_equality + + all_verts: list[np.ndarray] = [] + all_tex_coords: list[np.ndarray] = [] + all_tex_modes: list[np.ndarray] = [] + + for subpath in subpaths: + n_curves = len(subpath) // nppcc + if n_curves == 0: + continue + + pts = subpath[: n_curves * nppcc] # (4n, 3) + + # Cubic control points. + b0s_cubic = pts[0::nppcc] # start anchors (n, 3) + h0s_cubic = pts[1::nppcc] # handle 0 + h1s_cubic = pts[2::nppcc] # handle 1 + b2s_cubic = pts[3::nppcc] # end anchors (n, 3) + + # Subdivide each cubic into 4 quadratic approximations (2 de Casteljau levels). + b0s, b1s, b2s = _cubic_to_quadratics(b0s_cubic, h0s_cubic, h1s_cubic, b2s_cubic) + n_curves = len(b0s) # now 4 × original + + # Build flat (3*n_curves, 3) quadratic representation for the triangulation. + quad_pts = np.empty((n_curves * 3, 3), dtype=np.float64) + quad_pts[0::3] = b0s + quad_pts[1::3] = b1s + quad_pts[2::3] = b2s + + # Classify curves. + v01s = b1s - b0s + v12s = b2s - b1s + crosses = cross2d(v01s, v12s) + convexities = np.sign(crosses) + + # Orientation from signed area of anchor polygon. + ax, ay = b0s[:, 0], b0s[:, 1] + signed_area = float( + np.sum(ax * np.roll(ay, -1) - np.roll(ax, -1) * ay) + ) + if signed_area >= 0: + concave_parts = convexities > 0 + convex_parts = convexities <= 0 + else: + concave_parts = convexities < 0 + convex_parts = convexities >= 0 + + # ── Bezier boundary triangles ────────────────────────────────────── + # UVs for every bezier triangle: (0,0) at b0, (0.5,0) at b1, (1,1) at b2. + _UV_TILE = np.array([[0.0, 0.0], [0.5, 0.0], [1.0, 1.0]], dtype=np.float32) + + if np.any(concave_parts): + n_c = int(np.sum(concave_parts)) + tri = np.empty((n_c * 3, 3)) + tri[0::3] = b0s[concave_parts] + tri[1::3] = b1s[concave_parts] + tri[2::3] = b2s[concave_parts] + all_verts.append(tri) + all_tex_coords.append(np.tile(_UV_TILE, (n_c, 1))) + all_tex_modes.append(np.ones(n_c * 3, dtype=np.float32)) + + if np.any(convex_parts): + n_v = int(np.sum(convex_parts)) + tri = np.empty((n_v * 3, 3)) + tri[0::3] = b0s[convex_parts] + tri[1::3] = b1s[convex_parts] + tri[2::3] = b2s[convex_parts] + all_verts.append(tri) + all_tex_coords.append(np.tile(_UV_TILE, (n_v, 1))) + all_tex_modes.append(-np.ones(n_v * 3, dtype=np.float32)) + + # ── Flat interior (earclip) ──────────────────────────────────────── + # Inner polygon = all b0s + b1s of concave curves + b2s at loop ends. + end_of_loop = np.zeros(n_curves, dtype=bool) + if n_curves > 1: + end_of_loop[:-1] = (np.abs(b2s[:-1] - b0s[1:]) > atol).any(1) + end_of_loop[-1] = True + + # Indices into quad_pts (0::3=b0, 1::3=b1, 2::3=b2). + idx = np.arange(n_curves) + inner_vert_indices = np.hstack( + [ + idx * 3, # b0 indices in quad_pts + idx[concave_parts] * 3 + 1, # b1 of concave curves + idx[end_of_loop] * 3 + 2, # b2 at loop ends + ] + ) + inner_vert_indices.sort() + + # Ring ends: positions of b2-index entries (index % 3 == 2). + rings = ( + np.arange(1, len(inner_vert_indices) + 1)[inner_vert_indices % 3 == 2] + ).tolist() + + inner_verts = quad_pts[inner_vert_indices] # (M, 3) + if len(inner_verts) < 3 or not rings: + continue + + tri_indices_raw = earclip_triangulation(inner_verts[:, :2], rings) + if not tri_indices_raw: + continue + + # Map back to quad_pts indices. + inner_tri_indices = inner_vert_indices[ + np.array(tri_indices_raw, dtype=int) + ] + inner_pts = quad_pts[inner_tri_indices] # (K, 3) + n_inner = len(inner_pts) + + all_verts.append(inner_pts) + all_tex_coords.append(np.zeros((n_inner, 2), dtype=np.float32)) + all_tex_modes.append(np.zeros(n_inner, dtype=np.float32)) + + if not all_verts: + return None + + return ( + np.concatenate(all_verts, axis=0), + np.concatenate(all_tex_coords, axis=0), + np.concatenate(all_tex_modes, axis=0), + ) + + +# --------------------------------------------------------------------------- +# Stroke draw helper +# --------------------------------------------------------------------------- + + +def _draw_vmobject_stroke( + renderer: WebGPURenderer, + vmobject: VMobject, +) -> None: + stroke_rgba = vmobject.get_stroke_rgbas() + stroke_width = float(vmobject.get_stroke_width()) + if stroke_rgba.shape[0] == 0 or stroke_rgba[0, 3] == 0 or stroke_width == 0: + return # invisible stroke + + color = stroke_rgba[0].astype(np.float32) + nppcc = vmobject.n_points_per_cubic_curve + + curve_list: list[np.ndarray] = [] + for subpath in vmobject.get_subpaths(): + n_curves = len(subpath) // nppcc + if n_curves == 0: + continue + pts = subpath[: n_curves * nppcc] + b0s = pts[0::nppcc] + h0s = pts[1::nppcc] # first handle + h1s = pts[2::nppcc] # second handle + b2s = pts[3::nppcc] # end anchor + + # Stack into (n_curves, 4, 3): all 4 cubic control points per curve. + curve_list.append(np.stack([b0s, h0s, h1s, b2s], axis=1)) + + if not curve_list: + return + + all_curves = np.concatenate(curve_list, axis=0).astype(np.float32) # (N, 4, 3) + n_total = len(all_curves) + + # Each curve → 3 identical vertex records (repeated for the two triangles). + base = np.zeros(n_total * 3, dtype=_STROKE_DTYPE) + base["current_curve"] = np.repeat(all_curves, 3, axis=0) + base["in_color"] = color + base["in_width"] = stroke_width + + # Tile × 2 to form 6 vertices per curve (2 triangles). + stroke_data = np.tile(base, 2) + n_half = n_total * 3 + + stroke_data["tile_coordinate"][:n_half] = np.tile( + [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0]], (n_total, 1) + ) + stroke_data["tile_coordinate"][n_half:] = np.tile( + [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]], (n_total, 1) + ) + + import wgpu # local import so module loads without wgpu installed + + device: wgpu_t.GPUDevice = renderer.device + vbo = device.create_buffer_with_data( + data=stroke_data.tobytes(), + usage=wgpu.BufferUsage.VERTEX, + ) + renderer.frame_vbos.append(vbo) + + rp = renderer.current_render_pass + rp.set_pipeline(renderer.stroke_pipeline) + rp.set_bind_group(0, renderer.camera_bind_group, [], 0, 0) + rp.set_vertex_buffer(0, vbo) + rp.draw(len(stroke_data), 1, 0, 0) + + +# --------------------------------------------------------------------------- +# Surface draw helper (Phase 3) +# --------------------------------------------------------------------------- + + +def _draw_surface_face( + renderer: WebGPURenderer, + vmobject: VMobject, +) -> None: + """Fan-triangulate subpaths of a shade_in_3d VMobject and draw them.""" + fill_rgba = vmobject.get_fill_rgbas() + if fill_rgba.shape[0] == 0 or fill_rgba[0, 3] == 0: + return + + color = fill_rgba[0].astype(np.float32) + nppcc = vmobject.n_points_per_cubic_curve + + all_verts: list[np.ndarray] = [] + all_normals: list[np.ndarray] = [] + + for subpath in vmobject.get_subpaths(): + n_curves = len(subpath) // nppcc + if n_curves < 2: + continue + # Anchor points only (every 4th point starting at 0). + anchors = subpath[0::nppcc] # (n_curves, 3) + # Include the last endpoint so the polygon closes. + last = subpath[n_curves * nppcc - 1 : n_curves * nppcc] + if len(last) and not np.allclose(anchors[-1], last[0], atol=1e-6): + anchors = np.vstack([anchors, last]) + + n_pts = len(anchors) + if n_pts < 3: + continue + + # Face normal — cross product of first two edges from centroid. + centroid = anchors.mean(axis=0) + v0 = anchors[0] - centroid + v1 = anchors[1] - centroid + raw_normal = np.cross(v0, v1).astype(np.float64) + norm_len = np.linalg.norm(raw_normal) + normal = (raw_normal / norm_len).astype(np.float32) if norm_len > 1e-9 else np.array([0, 0, 1], dtype=np.float32) + + # Fan triangulation from centroid: + # (centroid, anchors[i], anchors[i+1]) for i in 0..n_pts-1 + fan_verts = np.empty((n_pts * 3, 3), dtype=np.float32) + fan_verts[0::3] = centroid.astype(np.float32) + fan_verts[1::3] = anchors.astype(np.float32) + fan_verts[2::3] = np.roll(anchors, -1, axis=0).astype(np.float32) + + all_verts.append(fan_verts) + all_normals.append(np.tile(normal, (n_pts * 3, 1))) + + if not all_verts: + return + + verts = np.concatenate(all_verts, axis=0) + normals = np.concatenate(all_normals, axis=0) + n_total = len(verts) + + attrs = np.empty(n_total, dtype=_SURFACE_DTYPE) + attrs["in_vert"] = verts + attrs["in_normal"] = normals + attrs["in_color"] = color # broadcast + + import wgpu # local import so module loads without wgpu installed + + device: wgpu_t.GPUDevice = renderer.device + vbo = device.create_buffer_with_data( + data=attrs.tobytes(), + usage=wgpu.BufferUsage.VERTEX, + ) + renderer.frame_vbos.append(vbo) + + rp = renderer.current_render_pass + rp.set_pipeline(renderer.surface_pipeline) + rp.set_bind_group(0, renderer.camera_bind_group, [], 0, 0) + rp.set_vertex_buffer(0, vbo) + rp.draw(n_total, 1, 0, 0) diff --git a/manim/scene/scene.py b/manim/scene/scene.py index 845fafd0b9..0839c73ef7 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -50,6 +50,7 @@ from ..camera.camera import Camera from ..constants import * from ..renderer.cairo_renderer import CairoRenderer +from ..renderer.webgpu.webgpu_renderer import WebGPURenderer from ..renderer.opengl_renderer import OpenGLCamera, OpenGLMobject, OpenGLRenderer from ..renderer.shader import Object3D from ..utils import opengl, space_ops @@ -169,7 +170,7 @@ def construct(self): def __init__( self, - renderer: CairoRenderer | OpenGLRenderer | None = None, + renderer: CairoRenderer | OpenGLRenderer | WebGPURenderer | None = None, camera_class: type[Camera] = Camera, always_update_mobjects: bool = False, random_seed: int | None = None, @@ -205,6 +206,10 @@ def __init__( if renderer is None: renderer = OpenGLRenderer() + elif config.renderer == RendererType.WEBGPU: + if renderer is None: + renderer = WebGPURenderer() + if renderer is None: self.renderer: CairoRenderer | OpenGLRenderer = CairoRenderer( # TODO: Is it a suitable approach to make an instance of @@ -470,7 +475,7 @@ def get_mobject_family_members(self) -> list[Mobject]: family_members.extend(mob.get_family()) return family_members else: - assert config.renderer == RendererType.CAIRO + assert config.renderer in {RendererType.CAIRO, RendererType.WEBGPU} return extract_mobject_family_members( self.mobjects, use_z_index=self.renderer.camera.use_z_index, @@ -505,7 +510,7 @@ def add(self, *mobjects: Mobject | OpenGLMobject) -> Self: self.remove(*new_meshes) # type: ignore[arg-type] self.meshes += new_meshes else: - assert config.renderer == RendererType.CAIRO + assert config.renderer in {RendererType.CAIRO, RendererType.WEBGPU} new_and_foreground_mobjects: list[Mobject] = [ *mobjects, # type: ignore[list-item] *self.foreground_mobjects, @@ -565,7 +570,7 @@ def lambda_function(mesh: Object3D) -> bool: ) return self else: - assert config.renderer == RendererType.CAIRO + assert config.renderer in {RendererType.CAIRO, RendererType.WEBGPU} for list_name in "mobjects", "foreground_mobjects": self.restructure_mobjects(mobjects, list_name, False) return self @@ -1331,7 +1336,7 @@ def begin_animations(self) -> None: animation._setup_scene(self) animation.begin() - if config.renderer == RendererType.CAIRO: + if config.renderer in {RendererType.CAIRO, RendererType.WEBGPU}: # Paint all non-moving objects onto the screen, so they don't # have to be rendered every frame ( diff --git a/manim/scene/scene_file_writer.py b/manim/scene/scene_file_writer.py index 21425af759..e5324d308b 100644 --- a/manim/scene/scene_file_writer.py +++ b/manim/scene/scene_file_writer.py @@ -52,6 +52,7 @@ from av.stream import Stream from manim.renderer.cairo_renderer import CairoRenderer + from manim.renderer.webgpu.webgpu_renderer import WebGPURenderer from manim.renderer.opengl_renderer import OpenGLRenderer from manim.typing import PixelArray, StrPath @@ -122,7 +123,7 @@ class SceneFileWriter: def __init__( self, - renderer: CairoRenderer | OpenGLRenderer, + renderer: CairoRenderer | OpenGLRenderer | WebGPURenderer, scene_name: str, **kwargs: Any, ) -> None: @@ -468,7 +469,7 @@ def write_frame( else: frame = ( frame_or_renderer.get_frame() - if config.renderer == RendererType.OPENGL + if config.renderer in (RendererType.OPENGL, RendererType.WEBGPU) else frame_or_renderer ) @@ -481,7 +482,7 @@ def write_frame( else: image = ( frame_or_renderer.get_image() - if config.renderer == RendererType.OPENGL + if config.renderer in (RendererType.OPENGL, RendererType.WEBGPU) else Image.fromarray(frame_or_renderer) ) target_dir = self.image_file_path.parent / self.image_file_path.stem From 55102f7047ecb55e0c30a7dbe7793f2592a17e9f Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Sat, 4 Apr 2026 22:02:08 +0530 Subject: [PATCH 05/33] Batch GPU buffers support Concatenate all fill vertices into one buffer, all stroke vertices into another. Results in lower number of call to shader and faster execution. --- manim/renderer/webgpu/webgpu_renderer.py | 7 +- .../webgpu/webgpu_vmobject_rendering.py | 595 +++++++++++------- 2 files changed, 358 insertions(+), 244 deletions(-) diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index 7cadd1861d..f276e41ccb 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -655,11 +655,8 @@ def update_frame(self, scene: Scene) -> None: # One camera bind group per frame (projection may change). self.camera_bind_group = self._build_camera_bind_group() - for mobject in scene.mobjects: - if isinstance(mobject, VMobject): - # render_webgpu_mobject routes each family member to fill/stroke - # or the Phong surface pipeline based on shade_in_3d per submobject. - render_webgpu_mobject(self, mobject) + # Batch render: collect all geometry first, then 1–3 GPU uploads total. + render_webgpu_mobject(self, scene.mobjects) render_pass.end() self._device.queue.submit([encoder.finish()]) diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py index e1fce7c3e9..0091e6b685 100644 --- a/manim/renderer/webgpu/webgpu_vmobject_rendering.py +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -18,6 +18,15 @@ The fragment shader computes the exact unsigned distance to the cubic bezier curve via Newton's-method minimisation and discards pixels outside the stroke half-width — no quadratic approximation is made. + +Batched rendering +----------------- +``render_webgpu_mobject`` collects geometry for *all* scene mobjects before +touching the GPU. All fill data is concatenated into one GPU buffer, all +stroke data into another, all surface data into a third (1–3 allocations per +frame regardless of mobject count). Draw calls reference sub-ranges of those +shared buffers via ``set_vertex_buffer(slot, buf, offset)``, preserving the +exact painter's-algorithm order. """ from __future__ import annotations @@ -26,12 +35,12 @@ import numpy as np +from manim.mobject.types.vectorized_mobject import VMobject from manim.utils.space_ops import cross2d, earclip_triangulation if TYPE_CHECKING: import wgpu as wgpu_t - from manim.mobject.types.vectorized_mobject import VMobject from manim.renderer.webgpu.webgpu_renderer import WebGPURenderer @@ -149,43 +158,125 @@ def _stroke_field_offset(name: str) -> int: # --------------------------------------------------------------------------- -# Public entry points +# Public entry point — batched rendering # --------------------------------------------------------------------------- def render_webgpu_mobject( renderer: WebGPURenderer, - mobject: VMobject, + mobjects: list, ) -> None: - """Dispatch every family member of *mobject* to the correct pipeline. + """Batch-render all VMobjects in *mobjects* (the scene's top-level list). + + Three phases: - Each family member is routed independently: + 1. **Tessellate** — iterate every family member of every mobject and + collect fill / stroke / surface geometry into plain numpy arrays. + No GPU calls are made in this phase. - * ``shade_in_3d=True`` → surface pipeline (Phong-lit, depth-tested). - * otherwise → fill + stroke pipelines (2-D painter's algorithm). + 2. **Batch upload** — concatenate all fill arrays into one bytes blob and + upload as a single ``VERTEX`` buffer; same for stroke and surface. + This yields at most 3 ``create_buffer_with_data`` calls per frame + regardless of how many mobjects are in the scene. - This correctly handles ``Surface`` objects (a ``VGroup`` whose individual - face pieces carry ``shade_in_3d=True``) as well as mixed scenes where some - sub-mobjects are 3-D and others are flat overlays. + 3. **Draw** — issue draw commands in scene order, pointing each command at + the correct byte-offset within the shared buffer. The pipeline is only + switched when the type changes (fill → stroke → surface), so + ``set_pipeline`` / ``set_bind_group`` calls are minimised too. + + Painter's-algorithm order is fully preserved: each submobject's fill draw + command comes before its stroke draw command, and mobjects are processed in + the same order as ``scene.mobjects``. """ - for submob in mobject.family_members_with_points(): - if getattr(submob, "shade_in_3d", False): - _draw_surface_face(renderer, submob) + import wgpu # local import so module loads without wgpu installed + + # ── Phase 1: tessellate ─────────────────────────────────────────────── + fill_parts: list[np.ndarray] = [] + stroke_parts: list[np.ndarray] = [] + surface_parts: list[np.ndarray] = [] + + # draw_plan entry: ("fill" | "stroke" | "surface", index into *_parts list) + draw_plan: list[tuple[str, int]] = [] + + for mob in mobjects: + if not isinstance(mob, VMobject): + continue + for submob in mob.family_members_with_points(): + if getattr(submob, "shade_in_3d", False): + data = _collect_surface_geometry(submob) + if data is not None: + draw_plan.append(("surface", len(surface_parts))) + surface_parts.append(data) + else: + fill_data = _collect_fill_geometry(submob) + if fill_data is not None: + draw_plan.append(("fill", len(fill_parts))) + fill_parts.append(fill_data) + stroke_data = _collect_stroke_geometry(submob) + if stroke_data is not None: + draw_plan.append(("stroke", len(stroke_parts))) + stroke_parts.append(stroke_data) + + if not draw_plan: + return + + # ── Phase 2: batch upload — 1 buffer per pipeline type ─────────────── + device: wgpu_t.GPUDevice = renderer.device + + fill_buf = fill_byte_offsets = None + stroke_buf = stroke_byte_offsets = None + surface_buf = surface_byte_offsets = None + + if fill_parts: + fill_buf, fill_byte_offsets = _batch_upload(device, fill_parts) + renderer.frame_vbos.append(fill_buf) + if stroke_parts: + stroke_buf, stroke_byte_offsets = _batch_upload(device, stroke_parts) + renderer.frame_vbos.append(stroke_buf) + if surface_parts: + surface_buf, surface_byte_offsets = _batch_upload(device, surface_parts) + renderer.frame_vbos.append(surface_buf) + + # ── Phase 3: draw in scene order ───────────────────────────────────── + rp = renderer.current_render_pass + current_pipeline: str | None = None + + for cmd_type, idx in draw_plan: + # Switch pipeline only when the type changes. + if cmd_type != current_pipeline: + if cmd_type == "fill": + rp.set_pipeline(renderer.fill_pipeline) + elif cmd_type == "stroke": + rp.set_pipeline(renderer.stroke_pipeline) + else: + rp.set_pipeline(renderer.surface_pipeline) + rp.set_bind_group(0, renderer.camera_bind_group, [], 0, 0) + current_pipeline = cmd_type + + if cmd_type == "fill": + arr = fill_parts[idx] + rp.set_vertex_buffer(0, fill_buf, fill_byte_offsets[idx], arr.nbytes) + rp.draw(len(arr), 1, 0, 0) + elif cmd_type == "stroke": + arr = stroke_parts[idx] + rp.set_vertex_buffer(0, stroke_buf, stroke_byte_offsets[idx], arr.nbytes) + rp.draw(len(arr), 1, 0, 0) else: - _draw_vmobject_fill(renderer, submob) - _draw_vmobject_stroke(renderer, submob) + arr = surface_parts[idx] + rp.set_vertex_buffer(0, surface_buf, surface_byte_offsets[idx], arr.nbytes) + rp.draw(len(arr), 1, 0, 0) + + +# --------------------------------------------------------------------------- +# Explicit single-mobject public helpers (kept for callers outside update_frame) +# --------------------------------------------------------------------------- def render_webgpu_surface( renderer: WebGPURenderer, mobject: VMobject, ) -> None: - """Record surface draw calls for *mobject* and its descendants. - - Only processes VMobjects that have ``shade_in_3d=True`` and non-zero fill - alpha. The geometry is fan-triangulated from each subpath's anchor points - with a per-face normal computed via the cross product. - """ + """Record surface draw calls for *mobject* and its descendants.""" for submob in mobject.family_members_with_points(): if getattr(submob, "shade_in_3d", False): _draw_surface_face(renderer, submob) @@ -209,6 +300,233 @@ def render_webgpu_vmobject_stroke( _draw_vmobject_stroke(renderer, submob) +# --------------------------------------------------------------------------- +# GPU upload helpers +# --------------------------------------------------------------------------- + + +def _batch_upload( + device: wgpu_t.GPUDevice, + arrays: list[np.ndarray], +) -> tuple[wgpu_t.GPUBuffer, list[int]]: + """Concatenate *arrays* into one bytes blob and upload as a single VERTEX buffer. + + Returns ``(gpu_buffer, byte_offsets)`` where ``byte_offsets[i]`` is the + byte position of ``arrays[i]`` within the buffer. + """ + import wgpu + + byte_offsets: list[int] = [] + parts: list[bytes] = [] + offset = 0 + for arr in arrays: + byte_offsets.append(offset) + b = arr.tobytes() + parts.append(b) + offset += len(b) + + buf = device.create_buffer_with_data( + data=b"".join(parts), + usage=wgpu.BufferUsage.VERTEX, + ) + return buf, byte_offsets + + +# --------------------------------------------------------------------------- +# Geometry collectors — CPU only, no GPU calls +# --------------------------------------------------------------------------- + + +def _collect_fill_geometry(vmobject: VMobject) -> np.ndarray | None: + """Return a ``_FILL_DTYPE`` array for *vmobject*'s fill, or ``None``.""" + fill_rgba = vmobject.get_fill_rgbas() + if fill_rgba.shape[0] == 0 or fill_rgba[0, 3] == 0: + return None + + color = fill_rgba[0].astype(np.float32) + result = _triangulate_cairo_vmobject(vmobject) + if result is None: + return None + verts, tex_coords, tex_modes = result + if len(verts) == 0: + return None + + n_verts = len(verts) + attrs = np.empty(n_verts, dtype=_FILL_DTYPE) + attrs["in_vert"] = verts.astype(np.float32) + attrs["in_color"] = color + attrs["texture_coords"] = tex_coords.astype(np.float32) + attrs["texture_mode"] = tex_modes.astype(np.float32) + return attrs + + +def _collect_stroke_geometry(vmobject: VMobject) -> np.ndarray | None: + """Return a ``_STROKE_DTYPE`` array for *vmobject*'s stroke, or ``None``.""" + stroke_rgba = vmobject.get_stroke_rgbas() + stroke_width = float(vmobject.get_stroke_width()) + if stroke_rgba.shape[0] == 0 or stroke_rgba[0, 3] == 0 or stroke_width == 0: + return None + + color = stroke_rgba[0].astype(np.float32) + nppcc = vmobject.n_points_per_cubic_curve + + curve_list: list[np.ndarray] = [] + for subpath in vmobject.get_subpaths(): + n_curves = len(subpath) // nppcc + if n_curves == 0: + continue + pts = subpath[: n_curves * nppcc] + b0s = pts[0::nppcc] + h0s = pts[1::nppcc] + h1s = pts[2::nppcc] + b2s = pts[3::nppcc] + curve_list.append(np.stack([b0s, h0s, h1s, b2s], axis=1)) + + if not curve_list: + return None + + all_curves = np.concatenate(curve_list, axis=0).astype(np.float32) # (N, 4, 3) + n_total = len(all_curves) + + base = np.zeros(n_total * 3, dtype=_STROKE_DTYPE) + base["current_curve"] = np.repeat(all_curves, 3, axis=0) + base["in_color"] = color + base["in_width"] = stroke_width + + stroke_data = np.tile(base, 2) + n_half = n_total * 3 + stroke_data["tile_coordinate"][:n_half] = np.tile( + [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0]], (n_total, 1) + ) + stroke_data["tile_coordinate"][n_half:] = np.tile( + [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]], (n_total, 1) + ) + return stroke_data + + +def _collect_surface_geometry(vmobject: VMobject) -> np.ndarray | None: + """Return a ``_SURFACE_DTYPE`` array for a shade_in_3d VMobject, or ``None``.""" + fill_rgba = vmobject.get_fill_rgbas() + if fill_rgba.shape[0] == 0 or fill_rgba[0, 3] == 0: + return None + + color = fill_rgba[0].astype(np.float32) + nppcc = vmobject.n_points_per_cubic_curve + + all_verts: list[np.ndarray] = [] + all_normals: list[np.ndarray] = [] + + for subpath in vmobject.get_subpaths(): + n_curves = len(subpath) // nppcc + if n_curves < 2: + continue + anchors = subpath[0::nppcc] + last = subpath[n_curves * nppcc - 1 : n_curves * nppcc] + if len(last) and not np.allclose(anchors[-1], last[0], atol=1e-6): + anchors = np.vstack([anchors, last]) + + n_pts = len(anchors) + if n_pts < 3: + continue + + centroid = anchors.mean(axis=0) + v0 = anchors[0] - centroid + v1 = anchors[1] - centroid + raw_normal = np.cross(v0, v1).astype(np.float64) + norm_len = np.linalg.norm(raw_normal) + normal = ( + (raw_normal / norm_len).astype(np.float32) + if norm_len > 1e-9 + else np.array([0.0, 0.0, 1.0], dtype=np.float32) + ) + + fan_verts = np.empty((n_pts * 3, 3), dtype=np.float32) + fan_verts[0::3] = centroid.astype(np.float32) + fan_verts[1::3] = anchors.astype(np.float32) + fan_verts[2::3] = np.roll(anchors, -1, axis=0).astype(np.float32) + + all_verts.append(fan_verts) + all_normals.append(np.tile(normal, (n_pts * 3, 1))) + + if not all_verts: + return None + + verts = np.concatenate(all_verts, axis=0) + normals = np.concatenate(all_normals, axis=0) + n_total = len(verts) + + attrs = np.empty(n_total, dtype=_SURFACE_DTYPE) + attrs["in_vert"] = verts + attrs["in_normal"] = normals + attrs["in_color"] = color + return attrs + + +# --------------------------------------------------------------------------- +# Single-mobject draw helpers (used by the explicit public helpers above) +# --------------------------------------------------------------------------- + + +def _draw_vmobject_fill(renderer: WebGPURenderer, vmobject: VMobject) -> None: + data = _collect_fill_geometry(vmobject) + if data is None: + return + + import wgpu + + device: wgpu_t.GPUDevice = renderer.device + vbo = device.create_buffer_with_data( + data=data.tobytes(), usage=wgpu.BufferUsage.VERTEX + ) + renderer.frame_vbos.append(vbo) + + rp = renderer.current_render_pass + rp.set_pipeline(renderer.fill_pipeline) + rp.set_bind_group(0, renderer.camera_bind_group, [], 0, 0) + rp.set_vertex_buffer(0, vbo) + rp.draw(len(data), 1, 0, 0) + + +def _draw_vmobject_stroke(renderer: WebGPURenderer, vmobject: VMobject) -> None: + data = _collect_stroke_geometry(vmobject) + if data is None: + return + + import wgpu + + device: wgpu_t.GPUDevice = renderer.device + vbo = device.create_buffer_with_data( + data=data.tobytes(), usage=wgpu.BufferUsage.VERTEX + ) + renderer.frame_vbos.append(vbo) + + rp = renderer.current_render_pass + rp.set_pipeline(renderer.stroke_pipeline) + rp.set_bind_group(0, renderer.camera_bind_group, [], 0, 0) + rp.set_vertex_buffer(0, vbo) + rp.draw(len(data), 1, 0, 0) + + +def _draw_surface_face(renderer: WebGPURenderer, vmobject: VMobject) -> None: + data = _collect_surface_geometry(vmobject) + if data is None: + return + + import wgpu + + device: wgpu_t.GPUDevice = renderer.device + vbo = device.create_buffer_with_data( + data=data.tobytes(), usage=wgpu.BufferUsage.VERTEX + ) + renderer.frame_vbos.append(vbo) + + rp = renderer.current_render_pass + rp.set_pipeline(renderer.surface_pipeline) + rp.set_bind_group(0, renderer.camera_bind_group, [], 0, 0) + rp.set_vertex_buffer(0, vbo) + rp.draw(len(data), 1, 0, 0) + + # --------------------------------------------------------------------------- # Cubic → quadratic subdivision (used by fill triangulation) # --------------------------------------------------------------------------- @@ -256,51 +574,6 @@ def _cubic_to_quadratics( return qb0s, qmids, qb2s -# --------------------------------------------------------------------------- -# Fill draw helper -# --------------------------------------------------------------------------- - - -def _draw_vmobject_fill( - renderer: WebGPURenderer, - vmobject: VMobject, -) -> None: - fill_rgba = vmobject.get_fill_rgbas() - if fill_rgba.shape[0] == 0 or fill_rgba[0, 3] == 0: - return # transparent or no fill - - color = fill_rgba[0].astype(np.float32) - - result = _triangulate_cairo_vmobject(vmobject) - if result is None: - return - verts, tex_coords, tex_modes = result - if len(verts) == 0: - return - - n_verts = len(verts) - attrs = np.empty(n_verts, dtype=_FILL_DTYPE) - attrs["in_vert"] = verts.astype(np.float32) - attrs["in_color"] = color # broadcast: same color for all vertices - attrs["texture_coords"] = tex_coords.astype(np.float32) - attrs["texture_mode"] = tex_modes.astype(np.float32) - - import wgpu # local import so module loads without wgpu installed - - device: wgpu_t.GPUDevice = renderer.device - vbo = device.create_buffer_with_data( - data=attrs.tobytes(), - usage=wgpu.BufferUsage.VERTEX, - ) - renderer.frame_vbos.append(vbo) - - rp = renderer.current_render_pass - rp.set_pipeline(renderer.fill_pipeline) - rp.set_bind_group(0, renderer.camera_bind_group, [], 0, 0) - rp.set_vertex_buffer(0, vbo) - rp.draw(n_verts, 1, 0, 0) - - # --------------------------------------------------------------------------- # Fill triangulation: cubic VMobject → Loop-Blinn triangles # --------------------------------------------------------------------------- @@ -320,11 +593,11 @@ def _triangulate_cairo_vmobject( return None nppcc = vmobject.n_points_per_cubic_curve # 4 - atol = vmobject.tolerance_for_point_equality + atol = vmobject.tolerance_for_point_equality - all_verts: list[np.ndarray] = [] + all_verts: list[np.ndarray] = [] all_tex_coords: list[np.ndarray] = [] - all_tex_modes: list[np.ndarray] = [] + all_tex_modes: list[np.ndarray] = [] for subpath in subpaths: n_curves = len(subpath) // nppcc @@ -339,7 +612,7 @@ def _triangulate_cairo_vmobject( h1s_cubic = pts[2::nppcc] # handle 1 b2s_cubic = pts[3::nppcc] # end anchors (n, 3) - # Subdivide each cubic into 4 quadratic approximations (2 de Casteljau levels). + # Subdivide each cubic into 4 quadratic approximations. b0s, b1s, b2s = _cubic_to_quadratics(b0s_cubic, h0s_cubic, h1s_cubic, b2s_cubic) n_curves = len(b0s) # now 4 × original @@ -350,25 +623,24 @@ def _triangulate_cairo_vmobject( quad_pts[2::3] = b2s # Classify curves. - v01s = b1s - b0s - v12s = b2s - b1s - crosses = cross2d(v01s, v12s) + v01s = b1s - b0s + v12s = b2s - b1s + crosses = cross2d(v01s, v12s) convexities = np.sign(crosses) # Orientation from signed area of anchor polygon. - ax, ay = b0s[:, 0], b0s[:, 1] + ax, ay = b0s[:, 0], b0s[:, 1] signed_area = float( np.sum(ax * np.roll(ay, -1) - np.roll(ax, -1) * ay) ) if signed_area >= 0: concave_parts = convexities > 0 - convex_parts = convexities <= 0 + convex_parts = convexities <= 0 else: concave_parts = convexities < 0 - convex_parts = convexities >= 0 + convex_parts = convexities >= 0 # ── Bezier boundary triangles ────────────────────────────────────── - # UVs for every bezier triangle: (0,0) at b0, (0.5,0) at b1, (1,1) at b2. _UV_TILE = np.array([[0.0, 0.0], [0.5, 0.0], [1.0, 1.0]], dtype=np.float32) if np.any(concave_parts): @@ -392,24 +664,21 @@ def _triangulate_cairo_vmobject( all_tex_modes.append(-np.ones(n_v * 3, dtype=np.float32)) # ── Flat interior (earclip) ──────────────────────────────────────── - # Inner polygon = all b0s + b1s of concave curves + b2s at loop ends. end_of_loop = np.zeros(n_curves, dtype=bool) if n_curves > 1: end_of_loop[:-1] = (np.abs(b2s[:-1] - b0s[1:]) > atol).any(1) end_of_loop[-1] = True - # Indices into quad_pts (0::3=b0, 1::3=b1, 2::3=b2). idx = np.arange(n_curves) inner_vert_indices = np.hstack( [ - idx * 3, # b0 indices in quad_pts - idx[concave_parts] * 3 + 1, # b1 of concave curves - idx[end_of_loop] * 3 + 2, # b2 at loop ends + idx * 3, + idx[concave_parts] * 3 + 1, + idx[end_of_loop] * 3 + 2, ] ) inner_vert_indices.sort() - # Ring ends: positions of b2-index entries (index % 3 == 2). rings = ( np.arange(1, len(inner_vert_indices) + 1)[inner_vert_indices % 3 == 2] ).tolist() @@ -422,12 +691,11 @@ def _triangulate_cairo_vmobject( if not tri_indices_raw: continue - # Map back to quad_pts indices. inner_tri_indices = inner_vert_indices[ np.array(tri_indices_raw, dtype=int) ] - inner_pts = quad_pts[inner_tri_indices] # (K, 3) - n_inner = len(inner_pts) + inner_pts = quad_pts[inner_tri_indices] + n_inner = len(inner_pts) all_verts.append(inner_pts) all_tex_coords.append(np.zeros((n_inner, 2), dtype=np.float32)) @@ -437,158 +705,7 @@ def _triangulate_cairo_vmobject( return None return ( - np.concatenate(all_verts, axis=0), + np.concatenate(all_verts, axis=0), np.concatenate(all_tex_coords, axis=0), - np.concatenate(all_tex_modes, axis=0), - ) - - -# --------------------------------------------------------------------------- -# Stroke draw helper -# --------------------------------------------------------------------------- - - -def _draw_vmobject_stroke( - renderer: WebGPURenderer, - vmobject: VMobject, -) -> None: - stroke_rgba = vmobject.get_stroke_rgbas() - stroke_width = float(vmobject.get_stroke_width()) - if stroke_rgba.shape[0] == 0 or stroke_rgba[0, 3] == 0 or stroke_width == 0: - return # invisible stroke - - color = stroke_rgba[0].astype(np.float32) - nppcc = vmobject.n_points_per_cubic_curve - - curve_list: list[np.ndarray] = [] - for subpath in vmobject.get_subpaths(): - n_curves = len(subpath) // nppcc - if n_curves == 0: - continue - pts = subpath[: n_curves * nppcc] - b0s = pts[0::nppcc] - h0s = pts[1::nppcc] # first handle - h1s = pts[2::nppcc] # second handle - b2s = pts[3::nppcc] # end anchor - - # Stack into (n_curves, 4, 3): all 4 cubic control points per curve. - curve_list.append(np.stack([b0s, h0s, h1s, b2s], axis=1)) - - if not curve_list: - return - - all_curves = np.concatenate(curve_list, axis=0).astype(np.float32) # (N, 4, 3) - n_total = len(all_curves) - - # Each curve → 3 identical vertex records (repeated for the two triangles). - base = np.zeros(n_total * 3, dtype=_STROKE_DTYPE) - base["current_curve"] = np.repeat(all_curves, 3, axis=0) - base["in_color"] = color - base["in_width"] = stroke_width - - # Tile × 2 to form 6 vertices per curve (2 triangles). - stroke_data = np.tile(base, 2) - n_half = n_total * 3 - - stroke_data["tile_coordinate"][:n_half] = np.tile( - [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0]], (n_total, 1) + np.concatenate(all_tex_modes, axis=0), ) - stroke_data["tile_coordinate"][n_half:] = np.tile( - [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]], (n_total, 1) - ) - - import wgpu # local import so module loads without wgpu installed - - device: wgpu_t.GPUDevice = renderer.device - vbo = device.create_buffer_with_data( - data=stroke_data.tobytes(), - usage=wgpu.BufferUsage.VERTEX, - ) - renderer.frame_vbos.append(vbo) - - rp = renderer.current_render_pass - rp.set_pipeline(renderer.stroke_pipeline) - rp.set_bind_group(0, renderer.camera_bind_group, [], 0, 0) - rp.set_vertex_buffer(0, vbo) - rp.draw(len(stroke_data), 1, 0, 0) - - -# --------------------------------------------------------------------------- -# Surface draw helper (Phase 3) -# --------------------------------------------------------------------------- - - -def _draw_surface_face( - renderer: WebGPURenderer, - vmobject: VMobject, -) -> None: - """Fan-triangulate subpaths of a shade_in_3d VMobject and draw them.""" - fill_rgba = vmobject.get_fill_rgbas() - if fill_rgba.shape[0] == 0 or fill_rgba[0, 3] == 0: - return - - color = fill_rgba[0].astype(np.float32) - nppcc = vmobject.n_points_per_cubic_curve - - all_verts: list[np.ndarray] = [] - all_normals: list[np.ndarray] = [] - - for subpath in vmobject.get_subpaths(): - n_curves = len(subpath) // nppcc - if n_curves < 2: - continue - # Anchor points only (every 4th point starting at 0). - anchors = subpath[0::nppcc] # (n_curves, 3) - # Include the last endpoint so the polygon closes. - last = subpath[n_curves * nppcc - 1 : n_curves * nppcc] - if len(last) and not np.allclose(anchors[-1], last[0], atol=1e-6): - anchors = np.vstack([anchors, last]) - - n_pts = len(anchors) - if n_pts < 3: - continue - - # Face normal — cross product of first two edges from centroid. - centroid = anchors.mean(axis=0) - v0 = anchors[0] - centroid - v1 = anchors[1] - centroid - raw_normal = np.cross(v0, v1).astype(np.float64) - norm_len = np.linalg.norm(raw_normal) - normal = (raw_normal / norm_len).astype(np.float32) if norm_len > 1e-9 else np.array([0, 0, 1], dtype=np.float32) - - # Fan triangulation from centroid: - # (centroid, anchors[i], anchors[i+1]) for i in 0..n_pts-1 - fan_verts = np.empty((n_pts * 3, 3), dtype=np.float32) - fan_verts[0::3] = centroid.astype(np.float32) - fan_verts[1::3] = anchors.astype(np.float32) - fan_verts[2::3] = np.roll(anchors, -1, axis=0).astype(np.float32) - - all_verts.append(fan_verts) - all_normals.append(np.tile(normal, (n_pts * 3, 1))) - - if not all_verts: - return - - verts = np.concatenate(all_verts, axis=0) - normals = np.concatenate(all_normals, axis=0) - n_total = len(verts) - - attrs = np.empty(n_total, dtype=_SURFACE_DTYPE) - attrs["in_vert"] = verts - attrs["in_normal"] = normals - attrs["in_color"] = color # broadcast - - import wgpu # local import so module loads without wgpu installed - - device: wgpu_t.GPUDevice = renderer.device - vbo = device.create_buffer_with_data( - data=attrs.tobytes(), - usage=wgpu.BufferUsage.VERTEX, - ) - renderer.frame_vbos.append(vbo) - - rp = renderer.current_render_pass - rp.set_pipeline(renderer.surface_pipeline) - rp.set_bind_group(0, renderer.camera_bind_group, [], 0, 0) - rp.set_vertex_buffer(0, vbo) - rp.draw(n_total, 1, 0, 0) From 699c0e561e24698e4fe04988a76678cc9a07217b Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Sun, 5 Apr 2026 08:43:09 +0530 Subject: [PATCH 06/33] Implemented efficient Fill shader based on Slug Algorithm The reference Slug implementation is available at https://github.com/EricLengyel/Slug authored by Eric Lengyel. A fill shader based on Slug algorithm is implemented in this commit. --- manim/renderer/webgpu/shaders/slug_fill.wgsl | 206 ++++++++ .../webgpu/shaders/vmobject_fill.wgsl | 63 --- manim/renderer/webgpu/webgpu_renderer.py | 184 ++++--- .../webgpu/webgpu_vmobject_rendering.py | 500 ++++++++---------- 4 files changed, 546 insertions(+), 407 deletions(-) create mode 100644 manim/renderer/webgpu/shaders/slug_fill.wgsl delete mode 100644 manim/renderer/webgpu/shaders/vmobject_fill.wgsl diff --git a/manim/renderer/webgpu/shaders/slug_fill.wgsl b/manim/renderer/webgpu/shaders/slug_fill.wgsl new file mode 100644 index 0000000000..e3b51b5618 --- /dev/null +++ b/manim/renderer/webgpu/shaders/slug_fill.wgsl @@ -0,0 +1,206 @@ +// WebGPU fill shader using the Slug algorithm. +// +// Renders VMobject fill with exact, analytical winding-number coverage and +// smooth sub-pixel anti-aliasing. No CPU tessellation is required — raw +// quadratic bezier control points are uploaded once per frame in a storage +// buffer, and coverage is computed entirely in the fragment shader. +// +// Reference: +// E. Lengyel, "GPU-Centered Font Rendering Directly from Glyph Outlines", +// JCGT Vol. 6 No. 2, 2017. https://github.com/EricLengyel/Slug +// Patent dedicated to public domain. Code: MIT license. +// +// Uniform layout (group 0, binding 0) — 144 bytes: +// offset 0 — projection mat4x4 (64 bytes) +// offset 64 — view mat4x4 (64 bytes) +// offset 128 — light_pos vec3 (12 bytes, padded to 16) +// +// Storage buffer (group 0, binding 1) — flat array of vec2: +// curves[i*3 + 0] = p1 (start anchor of quadratic bezier i) +// curves[i*3 + 1] = p2 (single control point) +// curves[i*3 + 2] = p3 (end anchor) +// All coordinates in Manim world space. +// +// Vertex attributes: +// location 0 — in_pos vec2 world-space bounding-quad corner +// location 1 — in_color vec4 RGBA fill colour +// location 2 — curve_start u32 first curve index in storage buffer +// location 3 — n_curves u32 number of quadratic bezier curves + +struct Uniforms { + projection : mat4x4, + view : mat4x4, + light_pos : vec3, + _pad : f32, +}; +@group(0) @binding(0) var u : Uniforms; + +// Flat array of vec2 control points: three entries per quadratic bezier curve. +@group(0) @binding(1) var curves : array>; + +struct VertexInput { + @location(0) in_pos : vec2, + @location(1) in_color : vec4, + @location(2) curve_start : u32, + @location(3) n_curves : u32, +}; + +struct VertexOutput { + @builtin(position) clip_pos : vec4, + @location(0) world_pos : vec2, + @location(1) v_color : vec4, + @location(2) @interpolate(flat) curve_start : u32, + @location(3) @interpolate(flat) n_curves : u32, +}; + +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + var out: VertexOutput; + out.clip_pos = u.projection * u.view * vec4(in.in_pos, 0.0, 1.0); + out.world_pos = in.in_pos; + out.v_color = in.in_color; + out.curve_start = in.curve_start; + out.n_curves = in.n_curves; + return out; +} + +// --------------------------------------------------------------------------- +// Slug algorithm — adapted from Lengyel 2017 (HLSL → WGSL) +// --------------------------------------------------------------------------- + +// Return root eligibility code for a sample-relative quadratic bezier. +// Extracts the sign bits of the three y-coordinates and maps them through +// a lookup table to determine which roots of the quadratic cross y = 0 in +// a winding-compatible direction. +// Result: bit 0 = root 1 eligible, bit 8 = root 2 eligible. +fn calc_root_code(y1: f32, y2: f32, y3: f32) -> u32 { + let i1 = (bitcast(y1) >> 31u) & 1u; + let i2 = (bitcast(y2) >> 30u) & 2u; + let i3 = (bitcast(y3) >> 29u) & 4u; + let shift = i3 | i2 | i1; + return (0x2E74u >> shift) & 0x0101u; +} + +// Solve quadratic bezier for y = 0 crossings; return x-coordinates. +// C(t) = (1-t)^2 p1 + 2t(1-t) p2 + t^2 p3, t in [0,1]. +// Polynomial: a*t^2 - 2*b*t + c = 0 +// a = p1.y - 2*p2.y + p3.y +// b = p1.y - p2.y +// c = p1.y (the sample has already been subtracted) +fn solve_horiz(p1: vec2, p2: vec2, p3: vec2) -> vec2 { + let ay = p1.y - 2.0 * p2.y + p3.y; + let by = p1.y - p2.y; + let ax = p1.x - 2.0 * p2.x + p3.x; + let bx = p1.x - p2.x; + + var t1: f32; + var t2: f32; + + if abs(ay) < (1.0 / 65536.0) { + // Nearly linear — solve -2*by*t + p1.y = 0. + let denom = select(1.0, by, abs(by) > 1e-10); + t1 = p1.y * 0.5 / denom; + t2 = t1; + } else { + let ra = 1.0 / ay; + let d = sqrt(max(by * by - ay * p1.y, 0.0)); + t1 = (by - d) * ra; + t2 = (by + d) * ra; + } + + let x1 = (ax * t1 - bx * 2.0) * t1 + p1.x; + let x2 = (ax * t2 - bx * 2.0) * t2 + p1.x; + return vec2(x1, x2); +} + +// Solve quadratic bezier for x = 0 crossings; return y-coordinates. +fn solve_vert(p1: vec2, p2: vec2, p3: vec2) -> vec2 { + let ax = p1.x - 2.0 * p2.x + p3.x; + let bx = p1.x - p2.x; + let ay = p1.y - 2.0 * p2.y + p3.y; + let by = p1.y - p2.y; + + var t1: f32; + var t2: f32; + + if abs(ax) < (1.0 / 65536.0) { + let denom = select(1.0, bx, abs(bx) > 1e-10); + t1 = p1.x * 0.5 / denom; + t2 = t1; + } else { + let ra = 1.0 / ax; + let d = sqrt(max(bx * bx - ax * p1.x, 0.0)); + t1 = (bx - d) * ra; + t2 = (bx + d) * ra; + } + + let y1 = (ay * t1 - by * 2.0) * t1 + p1.y; + let y2 = (ay * t2 - by * 2.0) * t2 + p1.y; + return vec2(y1, y2); +} + +// Combine horizontal and vertical winding coverage into [0, 1]. +// The weighted blend handles pixels where one ray's result is more reliable. +fn calc_coverage(xcov: f32, ycov: f32, xwgt: f32, ywgt: f32) -> f32 { + let blended = abs(xcov * xwgt + ycov * ywgt) / max(xwgt + ywgt, 1.0 / 65536.0); + let fallback = min(abs(xcov), abs(ycov)); + return clamp(max(blended, fallback), 0.0, 1.0); +} + +// --------------------------------------------------------------------------- +// Fragment shader +// --------------------------------------------------------------------------- + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + // World units per screen pixel — lets coverage calculations work in + // pixel units regardless of zoom or resolution. + let ems_per_pixel = fwidth(in.world_pos); + let pixels_per_em = 1.0 / max(ems_per_pixel, vec2(1e-9)); + + var xcov = 0.0; var xwgt = 0.0; + var ycov = 0.0; var ywgt = 0.0; + + for (var i = 0u; i < in.n_curves; i = i + 1u) { + let base = (in.curve_start + i) * 3u; + + // Shift curve so the current fragment is the origin. + let p1 = curves[base ] - in.world_pos; + let p2 = curves[base + 1u ] - in.world_pos; + let p3 = curves[base + 2u ] - in.world_pos; + + // ── Horizontal ray: accumulate x-coverage ──────────────────────── + let hcode = calc_root_code(p1.y, p2.y, p3.y); + if hcode != 0u { + let r = solve_horiz(p1, p2, p3) * pixels_per_em.x; + if (hcode & 1u) != 0u { + xcov += clamp(r.x + 0.5, 0.0, 1.0); + xwgt = max(xwgt, clamp(1.0 - abs(r.x) * 2.0, 0.0, 1.0)); + } + if hcode > 1u { + xcov -= clamp(r.y + 0.5, 0.0, 1.0); + xwgt = max(xwgt, clamp(1.0 - abs(r.y) * 2.0, 0.0, 1.0)); + } + } + + // ── Vertical ray: accumulate y-coverage ────────────────────────── + let vcode = calc_root_code(p1.x, p2.x, p3.x); + if vcode != 0u { + let r = solve_vert(p1, p2, p3) * pixels_per_em.y; + if (vcode & 1u) != 0u { + ycov -= clamp(r.x + 0.5, 0.0, 1.0); + ywgt = max(ywgt, clamp(1.0 - abs(r.x) * 2.0, 0.0, 1.0)); + } + if vcode > 1u { + ycov += clamp(r.y + 0.5, 0.0, 1.0); + ywgt = max(ywgt, clamp(1.0 - abs(r.y) * 2.0, 0.0, 1.0)); + } + } + } + + let coverage = calc_coverage(xcov, ycov, xwgt, ywgt); + if coverage <= 0.0 { + discard; + } + return vec4(in.v_color.rgb, in.v_color.a * coverage); +} diff --git a/manim/renderer/webgpu/shaders/vmobject_fill.wgsl b/manim/renderer/webgpu/shaders/vmobject_fill.wgsl deleted file mode 100644 index 337baf8f14..0000000000 --- a/manim/renderer/webgpu/shaders/vmobject_fill.wgsl +++ /dev/null @@ -1,63 +0,0 @@ -// WebGPU fill shader for VMobject — Phase 2. -// -// Uses the Loop-Blinn quadratic bezier test for smooth, anti-aliased fill -// boundaries. The CPU produces three kinds of triangles: -// -// texture_mode = +1 concave bezier region — fill where u²−v ≥ 0 -// texture_mode = −1 convex bezier region — fill where u²−v ≤ 0 -// texture_mode = 0 flat interior — always fill -// -// Uniform layout (group 0, binding 0) — 144 bytes total: -// offset 0 — projection mat4x4 (64 bytes) -// offset 64 — view mat4x4 (64 bytes) -// offset 128 — light_pos vec3 (12 bytes, padded to 16) -// -// Vertex attributes: -// location 0 — in_vert vec3 world-space position -// location 1 — in_color vec4 RGBA fill colour -// location 2 — texture_coords vec2 Loop-Blinn UV (u, v) -// location 3 — texture_mode f32 0 / +1 / −1 (stored as float) - -struct Uniforms { - projection : mat4x4, - view : mat4x4, - light_pos : vec3, - _pad : f32, -}; -@group(0) @binding(0) var u : Uniforms; - -struct VertexInput { - @location(0) in_vert : vec3, - @location(1) in_color : vec4, - @location(2) texture_coords : vec2, - @location(3) texture_mode : f32, -}; - -struct VertexOutput { - @builtin(position) clip_position : vec4, - @location(0) v_color : vec4, - @location(1) v_texture_coords : vec2, - @location(2) @interpolate(flat) v_texture_mode : i32, -}; - -@vertex -fn vs_main(in: VertexInput) -> VertexOutput { - var out: VertexOutput; - out.clip_position = u.projection * u.view * vec4(in.in_vert, 1.0); - out.v_color = in.in_color; - out.v_texture_coords = in.texture_coords; - out.v_texture_mode = i32(in.texture_mode); - return out; -} - -@fragment -fn fs_main(in: VertexOutput) -> @location(0) vec4 { - let uv = in.v_texture_coords; - let curve_func = uv.x * uv.x - uv.y; - // texture_mode == 0 → always keep (interior) - // sign(texture_mode) * curve_func >= 0 → keep (bezier edge region) - if (f32(in.v_texture_mode) * curve_func >= 0.0) { - return in.v_color; - } - discard; -} diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index f276e41ccb..eaa1c8ab23 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -42,7 +42,7 @@ ) from .webgpu_vmobject_rendering import ( - FILL_VERTEX_LAYOUT, + SLUG_FILL_VERTEX_LAYOUT, STROKE_VERTEX_LAYOUT, SURFACE_VERTEX_LAYOUT, render_webgpu_mobject, @@ -339,13 +339,15 @@ def __init__( self._depth_texture: wgpu_t.GPUTexture | None = None self._depth_texture_view: wgpu_t.GPUTextureView | None = None self._proj_bgl: wgpu_t.GPUBindGroupLayout | None = None - self._fill_pipeline: wgpu_t.GPURenderPipeline | None = None + self._slug_bgl: wgpu_t.GPUBindGroupLayout | None = None + self._slug_fill_pipeline: wgpu_t.GPURenderPipeline | None = None self._stroke_pipeline: wgpu_t.GPURenderPipeline | None = None self._surface_pipeline: wgpu_t.GPURenderPipeline | None = None # Per-frame state (set during update_frame, cleared after submit). self.current_render_pass: wgpu_t.GPURenderPassEncoder | None = None self.camera_bind_group: wgpu_t.GPUBindGroup | None = None + self._camera_uniform_buf: wgpu_t.GPUBuffer | None = None self.frame_vbos: list[wgpu_t.GPUBuffer] = [] # ------------------------------------------------------------------ @@ -386,25 +388,23 @@ def init_scene(self, scene: Scene) -> None: ) self._depth_texture_view = self._depth_texture.create_view() - self._proj_bgl, self._fill_pipeline = self._create_fill_pipeline() + self._proj_bgl = self._create_camera_bgl() self._stroke_pipeline = self._create_stroke_pipeline(self._proj_bgl) self._surface_pipeline = self._create_surface_pipeline(self._proj_bgl) + self._slug_bgl, self._slug_fill_pipeline = self._create_slug_fill_pipeline() # ------------------------------------------------------------------ # Pipeline creation # ------------------------------------------------------------------ - def _create_fill_pipeline( - self, - ) -> tuple[wgpu_t.GPUBindGroupLayout, wgpu_t.GPURenderPipeline]: - assert self._device is not None - shader_path = Path(__file__).parent / "shaders" / "vmobject_fill.wgsl" - shader_module = self._device.create_shader_module( - code=shader_path.read_text(encoding="utf-8") - ) + def _create_camera_bgl(self) -> wgpu_t.GPUBindGroupLayout: + """Create the bind group layout shared by stroke, surface, and Slug pipelines. - # One uniform buffer: projection (64 B) + view (64 B) + light_pos+pad (16 B) = 144 bytes. - proj_bgl = self._device.create_bind_group_layout( + Layout: binding 0 — one uniform buffer carrying projection (64 B) + + view (64 B) + light_pos+pad (16 B) = 144 bytes total. + """ + assert self._device is not None + return self._device.create_bind_group_layout( entries=[ { "binding": 0, @@ -414,35 +414,39 @@ def _create_fill_pipeline( ] ) - pipeline = self._device.create_render_pipeline( + def _create_stroke_pipeline( + self, proj_bgl: wgpu_t.GPUBindGroupLayout + ) -> wgpu_t.GPURenderPipeline: + assert self._device is not None + shader_path = Path(__file__).parent / "shaders" / "vmobject_stroke.wgsl" + shader_module = self._device.create_shader_module( + code=shader_path.read_text(encoding="utf-8") + ) + _blend = { + "color": { + "src_factor": "src-alpha", + "dst_factor": "one-minus-src-alpha", + "operation": "add", + }, + "alpha": { + "src_factor": "one", + "dst_factor": "one", + "operation": "add", + }, + } + return self._device.create_render_pipeline( layout=self._device.create_pipeline_layout( bind_group_layouts=[proj_bgl] ), vertex={ "module": shader_module, "entry_point": "vs_main", - "buffers": [FILL_VERTEX_LAYOUT], + "buffers": [STROKE_VERTEX_LAYOUT], }, fragment={ "module": shader_module, "entry_point": "fs_main", - "targets": [ - { - "format": wgpu.TextureFormat.rgba8unorm, - "blend": { - "color": { - "src_factor": "src-alpha", - "dst_factor": "one-minus-src-alpha", - "operation": "add", - }, - "alpha": { - "src_factor": "one", - "dst_factor": "one", - "operation": "add", - }, - }, - } - ], + "targets": [{"format": wgpu.TextureFormat.rgba8unorm, "blend": _blend}], }, primitive={"topology": "triangle-list", "cull_mode": "none"}, depth_stencil={ @@ -460,13 +464,12 @@ def _create_fill_pipeline( "alpha_to_coverage_enabled": False, }, ) - return proj_bgl, pipeline - def _create_stroke_pipeline( + def _create_surface_pipeline( self, proj_bgl: wgpu_t.GPUBindGroupLayout ) -> wgpu_t.GPURenderPipeline: assert self._device is not None - shader_path = Path(__file__).parent / "shaders" / "vmobject_stroke.wgsl" + shader_path = Path(__file__).parent / "shaders" / "surface.wgsl" shader_module = self._device.create_shader_module( code=shader_path.read_text(encoding="utf-8") ) @@ -489,7 +492,7 @@ def _create_stroke_pipeline( vertex={ "module": shader_module, "entry_point": "vs_main", - "buffers": [STROKE_VERTEX_LAYOUT], + "buffers": [SURFACE_VERTEX_LAYOUT], }, fragment={ "module": shader_module, @@ -499,8 +502,8 @@ def _create_stroke_pipeline( primitive={"topology": "triangle-list", "cull_mode": "none"}, depth_stencil={ "format": wgpu.TextureFormat.depth24plus, - "depth_write_enabled": False, - "depth_compare": "always", + "depth_write_enabled": True, + "depth_compare": "less", "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, "stencil_read_mask": 0, @@ -513,14 +516,32 @@ def _create_stroke_pipeline( }, ) - def _create_surface_pipeline( - self, proj_bgl: wgpu_t.GPUBindGroupLayout - ) -> wgpu_t.GPURenderPipeline: + def _create_slug_fill_pipeline( + self, + ) -> tuple[wgpu_t.GPUBindGroupLayout, wgpu_t.GPURenderPipeline]: + """Create the Slug fill pipeline with a storage-buffer bind group layout.""" assert self._device is not None - shader_path = Path(__file__).parent / "shaders" / "surface.wgsl" + shader_path = Path(__file__).parent / "shaders" / "slug_fill.wgsl" shader_module = self._device.create_shader_module( code=shader_path.read_text(encoding="utf-8") ) + + # Group 0: binding 0 = camera uniform, binding 1 = curves storage (read-only). + slug_bgl = self._device.create_bind_group_layout( + entries=[ + { + "binding": 0, + "visibility": wgpu.ShaderStage.VERTEX | wgpu.ShaderStage.FRAGMENT, + "buffer": {"type": "uniform"}, + }, + { + "binding": 1, + "visibility": wgpu.ShaderStage.FRAGMENT, + "buffer": {"type": "read-only-storage", "has_dynamic_offset": False}, + }, + ] + ) + _blend = { "color": { "src_factor": "src-alpha", @@ -533,14 +554,13 @@ def _create_surface_pipeline( "operation": "add", }, } - return self._device.create_render_pipeline( - layout=self._device.create_pipeline_layout( - bind_group_layouts=[proj_bgl] - ), + + pipeline = self._device.create_render_pipeline( + layout=self._device.create_pipeline_layout(bind_group_layouts=[slug_bgl]), vertex={ "module": shader_module, "entry_point": "vs_main", - "buffers": [SURFACE_VERTEX_LAYOUT], + "buffers": [SLUG_FILL_VERTEX_LAYOUT], }, fragment={ "module": shader_module, @@ -550,48 +570,64 @@ def _create_surface_pipeline( primitive={"topology": "triangle-list", "cull_mode": "none"}, depth_stencil={ "format": wgpu.TextureFormat.depth24plus, - "depth_write_enabled": True, - "depth_compare": "less", + "depth_write_enabled": False, + "depth_compare": "always", "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, "stencil_read_mask": 0, "stencil_write_mask": 0, }, - multisample={ - "count": 1, - "mask": 0xFFFF_FFFF, - "alpha_to_coverage_enabled": False, - }, + multisample={"count": 1, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, ) + return slug_bgl, pipeline # ------------------------------------------------------------------ # Camera bind group (rebuilt each frame when projection changes) # ------------------------------------------------------------------ + def _build_camera_uniform_buf(self) -> wgpu_t.GPUBuffer: + """Pack the 144-byte camera uniform and upload it; return the buffer.""" + assert self._device is not None + proj_bytes = self.camera.projection_matrix.T.flatten().tobytes() + view_bytes = self.camera.view_matrix.T.flatten().tobytes() + light = np.zeros(4, dtype=np.float32) + light[:3] = self.camera.light_source_position.astype(np.float32) + light_bytes = light.tobytes() + + buf = self._device.create_buffer_with_data( + data=proj_bytes + view_bytes + light_bytes, + usage=wgpu.BufferUsage.UNIFORM, + ) + self.frame_vbos.append(buf) + return buf + def _build_camera_bind_group(self) -> wgpu_t.GPUBindGroup: assert self._device is not None assert self._proj_bgl is not None - # Pack uniform buffer: projection (64 B) + view (64 B) + light_pos+pad (16 B) = 144 B. - # WGSL mat4x4 is column-major: transpose → flatten before packing. - proj_bytes = self.camera.projection_matrix.T.flatten().tobytes() # 64 bytes - view_bytes = self.camera.view_matrix.T.flatten().tobytes() # 64 bytes - light = np.zeros(4, dtype=np.float32) - light[:3] = self.camera.light_source_position.astype(np.float32) - light_bytes = light.tobytes() # 16 bytes - - uniform_data = proj_bytes + view_bytes + light_bytes # 144 bytes + self._camera_uniform_buf = self._build_camera_uniform_buf() - proj_buf = self._device.create_buffer_with_data( - data=uniform_data, - usage=wgpu.BufferUsage.UNIFORM, + return self._device.create_bind_group( + layout=self._proj_bgl, + entries=[ + {"binding": 0, "resource": {"buffer": self._camera_uniform_buf, "offset": 0, "size": 144}} + ], ) - self.frame_vbos.append(proj_buf) + def _build_slug_bind_group( + self, curves_buf: wgpu_t.GPUBuffer + ) -> wgpu_t.GPUBindGroup: + """Build the Slug fill bind group: camera uniform + curves storage buffer.""" + assert self._device is not None + assert self._slug_bgl is not None + assert self._camera_uniform_buf is not None, ( + "_build_camera_bind_group() must be called before _build_slug_bind_group()" + ) return self._device.create_bind_group( - layout=self._proj_bgl, + layout=self._slug_bgl, entries=[ - {"binding": 0, "resource": {"buffer": proj_buf, "offset": 0, "size": 144}} + {"binding": 0, "resource": {"buffer": self._camera_uniform_buf, "offset": 0, "size": 144}}, + {"binding": 1, "resource": {"buffer": curves_buf, "offset": 0, "size": curves_buf.size}}, ], ) @@ -604,11 +640,6 @@ def device(self) -> wgpu_t.GPUDevice: assert self._device is not None, "init_scene() has not been called" return self._device - @property - def fill_pipeline(self) -> wgpu_t.GPURenderPipeline: - assert self._fill_pipeline is not None, "init_scene() has not been called" - return self._fill_pipeline - @property def stroke_pipeline(self) -> wgpu_t.GPURenderPipeline: assert self._stroke_pipeline is not None, "init_scene() has not been called" @@ -619,6 +650,11 @@ def surface_pipeline(self) -> wgpu_t.GPURenderPipeline: assert self._surface_pipeline is not None, "init_scene() has not been called" return self._surface_pipeline + @property + def slug_fill_pipeline(self) -> wgpu_t.GPURenderPipeline: + assert self._slug_fill_pipeline is not None, "init_scene() has not been called" + return self._slug_fill_pipeline + # ------------------------------------------------------------------ # Frame rendering # ------------------------------------------------------------------ diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py index 0091e6b685..e79191d07a 100644 --- a/manim/renderer/webgpu/webgpu_vmobject_rendering.py +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -2,15 +2,10 @@ Fill ---- -Uses the Loop-Blinn quadratic bezier test for anti-aliased fill boundaries. -Each cubic bezier curve is first split into 4 sub-cubics via de Casteljau -subdivision (2 levels), then each sub-cubic is approximated as a quadratic. -The subdivision reduces the approximation error to negligible levels (<0.01 %). -Three kinds of triangles are emitted: - - texture_mode = +1 concave bezier region (Loop-Blinn: keep where u²−v ≥ 0) - texture_mode = −1 convex bezier region (Loop-Blinn: keep where u²−v ≤ 0) - texture_mode = 0 flat interior (always kept) +Uses the Slug algorithm (Lengyel 2017) for GPU-side analytical fill coverage. +Raw quadratic bezier control points are uploaded to a storage buffer; the +fragment shader computes exact winding-number coverage per pixel with smooth +sub-pixel anti-aliasing. No CPU tessellation is required. Stroke ------ @@ -31,12 +26,12 @@ from __future__ import annotations +import weakref from typing import TYPE_CHECKING import numpy as np from manim.mobject.types.vectorized_mobject import VMobject -from manim.utils.space_ops import cross2d, earclip_triangulation if TYPE_CHECKING: import wgpu as wgpu_t @@ -77,42 +72,6 @@ } -# --------------------------------------------------------------------------- -# Fill vertex layout — must match vmobject_fill.wgsl locations: -# location 0 → in_vert float32x3 offset 0 (12 bytes) -# location 1 → in_color float32x4 offset 12 (16 bytes) -# location 2 → texture_coords float32x2 offset 28 ( 8 bytes) -# location 3 → texture_mode float32 offset 36 ( 4 bytes) -# stride: 40 bytes -# --------------------------------------------------------------------------- - -_FILL_DTYPE = np.dtype( - [ - ("in_vert", np.float32, (3,)), - ("in_color", np.float32, (4,)), - ("texture_coords", np.float32, (2,)), - ("texture_mode", np.float32), - ] -) -_FILL_STRIDE: int = _FILL_DTYPE.itemsize # 40 bytes - -_FILL_OFFSETS: dict[str, int] = { - name: _FILL_DTYPE.fields[name][1] # type: ignore[index] - for name in _FILL_DTYPE.names -} - -FILL_VERTEX_LAYOUT: dict = { - "array_stride": _FILL_STRIDE, - "step_mode": "vertex", - "attributes": [ - {"format": "float32x3", "offset": _FILL_OFFSETS["in_vert"], "shader_location": 0}, - {"format": "float32x4", "offset": _FILL_OFFSETS["in_color"], "shader_location": 1}, - {"format": "float32x2", "offset": _FILL_OFFSETS["texture_coords"], "shader_location": 2}, - {"format": "float32", "offset": _FILL_OFFSETS["texture_mode"], "shader_location": 3}, - ], -} - - # --------------------------------------------------------------------------- # Stroke vertex layout — must match vmobject_stroke.wgsl locations: # location 0 → current_curve_0 float32x3 offset 0 (12 bytes) @@ -157,6 +116,65 @@ def _stroke_field_offset(name: str) -> int: } +# --------------------------------------------------------------------------- +# Slug fill vertex layout — must match slug_fill.wgsl locations: +# location 0 → in_pos float32x2 offset 0 ( 8 bytes) +# location 1 → in_color float32x4 offset 8 (16 bytes) +# location 2 → curve_start uint32 offset 24 ( 4 bytes) +# location 3 → n_curves uint32 offset 28 ( 4 bytes) +# stride: 32 bytes +# --------------------------------------------------------------------------- + +_SLUG_FILL_DTYPE = np.dtype( + [ + ("in_pos", np.float32, (2,)), + ("in_color", np.float32, (4,)), + ("curve_start", np.uint32), + ("n_curves", np.uint32), + ] +) +_SLUG_FILL_STRIDE: int = _SLUG_FILL_DTYPE.itemsize # 32 bytes + +_SLUG_FILL_OFFSETS: dict[str, int] = { + name: _SLUG_FILL_DTYPE.fields[name][1] # type: ignore[index] + for name in _SLUG_FILL_DTYPE.names +} + +SLUG_FILL_VERTEX_LAYOUT: dict = { + "array_stride": _SLUG_FILL_STRIDE, + "step_mode": "vertex", + "attributes": [ + {"format": "float32x2", "offset": _SLUG_FILL_OFFSETS["in_pos"], "shader_location": 0}, + {"format": "float32x4", "offset": _SLUG_FILL_OFFSETS["in_color"], "shader_location": 1}, + {"format": "uint32", "offset": _SLUG_FILL_OFFSETS["curve_start"], "shader_location": 2}, + {"format": "uint32", "offset": _SLUG_FILL_OFFSETS["n_curves"], "shader_location": 3}, + ], +} + + +# --------------------------------------------------------------------------- +# Geometry caches — eliminates repeated tessellation for static shapes. +# +# Both caches are WeakKeyDictionary so GC can reclaim vmobjects that have +# been removed from the scene. Each entry maps: +# vmobject → (points_hash: int, geometry: ndarray | tuple[ndarray, ndarray]) +# +# The points_hash is recomputed cheaply (tobytes hash) every frame; a mismatch +# means the shape changed and we re-tessellate. +# --------------------------------------------------------------------------- + +_slug_fill_cache: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() +_stroke_cache: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() + + +def _points_hash(vmobject: VMobject) -> int: + """Fast hash of vmobject.points — used to detect geometry changes.""" + pts = vmobject.points + if pts.size == 0: + return 0 + return hash(pts.tobytes()) + + # --------------------------------------------------------------------------- # Public entry point — batched rendering # --------------------------------------------------------------------------- @@ -168,34 +186,42 @@ def render_webgpu_mobject( ) -> None: """Batch-render all VMobjects in *mobjects* (the scene's top-level list). - Three phases: + Four phases: 1. **Tessellate** — iterate every family member of every mobject and - collect fill / stroke / surface geometry into plain numpy arrays. - No GPU calls are made in this phase. - - 2. **Batch upload** — concatenate all fill arrays into one bytes blob and - upload as a single ``VERTEX`` buffer; same for stroke and surface. - This yields at most 3 ``create_buffer_with_data`` calls per frame - regardless of how many mobjects are in the scene. - - 3. **Draw** — issue draw commands in scene order, pointing each command at - the correct byte-offset within the shared buffer. The pipeline is only - switched when the type changes (fill → stroke → surface), so - ``set_pipeline`` / ``set_bind_group`` calls are minimised too. - - Painter's-algorithm order is fully preserved: each submobject's fill draw - command comes before its stroke draw command, and mobjects are processed in - the same order as ``scene.mobjects``. + collect geometry into plain numpy arrays. No GPU calls are made. + + 2. **Batch upload** — at most 4 ``create_buffer_with_data`` calls total: + + * Slug fill quad vertex buffer (one bounding quad per shape) + * Slug fill curves storage buffer (all quadratic bezier data) + * Stroke vertex buffer + * Surface vertex buffer + + 3. **Build bind groups** — one per pipeline type. The Slug pipeline + gets a bind group that includes both the camera uniform buffer and + the curves storage buffer. + + 4. **Draw** — draw commands in scene order. Pipeline switches only happen + when the type changes, minimising state-change overhead. + + Fill rendering uses the Slug algorithm (exact winding-number coverage, + analytical anti-aliasing, no CPU tessellation). Stroke uses the existing + cubic-SDF Newton-method shader. 3-D surfaces use the Phong shader. + + Painter's-algorithm order is fully preserved. """ import wgpu # local import so module loads without wgpu installed # ── Phase 1: tessellate ─────────────────────────────────────────────── - fill_parts: list[np.ndarray] = [] + # Slug fill: one bounding-quad vertex record (6 verts) + flat curve array per shape. + slug_quad_parts: list[np.ndarray] = [] # _SLUG_FILL_DTYPE, 6 verts each + slug_curve_parts: list[np.ndarray] = [] # float32 (N*3, 2) each + stroke_parts: list[np.ndarray] = [] surface_parts: list[np.ndarray] = [] - # draw_plan entry: ("fill" | "stroke" | "surface", index into *_parts list) + # draw_plan entry: ("slug_fill" | "stroke" | "surface", index into *_parts) draw_plan: list[tuple[str, int]] = [] for mob in mobjects: @@ -208,31 +234,75 @@ def render_webgpu_mobject( draw_plan.append(("surface", len(surface_parts))) surface_parts.append(data) else: - fill_data = _collect_fill_geometry(submob) - if fill_data is not None: - draw_plan.append(("fill", len(fill_parts))) - fill_parts.append(fill_data) - stroke_data = _collect_stroke_geometry(submob) - if stroke_data is not None: + phash = _points_hash(submob) + + # ── Slug fill (cached) ────────────────────────────────────── + cached = _slug_fill_cache.get(submob) + if cached is not None and cached[0] == phash: + quad_verts, curves_flat = cached[1] + # quad_verts["curve_start"] will be patched in-place below; + # we must copy so the cached array stays at offset 0. + draw_plan.append(("slug_fill", len(slug_quad_parts))) + slug_quad_parts.append(quad_verts.copy()) + slug_curve_parts.append(curves_flat) + else: + slug_data = _collect_slug_fill_geometry(submob) + if slug_data is not None: + _slug_fill_cache[submob] = (phash, slug_data) + quad_verts, curves_flat = slug_data + draw_plan.append(("slug_fill", len(slug_quad_parts))) + slug_quad_parts.append(quad_verts.copy()) + slug_curve_parts.append(curves_flat) + + # ── Stroke (cached) ───────────────────────────────────────── + scached = _stroke_cache.get(submob) + if scached is not None and scached[0] == phash: draw_plan.append(("stroke", len(stroke_parts))) - stroke_parts.append(stroke_data) + stroke_parts.append(scached[1]) + else: + stroke_data = _collect_stroke_geometry(submob) + if stroke_data is not None: + _stroke_cache[submob] = (phash, stroke_data) + draw_plan.append(("stroke", len(stroke_parts))) + stroke_parts.append(stroke_data) if not draw_plan: return - # ── Phase 2: batch upload — 1 buffer per pipeline type ─────────────── + # ── Phase 2: batch upload ───────────────────────────────────────────── device: wgpu_t.GPUDevice = renderer.device - fill_buf = fill_byte_offsets = None + slug_fill_vbo = slug_fill_byte_offsets = None + slug_bind_group = None stroke_buf = stroke_byte_offsets = None surface_buf = surface_byte_offsets = None - if fill_parts: - fill_buf, fill_byte_offsets = _batch_upload(device, fill_parts) - renderer.frame_vbos.append(fill_buf) + if slug_quad_parts: + # Fix up per-shape curve_start offsets into the shared curves buffer. + curve_global_offset = 0 + for i, curves_flat in enumerate(slug_curve_parts): + # curves_flat has shape (n_quads * 3, 2); each curve = 3 entries. + n_quads_i = len(curves_flat) // 3 + slug_quad_parts[i]["curve_start"] = curve_global_offset + curve_global_offset += n_quads_i + + slug_fill_vbo, slug_fill_byte_offsets = _batch_upload(device, slug_quad_parts) + renderer.frame_vbos.append(slug_fill_vbo) + + all_curves = np.concatenate(slug_curve_parts, axis=0) # (total * 3, 2) + slug_curves_buf = device.create_buffer_with_data( + data=all_curves.tobytes(), + usage=wgpu.BufferUsage.STORAGE, + ) + renderer.frame_vbos.append(slug_curves_buf) + + # Bind group for Slug pipeline: camera uniform + curves storage. + slug_bind_group = renderer._build_slug_bind_group(slug_curves_buf) + if stroke_parts: stroke_buf, stroke_byte_offsets = _batch_upload(device, stroke_parts) renderer.frame_vbos.append(stroke_buf) + if surface_parts: surface_buf, surface_byte_offsets = _batch_upload(device, surface_parts) renderer.frame_vbos.append(surface_buf) @@ -242,20 +312,21 @@ def render_webgpu_mobject( current_pipeline: str | None = None for cmd_type, idx in draw_plan: - # Switch pipeline only when the type changes. if cmd_type != current_pipeline: - if cmd_type == "fill": - rp.set_pipeline(renderer.fill_pipeline) + if cmd_type == "slug_fill": + rp.set_pipeline(renderer.slug_fill_pipeline) + rp.set_bind_group(0, slug_bind_group, [], 0, 0) elif cmd_type == "stroke": rp.set_pipeline(renderer.stroke_pipeline) - else: + rp.set_bind_group(0, renderer.camera_bind_group, [], 0, 0) + else: # surface rp.set_pipeline(renderer.surface_pipeline) - rp.set_bind_group(0, renderer.camera_bind_group, [], 0, 0) + rp.set_bind_group(0, renderer.camera_bind_group, [], 0, 0) current_pipeline = cmd_type - if cmd_type == "fill": - arr = fill_parts[idx] - rp.set_vertex_buffer(0, fill_buf, fill_byte_offsets[idx], arr.nbytes) + if cmd_type == "slug_fill": + arr = slug_quad_parts[idx] + rp.set_vertex_buffer(0, slug_fill_vbo, slug_fill_byte_offsets[idx], arr.nbytes) rp.draw(len(arr), 1, 0, 0) elif cmd_type == "stroke": arr = stroke_parts[idx] @@ -282,15 +353,6 @@ def render_webgpu_surface( _draw_surface_face(renderer, submob) -def render_webgpu_vmobject_fill( - renderer: WebGPURenderer, - mobject: VMobject, -) -> None: - """Record fill draw calls for *mobject* and all its descendants.""" - for submob in mobject.family_members_with_points(): - _draw_vmobject_fill(renderer, submob) - - def render_webgpu_vmobject_stroke( renderer: WebGPURenderer, mobject: VMobject, @@ -337,29 +399,6 @@ def _batch_upload( # --------------------------------------------------------------------------- -def _collect_fill_geometry(vmobject: VMobject) -> np.ndarray | None: - """Return a ``_FILL_DTYPE`` array for *vmobject*'s fill, or ``None``.""" - fill_rgba = vmobject.get_fill_rgbas() - if fill_rgba.shape[0] == 0 or fill_rgba[0, 3] == 0: - return None - - color = fill_rgba[0].astype(np.float32) - result = _triangulate_cairo_vmobject(vmobject) - if result is None: - return None - verts, tex_coords, tex_modes = result - if len(verts) == 0: - return None - - n_verts = len(verts) - attrs = np.empty(n_verts, dtype=_FILL_DTYPE) - attrs["in_vert"] = verts.astype(np.float32) - attrs["in_color"] = color - attrs["texture_coords"] = tex_coords.astype(np.float32) - attrs["texture_mode"] = tex_modes.astype(np.float32) - return attrs - - def _collect_stroke_geometry(vmobject: VMobject) -> np.ndarray | None: """Return a ``_STROKE_DTYPE`` array for *vmobject*'s stroke, or ``None``.""" stroke_rgba = vmobject.get_stroke_rgbas() @@ -463,28 +502,86 @@ def _collect_surface_geometry(vmobject: VMobject) -> np.ndarray | None: # --------------------------------------------------------------------------- -# Single-mobject draw helpers (used by the explicit public helpers above) +# Slug fill geometry collector # --------------------------------------------------------------------------- -def _draw_vmobject_fill(renderer: WebGPURenderer, vmobject: VMobject) -> None: - data = _collect_fill_geometry(vmobject) - if data is None: - return +def _collect_slug_fill_geometry( + vmobject: VMobject, +) -> tuple[np.ndarray, np.ndarray] | None: + """Return ``(quad_verts, curves_flat)`` for the Slug fill pipeline, or ``None``. - import wgpu + *quad_verts* is a ``_SLUG_FILL_DTYPE`` array of 6 vertices forming the + axis-aligned bounding quad for this shape. ``curve_start`` is set to 0 + and must be patched to the global offset by the caller before upload. - device: wgpu_t.GPUDevice = renderer.device - vbo = device.create_buffer_with_data( - data=data.tobytes(), usage=wgpu.BufferUsage.VERTEX + *curves_flat* is a ``float32`` array of shape ``(n_quads * 3, 2)`` + containing the world-space XY coordinates of every quadratic bezier + control point — three consecutive entries (p1, p2, p3) per curve. + """ + fill_rgba = vmobject.get_fill_rgbas() + if fill_rgba.shape[0] == 0 or fill_rgba[0, 3] == 0: + return None + + color = fill_rgba[0].astype(np.float32) + subpaths = vmobject.get_subpaths() + if not subpaths: + return None + + nppcc = vmobject.n_points_per_cubic_curve + + per_subpath: list[np.ndarray] = [] # each: (n, 3, 2) + + for subpath in subpaths: + n_curves = len(subpath) // nppcc + if n_curves == 0: + continue + pts = subpath[: n_curves * nppcc] + + b0s = pts[0::nppcc] + h0s = pts[1::nppcc] + h1s = pts[2::nppcc] + b2s = pts[3::nppcc] + + # Subdivide cubics into quadratics (2 de Casteljau levels → 4 per cubic). + qb0s, qmids, qb2s = _cubic_to_quadratics(b0s, h0s, h1s, b2s) + + # Stack to (n_quads, 3, 3): [p1, p2, p3] in xyz; keep only xy. + curves_xyz = np.stack([qb0s, qmids, qb2s], axis=1) # (n, 3, 3) + per_subpath.append(curves_xyz[:, :, :2].astype(np.float32)) # (n, 3, 2) + + if not per_subpath: + return None + + curves_stacked = np.concatenate(per_subpath, axis=0) # (N_total, 3, 2) + n_quads = len(curves_stacked) + curves_flat = curves_stacked.reshape(-1, 2) # (N_total * 3, 2) + + # Bounding box of all control points + small AA padding. + bbox_min = curves_flat.min(axis=0) - 0.05 + bbox_max = curves_flat.max(axis=0) + 0.05 + x0, y0 = bbox_min + x1, y1 = bbox_max + + # 6 vertices: two counter-clockwise triangles covering the bounding rect. + quad_pos = np.array( + [[x0, y0], [x1, y0], [x0, y1], + [x1, y0], [x1, y1], [x0, y1]], + dtype=np.float32, ) - renderer.frame_vbos.append(vbo) - rp = renderer.current_render_pass - rp.set_pipeline(renderer.fill_pipeline) - rp.set_bind_group(0, renderer.camera_bind_group, [], 0, 0) - rp.set_vertex_buffer(0, vbo) - rp.draw(len(data), 1, 0, 0) + quad_verts = np.empty(6, dtype=_SLUG_FILL_DTYPE) + quad_verts["in_pos"] = quad_pos + quad_verts["in_color"] = color # broadcast + quad_verts["curve_start"] = 0 # patched to global offset by caller + quad_verts["n_curves"] = n_quads + + return quad_verts, curves_flat + + +# --------------------------------------------------------------------------- +# Single-mobject draw helpers (used by the explicit public helpers above) +# --------------------------------------------------------------------------- def _draw_vmobject_stroke(renderer: WebGPURenderer, vmobject: VMobject) -> None: @@ -528,7 +625,7 @@ def _draw_surface_face(renderer: WebGPURenderer, vmobject: VMobject) -> None: # --------------------------------------------------------------------------- -# Cubic → quadratic subdivision (used by fill triangulation) +# Cubic → quadratic subdivision (used by Slug fill geometry collector) # --------------------------------------------------------------------------- _CUBIC_SUBDIVISION_LEVELS: int = 2 # 4 quadratic pieces per cubic bezier @@ -572,140 +669,3 @@ def _cubic_to_quadratics( qmids = (curves[:, 1] + curves[:, 2]) * 0.5 # midpoint of sub-handles qb2s = curves[:, 3] return qb0s, qmids, qb2s - - -# --------------------------------------------------------------------------- -# Fill triangulation: cubic VMobject → Loop-Blinn triangles -# --------------------------------------------------------------------------- - - -def _triangulate_cairo_vmobject( - vmobject: VMobject, -) -> tuple[np.ndarray, np.ndarray, np.ndarray] | None: - """Return (verts, tex_coords, tex_modes) for all fill triangles. - - Converts each cubic bezier to a quadratic approximation, classifies each - curve as concave (+1) or convex (−1), emits bezier boundary triangles with - Loop-Blinn UVs, and earclip-triangulates the flat interior (tex_mode=0). - """ - subpaths = vmobject.get_subpaths() - if not subpaths: - return None - - nppcc = vmobject.n_points_per_cubic_curve # 4 - atol = vmobject.tolerance_for_point_equality - - all_verts: list[np.ndarray] = [] - all_tex_coords: list[np.ndarray] = [] - all_tex_modes: list[np.ndarray] = [] - - for subpath in subpaths: - n_curves = len(subpath) // nppcc - if n_curves == 0: - continue - - pts = subpath[: n_curves * nppcc] # (4n, 3) - - # Cubic control points. - b0s_cubic = pts[0::nppcc] # start anchors (n, 3) - h0s_cubic = pts[1::nppcc] # handle 0 - h1s_cubic = pts[2::nppcc] # handle 1 - b2s_cubic = pts[3::nppcc] # end anchors (n, 3) - - # Subdivide each cubic into 4 quadratic approximations. - b0s, b1s, b2s = _cubic_to_quadratics(b0s_cubic, h0s_cubic, h1s_cubic, b2s_cubic) - n_curves = len(b0s) # now 4 × original - - # Build flat (3*n_curves, 3) quadratic representation for the triangulation. - quad_pts = np.empty((n_curves * 3, 3), dtype=np.float64) - quad_pts[0::3] = b0s - quad_pts[1::3] = b1s - quad_pts[2::3] = b2s - - # Classify curves. - v01s = b1s - b0s - v12s = b2s - b1s - crosses = cross2d(v01s, v12s) - convexities = np.sign(crosses) - - # Orientation from signed area of anchor polygon. - ax, ay = b0s[:, 0], b0s[:, 1] - signed_area = float( - np.sum(ax * np.roll(ay, -1) - np.roll(ax, -1) * ay) - ) - if signed_area >= 0: - concave_parts = convexities > 0 - convex_parts = convexities <= 0 - else: - concave_parts = convexities < 0 - convex_parts = convexities >= 0 - - # ── Bezier boundary triangles ────────────────────────────────────── - _UV_TILE = np.array([[0.0, 0.0], [0.5, 0.0], [1.0, 1.0]], dtype=np.float32) - - if np.any(concave_parts): - n_c = int(np.sum(concave_parts)) - tri = np.empty((n_c * 3, 3)) - tri[0::3] = b0s[concave_parts] - tri[1::3] = b1s[concave_parts] - tri[2::3] = b2s[concave_parts] - all_verts.append(tri) - all_tex_coords.append(np.tile(_UV_TILE, (n_c, 1))) - all_tex_modes.append(np.ones(n_c * 3, dtype=np.float32)) - - if np.any(convex_parts): - n_v = int(np.sum(convex_parts)) - tri = np.empty((n_v * 3, 3)) - tri[0::3] = b0s[convex_parts] - tri[1::3] = b1s[convex_parts] - tri[2::3] = b2s[convex_parts] - all_verts.append(tri) - all_tex_coords.append(np.tile(_UV_TILE, (n_v, 1))) - all_tex_modes.append(-np.ones(n_v * 3, dtype=np.float32)) - - # ── Flat interior (earclip) ──────────────────────────────────────── - end_of_loop = np.zeros(n_curves, dtype=bool) - if n_curves > 1: - end_of_loop[:-1] = (np.abs(b2s[:-1] - b0s[1:]) > atol).any(1) - end_of_loop[-1] = True - - idx = np.arange(n_curves) - inner_vert_indices = np.hstack( - [ - idx * 3, - idx[concave_parts] * 3 + 1, - idx[end_of_loop] * 3 + 2, - ] - ) - inner_vert_indices.sort() - - rings = ( - np.arange(1, len(inner_vert_indices) + 1)[inner_vert_indices % 3 == 2] - ).tolist() - - inner_verts = quad_pts[inner_vert_indices] # (M, 3) - if len(inner_verts) < 3 or not rings: - continue - - tri_indices_raw = earclip_triangulation(inner_verts[:, :2], rings) - if not tri_indices_raw: - continue - - inner_tri_indices = inner_vert_indices[ - np.array(tri_indices_raw, dtype=int) - ] - inner_pts = quad_pts[inner_tri_indices] - n_inner = len(inner_pts) - - all_verts.append(inner_pts) - all_tex_coords.append(np.zeros((n_inner, 2), dtype=np.float32)) - all_tex_modes.append(np.zeros(n_inner, dtype=np.float32)) - - if not all_verts: - return None - - return ( - np.concatenate(all_verts, axis=0), - np.concatenate(all_tex_coords, axis=0), - np.concatenate(all_tex_modes, axis=0), - ) From 95e00f7cd41cc05cf21f9c53823dda42330c3c80 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Sun, 5 Apr 2026 17:16:56 +0530 Subject: [PATCH 07/33] Added Analytic SDF Anti-Aliasing for strokes --- .../webgpu/shaders/vmobject_stroke.wgsl | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/manim/renderer/webgpu/shaders/vmobject_stroke.wgsl b/manim/renderer/webgpu/shaders/vmobject_stroke.wgsl index b396c016f2..74c942bb72 100644 --- a/manim/renderer/webgpu/shaders/vmobject_stroke.wgsl +++ b/manim/renderer/webgpu/shaders/vmobject_stroke.wgsl @@ -232,11 +232,15 @@ fn vs_main(in: VertexInput) -> VertexOutput { out.uv_curve_2 = uv2; out.uv_curve_3 = uv3; - // Tight bounding quad: cubic AABB padded by the stroke thickness. + // Tight bounding quad: cubic AABB padded by the stroke thickness plus a + // small extra margin for the anti-aliasing fringe (≈1 pixel in UV space). + // thickness_multiplier * 1.0 pixel ≈ aa_pad estimate; we use a fixed + // conservative constant that matches the thickness_multiplier scale. let t = out.v_thickness; + let aa_pad = thickness_multiplier * 2.0; // ~1–2 px feather margin in UV let uv_bb = bbox_cubic(uv0, uv1, uv2, uv3); - let uv_min = uv_bb.xy - vec2(t); - let uv_max = uv_bb.zw + vec2(t); + let uv_min = uv_bb.xy - vec2(t + aa_pad); + let uv_max = uv_bb.zw + vec2(t + aa_pad); // tile_coordinate ∈ [0,1]² → lerp within [uv_min, uv_max]. let uv_tile = mix(uv_min, uv_max, in.tile_coordinate); @@ -257,8 +261,28 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { in.uv_curve_0, in.uv_curve_1, in.uv_curve_2, in.uv_curve_3, in.uv_point, ); - if (dist < in.v_thickness) { - return in.v_color; - } - discard; + + // ── Analytic SDF anti-aliasing ────────────────────────────────────────── + // + // fwidthFine(dist) returns |∂dist/∂x| + |∂dist/∂y|, which approximates + // the change in UV-space distance over one screen pixel. We use half of + // that as the half-width of the smooth transition band: + // + // coverage = 1 when dist ≤ thickness − half_px (fully inside) + // coverage = 0 when dist ≥ thickness + half_px (fully outside) + // + // smoothstep interpolates smoothly between those limits. + let px = fwidthFine(dist); // ≈ 1 px in UV units + let half_px = 0.5 * px; + let edge_low = in.v_thickness - half_px; + let edge_high = in.v_thickness + half_px; + let coverage = 1.0 - smoothstep(edge_low, edge_high, dist); + + // Fully outside the anti-aliased fringe — discard to avoid touching the + // depth/stencil buffer unnecessarily. + if (coverage <= 0.0) { discard; } + + // Multiply the stored alpha by the smooth SDF coverage so transparent + // strokes composite correctly. + return vec4(in.v_color.rgb, in.v_color.a * coverage); } From bd56ca931dabbaa7946e772c3e1f907e4d632cfe Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Tue, 7 Apr 2026 18:43:08 +0530 Subject: [PATCH 08/33] 3D scene and animation is working in WebGPU Renderer Added initial differentiation between camera and lighting. Right now Camera only uses orthographic projection. --- manim/mobject/three_d/three_dimensions.py | 8 +- manim/renderer/base_renderer.py | 74 +- .../renderer/webgpu/shaders/oit_compose.wgsl | 53 + .../webgpu/shaders/readback_compact.wgsl | 48 + manim/renderer/webgpu/shaders/slug_fill.wgsl | 58 +- manim/renderer/webgpu/shaders/surface.wgsl | 103 +- .../renderer/webgpu/shaders/surface_oit.wgsl | 120 +++ .../webgpu/shaders/vmobject_stroke.wgsl | 143 ++- manim/renderer/webgpu/webgpu_renderer.py | 998 ++++++++++++++++-- .../renderer/webgpu/webgpu_renderer_window.py | 243 +++++ .../webgpu/webgpu_vmobject_rendering.py | 442 ++++++-- manim/scene/section.py | 1 + manim/scene/three_d_scene.py | 75 ++ 13 files changed, 2004 insertions(+), 362 deletions(-) create mode 100644 manim/renderer/webgpu/shaders/oit_compose.wgsl create mode 100644 manim/renderer/webgpu/shaders/readback_compact.wgsl create mode 100644 manim/renderer/webgpu/shaders/surface_oit.wgsl create mode 100644 manim/renderer/webgpu/webgpu_renderer_window.py diff --git a/manim/mobject/three_d/three_dimensions.py b/manim/mobject/three_d/three_dimensions.py index f2698de7e8..ac2fa8b0ee 100644 --- a/manim/mobject/three_d/three_dimensions.py +++ b/manim/mobject/three_d/three_dimensions.py @@ -343,7 +343,7 @@ def param_surface(u, v): if config.renderer == RendererType.OPENGL: assert isinstance(mob, OpenGLMobject) mob.set_color(mob_color, recurse=False) - elif config.renderer == RendererType.CAIRO: + elif config.renderer in {RendererType.CAIRO, RendererType.WEBGPU}: mob.set_color(mob_color, family=False) break @@ -452,7 +452,7 @@ def __init__( ) -> None: if config.renderer == RendererType.OPENGL: res_value = (101, 51) - elif config.renderer == RendererType.CAIRO: + elif config.renderer in {RendererType.CAIRO, RendererType.WEBGPU}: res_value = (24, 12) else: raise Exception("Unknown renderer") @@ -882,7 +882,7 @@ def add_bases(self) -> None: assert isinstance(self, OpenGLMobject) color = self.color opacity = self.opacity - elif config.renderer == RendererType.CAIRO: + elif config.renderer in {RendererType.CAIRO, RendererType.WEBGPU}: color = self.fill_color opacity = self.fill_opacity @@ -1316,7 +1316,7 @@ def __init__( ) -> None: if config.renderer == RendererType.OPENGL: res_value = (101, 101) - elif config.renderer == RendererType.CAIRO: + elif config.renderer in {RendererType.CAIRO, RendererType.WEBGPU}: res_value = (24, 24) resolution = resolution if resolution is not None else res_value diff --git a/manim/renderer/base_renderer.py b/manim/renderer/base_renderer.py index b300ddce10..9e37470e8a 100644 --- a/manim/renderer/base_renderer.py +++ b/manim/renderer/base_renderer.py @@ -15,9 +15,68 @@ if TYPE_CHECKING: from PIL import Image + from manim.mobject.mobject import Mobject + from manim.mobject.value_tracker import ValueTracker from manim.scene.scene import Scene +# --------------------------------------------------------------------------- +# Camera protocols +# --------------------------------------------------------------------------- + + +@runtime_checkable +class ThreeDCameraProtocol(Protocol): + """Interface that every 3D camera must satisfy. + + ``ThreeDScene`` calls these attributes and methods on + ``renderer.camera``; declaring them here makes the contract explicit + for all three renderers (Cairo ``ThreeDCamera``, ``OpenGLCamera``, + and ``WebGPUCamera``). + """ + + # ── angle trackers (Cairo uses ValueTrackers; OpenGL/WebGPU store + # the angles directly and expose them as plain attributes) ───────────── + theta_tracker: ValueTracker + phi_tracker: ValueTracker + gamma_tracker: ValueTracker + focal_distance_tracker: ValueTracker + zoom_tracker: ValueTracker + + # Mobject used as the camera frame-centre (moved to pan the scene). + _frame_center: Mobject + + # ── orientation setters ────────────────────────────────────────────────── + def set_phi(self, phi: float) -> None: ... + def set_theta(self, theta: float) -> None: ... + def set_gamma(self, gamma: float) -> None: ... + def set_zoom(self, zoom: float) -> None: ... + def set_focal_distance(self, focal_distance: float) -> None: ... + + # ── incremental rotation (OpenGL / WebGPU ambient rotation) ───────────── + def increment_theta(self, dtheta: float) -> None: ... + def increment_phi(self, dphi: float) -> None: ... + def increment_gamma(self, dgamma: float) -> None: ... + + # ── updater support (camera is a Mobject in OpenGL / WebGPU) ──────────── + def add_updater(self, func: Any, **kwargs: Any) -> None: ... + def clear_updaters(self) -> None: ... + + # ── moving-mobject tracking ────────────────────────────────────────────── + def get_value_trackers(self) -> list[ValueTracker]: ... + + # ── fixed-orientation / fixed-in-frame helpers (Cairo) ────────────────── + def add_fixed_orientation_mobjects(self, *mobjects: Mobject, **kwargs: Any) -> None: ... + def remove_fixed_orientation_mobjects(self, *mobjects: Mobject) -> None: ... + def add_fixed_in_frame_mobjects(self, *mobjects: Mobject) -> None: ... + def remove_fixed_in_frame_mobjects(self, *mobjects: Mobject) -> None: ... + + +# --------------------------------------------------------------------------- +# Renderer protocol +# --------------------------------------------------------------------------- + + @runtime_checkable class RendererProtocol(Protocol): """Protocol that every Manim renderer must satisfy. @@ -27,15 +86,25 @@ class RendererProtocol(Protocol): checking without breaking existing renderer classes. """ - camera: Any + # ── core attributes ────────────────────────────────────────────────────── + camera: Any # ThreeDCameraProtocol for 3D renderers; Any for 2D skip_animations: bool num_plays: int time: float file_writer: Any - window: Any + window: Any # WebGPUWindow | pyglet window | None animation_start_time: float static_image: Any + # Camera configuration dict (pixel_width, pixel_height, …). + # Accessed by SpecialThreeDScene to choose low/high quality config. + camera_config: dict + + # Set of currently-held key codes; polled by Scene.interact() and + # the WebGPU window event handler. + pressed_keys: set + + # ── lifecycle ──────────────────────────────────────────────────────────── def init_scene(self, scene: Scene) -> None: ... def play(self, scene: Scene, *args: Any, **kwargs: Any) -> None: ... @@ -50,6 +119,7 @@ def scene_finished(self, scene: Scene) -> None: ... def clear_screen(self) -> None: ... + # ── frame access ───────────────────────────────────────────────────────── def get_image(self) -> Image.Image: ... def get_frame(self) -> np.ndarray: ... diff --git a/manim/renderer/webgpu/shaders/oit_compose.wgsl b/manim/renderer/webgpu/shaders/oit_compose.wgsl new file mode 100644 index 0000000000..409651126b --- /dev/null +++ b/manim/renderer/webgpu/shaders/oit_compose.wgsl @@ -0,0 +1,53 @@ +// WebGPU OIT composition shader. +// +// Reads the two OIT accumulation textures produced by surface_oit.wgsl and +// composites the transparent geometry result onto the existing opaque framebuffer. +// +// No vertex buffer is needed — three vertices are generated from the built-in +// vertex index, covering the full screen with a single oversized triangle. +// +// The output is alpha-blended onto the main render texture using standard +// {src-alpha, one-minus-src-alpha} blending, so the pipeline must be +// configured with that blend mode. + +@group(0) @binding(0) var oit_accum : texture_2d; // rgba16float +@group(0) @binding(1) var oit_reveal : texture_2d; // rgba16float + +struct VertexOutput { + @builtin(position) clip_position : vec4, +}; + +// Full-screen triangle: 3 vertices cover [-1,1]x[-1,1] without a VBO. +@vertex +fn vs_main(@builtin(vertex_index) vi: u32) -> VertexOutput { + var pos = array, 3>( + vec2(-1.0, -1.0), + vec2( 3.0, -1.0), + vec2(-1.0, 3.0), + ); + var out: VertexOutput; + out.clip_position = vec4(pos[vi], 0.0, 1.0); + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let coord = vec2(in.clip_position.xy); + let accum = textureLoad(oit_accum, coord, 0); + let reveal = textureLoad(oit_reveal, coord, 0).r; + + // Nothing accumulated at this pixel — don't touch the framebuffer. + if (accum.a < 1e-5) { discard; } + + // Weighted average colour. + let avg_color = accum.rgb / accum.a; + + // Overall opacity: 1 − ∏(1 − αᵢ). + // `reveal` started at 1 and each fragment multiplied it by (1 − α), + // so reveal == ∏(1 − αᵢ) and 1 − reveal is the accumulated opacity. + let alpha = clamp(1.0 - reveal, 0.0, 1.0); + + // Output with premultiplied alpha so the {src-alpha, one-minus-src-alpha} + // pipeline blend correctly composites over the opaque framebuffer. + return vec4(avg_color, alpha); +} diff --git a/manim/renderer/webgpu/shaders/readback_compact.wgsl b/manim/renderer/webgpu/shaders/readback_compact.wgsl new file mode 100644 index 0000000000..a9682c3020 --- /dev/null +++ b/manim/renderer/webgpu/shaders/readback_compact.wgsl @@ -0,0 +1,48 @@ +// GPU compact-readback compute shader. +// +// Reads every pixel from the bgra8unorm render texture and writes tightly-packed +// RGBA bytes into a storage buffer — one u32 per pixel, little-endian: +// byte 0 = R, byte 1 = G, byte 2 = B, byte 3 = A +// +// Two CPU operations are eliminated in a single pass: +// +// 1. Row-padding strip +// copy_texture_to_buffer requires bytes_per_row to be a multiple of 256. +// The CPU previously looped over every row to remove the padding bytes. +// Here each thread writes directly to the tight index y*width + x, +// so the output buffer is already compact — no post-processing needed. +// +// 2. B↔R channel swap +// The render texture is bgra8unorm (GPU memory layout: B G R A). +// textureLoad() always returns components as (r, g, b, a) regardless of +// the physical layout, so the output is already in RGBA byte order. +// The CPU numpy channel-swap is no longer required. +// +// Workgroup size 16×16 = 256 threads. Each thread handles one pixel. +// Caller dispatches ceil(width/16) × ceil(height/16) workgroups; the +// out-of-bounds guard below is a no-op for tiles that fit exactly. + +@group(0) @binding(0) var src_tex : texture_2d; +@group(0) @binding(1) var dst : array; + +@compute @workgroup_size(16, 16) +fn main(@builtin(global_invocation_id) gid : vec3) { + let dims = textureDimensions(src_tex); + + // Discard threads outside the image boundary (last tile edge). + if (gid.x >= dims.x || gid.y >= dims.y) { return; } + + // textureLoad returns (r, g, b, a) as normalized f32 regardless of bgra + // memory layout — no manual component swap needed. + let c = textureLoad(src_tex, vec2(i32(gid.x), i32(gid.y)), 0); + + let r = u32(clamp(c.r * 255.0 + 0.5, 0.0, 255.0)); + let g = u32(clamp(c.g * 255.0 + 0.5, 0.0, 255.0)); + let b = u32(clamp(c.b * 255.0 + 0.5, 0.0, 255.0)); + let a = u32(clamp(c.a * 255.0 + 0.5, 0.0, 255.0)); + + // Pack as little-endian u32: byte0=R, byte1=G, byte2=B, byte3=A. + // NumPy / PIL both read this as RGBA when the buffer is reinterpreted as + // uint8 in row-major order. + dst[gid.y * dims.x + gid.x] = r | (g << 8u) | (b << 16u) | (a << 24u); +} diff --git a/manim/renderer/webgpu/shaders/slug_fill.wgsl b/manim/renderer/webgpu/shaders/slug_fill.wgsl index e3b51b5618..297802ed9b 100644 --- a/manim/renderer/webgpu/shaders/slug_fill.wgsl +++ b/manim/renderer/webgpu/shaders/slug_fill.wgsl @@ -1,9 +1,9 @@ // WebGPU fill shader using the Slug algorithm. // -// Renders VMobject fill with exact, analytical winding-number coverage and -// smooth sub-pixel anti-aliasing. No CPU tessellation is required — raw -// quadratic bezier control points are uploaded once per frame in a storage -// buffer, and coverage is computed entirely in the fragment shader. +// Supports both 2-D and 3-D VMobjects. Coverage is computed in view-space XY, +// which is a rigid transform of world space so pixel-scale distances remain +// valid. Curve control points are stored as world-space vec3 and transformed +// to view-space XY in the fragment shader. // // Reference: // E. Lengyel, "GPU-Centered Font Rendering Directly from Glyph Outlines", @@ -15,14 +15,16 @@ // offset 64 — view mat4x4 (64 bytes) // offset 128 — light_pos vec3 (12 bytes, padded to 16) // -// Storage buffer (group 0, binding 1) — flat array of vec2: -// curves[i*3 + 0] = p1 (start anchor of quadratic bezier i) -// curves[i*3 + 1] = p2 (single control point) -// curves[i*3 + 2] = p3 (end anchor) -// All coordinates in Manim world space. +// Storage buffer (group 0, binding 1) — tightly packed array: +// For quadratic bezier i: floats at indices [i*9 .. i*9+8] +// [0,1,2] = p1 XYZ (start anchor) +// [3,4,5] = p2 XYZ (control point) +// [6,7,8] = p3 XYZ (end anchor) +// Using array rather than array> because WGSL gives vec3 +// a 16-byte stride in storage buffers, while Python packs them at 12 bytes. // // Vertex attributes: -// location 0 — in_pos vec2 world-space bounding-quad corner +// location 0 — in_pos vec3 world-space 3-D position of bounding-quad corner // location 1 — in_color vec4 RGBA fill colour // location 2 — curve_start u32 first curve index in storage buffer // location 3 — n_curves u32 number of quadratic bezier curves @@ -35,11 +37,11 @@ struct Uniforms { }; @group(0) @binding(0) var u : Uniforms; -// Flat array of vec2 control points: three entries per quadratic bezier curve. -@group(0) @binding(1) var curves : array>; +// Tightly packed floats: 9 floats per quadratic bezier (3 points × 3 floats). +@group(0) @binding(1) var curves : array; struct VertexInput { - @location(0) in_pos : vec2, + @location(0) in_pos : vec3, @location(1) in_color : vec4, @location(2) curve_start : u32, @location(3) n_curves : u32, @@ -47,7 +49,7 @@ struct VertexInput { struct VertexOutput { @builtin(position) clip_pos : vec4, - @location(0) world_pos : vec2, + @location(0) view_pos_xy : vec2, // view-space XY for coverage @location(1) v_color : vec4, @location(2) @interpolate(flat) curve_start : u32, @location(3) @interpolate(flat) n_curves : u32, @@ -56,8 +58,9 @@ struct VertexOutput { @vertex fn vs_main(in: VertexInput) -> VertexOutput { var out: VertexOutput; - out.clip_pos = u.projection * u.view * vec4(in.in_pos, 0.0, 1.0); - out.world_pos = in.in_pos; + let view_pos = u.view * vec4(in.in_pos, 1.0); + out.clip_pos = u.projection * view_pos; + out.view_pos_xy = view_pos.xy; out.v_color = in.in_color; out.curve_start = in.curve_start; out.n_curves = in.n_curves; @@ -153,21 +156,26 @@ fn calc_coverage(xcov: f32, ycov: f32, xwgt: f32, ywgt: f32) -> f32 { @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { - // World units per screen pixel — lets coverage calculations work in - // pixel units regardless of zoom or resolution. - let ems_per_pixel = fwidth(in.world_pos); + // View-space units per screen pixel — coverage math works in these units + // regardless of zoom, resolution, or 3-D orientation. + let ems_per_pixel = fwidth(in.view_pos_xy); let pixels_per_em = 1.0 / max(ems_per_pixel, vec2(1e-9)); var xcov = 0.0; var xwgt = 0.0; var ycov = 0.0; var ywgt = 0.0; for (var i = 0u; i < in.n_curves; i = i + 1u) { - let base = (in.curve_start + i) * 3u; - - // Shift curve so the current fragment is the origin. - let p1 = curves[base ] - in.world_pos; - let p2 = curves[base + 1u ] - in.world_pos; - let p3 = curves[base + 2u ] - in.world_pos; + // 9 floats per quadratic: p1 (xyz), p2 (xyz), p3 (xyz). + let f = (in.curve_start + i) * 9u; + let p1w = vec3(curves[f ], curves[f + 1u], curves[f + 2u]); + let p2w = vec3(curves[f + 3u], curves[f + 4u], curves[f + 5u]); + let p3w = vec3(curves[f + 6u], curves[f + 7u], curves[f + 8u]); + + // Transform world-space curve control points to view-space XY, + // then shift so the current fragment is the origin. + let p1 = (u.view * vec4(p1w, 1.0)).xy - in.view_pos_xy; + let p2 = (u.view * vec4(p2w, 1.0)).xy - in.view_pos_xy; + let p3 = (u.view * vec4(p3w, 1.0)).xy - in.view_pos_xy; // ── Horizontal ray: accumulate x-coverage ──────────────────────── let hcode = calc_root_code(p1.y, p2.y, p3.y); diff --git a/manim/renderer/webgpu/shaders/surface.wgsl b/manim/renderer/webgpu/shaders/surface.wgsl index fbc0061b5d..10433f22ce 100644 --- a/manim/renderer/webgpu/shaders/surface.wgsl +++ b/manim/renderer/webgpu/shaders/surface.wgsl @@ -1,16 +1,17 @@ // WebGPU surface shader for Manim — Phase 3. // -// Renders flat-shaded triangulated Surface faces (shade_in_3d=True VMobjects). -// Each vertex carries its world-space position, the face normal, and the fill -// colour. A Phong diffuse + ambient lighting model is applied in the fragment -// shader using the light_pos uniform. -// -// Depth test is enabled so that 3-D surfaces occlude each other correctly. +// Blinn-Phong ambient + diffuse + specular lighting computed in view space. // // Uniform layout (group 0, binding 0) — shared with fill/stroke: -// offset 0 — projection mat4x4 (64 bytes) -// offset 64 — view mat4x4 (64 bytes) -// offset 128 — light_pos vec3 (12 bytes, padded to 16) +// offset 0 — projection mat4x4 (64 bytes) +// offset 64 — view mat4x4 (64 bytes) +// offset 128 — light_pos vec3 (12 bytes) +// offset 140 — light_intensity f32 ( 4 bytes) +// offset 144 — light_color vec3 (12 bytes) +// offset 156 — ambient_intensity f32 ( 4 bytes) +// offset 160 — ambient_color vec3 (12 bytes) +// offset 172 — _pad f32 ( 4 bytes) +// total: 176 bytes // // Vertex attributes: // location 0 — in_vert vec3 world-space position @@ -18,10 +19,14 @@ // location 2 — in_color vec4 RGBA fill colour struct Uniforms { - projection : mat4x4, - view : mat4x4, - light_pos : vec3, - _pad : f32, + projection : mat4x4, + view : mat4x4, + light_pos : vec3, + light_intensity : f32, + light_color : vec3, + ambient_intensity : f32, + ambient_color : vec3, + _pad : f32, }; @group(0) @binding(0) var u : Uniforms; @@ -32,35 +37,71 @@ struct VertexInput { }; struct VertexOutput { - @builtin(position) clip_position : vec4, - @location(0) v_color : vec4, - @location(1) v_normal : vec3, - @location(2) v_world_pos : vec3, + @builtin(position) clip_position : vec4, + @location(0) v_color : vec4, + @location(1) v_view_normal : vec3, // normal in view space + @location(2) v_view_pos : vec3, // position in view space + @location(3) v_view_light : vec3, // light position in view space }; @vertex fn vs_main(in: VertexInput) -> VertexOutput { var out: VertexOutput; - let world_pos = vec4(in.in_vert, 1.0); - out.clip_position = u.projection * u.view * world_pos; - out.v_world_pos = in.in_vert; - out.v_normal = in.in_normal; - out.v_color = in.in_color; + + let view_pos = u.view * vec4(in.in_vert, 1.0); + out.clip_position = u.projection * view_pos; + out.v_view_pos = view_pos.xyz; + + // Normal transform: use the upper-left 3×3 of the view matrix. + // Assumes uniform scaling (no shear), which holds for Manim cameras. + let view3 = mat3x3(u.view[0].xyz, u.view[1].xyz, u.view[2].xyz); + out.v_view_normal = view3 * in.in_normal; + + out.v_view_light = (u.view * vec4(u.light_pos, 1.0)).xyz; + out.v_color = in.in_color; return out; } @fragment -fn fs_main(in: VertexOutput) -> @location(0) vec4 { - let ambient_strength = 0.3; - let diffuse_strength = 0.7; +fn fs_main(in: VertexOutput, @builtin(front_facing) front_facing: bool) -> @location(0) vec4 { + // Per-material diffuse and specular strengths. + // Will be replaced by per-surface gloss/shadow when LightSource system lands. + let diffuse_strength = 0.9; + let specular_strength = 0.8; + let specular_exp = 16.0; + + // Two-sided lighting: flip the normal for back-facing fragments so that + // both sides of open surfaces (flat planes, shade_in_3d VMobjects) are + // correctly lit when seen from either direction. + // For closed opaque surfaces (sphere, torus) the back faces lose the depth + // test before shading, so this select is a no-op in that case. + let raw_normal = select(-in.v_view_normal, in.v_view_normal, front_facing); + let norm = normalize(raw_normal); + let light_dir_vec = in.v_view_light - in.v_view_pos; + let light_distance2 = dot(light_dir_vec, light_dir_vec); + let light_dir = normalize(light_dir_vec); + let view_dir = normalize(-in.v_view_pos); // camera at origin in view space + + // Diffuse — one-sided: surfaces facing away from the light are dark. + let diff = clamp(dot(norm, light_dir), 0.0, 1.0); + + // Blinn-Phong specular. + let half_vec = normalize(light_dir + view_dir); + let spec = pow(max(dot(norm, half_vec), 0.0), specular_exp); + + let attenuation = u.light_intensity / light_distance2; + + // Ambient: object color × ambient light color. + let ambient_rgb = in.v_color.rgb * u.ambient_color * u.ambient_intensity; - let norm = normalize(in.v_normal); - let light_dir = normalize(u.light_pos - in.v_world_pos); + // Diffuse: object color × light color (pigment modulates incoming light). + let diffuse_rgb = in.v_color.rgb * u.light_color * (diffuse_strength * diff * attenuation); - // Two-sided lighting: use abs so back-faces aren't fully dark. - let diff = abs(dot(norm, light_dir)); + // Specular: light color only — highlight is the light's color, not the object's. + // Correct for dielectrics (plastic, paint); metals would tint by object color, + // but that requires a metalness parameter (future work). + let specular_rgb = u.light_color * (specular_strength * spec * attenuation); - let lighting = ambient_strength + diffuse_strength * diff; - let lit_rgb = clamp(in.v_color.rgb * lighting, vec3(0.0), vec3(1.0)); + let lit_rgb = clamp(ambient_rgb + diffuse_rgb + specular_rgb, vec3(0.0), vec3(1.0)); return vec4(lit_rgb, in.v_color.a); } diff --git a/manim/renderer/webgpu/shaders/surface_oit.wgsl b/manim/renderer/webgpu/shaders/surface_oit.wgsl new file mode 100644 index 0000000000..22904a22a9 --- /dev/null +++ b/manim/renderer/webgpu/shaders/surface_oit.wgsl @@ -0,0 +1,120 @@ +// WebGPU OIT accumulation shader — Weighted Blended Order-Independent Transparency. +// +// McGuire & Bavoil 2013. Renders transparent surface fragments into two +// accumulation targets instead of the main framebuffer: +// +// location 0 accum rgba16float weighted colour + alpha sum +// location 1 reveal rgba16float per-channel transmittance product +// +// The main pipeline blend modes for these targets are set to: +// accum: {src: one, dst: one} — additive accumulation +// reveal: {src: zero, dst: one-minus-src-alpha} — transmittance multiplication +// +// A subsequent full-screen composition pass reads both textures and composites +// the result onto the opaque framebuffer. +// +// Lighting model and uniform layout are identical to surface.wgsl so that +// opaque and transparent surfaces are lit consistently. +// +// Uniform layout (group 0, binding 0): +// offset 0 — projection mat4x4 (64 bytes) +// offset 64 — view mat4x4 (64 bytes) +// offset 128 — light_pos vec3 (12 bytes) +// offset 140 — light_intensity f32 ( 4 bytes) +// offset 144 — light_color vec3 (12 bytes) +// offset 156 — ambient_intensity f32 ( 4 bytes) +// offset 160 — ambient_color vec3 (12 bytes) +// offset 172 — _pad f32 ( 4 bytes) +// total: 176 bytes + +struct Uniforms { + projection : mat4x4, + view : mat4x4, + light_pos : vec3, + light_intensity : f32, + light_color : vec3, + ambient_intensity : f32, + ambient_color : vec3, + _pad : f32, +}; +@group(0) @binding(0) var u : Uniforms; + +struct VertexInput { + @location(0) in_vert : vec3, + @location(1) in_normal : vec3, + @location(2) in_color : vec4, +}; + +struct VertexOutput { + @builtin(position) clip_position : vec4, + @location(0) v_color : vec4, + @location(1) v_view_normal : vec3, + @location(2) v_view_pos : vec3, + @location(3) v_view_light : vec3, +}; + +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + var out: VertexOutput; + let view_pos = u.view * vec4(in.in_vert, 1.0); + out.clip_position = u.projection * view_pos; + out.v_view_pos = view_pos.xyz; + let view3 = mat3x3(u.view[0].xyz, u.view[1].xyz, u.view[2].xyz); + out.v_view_normal = view3 * in.in_normal; + out.v_view_light = (u.view * vec4(u.light_pos, 1.0)).xyz; + out.v_color = in.in_color; + return out; +} + +struct FragOutput { + @location(0) accum : vec4, // weighted colour sum → rgba16float + @location(1) reveal : vec4, // transmittance product → rgba16float +}; + +@fragment +fn fs_main(in: VertexOutput, @builtin(front_facing) front_facing: bool) -> FragOutput { + // Per-material diffuse and specular strengths — identical to surface.wgsl. + // Will be replaced by per-surface gloss/shadow when LightSource system lands. + let diffuse_strength = 0.9; + let specular_strength = 0.8; + let specular_exp = 16.0; + + // Two-sided lighting: flip the normal for back-facing fragments so that + // both sides of open surfaces are correctly lit from either direction. + // Transparent surfaces (cull_mode="none") commonly show both sides — a + // semi-transparent sphere's inner hemisphere is visible through the front. + let raw_normal = select(-in.v_view_normal, in.v_view_normal, front_facing); + let norm = normalize(raw_normal); + let light_dir_vec = in.v_view_light - in.v_view_pos; + let light_distance2 = dot(light_dir_vec, light_dir_vec); + let light_dir = normalize(light_dir_vec); + let view_dir = normalize(-in.v_view_pos); + + let diff = clamp(dot(norm, light_dir), 0.0, 1.0); + let half_vec = normalize(light_dir + view_dir); + let spec = pow(max(dot(norm, half_vec), 0.0), specular_exp); + + // Identical lighting formula to surface.wgsl. + let attenuation = u.light_intensity / light_distance2; + + let ambient_rgb = in.v_color.rgb * u.ambient_color * u.ambient_intensity; + let diffuse_rgb = in.v_color.rgb * u.light_color * (diffuse_strength * diff * attenuation); + let specular_rgb = u.light_color * (specular_strength * spec * attenuation); + + let rgb = clamp(ambient_rgb + diffuse_rgb + specular_rgb, vec3(0.0), vec3(1.0)); + let alpha = in.v_color.a; + + // Depth-based weight that balances contributions from front and back layers. + let z = in.v_view_pos.z; + let w = clamp( + pow(alpha, 3.0) / (1e-5 + pow(abs(z) / 5.0, 4.0)), + 1e-2, 3e3 + ); + + var out: FragOutput; + // accum: additive blend (pipeline: src=one, dst=one) + out.accum = vec4(rgb * alpha * w, alpha * w); + // reveal: multiplicative blend (pipeline: src=zero, dst=one-minus-src-alpha) + out.reveal = vec4(alpha, alpha, alpha, alpha); + return out; +} diff --git a/manim/renderer/webgpu/shaders/vmobject_stroke.wgsl b/manim/renderer/webgpu/shaders/vmobject_stroke.wgsl index 74c942bb72..573e6a1072 100644 --- a/manim/renderer/webgpu/shaders/vmobject_stroke.wgsl +++ b/manim/renderer/webgpu/shaders/vmobject_stroke.wgsl @@ -1,10 +1,9 @@ -// WebGPU stroke shader for VMobject — Phase 2/3 (true cubic bezier). +// WebGPU stroke shader for VMobject — 2-D and 3-D curves. // -// All four cubic bezier control points (b0, h0, h1, b3) are passed from the -// CPU without approximation. The vertex shader builds a tight bounding quad -// from the cubic AABB, and the fragment shader computes the exact unsigned -// distance to the cubic bezier via Newton's-method minimisation, discarding -// fragments outside the stroke half-width. +// The vertex shader transforms control points to VIEW space first (camera +// looks along −Z, so view-space XY is the screen plane), then builds the +// bounding tile in that 2-D screen space. This correctly handles 3-D curves +// such as the world Z-axis whose world-XY chord collapses to zero length. // // Uniform layout (group 0, binding 0): // offset 0 — projection mat4x4 (64 bytes) @@ -12,13 +11,10 @@ // offset 128 — light_pos vec3 (12 bytes, padded to 16) // // Vertex attributes: -// location 0 — current_curve_0 vec3 start anchor (b0) -// location 1 — current_curve_1 vec3 first handle (h0) -// location 2 — current_curve_2 vec3 second handle (h1) -// location 3 — current_curve_3 vec3 end anchor (b3) -// location 4 — tile_coordinate vec2 quad corner ∈ [0,1] -// location 5 — in_color vec4 RGBA stroke colour -// location 6 — in_width f32 stroke width (Manim units) +// location 0-3 — current_curve_{0-3} vec3 cubic bezier control points +// location 4 — tile_coordinate vec2 quad corner ∈ [0,1] +// location 5 — in_color vec4 RGBA stroke colour +// location 6 — in_width f32 stroke width (Manim units) struct Uniforms { projection : mat4x4, @@ -50,13 +46,10 @@ fn cubic_deriv2( return 6.0 * ((1.0 - t)*(p2 - 2.0*p1 + p0) + t*(p3 - 2.0*p2 + p1)); } -// Unsigned distance from pos to the cubic bezier. Uses coarse sampling to -// seed Newton's-method minimisation of f(t) = |B(t) − pos|². fn ud_cubic_bezier( p0: vec2, p1: vec2, p2: vec2, p3: vec2, pos: vec2, ) -> f32 { - // Coarse sampling: 9 equally-spaced t values. var best_t : f32 = 0.0; var best_d2 : f32 = 1e18; for (var i = 0u; i <= 8u; i = i + 1u) { @@ -65,7 +58,6 @@ fn ud_cubic_bezier( let d2 = dot(pt - pos, pt - pos); if (d2 < best_d2) { best_d2 = d2; best_t = t; } } - // Newton refinement. for (var k = 0u; k < 4u; k = k + 1u) { let t = clamp(best_t, 0.0, 1.0); let pt = cubic_eval(p0, p1, p2, p3, t); @@ -86,16 +78,12 @@ fn cubic_eval_1d(p0: f32, p1: f32, p2: f32, p3: f32, t: f32) -> f32 { return s*s*s*p0 + 3.0*s*s*t*p1 + 3.0*s*t*t*p2 + t*t*t*p3; } -// Axis-aligned bounding box of a 2-D cubic bezier. Returns vec4(min_xy, max_xy). -// Roots of the quadratic derivative give potential extremes between the endpoints. fn bbox_cubic( p0: vec2, p1: vec2, p2: vec2, p3: vec2 ) -> vec4 { var mi = min(p0, p3); var ma = max(p0, p3); - // B'(t)/3 = a(1-t)^2 + 2b(1-t)t + ct^2 → At^2 + Bt + C = 0 - // where A = a-2b+c, B = 2(b-a), C = a, a=(p1-p0), b=(p2-p1), c=(p3-p2) let a_v = p1 - p0; let b_v = p2 - p1; let c_v = p3 - p2; @@ -103,7 +91,6 @@ fn bbox_cubic( let B = 2.0 * (b_v - a_v); let C = a_v; - // x component if (abs(A.x) > 1e-8) { let disc = B.x*B.x - 4.0*A.x*C.x; if (disc >= 0.0) { @@ -127,7 +114,6 @@ fn bbox_cubic( } } - // y component if (abs(A.y) > 1e-8) { let disc = B.y*B.y - 4.0*A.y*C.y; if (disc >= 0.0) { @@ -154,16 +140,6 @@ fn bbox_cubic( return vec4(mi, ma); } -fn to_uv(x_unit: vec3, y_unit: vec3, point: vec3) -> vec2 { - return vec2(dot(point, x_unit), dot(point, y_unit)); -} - -fn from_uv( - translation: vec3, x_unit: vec3, y_unit: vec3, p: vec2 -) -> vec3 { - return p.x * x_unit + p.y * y_unit + translation; -} - // ---- Vertex I/O ----------------------------------------------------------- struct VertexInput { @@ -187,6 +163,16 @@ struct VertexOutput { @location(6) v_color : vec4, }; +// ---- Vertex shader --------------------------------------------------------- +// +// Strategy: transform control points to VIEW space first. In view space the +// camera looks along −Z, so the XY plane is the screen plane. The chord XY +// always has the correct 2-D screen-space direction regardless of the 3-D +// curve orientation. The bounding tile is built in view-space XY, then the +// tile corners are projected to clip space using the projection matrix. +// Using avg-Z for all tile corners is a valid approximation for curves that +// are short relative to the viewing distance. + @vertex fn vs_main(in: VertexInput) -> VertexOutput { let thickness_multiplier = 0.004; @@ -194,24 +180,27 @@ fn vs_main(in: VertexInput) -> VertexOutput { out.v_color = in.in_color; out.v_thickness = thickness_multiplier * in.in_width; - // For 2-D scenes the scene-normal is always +Z. - let manim_unit_normal = vec3(0.0, 0.0, 1.0); + // Transform all 4 control points to view space. + let vs0 = (u.view * vec4(in.current_curve_0, 1.0)).xyz; + let vs1 = (u.view * vec4(in.current_curve_1, 1.0)).xyz; + let vs2 = (u.view * vec4(in.current_curve_2, 1.0)).xyz; + let vs3 = (u.view * vec4(in.current_curve_3, 1.0)).xyz; - // Tile x-axis follows the chord (b3 − b0); fall back to h0 direction. - let chord = in.current_curve_3 - in.current_curve_0; - let chord_len = length(chord.xy); + // Tile x-axis: chord direction in view-space XY (= screen plane). + let chord_xy = vs3.xy - vs0.xy; + let chord_len = length(chord_xy); - var tile_x_unit : vec3; + var tile_x : vec2; if (chord_len > 1e-6) { - tile_x_unit = vec3(normalize(chord.xy), 0.0); + tile_x = chord_xy / chord_len; } else { - let alt = in.current_curve_1 - in.current_curve_0; - let alt_len = length(alt.xy); + let alt = vs1.xy - vs0.xy; + let alt_len = length(alt); if (alt_len > 1e-6) { - tile_x_unit = vec3(normalize(alt.xy), 0.0); + tile_x = alt / alt_len; } else { - // Truly degenerate — collapse to a point, nothing to draw. - out.clip_position = u.projection * u.view * vec4(in.current_curve_0, 1.0); + // Curve points directly toward / away from camera — collapse quad. + out.clip_position = u.projection * vec4(vs0, 1.0); out.uv_point = vec2(0.0); out.uv_curve_0 = vec2(0.0); out.uv_curve_1 = vec2(0.0); @@ -220,36 +209,48 @@ fn vs_main(in: VertexInput) -> VertexOutput { return out; } } - let tile_y_unit = cross(manim_unit_normal, tile_x_unit); - - // Project all four cubic control points into the local tile UV space. - let uv0 = to_uv(tile_x_unit, tile_y_unit, in.current_curve_0); - let uv1 = to_uv(tile_x_unit, tile_y_unit, in.current_curve_1); - let uv2 = to_uv(tile_x_unit, tile_y_unit, in.current_curve_2); - let uv3 = to_uv(tile_x_unit, tile_y_unit, in.current_curve_3); + let tile_y = vec2(-tile_x.y, tile_x.x); // 90° CCW in screen plane + + // Project control points into the 2-D tile UV space (view-space XY). + // The view matrix is a rigid-body transform, so UV distances == world distances + // and the thickness comparison in the fragment shader remains correct. + let uv0 = vec2(dot(vs0.xy, tile_x), dot(vs0.xy, tile_y)); + let uv1 = vec2(dot(vs1.xy, tile_x), dot(vs1.xy, tile_y)); + let uv2 = vec2(dot(vs2.xy, tile_x), dot(vs2.xy, tile_y)); + let uv3 = vec2(dot(vs3.xy, tile_x), dot(vs3.xy, tile_y)); out.uv_curve_0 = uv0; out.uv_curve_1 = uv1; out.uv_curve_2 = uv2; out.uv_curve_3 = uv3; - // Tight bounding quad: cubic AABB padded by the stroke thickness plus a - // small extra margin for the anti-aliasing fringe (≈1 pixel in UV space). - // thickness_multiplier * 1.0 pixel ≈ aa_pad estimate; we use a fixed - // conservative constant that matches the thickness_multiplier scale. let t = out.v_thickness; - let aa_pad = thickness_multiplier * 2.0; // ~1–2 px feather margin in UV + let aa_pad = thickness_multiplier * 2.0; let uv_bb = bbox_cubic(uv0, uv1, uv2, uv3); let uv_min = uv_bb.xy - vec2(t + aa_pad); let uv_max = uv_bb.zw + vec2(t + aa_pad); - // tile_coordinate ∈ [0,1]² → lerp within [uv_min, uv_max]. - let uv_tile = mix(uv_min, uv_max, in.tile_coordinate); + let uv_tile = mix(uv_min, uv_max, in.tile_coordinate); + out.uv_point = uv_tile; - let tile_translation = manim_unit_normal * dot(in.current_curve_0, manim_unit_normal); - let tile_point = from_uv(tile_translation, tile_x_unit, tile_y_unit, uv_tile); + // Reconstruct the 3-D view-space position: XY from tile UV, Z from + // the average depth of the 4 control points. + // Reconstruct the 3-D view-space position: XY from tile UV. + // For Z, interpolate strictly along tile_x to maintain proper 3-D depth + // for perspective projection and correct occlusion with 3-D surfaces. + var vs_z : f32; + if (chord_len > 1e-6) { + let t = (uv_tile.x - uv0.x) / chord_len; + vs_z = mix(vs0.z, vs3.z, t); + } else { + vs_z = 0.5 * (vs0.z + vs3.z); + } - out.clip_position = u.projection * u.view * vec4(tile_point, 1.0); - out.uv_point = uv_tile; + let vs_tile = vec3( + uv_tile.x * tile_x.x + uv_tile.y * tile_y.x, + uv_tile.x * tile_x.y + uv_tile.y * tile_y.y, + vs_z, + ); + out.clip_position = u.projection * vec4(vs_tile, 1.0); return out; } @@ -262,27 +263,13 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { in.uv_point, ); - // ── Analytic SDF anti-aliasing ────────────────────────────────────────── - // - // fwidthFine(dist) returns |∂dist/∂x| + |∂dist/∂y|, which approximates - // the change in UV-space distance over one screen pixel. We use half of - // that as the half-width of the smooth transition band: - // - // coverage = 1 when dist ≤ thickness − half_px (fully inside) - // coverage = 0 when dist ≥ thickness + half_px (fully outside) - // - // smoothstep interpolates smoothly between those limits. - let px = fwidthFine(dist); // ≈ 1 px in UV units + let px = fwidthFine(dist); let half_px = 0.5 * px; let edge_low = in.v_thickness - half_px; let edge_high = in.v_thickness + half_px; let coverage = 1.0 - smoothstep(edge_low, edge_high, dist); - // Fully outside the anti-aliased fringe — discard to avoid touching the - // depth/stencil buffer unnecessarily. if (coverage <= 0.0) { discard; } - // Multiply the stored alpha by the smooth SDF coverage so transparent - // strokes composite correctly. return vec4(in.v_color.rgb, in.v_color.a * coverage); } diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index eaa1c8ab23..8c6a3df530 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -29,11 +29,13 @@ from PIL import Image from manim import config, logger -from manim.constants import OUT, PI, RIGHT +from manim.constants import IN, OUT, PI, RIGHT, DOWN, LEFT +from manim.mobject.mobject import Mobject from manim.mobject.types.vectorized_mobject import VMobject from manim.scene.scene_file_writer import SceneFileWriter from manim.utils.color import color_to_rgba from manim.utils.exceptions import EndSceneEarlyException +from manim.utils.hashing import get_hash_from_play_call from manim.utils.simple_functions import clip from manim.utils.space_ops import ( quaternion_from_angle_axis, @@ -52,6 +54,7 @@ import wgpu as wgpu_t from manim.scene.scene import Scene + from .webgpu_renderer_window import WebGPUWindow try: import wgpu @@ -68,9 +71,12 @@ # --------------------------------------------------------------------------- -class WebGPUCamera: +class WebGPUCamera(Mobject): """Camera for the WebGPU renderer. + Inherits from ``Mobject`` so it can carry updaters and be added to the + scene, matching the pattern used by ``OpenGLCamera(OpenGLMobject)``. + Matches the attribute / method surface of ``OpenGLCamera`` so that scene code that inspects ``renderer.camera`` works without changes. @@ -94,8 +100,6 @@ class WebGPUCamera: focal_distance Perspective focal distance expressed as a multiple of ``frame_height``. Only used when ``orthographic=False``. - light_source_position - World-space position of the key light. Defaults to (−10, 10, 10). orthographic Use orthographic (True) or perspective (False) projection. Default is True (matching Manim's default 2-D look). @@ -113,11 +117,11 @@ def __init__( center_point: np.ndarray | None = None, euler_angles: np.ndarray | None = None, focal_distance: float = 2.0, - light_source_position: np.ndarray | None = None, orthographic: bool = True, minimum_polar_angle: float = -PI / 2, maximum_polar_angle: float = PI / 2, ) -> None: + super().__init__() self.use_z_index = True self.frame_rate: int = config.get("frame_rate", 60) self.orthographic = orthographic @@ -133,11 +137,7 @@ def __init__( self.center_point: np.ndarray = ( np.asarray(center_point, dtype=float) if center_point is not None - else np.zeros(3) - ) - self.light_source_position: np.ndarray = np.asarray( - light_source_position if light_source_position is not None else [-10, 10, 10], - dtype=float, + else np.array([0.0, 0.0, 11.0], dtype=float) ) self.euler_angles: np.ndarray = np.asarray( euler_angles if euler_angles is not None else [0.0, 0.0, 0.0], @@ -145,6 +145,24 @@ def __init__( ) self.refresh_rotation_matrix() + # Fixed-mobject registries — populated by ThreeDScene helpers. + # fixed_in_frame: objects rendered with identity rotation + ortho + # projection as a 2-D overlay on top of the 3-D scene (e.g. title text). + # fixed_orientation: objects rendered with identity rotation + current + # projection so they don't tilt as the camera orbits (e.g. 3-D labels). + self._fixed_in_frame_mobjects: set[Mobject] = set() + self._fixed_orientation_mobjects: set[Mobject] = set() + + # ThreeDScene.get_moving_mobjects() checks _frame_center and + # get_value_trackers() to detect camera-driven animation. + # These are defined on ThreeDCamera (Cairo) but not on Mobject, + # so we provide equivalent stubs here. + self._frame_center: Mobject = Mobject() + + def get_value_trackers(self) -> list: + """Required by ThreeDScene.get_moving_mobjects.""" + return [] + # ------------------------------------------------------------------ # Frame geometry helpers (mirrors OpenGLCamera) # ------------------------------------------------------------------ @@ -189,10 +207,12 @@ def to_default_state(self) -> WebGPUCamera: # ------------------------------------------------------------------ def refresh_rotation_matrix(self) -> None: - """Recompute ``inverse_rotation_matrix`` from current Euler angles.""" + """Refresh the camera's inverse rotation matrix based on its Euler angles. + Matches Cairo's orientation. + """ theta, phi, gamma = self.euler_angles quat = quaternion_mult( - quaternion_from_angle_axis(theta, OUT, axis_normalized=True), + quaternion_from_angle_axis(theta, IN, axis_normalized=True), quaternion_from_angle_axis(phi, RIGHT, axis_normalized=True), quaternion_from_angle_axis(gamma, OUT, axis_normalized=True), ) @@ -225,6 +245,29 @@ def set_phi(self, phi: float) -> WebGPUCamera: def set_gamma(self, gamma: float) -> WebGPUCamera: return self.set_euler_angles(gamma=gamma) + _PERSPECTIVE_FAR: float = 50.0 # must match projection_matrix + + def set_focal_distance(self, focal_distance: float) -> WebGPUCamera: + """Set the perspective focal distance (= near plane distance). + + Larger values zoom in (more telephoto); smaller values zoom out. + Only has an effect when ``orthographic=False``. + Matches ``OpenGLCamera.focal_distance`` convention. + + ``focal_distance`` must be positive and strictly less than the far + plane (50.0). Values outside that range are clamped. + """ + max_near = self._PERSPECTIVE_FAR * (1.0 - 1e-4) + clamped = float(np.clip(focal_distance, 1e-4, max_near)) + if clamped != focal_distance: + logger.warning( + "WebGPUCamera.set_focal_distance: value %.4g clamped to %.4g " + "(must be in (0, far=%.4g))", + focal_distance, clamped, self._PERSPECTIVE_FAR, + ) + self.focal_distance = clamped + return self + def increment_theta(self, dtheta: float) -> WebGPUCamera: self.euler_angles[0] += dtheta self.refresh_rotation_matrix() @@ -253,16 +296,55 @@ def view_matrix(self) -> np.ndarray: """4×4 float32 view matrix: rotates and translates world space into camera space. - For default 2-D scenes (no rotation, center at origin) this is the - identity matrix, so 2-D rendering is unaffected. + Uses T(-c) @ R_inv, which rotates the world around the origin (matches + OpenGLCamera behavior where the camera orbits the focal point). """ R = np.asarray(self.inverse_rotation_matrix, dtype=np.float32) # 3×3 c = self.center_point.astype(np.float32) view = np.eye(4, dtype=np.float32) view[:3, :3] = R - view[:3, 3] = -(R @ c) + # Translation in camera space: T(-c) followed by rotation R is equivalent + # to rotating the origin then translating, or translating the origin then rotating. + # To stay centered on origin: rotate first, then translate by -distance. + # V = translation(0, 0, -11) @ R_inv + view[:3, 3] = [0.0, 0.0, -c[2]] + return view + + @property + def fixed_view_matrix(self) -> np.ndarray: + """View matrix with camera rotation stripped — z-translation only. + + Used for fixed-orientation and fixed-in-frame mobjects so they don't + tilt or spin when the camera orbits. The z-translation is preserved so + depth ordering within the fixed layer is consistent with the main scene. + """ + view = np.eye(4, dtype=np.float32) + view[2, 3] = -float(self.center_point[2]) return view + @property + def ortho_projection_matrix(self) -> np.ndarray: + """Forced orthographic projection matrix, regardless of self.orthographic. + + Fixed-in-frame overlays always use orthographic so that screen-space + coordinates map directly to Manim scene units (matching 2-D scenes). + """ + # Orthographic: map frame to NDC with z ∈ [0, 1]. + # Note: Manim's +Z is out of the screen (towards the viewer). + # So +Z should map to 0 (near) and -Z should map to 1 (far). + # Z_clip = -1/(far-near) * Z + far/(far-near) + fw, fh = self.frame_shape + near, far = self.near, self.far + return np.array( + [ + [2.0 / fw, 0.0, 0.0, 0.0], + [0.0, 2.0 / fh, 0.0, 0.0], + [0.0, 0.0, -1.0 / (far - near), far / (far - near)], + [0.0, 0.0, 0.0, 1.0], + ], + dtype=np.float32, + ) + # ------------------------------------------------------------------ # Projection matrix (used by the shader uniform upload) # ------------------------------------------------------------------ @@ -278,30 +360,58 @@ def projection_matrix(self) -> np.ndarray: near, far = self.near, self.far if self.orthographic: - # Orthographic: map frame to NDC with z ∈ [0, 1]. - return np.array( - [ - [2.0 / fw, 0.0, 0.0, 0.0], - [0.0, 2.0 / fh, 0.0, 0.0], - [0.0, 0.0, 1.0 / (far - near), -near / (far - near)], - [0.0, 0.0, 0.0, 1.0], - ], - dtype=np.float32, - ) + return self.ortho_projection_matrix else: - # Perspective: symmetric frustum, z ∈ [0, 1] (WebGPU NDC). - fd = self.get_focal_distance() + # Perspective mapping for WebGPU: W_clip = -z_view, z_clip ∈ [0, 1]. + # near = focal_distance (matches OpenGLCamera's implicit convention where + # the default focal_distance=2.0 equals OpenGL's hardcoded near=2). + # Changing focal_distance zooms the scene: larger → more telephoto. + # FOV is set by w=fw/6, h=fh/6 (same as opengl.perspective_projection_matrix). + f = self._PERSPECTIVE_FAR + n = float(np.clip(self.focal_distance, 1e-4, f * (1.0 - 1e-4))) + w, h = fw / 6.0, fh / 6.0 return np.array( [ - [2.0 * fd / fw, 0.0, 0.0, 0.0], - [0.0, 2.0 * fd / fh, 0.0, 0.0], - [0.0, 0.0, far / (far - near), -far * near / (far - near)], - [0.0, 0.0, 1.0, 0.0], + [2.0 * n / w, 0.0, 0.0, 0.0], + [0.0, 2.0 * n / h, 0.0, 0.0], + [0.0, 0.0, f / (n - f), n * f / (n - f)], + [0.0, 0.0, -1.0, 0.0], ], dtype=np.float32, ) + # ------------------------------------------------------------------ + # Fixed-mobject registry (used by ThreeDScene) + # ------------------------------------------------------------------ + + def add_fixed_in_frame_mobjects(self, *mobjects: Mobject) -> None: + """Register mobjects to be rendered as 2-D screen-space overlays. + + These objects are drawn after the 3-D scene with a fresh depth buffer, + identity camera rotation, and an orthographic projection so they always + appear on top at their 2-D screen-space coordinates. + """ + self._fixed_in_frame_mobjects.update(mobjects) + + def remove_fixed_in_frame_mobjects(self, *mobjects: Mobject) -> None: + """Unregister mobjects previously added with add_fixed_in_frame_mobjects.""" + self._fixed_in_frame_mobjects.difference_update(mobjects) + + def add_fixed_orientation_mobjects(self, *mobjects: Mobject) -> None: + """Register mobjects whose orientation is frozen relative to the camera. + + These objects still move in 3-D space (their world coordinates are used + normally) but the camera rotation is not applied — they remain upright as + the camera orbits. Useful for 3-D labels that should always face forward. + """ + self._fixed_orientation_mobjects.update(mobjects) + + def remove_fixed_orientation_mobjects(self, *mobjects: Mobject) -> None: + """Unregister mobjects previously added with add_fixed_orientation_mobjects.""" + self._fixed_orientation_mobjects.difference_update(mobjects) + + # --------------------------------------------------------------------------- # Main renderer class # --------------------------------------------------------------------------- @@ -326,12 +436,29 @@ def __init__( self.animations_hashes: list[str | None] = [] self.camera: WebGPUCamera = WebGPUCamera() - self.window: None = None + self.window: WebGPUWindow | None = None + self.pressed_keys: set[int] = set() self.static_image: Any = None self.file_writer: SceneFileWriter | None = None # set by init_scene() + # SpecialThreeDScene reads renderer.camera_config["pixel_width"] to decide + # whether to apply low-quality overrides. Mirrors the pattern used by + # OpenGLRenderer so that SpecialThreeDScene works unchanged with WebGPU. + self.camera_config: dict = { + "pixel_width": config.pixel_width, + "pixel_height": config.pixel_height, + } + self.background_color = config["background_color"] + # Scene-wide lighting — read by _build_camera_uniform_buf() each frame. + # light_color / ambient_color are RGB floats in [0, 1]. + self.light_source_position: np.ndarray = np.array([-10.0, 10.0, 5.0]) + self.light_color: np.ndarray = np.array([1.0, 1.0, 1.0]) + self.light_intensity: float = 100.0 + self.ambient_color: np.ndarray = np.array([1.0, 1.0, 1.0]) + self.ambient_intensity: float = 0.4 + # Filled by init_scene(): self._device: wgpu_t.GPUDevice | None = None self._render_texture: wgpu_t.GPUTexture | None = None @@ -340,14 +467,46 @@ def __init__( self._depth_texture_view: wgpu_t.GPUTextureView | None = None self._proj_bgl: wgpu_t.GPUBindGroupLayout | None = None self._slug_bgl: wgpu_t.GPUBindGroupLayout | None = None + # Slug fill: 2-D (no depth) and 3-D (depth-tested, shade_in_3d with fill). self._slug_fill_pipeline: wgpu_t.GPURenderPipeline | None = None + self._slug_fill_3d_pipeline: wgpu_t.GPURenderPipeline | None = None + # Stroke pipelines: 2-D, 3-D, and 3-D with depth bias (surface mesh lines). self._stroke_pipeline: wgpu_t.GPURenderPipeline | None = None - self._surface_pipeline: wgpu_t.GPURenderPipeline | None = None + self._stroke_3d_pipeline: wgpu_t.GPURenderPipeline | None = None + self._stroke_3d_surface_pipeline: wgpu_t.GPURenderPipeline | None = None + # Surface pipelines: opaque (depth write + backface cull) and OIT. + self._surface_pipeline: wgpu_t.GPURenderPipeline | None = None # opaque + self._surface_oit_pipeline: wgpu_t.GPURenderPipeline | None = None + # OIT accumulation textures (rgba16float each). + self._oit_accum_texture: wgpu_t.GPUTexture | None = None + self._oit_accum_view: wgpu_t.GPUTextureView | None = None + self._oit_reveal_texture: wgpu_t.GPUTexture | None = None + self._oit_reveal_view: wgpu_t.GPUTextureView | None = None + # OIT composition pipeline + bind group. + self._oit_compose_pipeline: wgpu_t.GPURenderPipeline | None = None + self._oit_compose_bgl: wgpu_t.GPUBindGroupLayout | None = None + self._oit_compose_bind_group: wgpu_t.GPUBindGroup | None = None + + # Compact readback compute pipeline (GPU row-depadding + B↔R fix). + self._readback_compute_pipeline: wgpu_t.GPUComputePipeline | None = None + self._readback_compute_bgl: wgpu_t.GPUBindGroupLayout | None = None + self._readback_compute_bind_group: wgpu_t.GPUBindGroup | None = None + # Storage buffer the compute shader writes into (STORAGE | COPY_SRC). + self._readback_storage_buf: wgpu_t.GPUBuffer | None = None + # Mappable buffer we copy into before CPU readback (COPY_DST | MAP_READ). + self._readback_map_buf: wgpu_t.GPUBuffer | None = None # Per-frame state (set during update_frame, cleared after submit). self.current_render_pass: wgpu_t.GPURenderPassEncoder | None = None self.camera_bind_group: wgpu_t.GPUBindGroup | None = None self._camera_uniform_buf: wgpu_t.GPUBuffer | None = None + # Fixed-mobject bind groups (rebuilt each frame with stripped-rotation view). + # fixed_camera_bind_group: identity rotation + current projection (fixed-orientation) + # fixed_frame_bind_group: identity rotation + orthographic projection (fixed-in-frame) + self.fixed_camera_bind_group: wgpu_t.GPUBindGroup | None = None + self._fixed_orient_uniform_buf: wgpu_t.GPUBuffer | None = None + self.fixed_frame_bind_group: wgpu_t.GPUBindGroup | None = None + self._fixed_frame_uniform_buf: wgpu_t.GPUBuffer | None = None self.frame_vbos: list[wgpu_t.GPUBuffer] = [] # ------------------------------------------------------------------ @@ -374,10 +533,18 @@ def init_scene(self, scene: Scene) -> None: width = config.pixel_width height = config.pixel_height + # bgra8unorm matches the window surface format on all major platforms + # (Metal/Vulkan/DX12), enabling copy_texture_to_texture without a + # blit shader. Readback in _get_raw_frame_data() swaps B↔R to + # produce the RGBA output expected by PIL / numpy callers. self._render_texture = self._device.create_texture( size=(width, height, 1), - format=wgpu.TextureFormat.rgba8unorm, - usage=wgpu.TextureUsage.RENDER_ATTACHMENT | wgpu.TextureUsage.COPY_SRC, + format=wgpu.TextureFormat.bgra8unorm, + usage=( + wgpu.TextureUsage.RENDER_ATTACHMENT + | wgpu.TextureUsage.COPY_SRC + | wgpu.TextureUsage.TEXTURE_BINDING # read by compact-readback compute shader + ), ) self._render_texture_view = self._render_texture.create_view() @@ -389,9 +556,31 @@ def init_scene(self, scene: Scene) -> None: self._depth_texture_view = self._depth_texture.create_view() self._proj_bgl = self._create_camera_bgl() - self._stroke_pipeline = self._create_stroke_pipeline(self._proj_bgl) - self._surface_pipeline = self._create_surface_pipeline(self._proj_bgl) - self._slug_bgl, self._slug_fill_pipeline = self._create_slug_fill_pipeline() + self._stroke_pipeline = self._create_stroke_pipeline(self._proj_bgl, depth_test=False) + self._stroke_3d_pipeline = self._create_stroke_pipeline(self._proj_bgl, depth_test=True) + # Surface mesh lines sit exactly on the surface triangles. A negative + # depth bias pulls each fragment slightly toward the camera so the mesh + # always wins the depth test without visually offsetting the lines. + # depth_bias=-100 gives ~6e-6 constant offset in [0,1] depth space + # (depth24plus unit ≈ 6e-8), which is large enough to reliably beat + # floating-point depth jitter on flat/low-slope surface regions where + # depth_bias_slope_scale alone contributes nearly zero. + self._stroke_3d_surface_pipeline = self._create_stroke_pipeline( + self._proj_bgl, + depth_test=True, + depth_bias=-1000, + depth_bias_slope_scale=-1.0, + depth_bias_clamp=0.001, + ) + self._surface_pipeline = self._create_surface_pipeline(self._proj_bgl, cull_mode="none", depth_write=True) + self._slug_bgl, self._slug_fill_pipeline = self._create_slug_fill_pipeline(depth_test=False) + _, self._slug_fill_3d_pipeline = self._create_slug_fill_pipeline(depth_test=True) + self._create_oit_resources(width, height) + self._create_readback_pipeline(width, height) + + if self.should_create_window(): + from .webgpu_renderer_window import WebGPUWindow + self.window = WebGPUWindow(self) # ------------------------------------------------------------------ # Pipeline creation @@ -400,8 +589,15 @@ def init_scene(self, scene: Scene) -> None: def _create_camera_bgl(self) -> wgpu_t.GPUBindGroupLayout: """Create the bind group layout shared by stroke, surface, and Slug pipelines. - Layout: binding 0 — one uniform buffer carrying projection (64 B) + - view (64 B) + light_pos+pad (16 B) = 144 bytes total. + Layout: binding 0 — one uniform buffer (176 bytes total): + offset 0 — projection mat4x4 64 B + offset 64 — view mat4x4 64 B + offset 128 — light_pos vec3 12 B + offset 140 — light_intensity f32 4 B + offset 144 — light_color vec3 12 B + offset 156 — ambient_intensity f32 4 B + offset 160 — ambient_color vec3 12 B + offset 172 — _pad f32 4 B """ assert self._device is not None return self._device.create_bind_group_layout( @@ -415,8 +611,30 @@ def _create_camera_bgl(self) -> wgpu_t.GPUBindGroupLayout: ) def _create_stroke_pipeline( - self, proj_bgl: wgpu_t.GPUBindGroupLayout + self, + proj_bgl: wgpu_t.GPUBindGroupLayout, + depth_test: bool = False, + depth_bias: int = 0, + depth_bias_slope_scale: float = 0.0, + depth_bias_clamp: float = 0.0, ) -> wgpu_t.GPURenderPipeline: + """Create a stroke pipeline. + + depth_test controls depth *writing* only — both 2-D and 3-D strokes + always depth-test (depth_compare="less") so they are correctly occluded + by opaque geometry. + + depth_test=False — 2-D strokes: depth-read-only. Occluded by any + opaque surface in front of them, but do not themselves + occlude later geometry. + depth_test=True — 3-D strokes (shade_in_3d): depth-write + depth-test + so they occlude geometry drawn behind them. + depth_bias / depth_bias_slope_scale / depth_bias_clamp + — WebGPU depth bias applied to every fragment. Use + negative values to push geometry toward the camera, + which prevents z-fighting when strokes lie exactly + on a surface (e.g. Surface mesh lines). + """ assert self._device is not None shader_path = Path(__file__).parent / "shaders" / "vmobject_stroke.wgsl" shader_module = self._device.create_shader_module( @@ -446,13 +664,16 @@ def _create_stroke_pipeline( fragment={ "module": shader_module, "entry_point": "fs_main", - "targets": [{"format": wgpu.TextureFormat.rgba8unorm, "blend": _blend}], + "targets": [{"format": wgpu.TextureFormat.bgra8unorm, "blend": _blend}], }, primitive={"topology": "triangle-list", "cull_mode": "none"}, depth_stencil={ "format": wgpu.TextureFormat.depth24plus, - "depth_write_enabled": False, - "depth_compare": "always", + "depth_write_enabled": depth_test, + "depth_compare": "less", # always depth-test; write only for 3-D strokes + "depth_bias": depth_bias, + "depth_bias_slope_scale": depth_bias_slope_scale, + "depth_bias_clamp": depth_bias_clamp, "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, "stencil_read_mask": 0, @@ -466,8 +687,23 @@ def _create_stroke_pipeline( ) def _create_surface_pipeline( - self, proj_bgl: wgpu_t.GPUBindGroupLayout + self, + proj_bgl: wgpu_t.GPUBindGroupLayout, + cull_mode: str = "none", + depth_write: bool = True, ) -> wgpu_t.GPURenderPipeline: + """Create a surface (mesh) pipeline. + + cull_mode — WebGPU cull mode passed directly to the pipeline. + Use "back" for opaque surfaces (back faces are never + visible and culling them halves fragment work). + Use "none" for OIT transparent surfaces (both faces + must contribute so the interior is visible through + the front face). + depth_write — True for opaque surfaces so they occlude later geometry. + False for OIT surfaces so transparent layers do not block + each other (they still depth-test against opaque geometry). + """ assert self._device is not None shader_path = Path(__file__).parent / "shaders" / "surface.wgsl" shader_module = self._device.create_shader_module( @@ -497,12 +733,12 @@ def _create_surface_pipeline( fragment={ "module": shader_module, "entry_point": "fs_main", - "targets": [{"format": wgpu.TextureFormat.rgba8unorm, "blend": _blend}], + "targets": [{"format": wgpu.TextureFormat.bgra8unorm, "blend": _blend}], }, - primitive={"topology": "triangle-list", "cull_mode": "none"}, + primitive={"topology": "triangle-list", "cull_mode": cull_mode}, depth_stencil={ "format": wgpu.TextureFormat.depth24plus, - "depth_write_enabled": True, + "depth_write_enabled": depth_write, "depth_compare": "less", "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, @@ -516,10 +752,136 @@ def _create_surface_pipeline( }, ) + def _create_oit_resources(self, width: int, height: int) -> None: + """Create OIT accumulation textures, pipelines, and bind groups.""" + assert self._device is not None + assert self._proj_bgl is not None + + # ── Accumulation textures ────────────────────────────────────────── + oit_usage = wgpu.TextureUsage.RENDER_ATTACHMENT | wgpu.TextureUsage.TEXTURE_BINDING + self._oit_accum_texture = self._device.create_texture( + size=(width, height, 1), + format=wgpu.TextureFormat.rgba16float, + usage=oit_usage, + ) + self._oit_accum_view = self._oit_accum_texture.create_view() + + self._oit_reveal_texture = self._device.create_texture( + size=(width, height, 1), + format=wgpu.TextureFormat.rgba16float, + usage=oit_usage, + ) + self._oit_reveal_view = self._oit_reveal_texture.create_view() + + # ── OIT accumulation pipeline ────────────────────────────────────── + oit_shader_path = Path(__file__).parent / "shaders" / "surface_oit.wgsl" + oit_shader = self._device.create_shader_module( + code=oit_shader_path.read_text(encoding="utf-8") + ) + _accum_blend = { + "color": {"src_factor": "one", "dst_factor": "one", "operation": "add"}, + "alpha": {"src_factor": "one", "dst_factor": "one", "operation": "add"}, + } + _reveal_blend = { + "color": {"src_factor": "zero", "dst_factor": "one-minus-src-alpha", "operation": "add"}, + "alpha": {"src_factor": "zero", "dst_factor": "one", "operation": "add"}, + } + self._surface_oit_pipeline = self._device.create_render_pipeline( + layout=self._device.create_pipeline_layout( + bind_group_layouts=[self._proj_bgl] + ), + vertex={ + "module": oit_shader, + "entry_point": "vs_main", + "buffers": [SURFACE_VERTEX_LAYOUT], + }, + fragment={ + "module": oit_shader, + "entry_point": "fs_main", + "targets": [ + {"format": wgpu.TextureFormat.rgba16float, "blend": _accum_blend}, + {"format": wgpu.TextureFormat.rgba16float, "blend": _reveal_blend}, + ], + }, + primitive={"topology": "triangle-list", "cull_mode": "none"}, + depth_stencil={ + "format": wgpu.TextureFormat.depth24plus, + "depth_write_enabled": False, + "depth_compare": "less", + "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_read_mask": 0, + "stencil_write_mask": 0, + }, + multisample={"count": 1, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, + ) + + # ── OIT composition pipeline ─────────────────────────────────────── + compose_path = Path(__file__).parent / "shaders" / "oit_compose.wgsl" + compose_shader = self._device.create_shader_module( + code=compose_path.read_text(encoding="utf-8") + ) + self._oit_compose_bgl = self._device.create_bind_group_layout( + entries=[ + { + "binding": 0, + "visibility": wgpu.ShaderStage.FRAGMENT, + "texture": { + "sample_type": "unfilterable-float", + "view_dimension": "2d", + "multisampled": False, + }, + }, + { + "binding": 1, + "visibility": wgpu.ShaderStage.FRAGMENT, + "texture": { + "sample_type": "unfilterable-float", + "view_dimension": "2d", + "multisampled": False, + }, + }, + ] + ) + _compose_blend = { + "color": {"src_factor": "src-alpha", "dst_factor": "one-minus-src-alpha", "operation": "add"}, + "alpha": {"src_factor": "one", "dst_factor": "one", "operation": "add"}, + } + self._oit_compose_pipeline = self._device.create_render_pipeline( + layout=self._device.create_pipeline_layout( + bind_group_layouts=[self._oit_compose_bgl] + ), + vertex={"module": compose_shader, "entry_point": "vs_main", "buffers": []}, + fragment={ + "module": compose_shader, + "entry_point": "fs_main", + "targets": [{"format": wgpu.TextureFormat.bgra8unorm, "blend": _compose_blend}], + }, + primitive={"topology": "triangle-list", "cull_mode": "none"}, + multisample={"count": 1, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, + ) + self._oit_compose_bind_group = self._device.create_bind_group( + layout=self._oit_compose_bgl, + entries=[ + {"binding": 0, "resource": self._oit_accum_view}, + {"binding": 1, "resource": self._oit_reveal_view}, + ], + ) + def _create_slug_fill_pipeline( self, + depth_test: bool = False, ) -> tuple[wgpu_t.GPUBindGroupLayout, wgpu_t.GPURenderPipeline]: - """Create the Slug fill pipeline with a storage-buffer bind group layout.""" + """Create a Slug fill pipeline. + + depth_test controls depth *writing* only — both 2-D and 3-D fills + always depth-test (depth_compare="less") so they are occluded by any + opaque surface rendered before them. + + depth_test=False — 2-D fills: depth-read-only (default). + depth_test=True — 3-D fills (shade_in_3d): depth-write + depth-test + so they occlude geometry drawn behind them. + """ assert self._device is not None shader_path = Path(__file__).parent / "shaders" / "slug_fill.wgsl" shader_module = self._device.create_shader_module( @@ -565,13 +927,13 @@ def _create_slug_fill_pipeline( fragment={ "module": shader_module, "entry_point": "fs_main", - "targets": [{"format": wgpu.TextureFormat.rgba8unorm, "blend": _blend}], + "targets": [{"format": wgpu.TextureFormat.bgra8unorm, "blend": _blend}], }, primitive={"topology": "triangle-list", "cull_mode": "none"}, depth_stencil={ "format": wgpu.TextureFormat.depth24plus, - "depth_write_enabled": False, - "depth_compare": "always", + "depth_write_enabled": depth_test, + "depth_compare": "less", # always depth-test; write only for 3-D fills "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, "stencil_read_mask": 0, @@ -585,34 +947,105 @@ def _create_slug_fill_pipeline( # Camera bind group (rebuilt each frame when projection changes) # ------------------------------------------------------------------ - def _build_camera_uniform_buf(self) -> wgpu_t.GPUBuffer: - """Pack the 144-byte camera uniform and upload it; return the buffer.""" + def _pack_camera_uniforms( + self, + proj: np.ndarray, + view: np.ndarray, + ) -> wgpu_t.GPUBuffer: + """Pack a 176-byte camera+lighting uniform buffer from explicit proj/view matrices. + + Layout (matches Uniforms struct in surface.wgsl / surface_oit.wgsl): + offset 0 — projection mat4x4 64 B + offset 64 — view mat4x4 64 B + offset 128 — light_pos vec3 12 B + offset 140 — light_intensity f32 4 B + offset 144 — light_color vec3 12 B + offset 156 — ambient_intensity f32 4 B + offset 160 — ambient_color vec3 12 B + offset 172 — _pad f32 4 B + + Called by _build_camera_bind_group for each of the three per-frame + variants: normal, fixed-orientation, and fixed-in-frame. + """ assert self._device is not None - proj_bytes = self.camera.projection_matrix.T.flatten().tobytes() - view_bytes = self.camera.view_matrix.T.flatten().tobytes() - light = np.zeros(4, dtype=np.float32) - light[:3] = self.camera.light_source_position.astype(np.float32) - light_bytes = light.tobytes() + + proj_bytes = proj.T.flatten().astype(np.float32).tobytes() + view_bytes = view.T.flatten().astype(np.float32).tobytes() + + # block A: light_pos (xyz) + light_intensity (w) + block_a = np.zeros(4, dtype=np.float32) + block_a[:3] = np.asarray(self.light_source_position, dtype=np.float32) + block_a[3] = np.float32(self.light_intensity) + + # block B: light_color (xyz) + ambient_intensity (w) + block_b = np.zeros(4, dtype=np.float32) + block_b[:3] = np.asarray(self.light_color, dtype=np.float32) + block_b[3] = np.float32(self.ambient_intensity) + + # block C: ambient_color (xyz) + _pad (w) + block_c = np.zeros(4, dtype=np.float32) + block_c[:3] = np.asarray(self.ambient_color, dtype=np.float32) buf = self._device.create_buffer_with_data( - data=proj_bytes + view_bytes + light_bytes, + data=(proj_bytes + view_bytes + + block_a.tobytes() + block_b.tobytes() + block_c.tobytes()), usage=wgpu.BufferUsage.UNIFORM, ) self.frame_vbos.append(buf) return buf + def _build_camera_uniform_buf(self) -> wgpu_t.GPUBuffer: + """Pack the 176-byte camera+lighting uniform with the current view/projection.""" + return self._pack_camera_uniforms( + self.camera.projection_matrix, + self.camera.view_matrix, + ) + def _build_camera_bind_group(self) -> wgpu_t.GPUBindGroup: + """Build all three per-frame camera bind groups. + + normal (camera_bind_group) + Full camera rotation + current projection. Used for all regular + mobjects. + + fixed_camera_bind_group + Rotation-stripped view + current projection. Used for + fixed-orientation mobjects: they don't tilt with the camera but + are still depth-sorted with the rest of the scene. + + fixed_frame_bind_group + Rotation-stripped view + forced orthographic projection. Used for + fixed-in-frame mobjects: 2-D overlays rendered after the 3-D scene + with a fresh depth buffer so they always appear on top. + """ assert self._device is not None assert self._proj_bgl is not None + def _make_bg(buf: wgpu_t.GPUBuffer) -> wgpu_t.GPUBindGroup: + return self._device.create_bind_group( + layout=self._proj_bgl, + entries=[{"binding": 0, "resource": {"buffer": buf, "offset": 0, "size": 176}}], + ) + + # Normal bind group self._camera_uniform_buf = self._build_camera_uniform_buf() + normal_bg = _make_bg(self._camera_uniform_buf) - return self._device.create_bind_group( - layout=self._proj_bgl, - entries=[ - {"binding": 0, "resource": {"buffer": self._camera_uniform_buf, "offset": 0, "size": 144}} - ], + fixed_view = self.camera.fixed_view_matrix + + # Fixed-orientation: rotation-stripped view, same projection as scene + self._fixed_orient_uniform_buf = self._pack_camera_uniforms( + self.camera.projection_matrix, fixed_view + ) + self.fixed_camera_bind_group = _make_bg(self._fixed_orient_uniform_buf) + + # Fixed-in-frame: rotation-stripped view, always orthographic + self._fixed_frame_uniform_buf = self._pack_camera_uniforms( + self.camera.ortho_projection_matrix, fixed_view ) + self.fixed_frame_bind_group = _make_bg(self._fixed_frame_uniform_buf) + + return normal_bg def _build_slug_bind_group( self, curves_buf: wgpu_t.GPUBuffer @@ -626,7 +1059,7 @@ def _build_slug_bind_group( return self._device.create_bind_group( layout=self._slug_bgl, entries=[ - {"binding": 0, "resource": {"buffer": self._camera_uniform_buf, "offset": 0, "size": 144}}, + {"binding": 0, "resource": {"buffer": self._camera_uniform_buf, "offset": 0, "size": 176}}, {"binding": 1, "resource": {"buffer": curves_buf, "offset": 0, "size": curves_buf.size}}, ], ) @@ -645,30 +1078,88 @@ def stroke_pipeline(self) -> wgpu_t.GPURenderPipeline: assert self._stroke_pipeline is not None, "init_scene() has not been called" return self._stroke_pipeline + @property + def stroke_3d_pipeline(self) -> wgpu_t.GPURenderPipeline: + assert self._stroke_3d_pipeline is not None, "init_scene() has not been called" + return self._stroke_3d_pipeline + + @property + def stroke_3d_surface_pipeline(self) -> wgpu_t.GPURenderPipeline: + """3-D stroke pipeline with depth bias — for surface mesh lines.""" + assert self._stroke_3d_surface_pipeline is not None, "init_scene() has not been called" + return self._stroke_3d_surface_pipeline + @property def surface_pipeline(self) -> wgpu_t.GPURenderPipeline: + """Opaque surface pipeline (cull_back, depth_write=True).""" assert self._surface_pipeline is not None, "init_scene() has not been called" return self._surface_pipeline + @property + def surface_oit_pipeline(self) -> wgpu_t.GPURenderPipeline: + assert self._surface_oit_pipeline is not None, "init_scene() has not been called" + return self._surface_oit_pipeline + @property def slug_fill_pipeline(self) -> wgpu_t.GPURenderPipeline: assert self._slug_fill_pipeline is not None, "init_scene() has not been called" return self._slug_fill_pipeline + @property + def slug_fill_3d_pipeline(self) -> wgpu_t.GPURenderPipeline: + assert self._slug_fill_3d_pipeline is not None, "init_scene() has not been called" + return self._slug_fill_3d_pipeline + # ------------------------------------------------------------------ # Frame rendering # ------------------------------------------------------------------ def update_frame(self, scene: Scene) -> None: - """Render one frame into the offscreen texture.""" + """Render one frame into the offscreen texture. + + Pass structure + -------------- + 1. **Main pass** — clears the frame; draws normal slug fills, opaque + surfaces, strokes. Fixed-orientation mobjects are drawn at the end + of this pass with a rotation-stripped camera bind group so they share + the same depth buffer as the rest of the scene. + 2. **OIT accumulation pass** — if any normal surface has alpha < 0.99, + renders those fragments into two OIT accumulation textures (rgba16float) + with Weighted Blended blending. + 3. **OIT composition pass** — full-screen triangle composites OIT result + onto the main texture. + 4. **Fixed-in-frame overlay pass** — only if fixed-in-frame mobjects exist. + Loads existing colour, clears depth, and renders overlays with a + rotation-stripped orthographic camera so they always appear on top. + """ assert self._device is not None assert self._render_texture_view is not None assert self._depth_texture_view is not None - bg = self._background_color # (r, g, b, a) floats in [0, 1] + bg = self._background_color + + # Build all three camera bind groups for this frame. + # camera_bind_group — normal rotated view (set on renderer for vmobject_rendering) + # fixed_camera_bind_group — rotation-stripped, current projection (fixed-orientation) + # fixed_frame_bind_group — rotation-stripped, ortho projection (fixed-in-frame) + self.camera_bind_group = self._build_camera_bind_group() + self.frame_vbos = [] + + # ── Partition mobjects ──────────────────────────────────────────── + cam = self.camera + fixed_in_frame = cam._fixed_in_frame_mobjects + fixed_orient = cam._fixed_orientation_mobjects + + normal_mobs = [m for m in scene.mobjects if m not in fixed_in_frame and m not in fixed_orient] + fixed_orient_mobs = [m for m in scene.mobjects if m in fixed_orient] + fixed_frame_mobs = [m for m in scene.mobjects if m in fixed_in_frame] encoder = self._device.create_command_encoder() - render_pass = encoder.begin_render_pass( + + # ── Pass 1: main ────────────────────────────────────────────────── + # Renders normal mobjects and fixed-orientation mobjects (same depth + # buffer; fixed-orient uses the rotation-stripped bind group). + main_pass = encoder.begin_render_pass( color_attachments=[ { "view": self._render_texture_view, @@ -681,20 +1172,102 @@ def update_frame(self, scene: Scene) -> None: "view": self._depth_texture_view, "depth_clear_value": 1.0, "depth_load_op": "clear", - "depth_store_op": "discard", + "depth_store_op": "store", }, ) + self.current_render_pass = main_pass + oit_data = render_webgpu_mobject(self, normal_mobs) + + # Fixed-orientation: same pass, swap to rotation-stripped bind group. + # The normal bind group and uniform buffer are saved and restored so + # the OIT accumulation pass (below) still uses the correct camera. + if fixed_orient_mobs: + _saved_bg = self.camera_bind_group + _saved_buf = self._camera_uniform_buf + self.camera_bind_group = self.fixed_camera_bind_group + self._camera_uniform_buf = self._fixed_orient_uniform_buf + render_webgpu_mobject(self, fixed_orient_mobs) + self.camera_bind_group = _saved_bg + self._camera_uniform_buf = _saved_buf + + main_pass.end() + + # ── Pass 2: OIT accumulation ────────────────────────────────────── + if oit_data is not None: + oit_pass = encoder.begin_render_pass( + color_attachments=[ + { + "view": self._oit_accum_view, + "load_op": "clear", + "store_op": "store", + "clear_value": (0.0, 0.0, 0.0, 0.0), + }, + { + "view": self._oit_reveal_view, + "load_op": "clear", + "store_op": "store", + "clear_value": (1.0, 1.0, 1.0, 1.0), + }, + ], + depth_stencil_attachment={ + "view": self._depth_texture_view, + "depth_load_op": "load", # read depth from main pass + "depth_store_op": "discard", + }, + ) + oit_pass.set_pipeline(self.surface_oit_pipeline) + oit_pass.set_bind_group(0, self.camera_bind_group, [], 0, 0) + for idx in oit_data.oit_indices: + arr = oit_data.surface_parts[idx] + oit_pass.set_vertex_buffer( + 0, oit_data.surface_buf, + oit_data.byte_offsets[idx], arr.nbytes, + ) + oit_pass.draw(len(arr), 1, 0, 0) + oit_pass.end() + + # ── Pass 3: OIT composition ─────────────────────────────────── + compose_pass = encoder.begin_render_pass( + color_attachments=[ + { + "view": self._render_texture_view, + "load_op": "load", + "store_op": "store", + } + ], + ) + compose_pass.set_pipeline(self._oit_compose_pipeline) + compose_pass.set_bind_group(0, self._oit_compose_bind_group, [], 0, 0) + compose_pass.draw(3, 1, 0, 0) + compose_pass.end() + + # ── Fixed-in-frame overlay pass ─────────────────────────────────── + # Rendered last, after OIT composition, so overlays always appear on + # top of the 3-D scene. The depth buffer is cleared to 1.0 (far) and + # discarded afterward — fixed-in-frame objects only depth-test against + # each other, not against the main scene. + if fixed_frame_mobs: + fixed_pass = encoder.begin_render_pass( + color_attachments=[ + { + "view": self._render_texture_view, + "load_op": "load", # preserve the composited 3-D scene + "store_op": "store", + } + ], + depth_stencil_attachment={ + "view": self._depth_texture_view, + "depth_clear_value": 1.0, + "depth_load_op": "clear", # fresh depth — overlays on top + "depth_store_op": "discard", + }, + ) + self.current_render_pass = fixed_pass + self.camera_bind_group = self.fixed_frame_bind_group + self._camera_uniform_buf = self._fixed_frame_uniform_buf + render_webgpu_mobject(self, fixed_frame_mobs) + fixed_pass.end() - self.current_render_pass = render_pass - self.frame_vbos = [] - - # One camera bind group per frame (projection may change). - self.camera_bind_group = self._build_camera_bind_group() - - # Batch render: collect all geometry first, then 1–3 GPU uploads total. - render_webgpu_mobject(self, scene.mobjects) - - render_pass.end() self._device.queue.submit([encoder.finish()]) self.current_render_pass = None @@ -707,44 +1280,121 @@ def update_frame(self, scene: Scene) -> None: # Frame readback # ------------------------------------------------------------------ - def _get_raw_frame_data(self) -> bytes: - """Copy the render texture to CPU memory and return tightly-packed RGBA bytes.""" + def _create_readback_pipeline(self, width: int, height: int) -> None: + """Create the compact-readback compute pipeline and its persistent buffers. + + The compute shader (readback_compact.wgsl) reads every pixel from the + bgra8unorm render texture and writes tightly-packed RGBA u32 values into + a storage buffer — eliminating two CPU operations that previously ran on + every frame: + + * Row-padding strip — copy_texture_to_buffer requires bytes_per_row + to be a multiple of 256; the shader writes directly to tight index + ``y * width + x``, so the output is already compact. + + * B↔R channel swap — textureLoad() returns components as (r, g, b, a) + regardless of the bgra physical layout, so the output is already in + RGBA byte order. + """ assert self._device is not None - assert self._render_texture is not None + assert self._render_texture_view is not None - width = config.pixel_width - height = config.pixel_height - bpr = width * 4 # bytes per row (unpadded) + shader_path = Path(__file__).parent / "shaders" / "readback_compact.wgsl" + shader = self._device.create_shader_module( + code=shader_path.read_text(encoding="utf-8") + ) - # WebGPU requires bytes_per_row to be a multiple of 256. - aligned_bpr = (bpr + 255) & ~255 + self._readback_compute_bgl = self._device.create_bind_group_layout( + entries=[ + { + "binding": 0, + "visibility": wgpu.ShaderStage.COMPUTE, + "texture": { + "sample_type": "float", + "view_dimension": "2d", + "multisampled": False, + }, + }, + { + "binding": 1, + "visibility": wgpu.ShaderStage.COMPUTE, + "buffer": {"type": "storage"}, + }, + ] + ) + + self._readback_compute_pipeline = self._device.create_compute_pipeline( + layout=self._device.create_pipeline_layout( + bind_group_layouts=[self._readback_compute_bgl] + ), + compute={"module": shader, "entry_point": "main"}, + ) - readback_buf = self._device.create_buffer( - size=aligned_bpr * height, + packed_size = width * height * 4 + self._readback_storage_buf = self._device.create_buffer( + size=packed_size, + usage=wgpu.BufferUsage.STORAGE | wgpu.BufferUsage.COPY_SRC, + ) + self._readback_map_buf = self._device.create_buffer( + size=packed_size, usage=wgpu.BufferUsage.COPY_DST | wgpu.BufferUsage.MAP_READ, ) + self._readback_compute_bind_group = self._device.create_bind_group( + layout=self._readback_compute_bgl, + entries=[ + {"binding": 0, "resource": self._render_texture_view}, + { + "binding": 1, + "resource": { + "buffer": self._readback_storage_buf, + "offset": 0, + "size": packed_size, + }, + }, + ], + ) + + def _get_raw_frame_data(self) -> bytes: + """Readback the current frame as tightly-packed RGBA bytes. + + A compute shader (readback_compact.wgsl) handles both row-depadding and + the bgra→rgba channel fix on the GPU. The CPU no longer needs to loop + over rows or touch a numpy channel-swap. + """ + assert self._device is not None + assert self._readback_compute_pipeline is not None + assert self._readback_compute_bind_group is not None + assert self._readback_storage_buf is not None + assert self._readback_map_buf is not None + + width = config.pixel_width + height = config.pixel_height + packed_size = width * height * 4 encoder = self._device.create_command_encoder() - encoder.copy_texture_to_buffer( - {"texture": self._render_texture, "mip_level": 0, "origin": (0, 0, 0)}, - { - "buffer": readback_buf, - "offset": 0, - "bytes_per_row": aligned_bpr, - "rows_per_image": height, - }, - (width, height, 1), + + # Compact pass: row-depad + bgra→rgba in one GPU dispatch. + compute_pass = encoder.begin_compute_pass() + compute_pass.set_pipeline(self._readback_compute_pipeline) + compute_pass.set_bind_group(0, self._readback_compute_bind_group) + compute_pass.dispatch_workgroups( + (width + 15) // 16, + (height + 15) // 16, + ) + compute_pass.end() + + # Copy packed storage buffer → mappable buffer. + encoder.copy_buffer_to_buffer( + self._readback_storage_buf, 0, + self._readback_map_buf, 0, + packed_size, ) - self._device.queue.submit([encoder.finish()]) - readback_buf.map_sync(wgpu.MapMode.READ) - raw = bytes(readback_buf.read_mapped()) - readback_buf.unmap() + self._device.queue.submit([encoder.finish()]) - if aligned_bpr != bpr: - raw = b"".join( - raw[i * aligned_bpr : i * aligned_bpr + bpr] for i in range(height) - ) + self._readback_map_buf.map_sync(wgpu.MapMode.READ) + raw = bytes(self._readback_map_buf.read_mapped()) + self._readback_map_buf.unmap() return raw def get_image(self) -> Image.Image: @@ -762,24 +1412,117 @@ def get_frame(self) -> np.ndarray: ) # ------------------------------------------------------------------ - # Scene lifecycle + # Window helpers + # ------------------------------------------------------------------ + + def should_create_window(self) -> bool: + """Mirror of ``OpenGLRenderer.should_create_window``. + + A preview window is opened when ``--preview`` is active and the + renderer is not writing a movie or saving a still frame. + """ + if config["force_window"]: + logger.warning( + "'--force_window' is enabled; this is intended for debugging " + "and may impact performance when combined with file output.", + ) + return True + return ( + config["preview"] + and not config["save_last_frame"] + and not config["format"] + and not config["write_to_movie"] + and not config["dry_run"] + ) + + def pixel_coords_to_space_coords( + self, + px: float, + py: float, + relative: bool = False, + top_left: bool = False, + ) -> np.ndarray: + """Convert pixel coordinates to Manim scene-space coordinates. + + Parameters + ---------- + px, py: + Pixel position. For ``relative=False``, these are absolute + pixel coordinates within the render texture. + relative: + When True, treat *px*/*py* as a delta and return the + corresponding scene-space delta (normalised to ``[-1, 1]`` + then scaled). + top_left: + When True (the default for ``rendercanvas``), the origin is + at the top-left corner; y increases downward. + """ + pixel_width = config.pixel_width + pixel_height = config.pixel_height + frame_height = config.frame_height + frame_center = self.camera.get_center() + + if relative: + return 2.0 * np.array([px / pixel_width, py / pixel_height, 0.0]) + + scale = frame_height / pixel_height + y_direction = -1 if top_left else 1 + return ( + frame_center + + scale + * np.array( + [(px - pixel_width / 2), y_direction * (py - pixel_height / 2), 0.0] + ) + ) + + # ------------------------------------------------------------------ + # Scene rendering # ------------------------------------------------------------------ def render(self, scene: Scene, frame_offset: float, moving_mobjects: list) -> None: self.update_frame(scene) - if not self.skip_animations: - self.file_writer.write_frame(self) + if self.skip_animations: + return + self.file_writer.write_frame(self) + if self.window is not None: + self.window.present() + while self.animation_elapsed_time < frame_offset: + if self.window.is_closing: + break + self.update_frame(scene) + self.window.present() def play(self, scene: Scene, *animations: Any, **kwargs: Any) -> None: self.animation_start_time = time.time() self.skip_animations = self._original_skipping_status - self._update_skipping_status() + self.update_skipping_status() + + # Compile first so we can compute a real hash (same order as CairoRenderer). + scene.compile_animation_data(*animations, **kwargs) - self.animations_hashes.append(None) - self.file_writer.add_partial_movie_file(None) + if self.skip_animations: + hash_current_animation = None + self.time += scene.duration + elif config["disable_caching"]: + hash_current_animation = f"uncached_{self.num_plays:05}" + else: + assert scene.animations is not None + hash_current_animation = get_hash_from_play_call( + scene, self.camera, scene.animations, scene.mobjects + ) + if self.file_writer.is_already_cached(hash_current_animation): + logger.info( + "Animation %d: using cached data (hash: %s)", + self.num_plays, + hash_current_animation, + ) + self.skip_animations = True + self.time += scene.duration + + self.animations_hashes.append(hash_current_animation) + self.file_writer.add_partial_movie_file(hash_current_animation) self.file_writer.begin_animation(not self.skip_animations) - scene.compile_animation_data(*animations, **kwargs) scene.begin_animations() if scene.is_current_animation_frozen_frame(): @@ -788,6 +1531,12 @@ def play(self, scene: Scene, *animations: Any, **kwargs: Any) -> None: self.file_writer.write_frame( self, num_frames=int(config.frame_rate * scene.duration) ) + if self.window is not None: + self.window.present() + while time.time() - self.animation_start_time < scene.duration: + if self.window.is_closing: + break + self.window.present() self.animation_elapsed_time = scene.duration else: scene.play_internal() @@ -812,13 +1561,30 @@ def save_static_frame_data(self, scene: Scene, static_mobjects: Any) -> None: pass # not implemented in Phase 1 def clear_screen(self) -> None: - pass # headless — no window + if self.window is not None: + self.window.present() # ------------------------------------------------------------------ # Skipping helpers # ------------------------------------------------------------------ - def _update_skipping_status(self) -> None: + def update_skipping_status(self) -> None: + """Check and update ``skip_animations`` for the current animation. + + Mirrors ``CairoRenderer.update_skipping_status`` and + ``OpenGLRenderer.update_skipping_status`` so the WebGPU renderer + honours the same configuration knobs: + + * ``file_writer.sections[-1].skip_animations`` — section-level skip + (e.g. the section was marked skip via ``scene.next_section``). + * ``config.save_last_frame`` — only the final frame matters; all + intermediate animation frames can be skipped. + * ``config.from_animation_number`` — skip animations before the given + index (useful for scrubbing to a specific animation). + * ``config.upto_animation_number`` — stop rendering after the given + index and raise ``EndSceneEarlyException``. + """ + # there is always at least one section → no out-of-bounds here if self.file_writer.sections[-1].skip_animations: self.skip_animations = True if config["save_last_frame"]: diff --git a/manim/renderer/webgpu/webgpu_renderer_window.py b/manim/renderer/webgpu/webgpu_renderer_window.py new file mode 100644 index 0000000000..c6fde3d447 --- /dev/null +++ b/manim/renderer/webgpu/webgpu_renderer_window.py @@ -0,0 +1,243 @@ +"""Preview window for the WebGPU renderer. + +Uses rendercanvas (bundled with wgpu-py) to open a native OS window. +The offscreen render texture (``bgra8unorm``) is copied directly to the +window surface via ``copy_texture_to_texture`` — no blit shader needed +because both textures share the same format. + +Event mapping +------------- +rendercanvas uses the Web standard key-name strings (``"ArrowLeft"``, +``"q"``, ...). Manim scene callbacks expect pyglet-compatible integer +key codes. A small mapping table is included below. Single printable +characters are mapped with ``ord()``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from manim import __version__, config + +if TYPE_CHECKING: + from .webgpu_renderer import WebGPURenderer + +try: + import wgpu + from rendercanvas.auto import RenderCanvas +except ImportError as exc: + raise ImportError( + "wgpu-py (with rendercanvas) is required for the preview window. " + "Install it with: pip install wgpu" + ) from exc + + +# --------------------------------------------------------------------------- +# Key / modifier mapping +# --------------------------------------------------------------------------- + +# rendercanvas key strings → pyglet-compatible integer codes. +# Printable single chars use ord() directly (see _key_to_int below). +_SPECIAL_KEY_MAP: dict[str, int] = { + "ArrowLeft": 65361, + "ArrowRight": 65363, + "ArrowUp": 65362, + "ArrowDown": 65364, + "Escape": 65307, + "Enter": 65293, + "Backspace": 65288, + "Tab": 65289, + "Delete": 65535, + "Home": 65360, + "End": 65367, + "PageUp": 65365, + "PageDown": 65366, + "Insert": 65379, + "F1": 65470, "F2": 65471, "F3": 65472, "F4": 65473, + "F5": 65474, "F6": 65475, "F7": 65476, "F8": 65477, + "F9": 65478, "F10": 65479, "F11": 65480, "F12": 65481, + "Shift": 65505, # SHIFT_VALUE in manim/constants.py + "Control": 65507, + "Alt": 65513, + "Meta": 65511, + "CapsLock": 65509, + "NumLock": 65407, + "ScrollLock": 65300, +} + +# rendercanvas modifier strings → pyglet modifier bitmask bits +_MODIFIER_BITS: dict[str, int] = { + "Shift": 1, + "Control": 4, + "Alt": 8, + "Meta": 16, +} + + +def _key_to_int(key: str) -> int: + """Convert a rendercanvas key string to a pyglet-compatible integer.""" + if key in _SPECIAL_KEY_MAP: + return _SPECIAL_KEY_MAP[key] + if len(key) == 1: + return ord(key) + # Unknown multi-char key — use a stable hash to avoid collisions with + # printable chars. + return (hash(key) & 0x7FFF_FFFF) | 0x8000_0000 + + +def _modifiers_to_int(modifiers: tuple | list) -> int: + """Convert a rendercanvas modifiers collection to a pyglet modifier int.""" + result = 0 + for m in modifiers: + result |= _MODIFIER_BITS.get(m, 0) + return result + + +# --------------------------------------------------------------------------- +# Window size helper +# --------------------------------------------------------------------------- + +def _compute_window_size() -> tuple[int, int]: + """Return ``(width, height)`` for the preview window in logical pixels. + + Defaults to ``(pixel_width, pixel_height)`` so that the surface texture + produced by the canvas always matches the offscreen render texture, + making ``copy_texture_to_texture`` safe without any size bookkeeping. + """ + return config.pixel_width, config.pixel_height + + +# --------------------------------------------------------------------------- +# Window class +# --------------------------------------------------------------------------- + +class WebGPUWindow: + """Preview window wrapping a ``rendercanvas.RenderCanvas``. + + Each call to :meth:`present` polls OS events and blits the offscreen + render texture to the window surface via ``copy_texture_to_texture``. + + Interface expected by ``scene.py`` and ``WebGPURenderer``: + + * ``is_closing`` — True once the user closes the window + * ``destroy()`` — tear down the underlying canvas + """ + + def __init__(self, renderer: WebGPURenderer) -> None: + self._renderer = renderer + + win_w, win_h = _compute_window_size() + self._canvas = RenderCanvas( + size=(win_w, win_h), + title=f"Manim Community {__version__}", + # "manual" means we call force_draw() ourselves; the scheduler + # never schedules draws on its own. + update_mode="manual", + ) + + # Configure the wgpu context. + # bgra8unorm matches the render texture format → copy_texture_to_texture + # works without any format conversion. + # COPY_DST is needed on the surface texture so we can copy into it. + self._context = self._canvas.get_wgpu_context() + self._context.configure( + device=renderer._device, + format=wgpu.TextureFormat.bgra8unorm, + usage=wgpu.TextureUsage.RENDER_ATTACHMENT | wgpu.TextureUsage.COPY_DST, + ) + + # Register the draw callback (executed inside the rendercanvas lifecycle + # on every force_draw() call). + self._canvas.request_draw(self._draw_frame) + + # Register event handlers. + self._canvas.add_event_handler(self._on_key_down, "key_down") + self._canvas.add_event_handler(self._on_key_up, "key_up") + self._canvas.add_event_handler(self._on_pointer_move, "pointer_move") + self._canvas.add_event_handler(self._on_pointer_down, "pointer_down") + self._canvas.add_event_handler(self._on_wheel, "wheel") + + # ------------------------------------------------------------------ + # Public interface (consumed by WebGPURenderer and scene.py) + # ------------------------------------------------------------------ + + @property + def is_closing(self) -> bool: + """True once the OS window has been closed.""" + return self._canvas.get_closed() + + def destroy(self) -> None: + """Close the OS window.""" + self._canvas.close() + + def present(self) -> None: + """Poll OS events and blit the current render texture to the window. + + Call this after every :meth:`~WebGPURenderer.update_frame` that + should be visible in the preview window. + """ + # Process pending OS events (keyboard, mouse, resize, close …). + self._canvas._process_events() + # Trigger _draw_frame → copy_texture_to_texture → present to screen. + self._canvas.force_draw() + + # ------------------------------------------------------------------ + # Draw callback (runs inside the rendercanvas present lifecycle) + # ------------------------------------------------------------------ + + def _draw_frame(self) -> None: + """Copy the offscreen render texture to the window surface texture.""" + renderer = self._renderer + if renderer._render_texture is None or renderer._device is None: + return + + surface_tex = self._context.get_current_texture() + w = config.pixel_width + h = config.pixel_height + + encoder = renderer._device.create_command_encoder() + encoder.copy_texture_to_texture( + {"texture": renderer._render_texture, "mip_level": 0, "origin": (0, 0, 0)}, + {"texture": surface_tex, "mip_level": 0, "origin": (0, 0, 0)}, + (w, h, 1), + ) + renderer._device.queue.submit([encoder.finish()]) + + # ------------------------------------------------------------------ + # Event handlers + # ------------------------------------------------------------------ + + def _on_key_down(self, event: dict) -> None: + key = event.get("key", "") + symbol = _key_to_int(key) + self._renderer.pressed_keys.add(symbol) + # scene.on_key_press asserts OpenGLCamera/Renderer — skip for WebGPU. + + def _on_key_up(self, event: dict) -> None: + key = event.get("key", "") + symbol = _key_to_int(key) + self._renderer.pressed_keys.discard(symbol) + + def _on_pointer_move(self, event: dict) -> None: + point = self._renderer.pixel_coords_to_space_coords( + event["x"], event["y"], top_left=True + ) + d_point = self._renderer.pixel_coords_to_space_coords( + event.get("dx", 0), event.get("dy", 0), relative=True + ) + # scene.on_mouse_motion asserts OpenGLCamera — skip for WebGPU. + _ = point, d_point # suppress unused-var warnings + + def _on_pointer_down(self, event: dict) -> None: + point = self._renderer.pixel_coords_to_space_coords( + event["x"], event["y"], top_left=True + ) + _ = point + + def _on_wheel(self, event: dict) -> None: + point = self._renderer.pixel_coords_to_space_coords( + event["x"], event["y"], top_left=True + ) + _ = point diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py index e79191d07a..24e91381dc 100644 --- a/manim/renderer/webgpu/webgpu_vmobject_rendering.py +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -27,10 +27,12 @@ from __future__ import annotations import weakref +from dataclasses import dataclass, field from typing import TYPE_CHECKING import numpy as np +from manim.mobject.three_d.three_dimensions import Surface from manim.mobject.types.vectorized_mobject import VMobject if TYPE_CHECKING: @@ -39,6 +41,28 @@ from manim.renderer.webgpu.webgpu_renderer import WebGPURenderer +@dataclass +class _OITPassData: + """Geometry needed for the OIT accumulation render pass.""" + surface_parts: list[np.ndarray] + surface_buf: wgpu_t.GPUBuffer + byte_offsets: list[int] + oit_indices: list[int] # indices into surface_parts that are OIT + + +def _surface_opacity_class(part: np.ndarray) -> str: + """Classify a surface part by its alpha distribution. + + Returns: + "opaque" — all alpha >= 0.99 → opaque pipeline, depth write + "oit" — any alpha < 0.99 → Weighted Blended OIT + """ + alphas = part["in_color"][:, 3] + if float(alphas.min()) >= 0.99: + return "opaque" + return "oit" + + # --------------------------------------------------------------------------- # Surface vertex layout — must match surface.wgsl locations: # location 0 → in_vert float32x3 offset 0 (12 bytes) @@ -118,22 +142,22 @@ def _stroke_field_offset(name: str) -> int: # --------------------------------------------------------------------------- # Slug fill vertex layout — must match slug_fill.wgsl locations: -# location 0 → in_pos float32x2 offset 0 ( 8 bytes) -# location 1 → in_color float32x4 offset 8 (16 bytes) -# location 2 → curve_start uint32 offset 24 ( 4 bytes) -# location 3 → n_curves uint32 offset 28 ( 4 bytes) -# stride: 32 bytes +# location 0 → in_pos float32x3 offset 0 (12 bytes) +# location 1 → in_color float32x4 offset 12 (16 bytes) +# location 2 → curve_start uint32 offset 28 ( 4 bytes) +# location 3 → n_curves uint32 offset 32 ( 4 bytes) +# stride: 36 bytes # --------------------------------------------------------------------------- _SLUG_FILL_DTYPE = np.dtype( [ - ("in_pos", np.float32, (2,)), + ("in_pos", np.float32, (3,)), ("in_color", np.float32, (4,)), ("curve_start", np.uint32), ("n_curves", np.uint32), ] ) -_SLUG_FILL_STRIDE: int = _SLUG_FILL_DTYPE.itemsize # 32 bytes +_SLUG_FILL_STRIDE: int = _SLUG_FILL_DTYPE.itemsize # 36 bytes _SLUG_FILL_OFFSETS: dict[str, int] = { name: _SLUG_FILL_DTYPE.fields[name][1] # type: ignore[index] @@ -144,7 +168,7 @@ def _stroke_field_offset(name: str) -> int: "array_stride": _SLUG_FILL_STRIDE, "step_mode": "vertex", "attributes": [ - {"format": "float32x2", "offset": _SLUG_FILL_OFFSETS["in_pos"], "shader_location": 0}, + {"format": "float32x3", "offset": _SLUG_FILL_OFFSETS["in_pos"], "shader_location": 0}, {"format": "float32x4", "offset": _SLUG_FILL_OFFSETS["in_color"], "shader_location": 1}, {"format": "uint32", "offset": _SLUG_FILL_OFFSETS["curve_start"], "shader_location": 2}, {"format": "uint32", "offset": _SLUG_FILL_OFFSETS["n_curves"], "shader_location": 3}, @@ -183,70 +207,124 @@ def _points_hash(vmobject: VMobject) -> int: def render_webgpu_mobject( renderer: WebGPURenderer, mobjects: list, -) -> None: +) -> _OITPassData | None: """Batch-render all VMobjects in *mobjects* (the scene's top-level list). - Four phases: - - 1. **Tessellate** — iterate every family member of every mobject and - collect geometry into plain numpy arrays. No GPU calls are made. + Rendering is split into typed draw commands executed in order: - 2. **Batch upload** — at most 4 ``create_buffer_with_data`` calls total: + * ``slug_fill`` — 2-D Slug fill (no depth test) + * ``surface_opaque`` — 3-D opaque surface fill (depth write + test) + * ``stroke_2d`` — 2-D stroke (depth-tested, depth_write=False — occluded by surfaces) + * ``stroke_3d`` — 3-D stroke for shade_in_3d objects (depth test) - * Slug fill quad vertex buffer (one bounding quad per shape) - * Slug fill curves storage buffer (all quadratic bezier data) - * Stroke vertex buffer - * Surface vertex buffer + The ``surface_oit`` category is NOT executed here; the function returns + an ``_OITPassData`` with the OIT geometry so ``update_frame`` can run it + in a separate OIT accumulation pass. All surfaces with any alpha < 0.99 + are routed through OIT for correct order-independent compositing. - 3. **Build bind groups** — one per pipeline type. The Slug pipeline - gets a bind group that includes both the camera uniform buffer and - the curves storage buffer. + **shade_in_3d objects**: fill (if present) goes to slug_fill_3d; stroke + (if present) goes to stroke_3d. Both are emitted independently, so an + object with both fill and stroke renders a filled surface with 3-D strokes + on top. Objects with no fill (axis lines, arrowheads, tick marks) emit + only stroke_3d. - 4. **Draw** — draw commands in scene order. Pipeline switches only happen - when the type changes, minimising state-change overhead. + **Issue 2 fix**: the stroke vertex shader now works in view space, so any + 3-D curve (including the world Z-axis) is rendered correctly. - Fill rendering uses the Slug algorithm (exact winding-number coverage, - analytical anti-aliasing, no CPU tessellation). Stroke uses the existing - cubic-SDF Newton-method shader. 3-D surfaces use the Phong shader. - - Painter's-algorithm order is fully preserved. + **Issue 3 fix**: stroke_3d uses depth_compare=less, so 3-D axis lines are + occluded by surfaces in front of them. """ import wgpu # local import so module loads without wgpu installed - # ── Phase 1: tessellate ─────────────────────────────────────────────── - # Slug fill: one bounding-quad vertex record (6 verts) + flat curve array per shape. - slug_quad_parts: list[np.ndarray] = [] # _SLUG_FILL_DTYPE, 6 verts each - slug_curve_parts: list[np.ndarray] = [] # float32 (N*3, 2) each - - stroke_parts: list[np.ndarray] = [] - surface_parts: list[np.ndarray] = [] + view_matrix: np.ndarray = renderer.camera.view_matrix - # draw_plan entry: ("slug_fill" | "stroke" | "surface", index into *_parts) + # ── Phase 1: tessellate ─────────────────────────────────────────────── + slug_quad_parts: list[np.ndarray] = [] + slug_curve_parts: list[np.ndarray] = [] + stroke_parts: list[np.ndarray] = [] + surface_parts: list[np.ndarray] = [] + + # Command types: slug_fill | slug_fill_3d + # surface_opaque | surface_oit + # stroke_2d | stroke_3d draw_plan: list[tuple[str, int]] = [] for mob in mobjects: + mob_type = type(mob).__name__ if not isinstance(mob, VMobject): continue - for submob in mob.family_members_with_points(): - if getattr(submob, "shade_in_3d", False): + + # ── Parametric Surface: triangle-mesh + Phong lighting ──────────── + # Also collect the stroke of each face so the mesh grid is visible, + # matching Cairo which renders both fill and stroke for every face. + if isinstance(mob, Surface): + for submob in mob.family_members_with_points(): data = _collect_surface_geometry(submob) if data is not None: - draw_plan.append(("surface", len(surface_parts))) + cls = _surface_opacity_class(data) + cmd = "surface_opaque" if cls == "opaque" else "surface_oit" + draw_plan.append((cmd, len(surface_parts))) surface_parts.append(data) - else: - phash = _points_hash(submob) - # ── Slug fill (cached) ────────────────────────────────────── + phash = _points_hash(submob) + scached = _stroke_cache.get(submob) + if scached is not None and scached[0] == phash: + draw_plan.append(("stroke_surface", len(stroke_parts))) + stroke_parts.append(scached[1]) + else: + stroke_data = _collect_stroke_geometry(submob) + if stroke_data is not None: + _stroke_cache[submob] = (phash, stroke_data) + draw_plan.append(("stroke_surface", len(stroke_parts))) + stroke_parts.append(stroke_data) + continue + + for submob in mob.family_members_with_points(): + phash = _points_hash(submob) + scached = _stroke_cache.get(submob) + + if getattr(submob, "shade_in_3d", False): + fill_rgba = submob.get_fill_rgbas() + has_fill = fill_rgba.shape[0] > 0 and float(fill_rgba[0, 3]) > 0.01 + + if has_fill: + # 3-D flat VMobject with fill (e.g. number-plane, polygon in 3D): + cached = _slug_fill_cache.get(submob) + if cached is not None and cached[0] == phash: + quad_verts, curves_flat = cached[1] + draw_plan.append(("slug_fill_3d", len(slug_quad_parts))) + slug_quad_parts.append(quad_verts.copy()) + slug_curve_parts.append(curves_flat) + else: + slug_data = _collect_slug_fill_geometry(submob, view_matrix) + if slug_data is not None: + _slug_fill_cache[submob] = (phash, slug_data) + quad_verts, curves_flat = slug_data + draw_plan.append(("slug_fill_3d", len(slug_quad_parts))) + slug_quad_parts.append(quad_verts.copy()) + slug_curve_parts.append(curves_flat) + + # 3-D stroke logic (axis lines, etc.) + if scached is not None and scached[0] == phash: + draw_plan.append(("stroke_3d", len(stroke_parts))) + stroke_parts.append(scached[1]) + else: + stroke_data = _collect_stroke_geometry(submob) + if stroke_data is not None: + _stroke_cache[submob] = (phash, stroke_data) + draw_plan.append(("stroke_3d", len(stroke_parts))) + stroke_parts.append(stroke_data) + + else: + # ── 2-D object: Slug fill + 2-D stroke ────────────────────── cached = _slug_fill_cache.get(submob) if cached is not None and cached[0] == phash: quad_verts, curves_flat = cached[1] - # quad_verts["curve_start"] will be patched in-place below; - # we must copy so the cached array stays at offset 0. draw_plan.append(("slug_fill", len(slug_quad_parts))) slug_quad_parts.append(quad_verts.copy()) slug_curve_parts.append(curves_flat) else: - slug_data = _collect_slug_fill_geometry(submob) + slug_data = _collect_slug_fill_geometry(submob, view_matrix) if slug_data is not None: _slug_fill_cache[submob] = (phash, slug_data) quad_verts, curves_flat = slug_data @@ -254,20 +332,18 @@ def render_webgpu_mobject( slug_quad_parts.append(quad_verts.copy()) slug_curve_parts.append(curves_flat) - # ── Stroke (cached) ───────────────────────────────────────── - scached = _stroke_cache.get(submob) if scached is not None and scached[0] == phash: - draw_plan.append(("stroke", len(stroke_parts))) + draw_plan.append(("stroke_2d", len(stroke_parts))) stroke_parts.append(scached[1]) else: stroke_data = _collect_stroke_geometry(submob) if stroke_data is not None: _stroke_cache[submob] = (phash, stroke_data) - draw_plan.append(("stroke", len(stroke_parts))) + draw_plan.append(("stroke_2d", len(stroke_parts))) stroke_parts.append(stroke_data) if not draw_plan: - return + return None # ── Phase 2: batch upload ───────────────────────────────────────────── device: wgpu_t.GPUDevice = renderer.device @@ -278,10 +354,8 @@ def render_webgpu_mobject( surface_buf = surface_byte_offsets = None if slug_quad_parts: - # Fix up per-shape curve_start offsets into the shared curves buffer. curve_global_offset = 0 for i, curves_flat in enumerate(slug_curve_parts): - # curves_flat has shape (n_quads * 3, 2); each curve = 3 entries. n_quads_i = len(curves_flat) // 3 slug_quad_parts[i]["curve_start"] = curve_global_offset curve_global_offset += n_quads_i @@ -289,14 +363,12 @@ def render_webgpu_mobject( slug_fill_vbo, slug_fill_byte_offsets = _batch_upload(device, slug_quad_parts) renderer.frame_vbos.append(slug_fill_vbo) - all_curves = np.concatenate(slug_curve_parts, axis=0) # (total * 3, 2) + all_curves = np.concatenate(slug_curve_parts, axis=0) slug_curves_buf = device.create_buffer_with_data( data=all_curves.tobytes(), usage=wgpu.BufferUsage.STORAGE, ) renderer.frame_vbos.append(slug_curves_buf) - - # Bind group for Slug pipeline: camera uniform + curves storage. slug_bind_group = renderer._build_slug_bind_group(slug_curves_buf) if stroke_parts: @@ -304,38 +376,100 @@ def render_webgpu_mobject( renderer.frame_vbos.append(stroke_buf) if surface_parts: + _smooth_surface_normals(surface_parts) surface_buf, surface_byte_offsets = _batch_upload(device, surface_parts) renderer.frame_vbos.append(surface_buf) - # ── Phase 3: draw in scene order ───────────────────────────────────── + # ── Phase 3: draw in the main render pass ───────────────────────────── + # Execute in this fixed order: + # slug_fill → surface_opaque → stroke_2d → stroke_3d → stroke_surface + # stroke_surface uses a depth-biased pipeline so mesh lines sitting exactly + # on the surface never z-fight with it. + # surface_oit entries are skipped here and returned for a separate OIT pass. rp = renderer.current_render_pass - current_pipeline: str | None = None + cam_bg = renderer.camera_bind_group - for cmd_type, idx in draw_plan: - if cmd_type != current_pipeline: - if cmd_type == "slug_fill": - rp.set_pipeline(renderer.slug_fill_pipeline) - rp.set_bind_group(0, slug_bind_group, [], 0, 0) - elif cmd_type == "stroke": - rp.set_pipeline(renderer.stroke_pipeline) - rp.set_bind_group(0, renderer.camera_bind_group, [], 0, 0) - else: # surface - rp.set_pipeline(renderer.surface_pipeline) - rp.set_bind_group(0, renderer.camera_bind_group, [], 0, 0) - current_pipeline = cmd_type - - if cmd_type == "slug_fill": - arr = slug_quad_parts[idx] - rp.set_vertex_buffer(0, slug_fill_vbo, slug_fill_byte_offsets[idx], arr.nbytes) - rp.draw(len(arr), 1, 0, 0) - elif cmd_type == "stroke": - arr = stroke_parts[idx] - rp.set_vertex_buffer(0, stroke_buf, stroke_byte_offsets[idx], arr.nbytes) - rp.draw(len(arr), 1, 0, 0) + def _draw_stroke(idx: int, pipeline_name: str, _cur: list) -> None: + if pipeline_name == "stroke_2d": + pipeline = renderer.stroke_pipeline + elif pipeline_name == "stroke_surface": + pipeline = renderer.stroke_3d_surface_pipeline else: - arr = surface_parts[idx] - rp.set_vertex_buffer(0, surface_buf, surface_byte_offsets[idx], arr.nbytes) - rp.draw(len(arr), 1, 0, 0) + pipeline = renderer.stroke_3d_pipeline + if _cur[0] != pipeline_name: + rp.set_pipeline(pipeline) + rp.set_bind_group(0, cam_bg, [], 0, 0) + _cur[0] = pipeline_name + arr = stroke_parts[idx] + rp.set_vertex_buffer(0, stroke_buf, stroke_byte_offsets[idx], arr.nbytes) + rp.draw(len(arr), 1, 0, 0) + + def _draw_surface(idx: int, pipeline, pipeline_key: str, _cur: list) -> None: + if _cur[0] != pipeline_key: + rp.set_pipeline(pipeline) + rp.set_bind_group(0, cam_bg, [], 0, 0) + _cur[0] = pipeline_key + arr = surface_parts[idx] + rp.set_vertex_buffer(0, surface_buf, surface_byte_offsets[idx], arr.nbytes) + rp.draw(len(arr), 1, 0, 0) + + _cur: list[str | None] = [None] # mutable current-pipeline tracker + + # 1. Slug fills (2-D — no depth test) + if slug_fill_vbo is not None: + for cmd_type, idx in draw_plan: + if cmd_type == "slug_fill": + if _cur[0] != "slug_fill": + rp.set_pipeline(renderer.slug_fill_pipeline) + rp.set_bind_group(0, slug_bind_group, [], 0, 0) + _cur[0] = "slug_fill" + arr = slug_quad_parts[idx] + rp.set_vertex_buffer(0, slug_fill_vbo, slug_fill_byte_offsets[idx], arr.nbytes) + rp.draw(len(arr), 1, 0, 0) + + # 1b. Slug fills (3-D — depth-tested, shade_in_3d with fill) + for cmd_type, idx in draw_plan: + if cmd_type == "slug_fill_3d": + if _cur[0] != "slug_fill_3d": + rp.set_pipeline(renderer.slug_fill_3d_pipeline) + rp.set_bind_group(0, slug_bind_group, [], 0, 0) + _cur[0] = "slug_fill_3d" + arr = slug_quad_parts[idx] + rp.set_vertex_buffer(0, slug_fill_vbo, slug_fill_byte_offsets[idx], arr.nbytes) + rp.draw(len(arr), 1, 0, 0) + + # 2. Opaque surfaces + if surface_buf is not None: + for cmd_type, idx in draw_plan: + if cmd_type == "surface_opaque": + _draw_surface(idx, renderer.surface_pipeline, "surface_opaque", _cur) + + # 5. 2-D strokes + if stroke_buf is not None: + for cmd_type, idx in draw_plan: + if cmd_type == "stroke_2d": + _draw_stroke(idx, "stroke_2d", _cur) + + # 6. 3-D strokes (depth-tested) + for cmd_type, idx in draw_plan: + if cmd_type == "stroke_3d": + _draw_stroke(idx, "stroke_3d", _cur) + + # 7. Surface mesh strokes (depth-biased to prevent z-fighting) + for cmd_type, idx in draw_plan: + if cmd_type == "stroke_surface": + _draw_stroke(idx, "stroke_surface", _cur) + + # ── OIT data for the caller ─────────────────────────────────────────── + oit_indices = [idx for cmd_type, idx in draw_plan if cmd_type == "surface_oit"] + if oit_indices and surface_buf is not None: + return _OITPassData( + surface_parts=surface_parts, + surface_buf=surface_buf, + byte_offsets=surface_byte_offsets, + oit_indices=oit_indices, + ) + return None # --------------------------------------------------------------------------- @@ -443,6 +577,45 @@ def _collect_stroke_geometry(vmobject: VMobject) -> np.ndarray | None: return stroke_data +def _smooth_surface_normals(surface_parts: list[np.ndarray]) -> None: + """Average normals at shared vertex positions to produce smooth shading. + + Modifies ``surface_parts`` in-place. Vertices whose positions match + (within 1e-5 world-space units) share a common averaged normal, removing + the hard crease lines produced by per-face flat normals. + """ + if not surface_parts: + return + + # Stack all vertex positions and flat face normals. + all_verts = np.concatenate([p["in_vert"] for p in surface_parts], axis=0) # (T, 3) f32 + all_norms = np.concatenate([p["in_normal"] for p in surface_parts], axis=0) # (T, 3) f32 + + # Quantise positions so that vertices within 1e-5 units map to the same key. + PREC = 1e-5 + quantized = np.round(all_verts.astype(np.float64) / PREC).astype(np.int64) # (T, 3) + + # np.unique on a 2-D array of int64 rows → unique vertex groups. + _, inverse = np.unique(quantized, axis=0, return_inverse=True) # inverse: (T,) + + # Accumulate face normals per unique position. + n_unique = int(inverse.max()) + 1 + smooth = np.zeros((n_unique, 3), dtype=np.float64) + np.add.at(smooth, inverse, all_norms.astype(np.float64)) + + # Normalise. + lengths = np.linalg.norm(smooth, axis=1, keepdims=True) + lengths = np.where(lengths < 1e-9, 1.0, lengths) + smooth = (smooth / lengths).astype(np.float32) + + # Write smoothed normals back into each part's structured array. + idx = 0 + for part in surface_parts: + n = len(part) + part["in_normal"] = smooth[inverse[idx : idx + n]] + idx += n + + def _collect_surface_geometry(vmobject: VMobject) -> np.ndarray | None: """Return a ``_SURFACE_DTYPE`` array for a shade_in_3d VMobject, or ``None``.""" fill_rgba = vmobject.get_fill_rgbas() @@ -471,7 +644,20 @@ def _collect_surface_geometry(vmobject: VMobject) -> np.ndarray | None: centroid = anchors.mean(axis=0) v0 = anchors[0] - centroid v1 = anchors[1] - centroid - raw_normal = np.cross(v0, v1).astype(np.float64) + # WebGPU evaluates front_face="ccw" in framebuffer space (Y points DOWN). + # Clip space has Y pointing UP, so Y is negated going clip → framebuffer, + # which reverses the apparent winding. A triangle that is CW in clip/ + # world Y-up space becomes CCW in framebuffer space = FRONT FACE. + # + # Manim's anchor ordering is CCW in world Y-up space when viewed from + # outside the surface (confirmed analytically for standard sphere/torus + # parameterisations). Therefore: + # (centroid, curr, next) → CW in world Y-up → CCW in framebuffer → FRONT FACE ✓ + # (centroid, next, curr) → CCW in world Y-up → CW in framebuffer → BACK FACE (culled) ✗ + # + # Normal: v1 × v0 with CCW-from-outside anchors gives the outward-pointing + # normal (v0 × v1 would be inward). + raw_normal = np.cross(v1, v0).astype(np.float64) norm_len = np.linalg.norm(raw_normal) normal = ( (raw_normal / norm_len).astype(np.float32) @@ -481,8 +667,8 @@ def _collect_surface_geometry(vmobject: VMobject) -> np.ndarray | None: fan_verts = np.empty((n_pts * 3, 3), dtype=np.float32) fan_verts[0::3] = centroid.astype(np.float32) - fan_verts[1::3] = anchors.astype(np.float32) - fan_verts[2::3] = np.roll(anchors, -1, axis=0).astype(np.float32) + fan_verts[1::3] = anchors.astype(np.float32) # curr + fan_verts[2::3] = np.roll(anchors, -1, axis=0).astype(np.float32) # next all_verts.append(fan_verts) all_normals.append(np.tile(normal, (n_pts * 3, 1))) @@ -508,16 +694,26 @@ def _collect_surface_geometry(vmobject: VMobject) -> np.ndarray | None: def _collect_slug_fill_geometry( vmobject: VMobject, + view_matrix: np.ndarray | None = None, ) -> tuple[np.ndarray, np.ndarray] | None: """Return ``(quad_verts, curves_flat)`` for the Slug fill pipeline, or ``None``. - *quad_verts* is a ``_SLUG_FILL_DTYPE`` array of 6 vertices forming the - axis-aligned bounding quad for this shape. ``curve_start`` is set to 0 - and must be patched to the global offset by the caller before upload. + *quad_verts* is a ``_SLUG_FILL_DTYPE`` array of 6 vertices (world-space + 3-D positions) forming a bounding quad for this shape in view space. + ``curve_start`` is set to 0 and must be patched to the global offset by + the caller before upload. - *curves_flat* is a ``float32`` array of shape ``(n_quads * 3, 2)`` - containing the world-space XY coordinates of every quadratic bezier + *curves_flat* is a ``float32`` array of shape ``(n_quads * 3, 3)`` + containing the world-space XYZ coordinates of every quadratic bezier control point — three consecutive entries (p1, p2, p3) per curve. + + Parameters + ---------- + view_matrix + 4×4 float32 view matrix (world → camera space). When ``None`` an + identity matrix is used (2-D orthographic default). The bounding quad + is computed in view space so it covers the projected shape regardless + of the object's 3-D orientation. """ fill_rgba = vmobject.get_fill_rgbas() if fill_rgba.shape[0] == 0 or fill_rgba[0, 3] == 0: @@ -530,7 +726,7 @@ def _collect_slug_fill_geometry( nppcc = vmobject.n_points_per_cubic_curve - per_subpath: list[np.ndarray] = [] # each: (n, 3, 2) + per_subpath: list[np.ndarray] = [] # each: (n, 3, 3) world-space XYZ for subpath in subpaths: n_curves = len(subpath) // nppcc @@ -546,33 +742,67 @@ def _collect_slug_fill_geometry( # Subdivide cubics into quadratics (2 de Casteljau levels → 4 per cubic). qb0s, qmids, qb2s = _cubic_to_quadratics(b0s, h0s, h1s, b2s) - # Stack to (n_quads, 3, 3): [p1, p2, p3] in xyz; keep only xy. + # Stack to (n_quads, 3, 3): [p1, p2, p3] in world-space XYZ. curves_xyz = np.stack([qb0s, qmids, qb2s], axis=1) # (n, 3, 3) - per_subpath.append(curves_xyz[:, :, :2].astype(np.float32)) # (n, 3, 2) + + # Implicit close: if the subpath is open (last anchor ≠ first anchor), + # add a degenerate quadratic representing the straight closing line from + # the last anchor back to the first anchor. This matches Cairo's + # implicit-close behaviour during partial animations (e.g. Create): + # without this segment the winding-number integral is wrong for open + # paths and the fill spills outside the intended region. + first_anchor = b0s[0].astype(np.float32) + last_anchor = b2s[-1].astype(np.float32) + if not np.allclose(first_anchor, last_anchor, atol=1e-6): + mid_pt = ((first_anchor + last_anchor) * 0.5).astype(np.float32) + closing = np.array( + [[last_anchor, mid_pt, first_anchor]], dtype=np.float32 + ) # (1, 3, 3) + curves_xyz = np.concatenate([curves_xyz, closing], axis=0) + + per_subpath.append(curves_xyz.astype(np.float32)) if not per_subpath: return None - curves_stacked = np.concatenate(per_subpath, axis=0) # (N_total, 3, 2) + curves_stacked = np.concatenate(per_subpath, axis=0) # (N_total, 3, 3) n_quads = len(curves_stacked) - curves_flat = curves_stacked.reshape(-1, 2) # (N_total * 3, 2) - - # Bounding box of all control points + small AA padding. - bbox_min = curves_flat.min(axis=0) - 0.05 - bbox_max = curves_flat.max(axis=0) + 0.05 - x0, y0 = bbox_min - x1, y1 = bbox_max - - # 6 vertices: two counter-clockwise triangles covering the bounding rect. - quad_pos = np.array( - [[x0, y0], [x1, y0], [x0, y1], - [x1, y0], [x1, y1], [x0, y1]], + curves_flat = curves_stacked.reshape(-1, 3) # (N_total * 3, 3) + + # ── Bounding quad in view space ─────────────────────────────────────── + # Transform all control points to view space, compute XY extent there, + # then map the 4 bounding corners back to world space for storage. + # This guarantees correct screen coverage for tilted 3-D objects. + if view_matrix is None: + view_matrix = np.eye(4, dtype=np.float32) + vm = view_matrix.astype(np.float32) + R, t = vm[:3, :3], vm[:3, 3] + + pts_v = (R @ curves_flat.T).T + t # (N*3, 3) view space + + bbox_min = pts_v[:, :2].min(axis=0) - 0.05 + bbox_max = pts_v[:, :2].max(axis=0) + 0.05 + x0, y0 = float(bbox_min[0]), float(bbox_min[1]) + x1, y1 = float(bbox_max[0]), float(bbox_max[1]) + avg_z_v = float(pts_v[:, 2].mean()) + + # Four corners in view space (same average Z). + corners_v = np.array( + [[x0, y0, avg_z_v], [x1, y0, avg_z_v], + [x0, y1, avg_z_v], [x1, y1, avg_z_v]], dtype=np.float32, ) + # Invert the view matrix (rigid-body: R^T, -R^T t). + R_inv = R.T + t_inv = -(R_inv @ t) + corners_w = (R_inv @ corners_v.T).T + t_inv # (4, 3) world space + + # 6 vertices: two CCW triangles. + quad_pos = corners_w[[0, 1, 2, 1, 3, 2]] # (6, 3) quad_verts = np.empty(6, dtype=_SLUG_FILL_DTYPE) quad_verts["in_pos"] = quad_pos - quad_verts["in_color"] = color # broadcast + quad_verts["in_color"] = color quad_verts["curve_start"] = 0 # patched to global offset by caller quad_verts["n_curves"] = n_quads diff --git a/manim/scene/section.py b/manim/scene/section.py index 99e62c3823..4b2ef650ac 100644 --- a/manim/scene/section.py +++ b/manim/scene/section.py @@ -57,6 +57,7 @@ class Section: :class:`.DefaultSectionType` :meth:`.CairoRenderer.update_skipping_status` :meth:`.OpenGLRenderer.update_skipping_status` + :meth:`.WebGPURenderer.update_skipping_status` """ def __init__( diff --git a/manim/scene/three_d_scene.py b/manim/scene/three_d_scene.py index 7f39f4cf32..5ba0fda97e 100644 --- a/manim/scene/three_d_scene.py +++ b/manim/scene/three_d_scene.py @@ -19,6 +19,7 @@ from .. import config from ..animation.animation import Animation from ..animation.transform import Transform +from ..animation.updaters.update import UpdateFromAlphaFunc from ..camera.three_d_camera import ThreeDCamera from ..constants import DEGREES, RendererType from ..mobject.mobject import Mobject @@ -134,6 +135,15 @@ def begin_ambient_camera_rotation(self, rate: float = 0.02, about: str = "theta" } cam.add_updater(lambda m, dt: methods[about](rate * dt)) self.add(self.camera) + elif config.renderer == RendererType.WEBGPU: + cam = self.renderer.camera + methods = { + "theta": cam.increment_theta, + "phi": cam.increment_phi, + "gamma": cam.increment_gamma, + } + cam.add_updater(lambda m, dt: methods[about](rate * dt)) + self.add(cam) except Exception as e: raise ValueError("Invalid ambient rotation angle.") from e @@ -152,6 +162,8 @@ def stop_ambient_camera_rotation(self, about="theta"): self.remove(x) elif config.renderer == RendererType.OPENGL: self.camera.clear_updaters() + elif config.renderer == RendererType.WEBGPU: + self.renderer.camera.clear_updaters() except Exception as e: raise ValueError("Invalid ambient rotation angle.") from e @@ -176,6 +188,28 @@ def begin_3dillusion_camera_rotation( The azimutal angle the camera should move around. Defaults to the current theta angle. """ + if config.renderer == RendererType.WEBGPU: + cam = self.renderer.camera + if origin_theta is None: + origin_theta = cam.euler_angles[0] + if origin_phi is None: + origin_phi = cam.euler_angles[1] + + _theta_t = ValueTracker(0) + _phi_t = ValueTracker(0) + + def update_cam_illusion(m, dt): + _theta_t.increment_value(dt * rate) + _phi_t.increment_value(dt * rate) + m.set_euler_angles( + theta=origin_theta + 0.2 * np.sin(_theta_t.get_value()), + phi=origin_phi + 0.1 * np.cos(_phi_t.get_value()) - 0.1, + ) + + cam.add_updater(update_cam_illusion) + self.add(cam) + return + if origin_theta is None: origin_theta = self.renderer.camera.theta_tracker.get_value() if origin_phi is None: @@ -203,6 +237,10 @@ def update_phi(m, dt): def stop_3dillusion_camera_rotation(self): """This method stops all illusion camera rotations.""" + if config.renderer == RendererType.WEBGPU: + self.renderer.camera.clear_updaters() + self.remove(self.renderer.camera) + return self.renderer.camera.theta_tracker.clear_updaters() self.remove(self.renderer.camera.theta_tracker) self.renderer.camera.phi_tracker.clear_updaters() @@ -300,6 +338,31 @@ def move_camera( anims += [Transform(cam, cam2)] + elif config.renderer == RendererType.WEBGPU: + cam = self.renderer.camera + start_theta = cam.euler_angles[0] + start_phi = cam.euler_angles[1] + start_gamma = cam.euler_angles[2] + target_theta = theta if theta is not None else start_theta + target_phi = phi if phi is not None else start_phi + target_gamma = gamma if gamma is not None else start_gamma + + def update_cam(m, alpha): + m.set_euler_angles( + theta=start_theta + alpha * (target_theta - start_theta), + phi=start_phi + alpha * (target_phi - start_phi), + gamma=start_gamma + alpha * (target_gamma - start_gamma), + ) + + anims.append(UpdateFromAlphaFunc(cam, update_cam)) + + if focal_distance is not None: + start_fd = cam.focal_distance + anims.append(UpdateFromAlphaFunc( + cam, + lambda m, a, _s=start_fd: setattr(m, "focal_distance", _s + a * (focal_distance - _s)), + )) + self.play(*anims + added_anims, **kwargs) # These lines are added to improve performance. If manim thinks that frame_center is moving, @@ -353,6 +416,9 @@ def add_fixed_orientation_mobjects(self, *mobjects: Mobject, **kwargs): mob: OpenGLMobject mob.fix_orientation() self.add(mob) + elif config.renderer == RendererType.WEBGPU: + self.renderer.camera.add_fixed_orientation_mobjects(*mobjects) + self.add(*mobjects) def add_fixed_in_frame_mobjects(self, *mobjects: Mobject): """ @@ -375,6 +441,9 @@ def add_fixed_in_frame_mobjects(self, *mobjects: Mobject): mob: OpenGLMobject mob.fix_in_frame() self.add(mob) + elif config.renderer == RendererType.WEBGPU: + self.renderer.camera.add_fixed_in_frame_mobjects(*mobjects) + self.add(*mobjects) def remove_fixed_orientation_mobjects(self, *mobjects: Mobject): """ @@ -395,6 +464,9 @@ def remove_fixed_orientation_mobjects(self, *mobjects: Mobject): mob: OpenGLMobject mob.unfix_orientation() self.remove(mob) + elif config.renderer == RendererType.WEBGPU: + self.renderer.camera.remove_fixed_orientation_mobjects(*mobjects) + self.remove(*mobjects) def remove_fixed_in_frame_mobjects(self, *mobjects: Mobject): """ @@ -414,6 +486,9 @@ def remove_fixed_in_frame_mobjects(self, *mobjects: Mobject): mob: OpenGLMobject mob.unfix_from_frame() self.remove(mob) + elif config.renderer == RendererType.WEBGPU: + self.renderer.camera.remove_fixed_in_frame_mobjects(*mobjects) + self.remove(*mobjects) ## def set_to_default_angled_camera_orientation(self, **kwargs): From f39cb685484c458a9270d29d83b1353746c71910 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Tue, 7 Apr 2026 22:40:41 +0530 Subject: [PATCH 09/33] Added prospective projection support to WebGPUCamera --- manim/renderer/webgpu/shaders/slug_fill.wgsl | 48 ++++-- manim/renderer/webgpu/webgpu_renderer.py | 16 +- .../webgpu/webgpu_vmobject_rendering.py | 161 +++++++++++------- 3 files changed, 133 insertions(+), 92 deletions(-) diff --git a/manim/renderer/webgpu/shaders/slug_fill.wgsl b/manim/renderer/webgpu/shaders/slug_fill.wgsl index 297802ed9b..279fc0c396 100644 --- a/manim/renderer/webgpu/shaders/slug_fill.wgsl +++ b/manim/renderer/webgpu/shaders/slug_fill.wgsl @@ -1,9 +1,11 @@ // WebGPU fill shader using the Slug algorithm. // -// Supports both 2-D and 3-D VMobjects. Coverage is computed in view-space XY, -// which is a rigid transform of world space so pixel-scale distances remain -// valid. Curve control points are stored as world-space vec3 and transformed -// to view-space XY in the fragment shader. +// Supports both 2-D and 3-D VMobjects, orthographic and perspective cameras. +// Coverage is computed in NDC space (clip.xy / clip.w), which is the correct +// 2-D space for all projection types: +// - Orthographic: clip.w = 1, so NDC = clip.xy (a uniform scale of view XY). +// - Perspective: NDC accounts for the depth-dependent scale, so fills and +// strokes rendered on tilted 3-D objects stay aligned. // // Reference: // E. Lengyel, "GPU-Centered Font Rendering Directly from Glyph Outlines", @@ -49,7 +51,7 @@ struct VertexInput { struct VertexOutput { @builtin(position) clip_pos : vec4, - @location(0) view_pos_xy : vec2, // view-space XY for coverage + @location(0) ndc_xy : vec2, // NDC XY = clip.xy / clip.w @location(1) v_color : vec4, @location(2) @interpolate(flat) curve_start : u32, @location(3) @interpolate(flat) n_curves : u32, @@ -59,8 +61,11 @@ struct VertexOutput { fn vs_main(in: VertexInput) -> VertexOutput { var out: VertexOutput; let view_pos = u.view * vec4(in.in_pos, 1.0); - out.clip_pos = u.projection * view_pos; - out.view_pos_xy = view_pos.xy; + let clip = u.projection * view_pos; + out.clip_pos = clip; + // Perspective divide: under ortho w=1 (no change), under perspective this + // maps the vertex to the correct screen-proportional 2-D position. + out.ndc_xy = clip.xy / clip.w; out.v_color = in.in_color; out.curve_start = in.curve_start; out.n_curves = in.n_curves; @@ -156,14 +161,16 @@ fn calc_coverage(xcov: f32, ycov: f32, xwgt: f32, ywgt: f32) -> f32 { @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { - // View-space units per screen pixel — coverage math works in these units - // regardless of zoom, resolution, or 3-D orientation. - let ems_per_pixel = fwidth(in.view_pos_xy); - let pixels_per_em = 1.0 / max(ems_per_pixel, vec2(1e-9)); + // NDC units per screen pixel — coverage math works in these units for both + // orthographic and perspective projections. + let ndc_per_pixel = fwidth(in.ndc_xy); + let pixels_per_ndc = 1.0 / max(ndc_per_pixel, vec2(1e-9)); var xcov = 0.0; var xwgt = 0.0; var ycov = 0.0; var ywgt = 0.0; + let pv = u.projection * u.view; // combined matrix — avoids recomputing per curve + for (var i = 0u; i < in.n_curves; i = i + 1u) { // 9 floats per quadratic: p1 (xyz), p2 (xyz), p3 (xyz). let f = (in.curve_start + i) * 9u; @@ -171,16 +178,21 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { let p2w = vec3(curves[f + 3u], curves[f + 4u], curves[f + 5u]); let p3w = vec3(curves[f + 6u], curves[f + 7u], curves[f + 8u]); - // Transform world-space curve control points to view-space XY, - // then shift so the current fragment is the origin. - let p1 = (u.view * vec4(p1w, 1.0)).xy - in.view_pos_xy; - let p2 = (u.view * vec4(p2w, 1.0)).xy - in.view_pos_xy; - let p3 = (u.view * vec4(p3w, 1.0)).xy - in.view_pos_xy; + // Transform world-space control points to NDC, then shift so the + // current fragment (in.ndc_xy) is the origin. + // NDC = clip.xy / clip.w handles both orthographic (w=1) and + // perspective (w = -z_view) correctly. + let c1 = pv * vec4(p1w, 1.0); + let c2 = pv * vec4(p2w, 1.0); + let c3 = pv * vec4(p3w, 1.0); + let p1 = c1.xy / c1.w - in.ndc_xy; + let p2 = c2.xy / c2.w - in.ndc_xy; + let p3 = c3.xy / c3.w - in.ndc_xy; // ── Horizontal ray: accumulate x-coverage ──────────────────────── let hcode = calc_root_code(p1.y, p2.y, p3.y); if hcode != 0u { - let r = solve_horiz(p1, p2, p3) * pixels_per_em.x; + let r = solve_horiz(p1, p2, p3) * pixels_per_ndc.x; if (hcode & 1u) != 0u { xcov += clamp(r.x + 0.5, 0.0, 1.0); xwgt = max(xwgt, clamp(1.0 - abs(r.x) * 2.0, 0.0, 1.0)); @@ -194,7 +206,7 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { // ── Vertical ray: accumulate y-coverage ────────────────────────── let vcode = calc_root_code(p1.x, p2.x, p3.x); if vcode != 0u { - let r = solve_vert(p1, p2, p3) * pixels_per_em.y; + let r = solve_vert(p1, p2, p3) * pixels_per_ndc.y; if (vcode & 1u) != 0u { ycov -= clamp(r.x + 0.5, 0.0, 1.0); ywgt = max(ywgt, clamp(1.0 - abs(r.x) * 2.0, 0.0, 1.0)); diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index 8c6a3df530..e55108a6fd 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -82,7 +82,7 @@ class WebGPUCamera(Mobject): Projection ---------- - * 2-D scenes (default): orthographic, z mapped to the WebGPU [0, 1] NDC + * 2-D scenes: orthographic, z mapped to the WebGPU [0, 1] NDC range. * 3-D scenes (Phase 3): perspective projection driven by ``focal_distance`` and the Euler-angle view matrix. @@ -117,7 +117,7 @@ def __init__( center_point: np.ndarray | None = None, euler_angles: np.ndarray | None = None, focal_distance: float = 2.0, - orthographic: bool = True, + orthographic: bool = False, minimum_polar_angle: float = -PI / 2, maximum_polar_angle: float = PI / 2, ) -> None: @@ -353,7 +353,7 @@ def ortho_projection_matrix(self) -> np.ndarray: def projection_matrix(self) -> np.ndarray: """4×4 float32 projection matrix in WebGPU NDC convention (z ∈ [0, 1]). - Orthographic when ``self.orthographic`` is True (default). + Perspective when ``self.orthographic`` is False (default). Perspective otherwise — focal distance drives the field of view. """ fw, fh = self.frame_shape @@ -556,24 +556,24 @@ def init_scene(self, scene: Scene) -> None: self._depth_texture_view = self._depth_texture.create_view() self._proj_bgl = self._create_camera_bgl() - self._stroke_pipeline = self._create_stroke_pipeline(self._proj_bgl, depth_test=False) + self._stroke_pipeline = self._create_stroke_pipeline(self._proj_bgl, depth_test=True) self._stroke_3d_pipeline = self._create_stroke_pipeline(self._proj_bgl, depth_test=True) # Surface mesh lines sit exactly on the surface triangles. A negative # depth bias pulls each fragment slightly toward the camera so the mesh # always wins the depth test without visually offsetting the lines. - # depth_bias=-100 gives ~6e-6 constant offset in [0,1] depth space + # depth_bias=-10000 gives ~6e-4 constant offset in [0,1] depth space # (depth24plus unit ≈ 6e-8), which is large enough to reliably beat # floating-point depth jitter on flat/low-slope surface regions where # depth_bias_slope_scale alone contributes nearly zero. self._stroke_3d_surface_pipeline = self._create_stroke_pipeline( self._proj_bgl, depth_test=True, - depth_bias=-1000, + depth_bias=-10000, depth_bias_slope_scale=-1.0, - depth_bias_clamp=0.001, + depth_bias_clamp=0.00001, ) self._surface_pipeline = self._create_surface_pipeline(self._proj_bgl, cull_mode="none", depth_write=True) - self._slug_bgl, self._slug_fill_pipeline = self._create_slug_fill_pipeline(depth_test=False) + self._slug_bgl, self._slug_fill_pipeline = self._create_slug_fill_pipeline(depth_test=True) _, self._slug_fill_3d_pipeline = self._create_slug_fill_pipeline(depth_test=True) self._create_oit_resources(width, height) self._create_readback_pipeline(width, height) diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py index 24e91381dc..c33d23a325 100644 --- a/manim/renderer/webgpu/webgpu_vmobject_rendering.py +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -237,6 +237,7 @@ def render_webgpu_mobject( import wgpu # local import so module loads without wgpu installed view_matrix: np.ndarray = renderer.camera.view_matrix + proj_matrix: np.ndarray = renderer.camera.projection_matrix # ── Phase 1: tessellate ─────────────────────────────────────────────── slug_quad_parts: list[np.ndarray] = [] @@ -289,20 +290,20 @@ def render_webgpu_mobject( if has_fill: # 3-D flat VMobject with fill (e.g. number-plane, polygon in 3D): + # Cache only geometry-dependent curve data; rebuild the bounding + # quad every frame so it tracks the current camera correctly. + cached = _slug_fill_cache.get(submob) + if cached is None or cached[0] != phash: + slug_data = _collect_slug_fill_geometry(submob) + if slug_data is not None: + _slug_fill_cache[submob] = (phash, slug_data) cached = _slug_fill_cache.get(submob) if cached is not None and cached[0] == phash: - quad_verts, curves_flat = cached[1] + color_c, curves_flat = cached[1] + quad_verts = _build_slug_quad(color_c, curves_flat, view_matrix, proj_matrix) draw_plan.append(("slug_fill_3d", len(slug_quad_parts))) - slug_quad_parts.append(quad_verts.copy()) + slug_quad_parts.append(quad_verts) slug_curve_parts.append(curves_flat) - else: - slug_data = _collect_slug_fill_geometry(submob, view_matrix) - if slug_data is not None: - _slug_fill_cache[submob] = (phash, slug_data) - quad_verts, curves_flat = slug_data - draw_plan.append(("slug_fill_3d", len(slug_quad_parts))) - slug_quad_parts.append(quad_verts.copy()) - slug_curve_parts.append(curves_flat) # 3-D stroke logic (axis lines, etc.) if scached is not None and scached[0] == phash: @@ -318,19 +319,17 @@ def render_webgpu_mobject( else: # ── 2-D object: Slug fill + 2-D stroke ────────────────────── cached = _slug_fill_cache.get(submob) + if cached is None or cached[0] != phash: + slug_data = _collect_slug_fill_geometry(submob) + if slug_data is not None: + _slug_fill_cache[submob] = (phash, slug_data) + cached = _slug_fill_cache.get(submob) if cached is not None and cached[0] == phash: - quad_verts, curves_flat = cached[1] + color_c, curves_flat = cached[1] + quad_verts = _build_slug_quad(color_c, curves_flat, view_matrix, proj_matrix) draw_plan.append(("slug_fill", len(slug_quad_parts))) - slug_quad_parts.append(quad_verts.copy()) + slug_quad_parts.append(quad_verts) slug_curve_parts.append(curves_flat) - else: - slug_data = _collect_slug_fill_geometry(submob, view_matrix) - if slug_data is not None: - _slug_fill_cache[submob] = (phash, slug_data) - quad_verts, curves_flat = slug_data - draw_plan.append(("slug_fill", len(slug_quad_parts))) - slug_quad_parts.append(quad_verts.copy()) - slug_curve_parts.append(curves_flat) if scached is not None and scached[0] == phash: draw_plan.append(("stroke_2d", len(stroke_parts))) @@ -694,26 +693,19 @@ def _collect_surface_geometry(vmobject: VMobject) -> np.ndarray | None: def _collect_slug_fill_geometry( vmobject: VMobject, - view_matrix: np.ndarray | None = None, ) -> tuple[np.ndarray, np.ndarray] | None: - """Return ``(quad_verts, curves_flat)`` for the Slug fill pipeline, or ``None``. + """Return ``(color, curves_flat)`` for the Slug fill pipeline, or ``None``. - *quad_verts* is a ``_SLUG_FILL_DTYPE`` array of 6 vertices (world-space - 3-D positions) forming a bounding quad for this shape in view space. - ``curve_start`` is set to 0 and must be patched to the global offset by - the caller before upload. + Only the camera-independent data is returned here so the result can be + cached purely on the vmobject's geometry. The caller builds the + per-frame bounding quad via ``_build_slug_quad()`` using the current + view and projection matrices. - *curves_flat* is a ``float32`` array of shape ``(n_quads * 3, 3)`` - containing the world-space XYZ coordinates of every quadratic bezier - control point — three consecutive entries (p1, p2, p3) per curve. + *color* is a float32 RGBA array. - Parameters - ---------- - view_matrix - 4×4 float32 view matrix (world → camera space). When ``None`` an - identity matrix is used (2-D orthographic default). The bounding quad - is computed in view space so it covers the projected shape regardless - of the object's 3-D orientation. + *curves_flat* is a ``float32`` array of shape ``(N * 3, 3)`` + containing the world-space XYZ of every quadratic bezier control point + — three consecutive entries (p1, p2, p3) per curve. """ fill_rgba = vmobject.get_fill_rgbas() if fill_rgba.shape[0] == 0 or fill_rgba[0, 3] == 0: @@ -766,52 +758,89 @@ def _collect_slug_fill_geometry( return None curves_stacked = np.concatenate(per_subpath, axis=0) # (N_total, 3, 3) - n_quads = len(curves_stacked) curves_flat = curves_stacked.reshape(-1, 3) # (N_total * 3, 3) - # ── Bounding quad in view space ─────────────────────────────────────── - # Transform all control points to view space, compute XY extent there, - # then map the 4 bounding corners back to world space for storage. - # This guarantees correct screen coverage for tilted 3-D objects. - if view_matrix is None: - view_matrix = np.eye(4, dtype=np.float32) + # Return only the camera-independent curve data. The caller builds the + # bounding quad each frame via _build_slug_quad() so it always reflects + # the current view and projection matrices. + return color, curves_flat + + +# --------------------------------------------------------------------------- +# Single-mobject draw helpers (used by the explicit public helpers above) +# --------------------------------------------------------------------------- + + +def _build_slug_quad( + color: np.ndarray, + curves_flat: np.ndarray, + view_matrix: np.ndarray, + proj_matrix: np.ndarray, +) -> np.ndarray: + """Build a ``_SLUG_FILL_DTYPE`` bounding-quad array from cached curve data. + + Called every frame so the quad always reflects the current view and + projection matrices. The bounding box is computed in NDC space + (clip.xy / clip.w), which is correct for both orthographic (w=1) and + perspective projections, then mapped back to world space at avg_z. + + Parameters + ---------- + color : float32 RGBA fill colour. + curves_flat : (N*3, 3) world-space control points from _collect_slug_fill_geometry. + view_matrix : current 4×4 view matrix. + proj_matrix : current 4×4 projection matrix. + """ + n_quads = len(curves_flat) // 3 vm = view_matrix.astype(np.float32) + pm = proj_matrix.astype(np.float32) R, t = vm[:3, :3], vm[:3, 3] - pts_v = (R @ curves_flat.T).T + t # (N*3, 3) view space + pts_v = (R @ curves_flat.T).T + t # (N*3, 3) view space + avg_z_v = float(pts_v[:, 2].mean()) + + # Perspective divide in NDC: works for both ortho (w=1) and perspective. + ones = np.ones((len(pts_v), 1), dtype=np.float32) + pts_vh = np.hstack([pts_v, ones]) # (N*3, 4) + clips = (pm @ pts_vh.T).T # (N*3, 4) clip space + w = clips[:, 3:4] + w_safe = np.where(np.abs(w) > 1e-8, w, np.sign(w + 1e-38) * 1e-8) + ndcs = clips[:, :2] / w_safe # (N*3, 2) NDC XY + + PAD = 0.05 + ndc_min = ndcs.min(axis=0) - PAD + ndc_max = ndcs.max(axis=0) + PAD + x0_n, y0_n = float(ndc_min[0]), float(ndc_min[1]) + x1_n, y1_n = float(ndc_max[0]), float(ndc_max[1]) + + # Invert NDC → view space at avg_z_v. + # ndc_x = (pm[0,0]*x_v + pm[0,3]) / w_clip where w_clip = pm[3,2]*z + pm[3,3] + # Ortho: w_clip=1 | Perspective: w_clip = -avg_z_v + avg_clip_w = float(pm[3, 2] * avg_z_v + pm[3, 3]) + avg_clip_w = avg_clip_w if abs(avg_clip_w) > 1e-8 else 1.0 + inv_px = 1.0 / (pm[0, 0] if abs(pm[0, 0]) > 1e-8 else 1.0) + inv_py = 1.0 / (pm[1, 1] if abs(pm[1, 1]) > 1e-8 else 1.0) + x0_v = (x0_n * avg_clip_w - float(pm[0, 3])) * inv_px + x1_v = (x1_n * avg_clip_w - float(pm[0, 3])) * inv_px + y0_v = (y0_n * avg_clip_w - float(pm[1, 3])) * inv_py + y1_v = (y1_n * avg_clip_w - float(pm[1, 3])) * inv_py - bbox_min = pts_v[:, :2].min(axis=0) - 0.05 - bbox_max = pts_v[:, :2].max(axis=0) + 0.05 - x0, y0 = float(bbox_min[0]), float(bbox_min[1]) - x1, y1 = float(bbox_max[0]), float(bbox_max[1]) - avg_z_v = float(pts_v[:, 2].mean()) - - # Four corners in view space (same average Z). corners_v = np.array( - [[x0, y0, avg_z_v], [x1, y0, avg_z_v], - [x0, y1, avg_z_v], [x1, y1, avg_z_v]], + [[x0_v, y0_v, avg_z_v], [x1_v, y0_v, avg_z_v], + [x0_v, y1_v, avg_z_v], [x1_v, y1_v, avg_z_v]], dtype=np.float32, ) - # Invert the view matrix (rigid-body: R^T, -R^T t). R_inv = R.T t_inv = -(R_inv @ t) - corners_w = (R_inv @ corners_v.T).T + t_inv # (4, 3) world space - - # 6 vertices: two CCW triangles. - quad_pos = corners_w[[0, 1, 2, 1, 3, 2]] # (6, 3) + corners_w = (R_inv @ corners_v.T).T + t_inv # (4, 3) world space + quad_pos = corners_w[[0, 1, 2, 1, 3, 2]] # (6, 3) two CCW triangles quad_verts = np.empty(6, dtype=_SLUG_FILL_DTYPE) quad_verts["in_pos"] = quad_pos quad_verts["in_color"] = color - quad_verts["curve_start"] = 0 # patched to global offset by caller + quad_verts["curve_start"] = 0 quad_verts["n_curves"] = n_quads - - return quad_verts, curves_flat - - -# --------------------------------------------------------------------------- -# Single-mobject draw helpers (used by the explicit public helpers above) -# --------------------------------------------------------------------------- + return quad_verts def _draw_vmobject_stroke(renderer: WebGPURenderer, vmobject: VMobject) -> None: From 18f1c2a1e1f17d3806537e71529adeeff323ca31 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Tue, 7 Apr 2026 22:55:49 +0530 Subject: [PATCH 10/33] Correct z-placement of 2D object by WebGPU Renderer The behavior now matches with Cairo's. --- manim/renderer/webgpu/webgpu_renderer.py | 2 +- .../webgpu/webgpu_vmobject_rendering.py | 49 +++++++++++-------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index e55108a6fd..b09c448a06 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -573,7 +573,7 @@ def init_scene(self, scene: Scene) -> None: depth_bias_clamp=0.00001, ) self._surface_pipeline = self._create_surface_pipeline(self._proj_bgl, cull_mode="none", depth_write=True) - self._slug_bgl, self._slug_fill_pipeline = self._create_slug_fill_pipeline(depth_test=True) + self._slug_bgl, self._slug_fill_pipeline = self._create_slug_fill_pipeline(depth_test=False) _, self._slug_fill_3d_pipeline = self._create_slug_fill_pipeline(depth_test=True) self._create_oit_resources(width, height) self._create_readback_pipeline(width, height) diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py index c33d23a325..06e97c20ae 100644 --- a/manim/renderer/webgpu/webgpu_vmobject_rendering.py +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -381,7 +381,10 @@ def render_webgpu_mobject( # ── Phase 3: draw in the main render pass ───────────────────────────── # Execute in this fixed order: - # slug_fill → surface_opaque → stroke_2d → stroke_3d → stroke_surface + # (slug_fill + stroke_2d interleaved, in draw_plan order) → + # slug_fill_3d → surface_opaque → stroke_3d → stroke_surface + # 2-D fill/stroke are interleaved per-object to match Cairo's painter's algorithm: + # object A: fill → stroke; object B: fill → stroke (in z_index order). # stroke_surface uses a depth-biased pipeline so mesh lines sitting exactly # on the surface never z-fight with it. # surface_oit entries are skipped here and returned for a separate OIT pass. @@ -414,19 +417,30 @@ def _draw_surface(idx: int, pipeline, pipeline_key: str, _cur: list) -> None: _cur: list[str | None] = [None] # mutable current-pipeline tracker - # 1. Slug fills (2-D — no depth test) + # 1. 2-D objects: fill and stroke interleaved in draw_plan order (painter's algorithm). + # slug_fill uses no-depth-test pipeline; stroke_2d uses no-depth-write pipeline. + # Iterating once keeps Cairo's per-object fill→stroke ordering so object B drawn + # on top of A has its fill above A's stroke, matching Cairo's painter's algorithm. + for cmd_type, idx in draw_plan: + if cmd_type == "slug_fill" and slug_fill_vbo is not None: + if _cur[0] != "slug_fill": + rp.set_pipeline(renderer.slug_fill_pipeline) + rp.set_bind_group(0, slug_bind_group, [], 0, 0) + _cur[0] = "slug_fill" + arr = slug_quad_parts[idx] + rp.set_vertex_buffer(0, slug_fill_vbo, slug_fill_byte_offsets[idx], arr.nbytes) + rp.draw(len(arr), 1, 0, 0) + elif cmd_type == "stroke_2d" and stroke_buf is not None: + if _cur[0] != "stroke_2d": + rp.set_pipeline(renderer.stroke_pipeline) + rp.set_bind_group(0, cam_bg, [], 0, 0) + _cur[0] = "stroke_2d" + arr = stroke_parts[idx] + rp.set_vertex_buffer(0, stroke_buf, stroke_byte_offsets[idx], arr.nbytes) + rp.draw(len(arr), 1, 0, 0) + + # 1b. 3-D Slug fills (shade_in_3d with fill — depth-tested) if slug_fill_vbo is not None: - for cmd_type, idx in draw_plan: - if cmd_type == "slug_fill": - if _cur[0] != "slug_fill": - rp.set_pipeline(renderer.slug_fill_pipeline) - rp.set_bind_group(0, slug_bind_group, [], 0, 0) - _cur[0] = "slug_fill" - arr = slug_quad_parts[idx] - rp.set_vertex_buffer(0, slug_fill_vbo, slug_fill_byte_offsets[idx], arr.nbytes) - rp.draw(len(arr), 1, 0, 0) - - # 1b. Slug fills (3-D — depth-tested, shade_in_3d with fill) for cmd_type, idx in draw_plan: if cmd_type == "slug_fill_3d": if _cur[0] != "slug_fill_3d": @@ -443,18 +457,13 @@ def _draw_surface(idx: int, pipeline, pipeline_key: str, _cur: list) -> None: if cmd_type == "surface_opaque": _draw_surface(idx, renderer.surface_pipeline, "surface_opaque", _cur) - # 5. 2-D strokes + # 3. 3-D strokes (depth-tested) if stroke_buf is not None: - for cmd_type, idx in draw_plan: - if cmd_type == "stroke_2d": - _draw_stroke(idx, "stroke_2d", _cur) - - # 6. 3-D strokes (depth-tested) for cmd_type, idx in draw_plan: if cmd_type == "stroke_3d": _draw_stroke(idx, "stroke_3d", _cur) - # 7. Surface mesh strokes (depth-biased to prevent z-fighting) + # 4. Surface mesh strokes (depth-biased to prevent z-fighting) for cmd_type, idx in draw_plan: if cmd_type == "stroke_surface": _draw_stroke(idx, "stroke_surface", _cur) From 5d7e1e1cf9c3a1b0c1950752e7438c68e8163595 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Wed, 8 Apr 2026 14:03:50 +0530 Subject: [PATCH 11/33] Combine fill and stroke for planar object. Having separate fill and stroke shaders was complicating design regarding drawing order. Combining them for planar object has reduced the CPU computation also. --- .../webgpu/shaders/cubic_to_quads.wgsl | 88 ++ .../webgpu/shaders/vmobject_fill_stroke.wgsl | 294 ++++ manim/renderer/webgpu/webgpu_renderer.py | 401 +++--- .../webgpu/webgpu_vmobject_rendering.py | 1245 ++++++++--------- 4 files changed, 1201 insertions(+), 827 deletions(-) create mode 100644 manim/renderer/webgpu/shaders/cubic_to_quads.wgsl create mode 100644 manim/renderer/webgpu/shaders/vmobject_fill_stroke.wgsl diff --git a/manim/renderer/webgpu/shaders/cubic_to_quads.wgsl b/manim/renderer/webgpu/shaders/cubic_to_quads.wgsl new file mode 100644 index 0000000000..465a7d3f8a --- /dev/null +++ b/manim/renderer/webgpu/shaders/cubic_to_quads.wgsl @@ -0,0 +1,88 @@ +// GPU compute shader: cubic Bezier → quadratic approximations. +// +// Each thread converts one cubic Bezier (4 × 3-D control points) into four +// quadratic Beziers (3 points each) using two levels of de Casteljau +// subdivision at t = 0.5 followed by midpoint degree-reduction. +// +// This runs in the same command encoder as the render pass (before it), +// so WebGPU's implicit pass ordering gives a barrier — the render shader +// safely reads the output quads without an explicit synchronisation step. +// +// Buffer layout +// ------------- +// binding 0 in_cubics read-only-storage array +// 12 floats per cubic: b0.xyz, b1.xyz, b2.xyz, b3.xyz (tightly packed) +// +// binding 1 out_quads storage (read_write) array +// 36 floats per input cubic (4 quads × 9 floats): +// quad k: p0.xyz, pmid.xyz, p1.xyz (start, control, end) +// Order: [sub-cubic 0, sub-cubic 1, sub-cubic 2, sub-cubic 3] +// +// binding 2 params uniform +// offset 0: n_cubics u32 +// (padded to 16 bytes) +// +// Dispatch: ceil(n_cubics / 64) workgroups × 1 × 1, workgroup_size = 64. + +struct Params { n_cubics : u32, _pad0: u32, _pad1: u32, _pad2: u32 }; + +@group(0) @binding(0) var in_cubics : array; +@group(0) @binding(1) var out_quads : array; +@group(0) @binding(2) var params : Params; + +// Write one quadratic (p0, pmid, p2) as 9 consecutive floats at base. +fn write_quad(base: u32, p0: vec3, pmid: vec3, p2: vec3) { + out_quads[base ] = p0.x; out_quads[base + 1u] = p0.y; out_quads[base + 2u] = p0.z; + out_quads[base + 3u] = pmid.x; out_quads[base + 4u] = pmid.y; out_quads[base + 5u] = pmid.z; + out_quads[base + 6u] = p2.x; out_quads[base + 7u] = p2.y; out_quads[base + 8u] = p2.z; +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let idx = gid.x; + if idx >= params.n_cubics { return; } + + // Read 4 control points of this cubic. + let bi = idx * 12u; + let b0 = vec3(in_cubics[bi ], in_cubics[bi + 1u], in_cubics[bi + 2u]); + let b1 = vec3(in_cubics[bi + 3u], in_cubics[bi + 4u], in_cubics[bi + 5u]); + let b2 = vec3(in_cubics[bi + 6u], in_cubics[bi + 7u], in_cubics[bi + 8u]); + let b3 = vec3(in_cubics[bi + 9u], in_cubics[bi + 10u], in_cubics[bi + 11u]); + + // ── Level 1: split [b0, b1, b2, b3] at t = 0.5 ────────────────────── + // Left half: [b0, m01, m012, m0123] + // Right half: [m0123, m123, m23, b3] + let m01 = (b0 + b1) * 0.5; + let m12 = (b1 + b2) * 0.5; + let m23 = (b2 + b3) * 0.5; + let m012 = (m01 + m12) * 0.5; + let m123 = (m12 + m23) * 0.5; + let m0123 = (m012 + m123) * 0.5; + + // ── Level 2a: split left half [b0, m01, m012, m0123] at t = 0.5 ────── + let lm01 = (b0 + m01) * 0.5; + let lm12 = (m01 + m012) * 0.5; + let lm23 = (m012 + m0123) * 0.5; + let lm012 = (lm01 + lm12) * 0.5; + let lm123 = (lm12 + lm23) * 0.5; + let lm0123 = (lm012 + lm123) * 0.5; + // Quad 0: [b0, lm01, lm012, lm0123] + // Quad 1: [lm0123, lm123, lm23, m0123] + + // ── Level 2b: split right half [m0123, m123, m23, b3] at t = 0.5 ───── + let rm01 = (m0123 + m123) * 0.5; + let rm12 = (m123 + m23) * 0.5; + let rm23 = (m23 + b3) * 0.5; + let rm012 = (rm01 + rm12) * 0.5; + let rm123 = (rm12 + rm23) * 0.5; + let rm0123 = (rm012 + rm123) * 0.5; + // Quad 2: [m0123, rm01, rm012, rm0123] + // Quad 3: [rm0123, rm123, rm23, b3] + + // Write 4 quadratics. Degree reduction: mid-handle = (h0 + h1) * 0.5. + let bo = idx * 36u; + write_quad(bo , b0, (lm01 + lm012) * 0.5, lm0123); + write_quad(bo + 9u, lm0123, (lm123 + lm23 ) * 0.5, m0123 ); + write_quad(bo + 18u, m0123, (rm01 + rm012) * 0.5, rm0123); + write_quad(bo + 27u, rm0123, (rm123 + rm23 ) * 0.5, b3 ); +} diff --git a/manim/renderer/webgpu/shaders/vmobject_fill_stroke.wgsl b/manim/renderer/webgpu/shaders/vmobject_fill_stroke.wgsl new file mode 100644 index 0000000000..ccd60d004c --- /dev/null +++ b/manim/renderer/webgpu/shaders/vmobject_fill_stroke.wgsl @@ -0,0 +1,294 @@ +// Combined VMobject fill + stroke shader. +// +// One bounding quad per object; one fragment loop simultaneously accumulates: +// 1. Fill coverage — Slug winding-number algorithm in NDC space +// (Lengyel 2017, patent-dedicated public domain, code MIT) +// 2. Stroke coverage — SDF minimum distance to each curve in pixel space +// +// Result is Porter-Duff "over" compositing: stroke painted on top of fill. +// +// The quadratic Bezier control points are written by cubic_to_quads.wgsl +// into a single shared storage buffer. Each object's fill and stroke +// curves occupy separate contiguous regions of that buffer referenced by +// (fill_curve_start, n_fill_curves) and (stroke_curve_start, n_stroke_curves). +// +// Objects with no fill: pass fill_color.a = 0 or n_fill_curves = 0. +// Objects with no stroke: pass stroke_half_ndc = 0 or n_stroke_curves = 0. +// +// Uniform layout (group 0, binding 0) — 176-byte block shared with surface.wgsl: +// offset 0 — projection mat4x4 (64 B) +// offset 64 — view mat4x4 (64 B) +// offset 128 — light_pos vec3 (12 B) ← unused here +// offset 140 — light_intensity f32 ( 4 B) ← unused here +// offset 144 — light_color vec3 (12 B) ← unused here +// offset 156 — ambient_intensity f32 ( 4 B) ← unused here +// offset 160 — ambient_color vec3 (12 B) ← unused here +// offset 172 — _pad f32 ( 4 B) +// +// Storage buffer (group 0, binding 1) — array, 9 floats per quadratic: +// [p0.x p0.y p0.z pmid.x pmid.y pmid.z p2.x p2.y p2.z] +// +// Vertex attributes (must match _FILL_STROKE_DTYPE, stride 64 bytes): +// location 0 — in_pos float32x3 offset 0 +// location 1 — in_fill_color float32x4 offset 12 +// location 2 — in_stroke_color float32x4 offset 28 +// location 3 — stroke_half_ndc float32 offset 44 +// location 4 — fill_curve_start uint32 offset 48 +// location 5 — n_fill_curves uint32 offset 52 +// location 6 — stroke_curve_start uint32 offset 56 +// location 7 — n_stroke_curves uint32 offset 60 + +struct Uniforms { + projection : mat4x4, + view : mat4x4, + light_pos : vec3, + light_intensity : f32, + light_color : vec3, + ambient_intensity : f32, + ambient_color : vec3, + _pad : f32, +}; +@group(0) @binding(0) var u : Uniforms; +@group(0) @binding(1) var quads : array; + +struct VertexInput { + @location(0) in_pos : vec3, + @location(1) in_fill_color : vec4, + @location(2) in_stroke_color : vec4, + @location(3) stroke_half_ndc : f32, + @location(4) fill_curve_start : u32, + @location(5) n_fill_curves : u32, + @location(6) stroke_curve_start : u32, + @location(7) n_stroke_curves : u32, +}; + +struct VertexOutput { + @builtin(position) clip_pos : vec4, + @location(0) ndc_xy : vec2, + @location(1) v_fill_color : vec4, + @location(2) v_stroke_color : vec4, + @location(3) @interpolate(flat) v_stroke_half_ndc : f32, + @location(4) @interpolate(flat) fill_curve_start : u32, + @location(5) @interpolate(flat) n_fill_curves : u32, + @location(6) @interpolate(flat) stroke_curve_start: u32, + @location(7) @interpolate(flat) n_stroke_curves : u32, +}; + +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + var out: VertexOutput; + let view_pos = u.view * vec4(in.in_pos, 1.0); + let clip = u.projection * view_pos; + out.clip_pos = clip; + // Perspective divide: ortho gives w=1 (no change); perspective maps + // the vertex to the correct 2-D screen-proportional position. + out.ndc_xy = clip.xy / clip.w; + out.v_fill_color = in.in_fill_color; + out.v_stroke_color = in.in_stroke_color; + out.v_stroke_half_ndc = in.stroke_half_ndc; + out.fill_curve_start = in.fill_curve_start; + out.n_fill_curves = in.n_fill_curves; + out.stroke_curve_start = in.stroke_curve_start; + out.n_stroke_curves = in.n_stroke_curves; + return out; +} + +// --------------------------------------------------------------------------- +// Slug helpers — winding-number fill (NDC space) +// Adapted from Lengyel 2017 (HLSL → WGSL). +// --------------------------------------------------------------------------- + +fn calc_root_code(y1: f32, y2: f32, y3: f32) -> u32 { + let i1 = (bitcast(y1) >> 31u) & 1u; + let i2 = (bitcast(y2) >> 30u) & 2u; + let i3 = (bitcast(y3) >> 29u) & 4u; + return (0x2E74u >> (i3 | i2 | i1)) & 0x0101u; +} + +fn solve_horiz(p1: vec2, p2: vec2, p3: vec2) -> vec2 { + let ay = p1.y - 2.0*p2.y + p3.y; + let by = p1.y - p2.y; + let ax = p1.x - 2.0*p2.x + p3.x; + let bx = p1.x - p2.x; + var t1: f32; var t2: f32; + if abs(ay) < (1.0 / 65536.0) { + let denom = select(1.0, by, abs(by) > 1e-10); + t1 = p1.y * 0.5 / denom; t2 = t1; + } else { + let ra = 1.0 / ay; + let d = sqrt(max(by*by - ay*p1.y, 0.0)); + t1 = (by - d) * ra; t2 = (by + d) * ra; + } + return vec2((ax*t1 - bx*2.0)*t1 + p1.x, (ax*t2 - bx*2.0)*t2 + p1.x); +} + +fn solve_vert(p1: vec2, p2: vec2, p3: vec2) -> vec2 { + let ax = p1.x - 2.0*p2.x + p3.x; + let bx = p1.x - p2.x; + let ay = p1.y - 2.0*p2.y + p3.y; + let by = p1.y - p2.y; + var t1: f32; var t2: f32; + if abs(ax) < (1.0 / 65536.0) { + let denom = select(1.0, bx, abs(bx) > 1e-10); + t1 = p1.x * 0.5 / denom; t2 = t1; + } else { + let ra = 1.0 / ax; + let d = sqrt(max(bx*bx - ax*p1.x, 0.0)); + t1 = (bx - d) * ra; t2 = (bx + d) * ra; + } + return vec2((ay*t1 - by*2.0)*t1 + p1.y, (ay*t2 - by*2.0)*t2 + p1.y); +} + +fn calc_coverage(xcov: f32, ycov: f32, xwgt: f32, ywgt: f32) -> f32 { + let blended = abs(xcov*xwgt + ycov*ywgt) / max(xwgt + ywgt, 1.0/65536.0); + return clamp(max(blended, min(abs(xcov), abs(ycov))), 0.0, 1.0); +} + +// --------------------------------------------------------------------------- +// Stroke SDF helper — min distance from origin to a 2-D quadratic Bezier. +// +// B(t) = a·t² + b·t + c, a = p1−2·p2+p3, b = 2(p2−p1), c = p1. +// Fragment is at the origin, so B(t)−origin = B(t). +// +// Minimise |B(t)|² by Newton on f(t) = B(t)·B'(t). +// f'(t) = |B'(t)|² + 2a·B(t). +// --------------------------------------------------------------------------- + +fn min_dist_to_quad_px(p1: vec2, p2: vec2, p3: vec2) -> f32 { + let a = p1 - 2.0*p2 + p3; + let b = 2.0*(p2 - p1); + let c = p1; + + // Coarse: sample t = 0, 0.25, 0.5, 0.75, 1.0. + var best_t = 0.0; + var best_d2 = dot(c, c); + for (var i = 1u; i <= 4u; i++) { + let t = f32(i) * 0.25; + let bt = a*t*t + b*t + c; + let d2 = dot(bt, bt); + if d2 < best_d2 { best_d2 = d2; best_t = t; } + } + + // Newton refinement (4 iterations). + for (var iter = 0u; iter < 4u; iter++) { + let t = clamp(best_t, 0.0, 1.0); + let Bt = a*t*t + b*t + c; + let Bpt = 2.0*a*t + b; + let f = dot(Bt, Bpt); + let fp = dot(Bpt, Bpt) + dot(2.0*a, Bt); + if abs(fp) < 1e-10 { break; } + best_t = clamp(t - f/fp, 0.0, 1.0); + } + + let t_f = clamp(best_t, 0.0, 1.0); + let Bf = a*t_f*t_f + b*t_f + c; + return sqrt(max(dot(Bf, Bf), 0.0)); +} + +// --------------------------------------------------------------------------- +// Fragment shader +// --------------------------------------------------------------------------- + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + // NDC units per screen pixel (non-zero denominator guard). + let ndc_per_pixel = fwidth(in.ndc_xy); + let pixels_per_ndc = 1.0 / max(ndc_per_pixel, vec2(1e-9)); + + let pv = u.projection * u.view; + + // ── Fill: Slug winding-number accumulation in NDC space ─────────────── + var xcov = 0.0; var xwgt = 0.0; + var ycov = 0.0; var ywgt = 0.0; + + for (var i = 0u; i < in.n_fill_curves; i++) { + let f = (in.fill_curve_start + i) * 9u; + let p1w = vec3(quads[f ], quads[f + 1u], quads[f + 2u]); + let p2w = vec3(quads[f + 3u], quads[f + 4u], quads[f + 5u]); + let p3w = vec3(quads[f + 6u], quads[f + 7u], quads[f + 8u]); + + // Transform world → NDC, shift so the current fragment is origin. + let c1 = pv * vec4(p1w, 1.0); + let c2 = pv * vec4(p2w, 1.0); + let c3 = pv * vec4(p3w, 1.0); + let p1 = c1.xy/c1.w - in.ndc_xy; + let p2 = c2.xy/c2.w - in.ndc_xy; + let p3 = c3.xy/c3.w - in.ndc_xy; + + // Horizontal ray (x-coverage accumulation). + let hcode = calc_root_code(p1.y, p2.y, p3.y); + if hcode != 0u { + let r = solve_horiz(p1, p2, p3) * pixels_per_ndc.x; + if (hcode & 1u) != 0u { + xcov += clamp(r.x + 0.5, 0.0, 1.0); + xwgt = max(xwgt, clamp(1.0 - abs(r.x)*2.0, 0.0, 1.0)); + } + if hcode > 1u { + xcov -= clamp(r.y + 0.5, 0.0, 1.0); + xwgt = max(xwgt, clamp(1.0 - abs(r.y)*2.0, 0.0, 1.0)); + } + } + + // Vertical ray (y-coverage accumulation). + let vcode = calc_root_code(p1.x, p2.x, p3.x); + if vcode != 0u { + let r = solve_vert(p1, p2, p3) * pixels_per_ndc.y; + if (vcode & 1u) != 0u { + ycov -= clamp(r.x + 0.5, 0.0, 1.0); + ywgt = max(ywgt, clamp(1.0 - abs(r.x)*2.0, 0.0, 1.0)); + } + if vcode > 1u { + ycov += clamp(r.y + 0.5, 0.0, 1.0); + ywgt = max(ywgt, clamp(1.0 - abs(r.y)*2.0, 0.0, 1.0)); + } + } + } + + let fill_cov = select(0.0, calc_coverage(xcov, ycov, xwgt, ywgt), in.n_fill_curves > 0u); + + // ── Stroke: SDF minimum distance in physical pixel space ────────────── + // stroke_half_ndc is in NDC units; pixels_per_ndc.x converts to pixels. + // (stroke_half_ndc was calibrated using pm[0,0], the NDC x-scale.) + let stroke_half_px = in.v_stroke_half_ndc * pixels_per_ndc.x; + var min_dist_px = 1e9; + + for (var i = 0u; i < in.n_stroke_curves; i++) { + let f = (in.stroke_curve_start + i) * 9u; + let p1w = vec3(quads[f ], quads[f + 1u], quads[f + 2u]); + let p2w = vec3(quads[f + 3u], quads[f + 4u], quads[f + 5u]); + let p3w = vec3(quads[f + 6u], quads[f + 7u], quads[f + 8u]); + + let c1 = pv * vec4(p1w, 1.0); + let c2 = pv * vec4(p2w, 1.0); + let c3 = pv * vec4(p3w, 1.0); + // NDC-relative coordinates (fragment at origin), then scaled to pixels. + let n1 = c1.xy/c1.w - in.ndc_xy; + let n2 = c2.xy/c2.w - in.ndc_xy; + let n3 = c3.xy/c3.w - in.ndc_xy; + let p1_px = n1 * pixels_per_ndc; + let p2_px = n2 * pixels_per_ndc; + let p3_px = n3 * pixels_per_ndc; + + let d = min_dist_to_quad_px(p1_px, p2_px, p3_px); + min_dist_px = min(min_dist_px, d); + } + + // Smooth SDF: 1 within stroke, 0 outside, ½-pixel anti-aliased transition. + var stroke_cov = 0.0; + if in.n_stroke_curves > 0u && stroke_half_px > 0.0 { + stroke_cov = clamp(stroke_half_px + 0.5 - min_dist_px, 0.0, 1.0); + } + + // ── Porter-Duff "over": stroke on top of fill ───────────────────────── + let fill_a = in.v_fill_color.a * fill_cov; + let stroke_a = in.v_stroke_color.a * stroke_cov; + let total_a = stroke_a + fill_a * (1.0 - stroke_a); + + if total_a <= 0.001 { discard; } + + let fill_rgb = in.v_fill_color.rgb; + let stroke_rgb = in.v_stroke_color.rgb; + let out_rgb = (stroke_a * stroke_rgb + fill_a * (1.0 - stroke_a) * fill_rgb) / total_a; + + return vec4(out_rgb, total_a); +} diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index b09c448a06..ae3c241c91 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -44,10 +44,12 @@ ) from .webgpu_vmobject_rendering import ( - SLUG_FILL_VERTEX_LAYOUT, + FILL_STROKE_VERTEX_LAYOUT, STROKE_VERTEX_LAYOUT, SURFACE_VERTEX_LAYOUT, - render_webgpu_mobject, + _FrameData, + collect_frame_data, + draw_frame_data, ) if TYPE_CHECKING: @@ -466,14 +468,21 @@ def __init__( self._depth_texture: wgpu_t.GPUTexture | None = None self._depth_texture_view: wgpu_t.GPUTextureView | None = None self._proj_bgl: wgpu_t.GPUBindGroupLayout | None = None - self._slug_bgl: wgpu_t.GPUBindGroupLayout | None = None - # Slug fill: 2-D (no depth) and 3-D (depth-tested, shade_in_3d with fill). - self._slug_fill_pipeline: wgpu_t.GPURenderPipeline | None = None - self._slug_fill_3d_pipeline: wgpu_t.GPURenderPipeline | None = None - # Stroke pipelines: 2-D, 3-D, and 3-D with depth bias (surface mesh lines). - self._stroke_pipeline: wgpu_t.GPURenderPipeline | None = None - self._stroke_3d_pipeline: wgpu_t.GPURenderPipeline | None = None + + # Combined fill+stroke pipelines (vmobject_fill_stroke.wgsl). + # _fill_stroke_bgl is reused for both compute output and render input + # (camera uniform + read-only quads storage). + self._fill_stroke_bgl: wgpu_t.GPUBindGroupLayout | None = None + self._fill_stroke_pipeline: wgpu_t.GPURenderPipeline | None = None # 2-D, no depth write + self._fill_stroke_3d_pipeline: wgpu_t.GPURenderPipeline | None = None # 3-D, depth write + + # Compute pipeline: cubic_to_quads.wgsl. + self._compute_bgl: wgpu_t.GPUBindGroupLayout | None = None + self._cubic_to_quads_pipeline: wgpu_t.GPUComputePipeline | None = None + + # Surface mesh stroke (depth-biased cubic stroke pipeline). self._stroke_3d_surface_pipeline: wgpu_t.GPURenderPipeline | None = None + # Surface pipelines: opaque (depth write + backface cull) and OIT. self._surface_pipeline: wgpu_t.GPURenderPipeline | None = None # opaque self._surface_oit_pipeline: wgpu_t.GPURenderPipeline | None = None @@ -556,8 +565,7 @@ def init_scene(self, scene: Scene) -> None: self._depth_texture_view = self._depth_texture.create_view() self._proj_bgl = self._create_camera_bgl() - self._stroke_pipeline = self._create_stroke_pipeline(self._proj_bgl, depth_test=True) - self._stroke_3d_pipeline = self._create_stroke_pipeline(self._proj_bgl, depth_test=True) + # Surface mesh lines sit exactly on the surface triangles. A negative # depth bias pulls each fragment slightly toward the camera so the mesh # always wins the depth test without visually offsetting the lines. @@ -572,9 +580,18 @@ def init_scene(self, scene: Scene) -> None: depth_bias_slope_scale=-1.0, depth_bias_clamp=0.00001, ) - self._surface_pipeline = self._create_surface_pipeline(self._proj_bgl, cull_mode="none", depth_write=True) - self._slug_bgl, self._slug_fill_pipeline = self._create_slug_fill_pipeline(depth_test=False) - _, self._slug_fill_3d_pipeline = self._create_slug_fill_pipeline(depth_test=True) + self._surface_pipeline = self._create_surface_pipeline(self._proj_bgl, cull_mode="none", depth_write=True) + + # Combined fill+stroke pipeline (replaces separate slug + stroke pipelines). + self._fill_stroke_bgl, self._fill_stroke_pipeline = \ + self._create_fill_stroke_pipeline(depth_test=False) + _, self._fill_stroke_3d_pipeline = \ + self._create_fill_stroke_pipeline(depth_test=True) + + # GPU compute: cubic → quadratic conversion. + self._compute_bgl, self._cubic_to_quads_pipeline = \ + self._create_cubic_to_quads_pipeline() + self._create_oit_resources(width, height) self._create_readback_pipeline(width, height) @@ -686,6 +703,123 @@ def _create_stroke_pipeline( }, ) + def _create_fill_stroke_pipeline( + self, + depth_test: bool = False, + ) -> tuple[wgpu_t.GPUBindGroupLayout, wgpu_t.GPURenderPipeline]: + """Create the combined fill+stroke pipeline (vmobject_fill_stroke.wgsl). + + The bind group layout mirrors the slug fill layout: + binding 0 — camera uniform (176 bytes) + binding 1 — quads storage buffer (read-only, output of compute shader) + + depth_test=False — 2-D objects: depth-read-only (painter's algorithm). + depth_test=True — 3-D objects: depth-write + depth-test. + """ + assert self._device is not None + shader_path = Path(__file__).parent / "shaders" / "vmobject_fill_stroke.wgsl" + shader_module = self._device.create_shader_module( + code=shader_path.read_text(encoding="utf-8") + ) + + bgl = self._device.create_bind_group_layout( + entries=[ + { + "binding": 0, + "visibility": wgpu.ShaderStage.VERTEX | wgpu.ShaderStage.FRAGMENT, + "buffer": {"type": "uniform"}, + }, + { + "binding": 1, + "visibility": wgpu.ShaderStage.FRAGMENT, + "buffer": {"type": "read-only-storage", "has_dynamic_offset": False}, + }, + ] + ) + + _blend = { + "color": { + "src_factor": "src-alpha", + "dst_factor": "one-minus-src-alpha", + "operation": "add", + }, + "alpha": { + "src_factor": "one", + "dst_factor": "one", + "operation": "add", + }, + } + + pipeline = self._device.create_render_pipeline( + layout=self._device.create_pipeline_layout(bind_group_layouts=[bgl]), + vertex={ + "module": shader_module, + "entry_point": "vs_main", + "buffers": [FILL_STROKE_VERTEX_LAYOUT], + }, + fragment={ + "module": shader_module, + "entry_point": "fs_main", + "targets": [{"format": wgpu.TextureFormat.bgra8unorm, "blend": _blend}], + }, + primitive={"topology": "triangle-list", "cull_mode": "none"}, + depth_stencil={ + "format": wgpu.TextureFormat.depth24plus, + "depth_write_enabled": depth_test, + "depth_compare": "less", + "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_read_mask": 0, + "stencil_write_mask": 0, + }, + multisample={"count": 1, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, + ) + return bgl, pipeline + + def _create_cubic_to_quads_pipeline( + self, + ) -> tuple[wgpu_t.GPUBindGroupLayout, wgpu_t.GPUComputePipeline]: + """Create the compute pipeline that converts cubics → quadratics. + + Bind group layout: + binding 0 — input cubics (read-only-storage, 12 floats/cubic) + binding 1 — output quads (storage read_write, 36 floats/cubic) + binding 2 — params uniform (n_cubics u32, padded to 16 bytes) + + Dispatch: ceil(n_cubics / 64) × 1 × 1 workgroups. + """ + assert self._device is not None + shader_path = Path(__file__).parent / "shaders" / "cubic_to_quads.wgsl" + shader_module = self._device.create_shader_module( + code=shader_path.read_text(encoding="utf-8") + ) + + bgl = self._device.create_bind_group_layout( + entries=[ + { + "binding": 0, + "visibility": wgpu.ShaderStage.COMPUTE, + "buffer": {"type": "read-only-storage"}, + }, + { + "binding": 1, + "visibility": wgpu.ShaderStage.COMPUTE, + "buffer": {"type": "storage"}, + }, + { + "binding": 2, + "visibility": wgpu.ShaderStage.COMPUTE, + "buffer": {"type": "uniform"}, + }, + ] + ) + + pipeline = self._device.create_compute_pipeline( + layout=self._device.create_pipeline_layout(bind_group_layouts=[bgl]), + compute={"module": shader_module, "entry_point": "main"}, + ) + return bgl, pipeline + def _create_surface_pipeline( self, proj_bgl: wgpu_t.GPUBindGroupLayout, @@ -868,81 +1002,6 @@ def _create_oit_resources(self, width: int, height: int) -> None: ], ) - def _create_slug_fill_pipeline( - self, - depth_test: bool = False, - ) -> tuple[wgpu_t.GPUBindGroupLayout, wgpu_t.GPURenderPipeline]: - """Create a Slug fill pipeline. - - depth_test controls depth *writing* only — both 2-D and 3-D fills - always depth-test (depth_compare="less") so they are occluded by any - opaque surface rendered before them. - - depth_test=False — 2-D fills: depth-read-only (default). - depth_test=True — 3-D fills (shade_in_3d): depth-write + depth-test - so they occlude geometry drawn behind them. - """ - assert self._device is not None - shader_path = Path(__file__).parent / "shaders" / "slug_fill.wgsl" - shader_module = self._device.create_shader_module( - code=shader_path.read_text(encoding="utf-8") - ) - - # Group 0: binding 0 = camera uniform, binding 1 = curves storage (read-only). - slug_bgl = self._device.create_bind_group_layout( - entries=[ - { - "binding": 0, - "visibility": wgpu.ShaderStage.VERTEX | wgpu.ShaderStage.FRAGMENT, - "buffer": {"type": "uniform"}, - }, - { - "binding": 1, - "visibility": wgpu.ShaderStage.FRAGMENT, - "buffer": {"type": "read-only-storage", "has_dynamic_offset": False}, - }, - ] - ) - - _blend = { - "color": { - "src_factor": "src-alpha", - "dst_factor": "one-minus-src-alpha", - "operation": "add", - }, - "alpha": { - "src_factor": "one", - "dst_factor": "one", - "operation": "add", - }, - } - - pipeline = self._device.create_render_pipeline( - layout=self._device.create_pipeline_layout(bind_group_layouts=[slug_bgl]), - vertex={ - "module": shader_module, - "entry_point": "vs_main", - "buffers": [SLUG_FILL_VERTEX_LAYOUT], - }, - fragment={ - "module": shader_module, - "entry_point": "fs_main", - "targets": [{"format": wgpu.TextureFormat.bgra8unorm, "blend": _blend}], - }, - primitive={"topology": "triangle-list", "cull_mode": "none"}, - depth_stencil={ - "format": wgpu.TextureFormat.depth24plus, - "depth_write_enabled": depth_test, - "depth_compare": "less", # always depth-test; write only for 3-D fills - "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, - "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, - "stencil_read_mask": 0, - "stencil_write_mask": 0, - }, - multisample={"count": 1, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, - ) - return slug_bgl, pipeline - # ------------------------------------------------------------------ # Camera bind group (rebuilt each frame when projection changes) # ------------------------------------------------------------------ @@ -1047,23 +1106,6 @@ def _make_bg(buf: wgpu_t.GPUBuffer) -> wgpu_t.GPUBindGroup: return normal_bg - def _build_slug_bind_group( - self, curves_buf: wgpu_t.GPUBuffer - ) -> wgpu_t.GPUBindGroup: - """Build the Slug fill bind group: camera uniform + curves storage buffer.""" - assert self._device is not None - assert self._slug_bgl is not None - assert self._camera_uniform_buf is not None, ( - "_build_camera_bind_group() must be called before _build_slug_bind_group()" - ) - return self._device.create_bind_group( - layout=self._slug_bgl, - entries=[ - {"binding": 0, "resource": {"buffer": self._camera_uniform_buf, "offset": 0, "size": 176}}, - {"binding": 1, "resource": {"buffer": curves_buf, "offset": 0, "size": curves_buf.size}}, - ], - ) - # ------------------------------------------------------------------ # Pipeline / device accessors (used by webgpu_vmobject_rendering) # ------------------------------------------------------------------ @@ -1074,24 +1116,26 @@ def device(self) -> wgpu_t.GPUDevice: return self._device @property - def stroke_pipeline(self) -> wgpu_t.GPURenderPipeline: - assert self._stroke_pipeline is not None, "init_scene() has not been called" - return self._stroke_pipeline + def fill_stroke_pipeline(self) -> wgpu_t.GPURenderPipeline: + """Combined fill+stroke pipeline — 2-D (no depth write).""" + assert self._fill_stroke_pipeline is not None, "init_scene() has not been called" + return self._fill_stroke_pipeline @property - def stroke_3d_pipeline(self) -> wgpu_t.GPURenderPipeline: - assert self._stroke_3d_pipeline is not None, "init_scene() has not been called" - return self._stroke_3d_pipeline + def fill_stroke_3d_pipeline(self) -> wgpu_t.GPURenderPipeline: + """Combined fill+stroke pipeline — 3-D (depth write + test).""" + assert self._fill_stroke_3d_pipeline is not None, "init_scene() has not been called" + return self._fill_stroke_3d_pipeline @property def stroke_3d_surface_pipeline(self) -> wgpu_t.GPURenderPipeline: - """3-D stroke pipeline with depth bias — for surface mesh lines.""" + """Cubic stroke pipeline with depth bias — for surface mesh lines.""" assert self._stroke_3d_surface_pipeline is not None, "init_scene() has not been called" return self._stroke_3d_surface_pipeline @property def surface_pipeline(self) -> wgpu_t.GPURenderPipeline: - """Opaque surface pipeline (cull_back, depth_write=True).""" + """Opaque surface pipeline (depth_write=True).""" assert self._surface_pipeline is not None, "init_scene() has not been called" return self._surface_pipeline @@ -1100,16 +1144,6 @@ def surface_oit_pipeline(self) -> wgpu_t.GPURenderPipeline: assert self._surface_oit_pipeline is not None, "init_scene() has not been called" return self._surface_oit_pipeline - @property - def slug_fill_pipeline(self) -> wgpu_t.GPURenderPipeline: - assert self._slug_fill_pipeline is not None, "init_scene() has not been called" - return self._slug_fill_pipeline - - @property - def slug_fill_3d_pipeline(self) -> wgpu_t.GPURenderPipeline: - assert self._slug_fill_3d_pipeline is not None, "init_scene() has not been called" - return self._slug_fill_3d_pipeline - # ------------------------------------------------------------------ # Frame rendering # ------------------------------------------------------------------ @@ -1119,34 +1153,34 @@ def update_frame(self, scene: Scene) -> None: Pass structure -------------- - 1. **Main pass** — clears the frame; draws normal slug fills, opaque - surfaces, strokes. Fixed-orientation mobjects are drawn at the end - of this pass with a rotation-stripped camera bind group so they share - the same depth buffer as the rest of the scene. - 2. **OIT accumulation pass** — if any normal surface has alpha < 0.99, - renders those fragments into two OIT accumulation textures (rgba16float) - with Weighted Blended blending. - 3. **OIT composition pass** — full-screen triangle composites OIT result - onto the main texture. - 4. **Fixed-in-frame overlay pass** — only if fixed-in-frame mobjects exist. - Loads existing colour, clears depth, and renders overlays with a - rotation-stripped orthographic camera so they always appear on top. + 0. **Compute pass** — cubic_to_quads.wgsl converts raw cubic Bezier + control points to quadratic approximations for all three mobject + groups (normal, fixed-orientation, fixed-in-frame). This runs + before any render pass in the same command encoder, so WebGPU's + implicit pass ordering provides the barrier. + 1. **Main pass** — clears the frame; draws normal and fixed-orientation + mobjects (shared depth buffer; fixed-orient uses a rotation-stripped + camera bind group). + 2. **OIT accumulation pass** — transparent surfaces use Weighted + Blended OIT into two rgba16float textures. + 3. **OIT composition pass** — full-screen triangle composites the OIT + result onto the main texture. + 4. **Fixed-in-frame overlay pass** — 2-D overlays rendered with a + fresh depth buffer so they always appear on top. """ assert self._device is not None assert self._render_texture_view is not None assert self._depth_texture_view is not None + assert self._cubic_to_quads_pipeline is not None bg = self._background_color - # Build all three camera bind groups for this frame. - # camera_bind_group — normal rotated view (set on renderer for vmobject_rendering) - # fixed_camera_bind_group — rotation-stripped, current projection (fixed-orientation) - # fixed_frame_bind_group — rotation-stripped, ortho projection (fixed-in-frame) + # Build all three per-frame camera uniform buffers + bind groups. self.camera_bind_group = self._build_camera_bind_group() self.frame_vbos = [] # ── Partition mobjects ──────────────────────────────────────────── - cam = self.camera + cam = self.camera fixed_in_frame = cam._fixed_in_frame_mobjects fixed_orient = cam._fixed_orientation_mobjects @@ -1154,11 +1188,29 @@ def update_frame(self, scene: Scene) -> None: fixed_orient_mobs = [m for m in scene.mobjects if m in fixed_orient] fixed_frame_mobs = [m for m in scene.mobjects if m in fixed_in_frame] + # ── CPU tessellation + GPU buffer upload (no commands yet) ──────── + assert self._camera_uniform_buf is not None + assert self._fixed_orient_uniform_buf is not None + assert self._fixed_frame_uniform_buf is not None + + normal_fd = collect_frame_data(self, normal_mobs, self._camera_uniform_buf) + fixed_orient_fd = collect_frame_data(self, fixed_orient_mobs, self._fixed_orient_uniform_buf) + fixed_frame_fd = collect_frame_data(self, fixed_frame_mobs, self._fixed_frame_uniform_buf) + encoder = self._device.create_command_encoder() - # ── Pass 1: main ────────────────────────────────────────────────── - # Renders normal mobjects and fixed-orientation mobjects (same depth - # buffer; fixed-orient uses the rotation-stripped bind group). + # ── Pass 0: compute — cubic → quadratic conversion ──────────────── + # Runs before any render pass; WebGPU guarantees the output buffer is + # ready by the time the fragment shader reads it in Pass 1. + cp = encoder.begin_compute_pass() + cp.set_pipeline(self._cubic_to_quads_pipeline) + for fd in (normal_fd, fixed_orient_fd, fixed_frame_fd): + if fd is not None and fd.n_cubics_total > 0 and fd.compute_bg is not None: + cp.set_bind_group(0, fd.compute_bg, [], 0, 0) + cp.dispatch_workgroups((fd.n_cubics_total + 63) // 64, 1, 1) + cp.end() + + # ── Pass 1: main render ─────────────────────────────────────────── main_pass = encoder.begin_render_pass( color_attachments=[ { @@ -1176,24 +1228,19 @@ def update_frame(self, scene: Scene) -> None: }, ) self.current_render_pass = main_pass - oit_data = render_webgpu_mobject(self, normal_mobs) - - # Fixed-orientation: same pass, swap to rotation-stripped bind group. - # The normal bind group and uniform buffer are saved and restored so - # the OIT accumulation pass (below) still uses the correct camera. - if fixed_orient_mobs: - _saved_bg = self.camera_bind_group - _saved_buf = self._camera_uniform_buf - self.camera_bind_group = self.fixed_camera_bind_group - self._camera_uniform_buf = self._fixed_orient_uniform_buf - render_webgpu_mobject(self, fixed_orient_mobs) - self.camera_bind_group = _saved_bg - self._camera_uniform_buf = _saved_buf + + if normal_fd is not None: + draw_frame_data(self, normal_fd, self.camera_bind_group) + + if fixed_orient_fd is not None: + draw_frame_data(self, fixed_orient_fd, self.fixed_camera_bind_group) main_pass.end() # ── Pass 2: OIT accumulation ────────────────────────────────────── - if oit_data is not None: + # Uses the normal (rotated) camera bind group for the surface OIT pass. + oit_fd = normal_fd # fixed-orient surfaces are uncommon; handle normally + if oit_fd is not None and oit_fd.oit_indices: oit_pass = encoder.begin_render_pass( color_attachments=[ { @@ -1211,17 +1258,17 @@ def update_frame(self, scene: Scene) -> None: ], depth_stencil_attachment={ "view": self._depth_texture_view, - "depth_load_op": "load", # read depth from main pass + "depth_load_op": "load", "depth_store_op": "discard", }, ) oit_pass.set_pipeline(self.surface_oit_pipeline) oit_pass.set_bind_group(0, self.camera_bind_group, [], 0, 0) - for idx in oit_data.oit_indices: - arr = oit_data.surface_parts[idx] + for idx in oit_fd.oit_indices: + arr = oit_fd.surface_parts[idx] oit_pass.set_vertex_buffer( - 0, oit_data.surface_buf, - oit_data.byte_offsets[idx], arr.nbytes, + 0, oit_fd.surface_buf, + oit_fd.surface_byte_offsets[idx], arr.nbytes, ) oit_pass.draw(len(arr), 1, 0, 0) oit_pass.end() @@ -1229,11 +1276,7 @@ def update_frame(self, scene: Scene) -> None: # ── Pass 3: OIT composition ─────────────────────────────────── compose_pass = encoder.begin_render_pass( color_attachments=[ - { - "view": self._render_texture_view, - "load_op": "load", - "store_op": "store", - } + {"view": self._render_texture_view, "load_op": "load", "store_op": "store"} ], ) compose_pass.set_pipeline(self._oit_compose_pipeline) @@ -1241,31 +1284,23 @@ def update_frame(self, scene: Scene) -> None: compose_pass.draw(3, 1, 0, 0) compose_pass.end() - # ── Fixed-in-frame overlay pass ─────────────────────────────────── - # Rendered last, after OIT composition, so overlays always appear on - # top of the 3-D scene. The depth buffer is cleared to 1.0 (far) and - # discarded afterward — fixed-in-frame objects only depth-test against - # each other, not against the main scene. - if fixed_frame_mobs: + # ── Pass 4: fixed-in-frame overlay ─────────────────────────────── + # Rendered after OIT so overlays always appear on top of the 3-D scene. + # Fresh depth buffer: overlays only depth-test against each other. + if fixed_frame_fd is not None: fixed_pass = encoder.begin_render_pass( color_attachments=[ - { - "view": self._render_texture_view, - "load_op": "load", # preserve the composited 3-D scene - "store_op": "store", - } + {"view": self._render_texture_view, "load_op": "load", "store_op": "store"} ], depth_stencil_attachment={ "view": self._depth_texture_view, "depth_clear_value": 1.0, - "depth_load_op": "clear", # fresh depth — overlays on top + "depth_load_op": "clear", "depth_store_op": "discard", }, ) self.current_render_pass = fixed_pass - self.camera_bind_group = self.fixed_frame_bind_group - self._camera_uniform_buf = self._fixed_frame_uniform_buf - render_webgpu_mobject(self, fixed_frame_mobs) + draw_frame_data(self, fixed_frame_fd, self.fixed_frame_bind_group) fixed_pass.end() self._device.queue.submit([encoder.finish()]) diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py index 06e97c20ae..b62511ede7 100644 --- a/manim/renderer/webgpu/webgpu_vmobject_rendering.py +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -1,31 +1,42 @@ -"""WebGPU draw calls for VMobject fill + stroke + surface rendering — Phase 2/3. - -Fill ----- -Uses the Slug algorithm (Lengyel 2017) for GPU-side analytical fill coverage. -Raw quadratic bezier control points are uploaded to a storage buffer; the -fragment shader computes exact winding-number coverage per pixel with smooth -sub-pixel anti-aliasing. No CPU tessellation is required. - -Stroke ------- -All four cubic bezier control points (b0, h0, h1, b3) are passed to the GPU. -The fragment shader computes the exact unsigned distance to the cubic bezier -curve via Newton's-method minimisation and discards pixels outside the stroke -half-width — no quadratic approximation is made. - -Batched rendering ------------------ -``render_webgpu_mobject`` collects geometry for *all* scene mobjects before -touching the GPU. All fill data is concatenated into one GPU buffer, all -stroke data into another, all surface data into a third (1–3 allocations per -frame regardless of mobject count). Draw calls reference sub-ranges of those -shared buffers via ``set_vertex_buffer(slot, buf, offset)``, preserving the -exact painter's-algorithm order. +"""WebGPU draw calls for VMobject fill + stroke + surface rendering. + +Fill + Stroke (combined pipeline) +---------------------------------- +``cubic_to_quads.wgsl`` — GPU compute shader converts raw cubic Bezier +control points to quadratic approximations (4 per cubic, two-level de +Casteljau subdivision). + +``vmobject_fill_stroke.wgsl`` — combined render shader: one bounding quad +per object, one fragment loop accumulates both Slug winding-number fill +coverage (in NDC space) and SDF stroke distance (in pixel space). Porter- +Duff "over" compositing produces the final colour. + +Closing segments +~~~~~~~~~~~~~~~~ +Every open subpath gets a linear closing cubic (degree-elevated line from +the last anchor back to the first) appended to the fill cubic list. This +makes the winding-number integral correct for partial paths (e.g. during +``Create`` animations). The closing cubic is NOT added to the stroke cubic +list — strokes should follow the visible part of the curve only. + +Surfaces +-------- +Parametric surfaces use a triangle-mesh pipeline (``surface.wgsl``) with +Phong lighting. Transparent surfaces go through the separate OIT +accumulation + composition passes (``surface_oit.wgsl`` + ``oit_compose.wgsl``). +Surface mesh grid lines use the old cubic-stroke pipeline with depth bias. + +Batching +-------- +``collect_frame_data`` tessellates *all* scene mobjects on the CPU, uploads +one cubics buffer (fill then stroke, all objects) and one vertex buffer (one +bounding quad per object), then returns a ``_FrameData`` ready for the GPU. +``draw_frame_data`` records draw calls into the active render pass. """ from __future__ import annotations +import struct import weakref from dataclasses import dataclass, field from typing import TYPE_CHECKING @@ -41,34 +52,8 @@ from manim.renderer.webgpu.webgpu_renderer import WebGPURenderer -@dataclass -class _OITPassData: - """Geometry needed for the OIT accumulation render pass.""" - surface_parts: list[np.ndarray] - surface_buf: wgpu_t.GPUBuffer - byte_offsets: list[int] - oit_indices: list[int] # indices into surface_parts that are OIT - - -def _surface_opacity_class(part: np.ndarray) -> str: - """Classify a surface part by its alpha distribution. - - Returns: - "opaque" — all alpha >= 0.99 → opaque pipeline, depth write - "oit" — any alpha < 0.99 → Weighted Blended OIT - """ - alphas = part["in_color"][:, 3] - if float(alphas.min()) >= 0.99: - return "opaque" - return "oit" - - # --------------------------------------------------------------------------- -# Surface vertex layout — must match surface.wgsl locations: -# location 0 → in_vert float32x3 offset 0 (12 bytes) -# location 1 → in_normal float32x3 offset 12 (12 bytes) -# location 2 → in_color float32x4 offset 24 (16 bytes) -# stride: 40 bytes +# Surface vertex layout — must match surface.wgsl locations # --------------------------------------------------------------------------- _SURFACE_DTYPE = np.dtype( @@ -97,23 +82,16 @@ def _surface_opacity_class(part: np.ndarray) -> str: # --------------------------------------------------------------------------- -# Stroke vertex layout — must match vmobject_stroke.wgsl locations: -# location 0 → current_curve_0 float32x3 offset 0 (12 bytes) -# location 1 → current_curve_1 float32x3 offset 12 (12 bytes) ← into current_curve -# location 2 → current_curve_2 float32x3 offset 24 (12 bytes) ← into current_curve -# location 3 → current_curve_3 float32x3 offset 36 (12 bytes) ← into current_curve -# location 4 → tile_coordinate float32x2 offset 48 ( 8 bytes) -# location 5 → in_color float32x4 offset 56 (16 bytes) -# location 6 → in_width float32 offset 72 ( 4 bytes) -# stride: 76 bytes +# Stroke vertex layout — used only for Surface mesh lines (stroke_surface). +# Must match vmobject_stroke.wgsl locations. # --------------------------------------------------------------------------- _STROKE_DTYPE = np.dtype( [ - ("current_curve", np.float32, (4, 3)), # 48 bytes at offset 0 (b0, h0, h1, b3) + ("current_curve", np.float32, (4, 3)), ("tile_coordinate", np.float32, (2,)), - ("in_color", np.float32, (4,)), - ("in_width", np.float32), + ("in_color", np.float32, (4,)), + ("in_width", np.float32), ] ) _STROKE_STRIDE: int = _STROKE_DTYPE.itemsize # 76 bytes @@ -127,8 +105,6 @@ def _stroke_field_offset(name: str) -> int: "array_stride": _STROKE_STRIDE, "step_mode": "vertex", "attributes": [ - # current_curve is a (4,3) sub-field starting at offset 0. - # Split into four vec3 bindings with explicit byte offsets. {"format": "float32x3", "offset": _stroke_field_offset("current_curve"), "shader_location": 0}, {"format": "float32x3", "offset": _stroke_field_offset("current_curve") + 12, "shader_location": 1}, {"format": "float32x3", "offset": _stroke_field_offset("current_curve") + 24, "shader_location": 2}, @@ -141,58 +117,117 @@ def _stroke_field_offset(name: str) -> int: # --------------------------------------------------------------------------- -# Slug fill vertex layout — must match slug_fill.wgsl locations: -# location 0 → in_pos float32x3 offset 0 (12 bytes) -# location 1 → in_color float32x4 offset 12 (16 bytes) -# location 2 → curve_start uint32 offset 28 ( 4 bytes) -# location 3 → n_curves uint32 offset 32 ( 4 bytes) -# stride: 36 bytes +# Combined fill+stroke vertex layout — must match vmobject_fill_stroke.wgsl. +# +# location 0 — in_pos float32x3 offset 0 (12 B) +# location 1 — in_fill_color float32x4 offset 12 (16 B) +# location 2 — in_stroke_color float32x4 offset 28 (16 B) +# location 3 — stroke_half_ndc float32 offset 44 ( 4 B) +# location 4 — fill_curve_start uint32 offset 48 ( 4 B) +# location 5 — n_fill_curves uint32 offset 52 ( 4 B) +# location 6 — stroke_curve_start uint32 offset 56 ( 4 B) +# location 7 — n_stroke_curves uint32 offset 60 ( 4 B) +# stride: 64 bytes # --------------------------------------------------------------------------- -_SLUG_FILL_DTYPE = np.dtype( +_FILL_STROKE_DTYPE = np.dtype( [ - ("in_pos", np.float32, (3,)), - ("in_color", np.float32, (4,)), - ("curve_start", np.uint32), - ("n_curves", np.uint32), + ("in_pos", np.float32, (3,)), + ("in_fill_color", np.float32, (4,)), + ("in_stroke_color", np.float32, (4,)), + ("stroke_half_ndc", np.float32), + ("fill_curve_start", np.uint32), + ("n_fill_curves", np.uint32), + ("stroke_curve_start", np.uint32), + ("n_stroke_curves", np.uint32), ] ) -_SLUG_FILL_STRIDE: int = _SLUG_FILL_DTYPE.itemsize # 36 bytes +_FILL_STROKE_STRIDE: int = _FILL_STROKE_DTYPE.itemsize # 64 bytes -_SLUG_FILL_OFFSETS: dict[str, int] = { - name: _SLUG_FILL_DTYPE.fields[name][1] # type: ignore[index] - for name in _SLUG_FILL_DTYPE.names +_FILL_STROKE_OFFSETS: dict[str, int] = { + name: _FILL_STROKE_DTYPE.fields[name][1] # type: ignore[index] + for name in _FILL_STROKE_DTYPE.names } -SLUG_FILL_VERTEX_LAYOUT: dict = { - "array_stride": _SLUG_FILL_STRIDE, +FILL_STROKE_VERTEX_LAYOUT: dict = { + "array_stride": _FILL_STROKE_STRIDE, "step_mode": "vertex", "attributes": [ - {"format": "float32x3", "offset": _SLUG_FILL_OFFSETS["in_pos"], "shader_location": 0}, - {"format": "float32x4", "offset": _SLUG_FILL_OFFSETS["in_color"], "shader_location": 1}, - {"format": "uint32", "offset": _SLUG_FILL_OFFSETS["curve_start"], "shader_location": 2}, - {"format": "uint32", "offset": _SLUG_FILL_OFFSETS["n_curves"], "shader_location": 3}, + {"format": "float32x3", "offset": _FILL_STROKE_OFFSETS["in_pos"], "shader_location": 0}, + {"format": "float32x4", "offset": _FILL_STROKE_OFFSETS["in_fill_color"], "shader_location": 1}, + {"format": "float32x4", "offset": _FILL_STROKE_OFFSETS["in_stroke_color"], "shader_location": 2}, + {"format": "float32", "offset": _FILL_STROKE_OFFSETS["stroke_half_ndc"], "shader_location": 3}, + {"format": "uint32", "offset": _FILL_STROKE_OFFSETS["fill_curve_start"], "shader_location": 4}, + {"format": "uint32", "offset": _FILL_STROKE_OFFSETS["n_fill_curves"], "shader_location": 5}, + {"format": "uint32", "offset": _FILL_STROKE_OFFSETS["stroke_curve_start"], "shader_location": 6}, + {"format": "uint32", "offset": _FILL_STROKE_OFFSETS["n_stroke_curves"], "shader_location": 7}, ], } # --------------------------------------------------------------------------- -# Geometry caches — eliminates repeated tessellation for static shapes. -# -# Both caches are WeakKeyDictionary so GC can reclaim vmobjects that have -# been removed from the scene. Each entry maps: -# vmobject → (points_hash: int, geometry: ndarray | tuple[ndarray, ndarray]) -# -# The points_hash is recomputed cheaply (tobytes hash) every frame; a mismatch -# means the shape changed and we re-tessellate. +# Per-frame data container +# --------------------------------------------------------------------------- + + +@dataclass +class _FrameData: + """All GPU-ready data for one group of mobjects (one camera bind group). + + Produced by ``collect_frame_data``; consumed by ``draw_frame_data`` and + the caller's OIT / fixed-frame passes. + """ + + # VMobject fill+stroke via combined pipeline + fs_parts: list[np.ndarray] # _FILL_STROKE_DTYPE arrays, one per draw call + fs_buf: wgpu_t.GPUBuffer | None # concatenated vertex buffer + fs_byte_offsets: list[int] # byte offset of each part in fs_buf + + # GPU compute: cubic → quadratic conversion + cubics_buf: wgpu_t.GPUBuffer | None # input (12 floats/cubic), all objects + quads_out_buf: wgpu_t.GPUBuffer | None # output (36 floats/cubic = 4 quads × 9) + n_cubics_total: int + compute_bg: wgpu_t.GPUBindGroup | None # compute pass bind group + render_bg: wgpu_t.GPUBindGroup | None # fragment bind group (camera + quads) + + # Parametric surfaces (unchanged pipeline) + surface_parts: list[np.ndarray] + surface_buf: wgpu_t.GPUBuffer | None + surface_byte_offsets: list[int] + + # Surface mesh strokes (depth-biased cubic stroke pipeline) + stroke_surface_parts: list[np.ndarray] + stroke_surface_buf: wgpu_t.GPUBuffer | None + stroke_surface_byte_offsets: list[int] + + # Ordered draw commands: + # "fill_stroke_2d" — 2-D VMobject (no depth write) + # "fill_stroke_3d" — shade_in_3d VMobject (depth write + test) + # "surface_opaque" — opaque parametric surface + # "surface_oit" — transparent parametric surface (OIT pass, caller handles) + # "stroke_surface" — surface mesh grid lines (depth-biased) + draw_plan: list[tuple[str, int]] + + # Indices into surface_parts that need OIT (handled by the caller). + oit_indices: list[int] + + # --------------------------------------------------------------------------- +# Geometry caches +# --------------------------------------------------------------------------- + +# fill_stroke_cache: vmobject → (points_hash, (fill_cubics, stroke_cubics)) +# fill_cubics : (N, 4, 3) float32 — includes closing segments for winding +# stroke_cubics: (M, 4, 3) float32 — no closing segments (visible curve only) +# Geometry only; colors/widths are fetched fresh every frame. +_fill_stroke_cache: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() -_slug_fill_cache: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() -_stroke_cache: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() +# _surface_stroke_cache: vmobject → (points_hash, stroke_data) +# Used for surface mesh grid lines only (the old cubic-stroke pipeline). +_surface_stroke_cache: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() def _points_hash(vmobject: VMobject) -> int: - """Fast hash of vmobject.points — used to detect geometry changes.""" pts = vmobject.points if pts.size == 0: return 0 @@ -200,64 +235,47 @@ def _points_hash(vmobject: VMobject) -> int: # --------------------------------------------------------------------------- -# Public entry point — batched rendering +# Public entry points # --------------------------------------------------------------------------- -def render_webgpu_mobject( +def collect_frame_data( renderer: WebGPURenderer, mobjects: list, -) -> _OITPassData | None: - """Batch-render all VMobjects in *mobjects* (the scene's top-level list). - - Rendering is split into typed draw commands executed in order: - - * ``slug_fill`` — 2-D Slug fill (no depth test) - * ``surface_opaque`` — 3-D opaque surface fill (depth write + test) - * ``stroke_2d`` — 2-D stroke (depth-tested, depth_write=False — occluded by surfaces) - * ``stroke_3d`` — 3-D stroke for shade_in_3d objects (depth test) - - The ``surface_oit`` category is NOT executed here; the function returns - an ``_OITPassData`` with the OIT geometry so ``update_frame`` can run it - in a separate OIT accumulation pass. All surfaces with any alpha < 0.99 - are routed through OIT for correct order-independent compositing. - - **shade_in_3d objects**: fill (if present) goes to slug_fill_3d; stroke - (if present) goes to stroke_3d. Both are emitted independently, so an - object with both fill and stroke renders a filled surface with 3-D strokes - on top. Objects with no fill (axis lines, arrowheads, tick marks) emit - only stroke_3d. + camera_uniform_buf: wgpu_t.GPUBuffer, +) -> _FrameData | None: + """Tessellate *mobjects*, upload to GPU, return a ``_FrameData``. - **Issue 2 fix**: the stroke vertex shader now works in view space, so any - 3-D curve (including the world Z-axis) is rendered correctly. + Does NOT record any GPU commands — only uploads buffers and creates bind + groups. The caller must run the compute pass (via ``_FrameData.compute_bg``) + before the render pass. - **Issue 3 fix**: stroke_3d uses depth_compare=less, so 3-D axis lines are - occluded by surfaces in front of them. + *camera_uniform_buf* is the 176-byte uniform buffer for this camera group. + It is stored in the render bind group so the fragment shader can project + world-space curve data into the correct NDC space. """ - import wgpu # local import so module loads without wgpu installed + import wgpu view_matrix: np.ndarray = renderer.camera.view_matrix proj_matrix: np.ndarray = renderer.camera.projection_matrix - # ── Phase 1: tessellate ─────────────────────────────────────────────── - slug_quad_parts: list[np.ndarray] = [] - slug_curve_parts: list[np.ndarray] = [] - stroke_parts: list[np.ndarray] = [] - surface_parts: list[np.ndarray] = [] + # Per-draw-call data collected across all mobjects. + fs_parts: list[np.ndarray] = [] + # Cubics: fill first (all objects), then stroke (all objects). + all_fill_cubics: list[np.ndarray] = [] # (Ni, 4, 3) per draw call + all_stroke_cubics: list[np.ndarray] = [] # (Mi, 4, 3) per draw call + n_fill_cubics_per: list[int] = [] # Ni per draw call + n_stroke_cubics_per: list[int] = [] # Mi per draw call - # Command types: slug_fill | slug_fill_3d - # surface_opaque | surface_oit - # stroke_2d | stroke_3d + surface_parts: list[np.ndarray] = [] + stroke_surface_parts: list[np.ndarray] = [] draw_plan: list[tuple[str, int]] = [] for mob in mobjects: - mob_type = type(mob).__name__ if not isinstance(mob, VMobject): continue - # ── Parametric Surface: triangle-mesh + Phong lighting ──────────── - # Also collect the stroke of each face so the mesh grid is visible, - # matching Cairo which renders both fill and stroke for every face. + # ── Parametric Surface ──────────────────────────────────────────── if isinstance(mob, Surface): for submob in mob.family_members_with_points(): data = _collect_surface_geometry(submob) @@ -267,241 +285,271 @@ def render_webgpu_mobject( draw_plan.append((cmd, len(surface_parts))) surface_parts.append(data) + # Surface mesh strokes (old cubic stroke pipeline). phash = _points_hash(submob) - scached = _stroke_cache.get(submob) + scached = _surface_stroke_cache.get(submob) if scached is not None and scached[0] == phash: - draw_plan.append(("stroke_surface", len(stroke_parts))) - stroke_parts.append(scached[1]) + draw_plan.append(("stroke_surface", len(stroke_surface_parts))) + stroke_surface_parts.append(scached[1]) else: - stroke_data = _collect_stroke_geometry(submob) - if stroke_data is not None: - _stroke_cache[submob] = (phash, stroke_data) - draw_plan.append(("stroke_surface", len(stroke_parts))) - stroke_parts.append(stroke_data) + sdata = _collect_surface_stroke_geometry(submob) + if sdata is not None: + _surface_stroke_cache[submob] = (phash, sdata) + draw_plan.append(("stroke_surface", len(stroke_surface_parts))) + stroke_surface_parts.append(sdata) continue + # ── Regular VMobject (2-D or shade_in_3d) ──────────────────────── for submob in mob.family_members_with_points(): - phash = _points_hash(submob) - scached = _stroke_cache.get(submob) - - if getattr(submob, "shade_in_3d", False): - fill_rgba = submob.get_fill_rgbas() - has_fill = fill_rgba.shape[0] > 0 and float(fill_rgba[0, 3]) > 0.01 - - if has_fill: - # 3-D flat VMobject with fill (e.g. number-plane, polygon in 3D): - # Cache only geometry-dependent curve data; rebuild the bounding - # quad every frame so it tracks the current camera correctly. - cached = _slug_fill_cache.get(submob) - if cached is None or cached[0] != phash: - slug_data = _collect_slug_fill_geometry(submob) - if slug_data is not None: - _slug_fill_cache[submob] = (phash, slug_data) - cached = _slug_fill_cache.get(submob) - if cached is not None and cached[0] == phash: - color_c, curves_flat = cached[1] - quad_verts = _build_slug_quad(color_c, curves_flat, view_matrix, proj_matrix) - draw_plan.append(("slug_fill_3d", len(slug_quad_parts))) - slug_quad_parts.append(quad_verts) - slug_curve_parts.append(curves_flat) - - # 3-D stroke logic (axis lines, etc.) - if scached is not None and scached[0] == phash: - draw_plan.append(("stroke_3d", len(stroke_parts))) - stroke_parts.append(scached[1]) - else: - stroke_data = _collect_stroke_geometry(submob) - if stroke_data is not None: - _stroke_cache[submob] = (phash, stroke_data) - draw_plan.append(("stroke_3d", len(stroke_parts))) - stroke_parts.append(stroke_data) - - else: - # ── 2-D object: Slug fill + 2-D stroke ────────────────────── - cached = _slug_fill_cache.get(submob) - if cached is None or cached[0] != phash: - slug_data = _collect_slug_fill_geometry(submob) - if slug_data is not None: - _slug_fill_cache[submob] = (phash, slug_data) - cached = _slug_fill_cache.get(submob) - if cached is not None and cached[0] == phash: - color_c, curves_flat = cached[1] - quad_verts = _build_slug_quad(color_c, curves_flat, view_matrix, proj_matrix) - draw_plan.append(("slug_fill", len(slug_quad_parts))) - slug_quad_parts.append(quad_verts) - slug_curve_parts.append(curves_flat) - - if scached is not None and scached[0] == phash: - draw_plan.append(("stroke_2d", len(stroke_parts))) - stroke_parts.append(scached[1]) + phash = _points_hash(submob) + cached = _fill_stroke_cache.get(submob) + if cached is None or cached[0] != phash: + result = _collect_cubics(submob) + if result is not None: + _fill_stroke_cache[submob] = (phash, result) else: - stroke_data = _collect_stroke_geometry(submob) - if stroke_data is not None: - _stroke_cache[submob] = (phash, stroke_data) - draw_plan.append(("stroke_2d", len(stroke_parts))) - stroke_parts.append(stroke_data) + _fill_stroke_cache.pop(submob, None) + continue + cached = _fill_stroke_cache.get(submob) + if cached is None: + continue + fill_cubics, stroke_cubics = cached[1] + + # Fetch current colors every frame (they change during animations). + fill_rgba = submob.get_fill_rgbas() + stroke_rgba = submob.get_stroke_rgbas() + fill_color = (fill_rgba[0].astype(np.float32) + if fill_rgba.shape[0] > 0 + else np.zeros(4, dtype=np.float32)) + stroke_color = (stroke_rgba[0].astype(np.float32) + if stroke_rgba.shape[0] > 0 + else np.zeros(4, dtype=np.float32)) + stroke_width = (float(submob.get_stroke_width()) + if stroke_rgba.shape[0] > 0 + else 0.0) + + # Skip entirely invisible objects (both fill and stroke transparent). + if fill_color[3] < 0.001 and (stroke_color[3] < 0.001 or stroke_width < 0.001): + continue + + # Build bounding quad with placeholder curve indices. + quad_verts = _build_fill_stroke_quad( + fill_cubics=fill_cubics, + stroke_cubics=stroke_cubics, + fill_color=fill_color, + stroke_color=stroke_color, + stroke_width=stroke_width, + fill_curve_start=0, # assigned below after all objects are collected + stroke_curve_start=0, # assigned below + view_matrix=view_matrix, + proj_matrix=proj_matrix, + ) + if len(quad_verts) == 0: + continue + + is_3d = getattr(submob, "shade_in_3d", False) + draw_plan.append(("fill_stroke_3d" if is_3d else "fill_stroke_2d", + len(fs_parts))) + fs_parts.append(quad_verts) + all_fill_cubics.append(fill_cubics) + all_stroke_cubics.append(stroke_cubics) + n_fill_cubics_per.append(len(fill_cubics)) + n_stroke_cubics_per.append(len(stroke_cubics)) if not draw_plan: return None - # ── Phase 2: batch upload ───────────────────────────────────────────── device: wgpu_t.GPUDevice = renderer.device - slug_fill_vbo = slug_fill_byte_offsets = None - slug_bind_group = None - stroke_buf = stroke_byte_offsets = None - surface_buf = surface_byte_offsets = None - - if slug_quad_parts: - curve_global_offset = 0 - for i, curves_flat in enumerate(slug_curve_parts): - n_quads_i = len(curves_flat) // 3 - slug_quad_parts[i]["curve_start"] = curve_global_offset - curve_global_offset += n_quads_i - - slug_fill_vbo, slug_fill_byte_offsets = _batch_upload(device, slug_quad_parts) - renderer.frame_vbos.append(slug_fill_vbo) + # ── Assign global curve start indices ──────────────────────────────── + # Cubics buffer layout: [fill_cubics_obj0, fill_cubics_obj1, ..., + # stroke_cubics_obj0, stroke_cubics_obj1, ...] + # Quads output layout: [fill_quads_obj0, fill_quads_obj1, ..., + # stroke_quads_obj0, stroke_quads_obj1, ...] + total_fill_cubics = sum(n_fill_cubics_per) + total_stroke_cubics = sum(n_stroke_cubics_per) + n_cubics_total = total_fill_cubics + total_stroke_cubics + + fill_global = 0 # running fill cubic index + stroke_global = total_fill_cubics # stroke cubics follow all fill cubics + + for i, part in enumerate(fs_parts): + part["fill_curve_start"] = fill_global * 4 + part["n_fill_curves"] = n_fill_cubics_per[i] * 4 + part["stroke_curve_start"] = stroke_global * 4 + part["n_stroke_curves"] = n_stroke_cubics_per[i] * 4 + fill_global += n_fill_cubics_per[i] + stroke_global += n_stroke_cubics_per[i] + + # ── Upload vertex data ─────────────────────────────────────────────── + fs_buf, fs_byte_offsets = None, [] + if fs_parts: + fs_buf, fs_byte_offsets = _batch_upload(device, fs_parts) + renderer.frame_vbos.append(fs_buf) + + # ── Upload cubics and create compute/render bind groups ────────────── + cubics_buf = quads_out_buf = compute_bg = render_bg = None + + if n_cubics_total > 0: + # Build flat float32 array: [all fill cubics..., all stroke cubics...] + fill_arrays = [c for c in all_fill_cubics if len(c) > 0] + stroke_arrays = [c for c in all_stroke_cubics if len(c) > 0] + all_arrays = fill_arrays + stroke_arrays + all_cubics = np.concatenate(all_arrays, axis=0) # (N, 4, 3) + cubics_flat = all_cubics.astype(np.float32).ravel() # N*12 floats + + cubics_buf = device.create_buffer_with_data( + data=cubics_flat.tobytes(), + usage=wgpu.BufferUsage.STORAGE, + ) + renderer.frame_vbos.append(cubics_buf) - all_curves = np.concatenate(slug_curve_parts, axis=0) - slug_curves_buf = device.create_buffer_with_data( - data=all_curves.tobytes(), + quads_size = n_cubics_total * 36 * 4 # 4 quads × 9 floats × 4 bytes + quads_out_buf = device.create_buffer( + size=max(quads_size, 16), # WebGPU minimum binding size usage=wgpu.BufferUsage.STORAGE, ) - renderer.frame_vbos.append(slug_curves_buf) - slug_bind_group = renderer._build_slug_bind_group(slug_curves_buf) + renderer.frame_vbos.append(quads_out_buf) - if stroke_parts: - stroke_buf, stroke_byte_offsets = _batch_upload(device, stroke_parts) - renderer.frame_vbos.append(stroke_buf) + # Params uniform (n_cubics, padded to 16 bytes for WebGPU alignment). + params_bytes = struct.pack("<4I", n_cubics_total, 0, 0, 0) + params_buf = device.create_buffer_with_data( + data=params_bytes, + usage=wgpu.BufferUsage.UNIFORM, + ) + renderer.frame_vbos.append(params_buf) + + compute_bg = device.create_bind_group( + layout=renderer._compute_bgl, + entries=[ + {"binding": 0, "resource": {"buffer": cubics_buf, "offset": 0, "size": cubics_buf.size}}, + {"binding": 1, "resource": {"buffer": quads_out_buf, "offset": 0, "size": quads_out_buf.size}}, + {"binding": 2, "resource": {"buffer": params_buf, "offset": 0, "size": 16}}, + ], + ) + + render_bg = device.create_bind_group( + layout=renderer._fill_stroke_bgl, + entries=[ + {"binding": 0, "resource": {"buffer": camera_uniform_buf, "offset": 0, "size": 176}}, + {"binding": 1, "resource": {"buffer": quads_out_buf, "offset": 0, "size": quads_out_buf.size}}, + ], + ) + # ── Upload surface and surface-stroke data ─────────────────────────── + surface_buf, surface_byte_offsets = None, [] if surface_parts: _smooth_surface_normals(surface_parts) surface_buf, surface_byte_offsets = _batch_upload(device, surface_parts) renderer.frame_vbos.append(surface_buf) - # ── Phase 3: draw in the main render pass ───────────────────────────── - # Execute in this fixed order: - # (slug_fill + stroke_2d interleaved, in draw_plan order) → - # slug_fill_3d → surface_opaque → stroke_3d → stroke_surface - # 2-D fill/stroke are interleaved per-object to match Cairo's painter's algorithm: - # object A: fill → stroke; object B: fill → stroke (in z_index order). - # stroke_surface uses a depth-biased pipeline so mesh lines sitting exactly - # on the surface never z-fight with it. - # surface_oit entries are skipped here and returned for a separate OIT pass. - rp = renderer.current_render_pass - cam_bg = renderer.camera_bind_group - - def _draw_stroke(idx: int, pipeline_name: str, _cur: list) -> None: - if pipeline_name == "stroke_2d": - pipeline = renderer.stroke_pipeline - elif pipeline_name == "stroke_surface": - pipeline = renderer.stroke_3d_surface_pipeline - else: - pipeline = renderer.stroke_3d_pipeline - if _cur[0] != pipeline_name: - rp.set_pipeline(pipeline) - rp.set_bind_group(0, cam_bg, [], 0, 0) - _cur[0] = pipeline_name - arr = stroke_parts[idx] - rp.set_vertex_buffer(0, stroke_buf, stroke_byte_offsets[idx], arr.nbytes) - rp.draw(len(arr), 1, 0, 0) - - def _draw_surface(idx: int, pipeline, pipeline_key: str, _cur: list) -> None: - if _cur[0] != pipeline_key: - rp.set_pipeline(pipeline) - rp.set_bind_group(0, cam_bg, [], 0, 0) - _cur[0] = pipeline_key - arr = surface_parts[idx] - rp.set_vertex_buffer(0, surface_buf, surface_byte_offsets[idx], arr.nbytes) - rp.draw(len(arr), 1, 0, 0) - - _cur: list[str | None] = [None] # mutable current-pipeline tracker - - # 1. 2-D objects: fill and stroke interleaved in draw_plan order (painter's algorithm). - # slug_fill uses no-depth-test pipeline; stroke_2d uses no-depth-write pipeline. - # Iterating once keeps Cairo's per-object fill→stroke ordering so object B drawn - # on top of A has its fill above A's stroke, matching Cairo's painter's algorithm. - for cmd_type, idx in draw_plan: - if cmd_type == "slug_fill" and slug_fill_vbo is not None: - if _cur[0] != "slug_fill": - rp.set_pipeline(renderer.slug_fill_pipeline) - rp.set_bind_group(0, slug_bind_group, [], 0, 0) - _cur[0] = "slug_fill" - arr = slug_quad_parts[idx] - rp.set_vertex_buffer(0, slug_fill_vbo, slug_fill_byte_offsets[idx], arr.nbytes) - rp.draw(len(arr), 1, 0, 0) - elif cmd_type == "stroke_2d" and stroke_buf is not None: - if _cur[0] != "stroke_2d": - rp.set_pipeline(renderer.stroke_pipeline) - rp.set_bind_group(0, cam_bg, [], 0, 0) - _cur[0] = "stroke_2d" - arr = stroke_parts[idx] - rp.set_vertex_buffer(0, stroke_buf, stroke_byte_offsets[idx], arr.nbytes) - rp.draw(len(arr), 1, 0, 0) + stroke_surface_buf, stroke_surface_byte_offsets = None, [] + if stroke_surface_parts: + stroke_surface_buf, stroke_surface_byte_offsets = _batch_upload(device, stroke_surface_parts) + renderer.frame_vbos.append(stroke_surface_buf) + + oit_indices = [idx for cmd, idx in draw_plan if cmd == "surface_oit"] + + return _FrameData( + fs_parts=fs_parts, + fs_buf=fs_buf, + fs_byte_offsets=fs_byte_offsets, + cubics_buf=cubics_buf, + quads_out_buf=quads_out_buf, + n_cubics_total=n_cubics_total, + compute_bg=compute_bg, + render_bg=render_bg, + surface_parts=surface_parts, + surface_buf=surface_buf, + surface_byte_offsets=surface_byte_offsets, + stroke_surface_parts=stroke_surface_parts, + stroke_surface_buf=stroke_surface_buf, + stroke_surface_byte_offsets=stroke_surface_byte_offsets, + draw_plan=draw_plan, + oit_indices=oit_indices, + ) - # 1b. 3-D Slug fills (shade_in_3d with fill — depth-tested) - if slug_fill_vbo is not None: - for cmd_type, idx in draw_plan: - if cmd_type == "slug_fill_3d": - if _cur[0] != "slug_fill_3d": - rp.set_pipeline(renderer.slug_fill_3d_pipeline) - rp.set_bind_group(0, slug_bind_group, [], 0, 0) - _cur[0] = "slug_fill_3d" - arr = slug_quad_parts[idx] - rp.set_vertex_buffer(0, slug_fill_vbo, slug_fill_byte_offsets[idx], arr.nbytes) - rp.draw(len(arr), 1, 0, 0) - - # 2. Opaque surfaces - if surface_buf is not None: - for cmd_type, idx in draw_plan: - if cmd_type == "surface_opaque": - _draw_surface(idx, renderer.surface_pipeline, "surface_opaque", _cur) - - # 3. 3-D strokes (depth-tested) - if stroke_buf is not None: - for cmd_type, idx in draw_plan: - if cmd_type == "stroke_3d": - _draw_stroke(idx, "stroke_3d", _cur) - - # 4. Surface mesh strokes (depth-biased to prevent z-fighting) - for cmd_type, idx in draw_plan: - if cmd_type == "stroke_surface": - _draw_stroke(idx, "stroke_surface", _cur) - - # ── OIT data for the caller ─────────────────────────────────────────── - oit_indices = [idx for cmd_type, idx in draw_plan if cmd_type == "surface_oit"] - if oit_indices and surface_buf is not None: - return _OITPassData( - surface_parts=surface_parts, - surface_buf=surface_buf, - byte_offsets=surface_byte_offsets, - oit_indices=oit_indices, - ) - return None +def draw_frame_data( + renderer: WebGPURenderer, + fd: _FrameData, + cam_bg: wgpu_t.GPUBindGroup, +) -> None: + """Record draw commands for *fd* into ``renderer.current_render_pass``. -# --------------------------------------------------------------------------- -# Explicit single-mobject public helpers (kept for callers outside update_frame) -# --------------------------------------------------------------------------- + Draw order + ---------- + 1. 2-D fill+stroke objects — interleaved in ``draw_plan`` order (painter's + algorithm; no depth write so objects paint over each other correctly). + 2. 3-D fill+stroke objects — depth write + test (shade_in_3d). + 3. Opaque parametric surfaces — depth write. + 4. Surface mesh strokes — depth-biased to avoid z-fighting. + + OIT surfaces are NOT drawn here; the caller reads ``fd.oit_indices`` and + handles them in a separate accumulation pass. + """ + rp = renderer.current_render_pass + cur_pipeline: list[str | None] = [None] + cur_bg: list[object | None] = [None] + + def _activate(name: str, bg: wgpu_t.GPUBindGroup) -> None: + if cur_pipeline[0] != name: + if name == "fill_stroke_2d": + rp.set_pipeline(renderer.fill_stroke_pipeline) + elif name == "fill_stroke_3d": + rp.set_pipeline(renderer.fill_stroke_3d_pipeline) + elif name == "surface_opaque": + rp.set_pipeline(renderer.surface_pipeline) + elif name == "stroke_surface": + rp.set_pipeline(renderer.stroke_3d_surface_pipeline) + cur_pipeline[0] = name + if cur_bg[0] is not bg: + rp.set_bind_group(0, bg, [], 0, 0) + cur_bg[0] = bg + + # 1. 2-D fill+stroke: interleaved in draw_plan order (painter's algorithm). + if fd.fs_buf is not None and fd.render_bg is not None: + for cmd, idx in fd.draw_plan: + if cmd != "fill_stroke_2d": + continue + _activate("fill_stroke_2d", fd.render_bg) + arr = fd.fs_parts[idx] + rp.set_vertex_buffer(0, fd.fs_buf, fd.fs_byte_offsets[idx], arr.nbytes) + rp.draw(len(arr), 1, 0, 0) -def render_webgpu_surface( - renderer: WebGPURenderer, - mobject: VMobject, -) -> None: - """Record surface draw calls for *mobject* and its descendants.""" - for submob in mobject.family_members_with_points(): - if getattr(submob, "shade_in_3d", False): - _draw_surface_face(renderer, submob) + # 2. 3-D fill+stroke: depth-tested and depth-written. + if fd.fs_buf is not None and fd.render_bg is not None: + for cmd, idx in fd.draw_plan: + if cmd != "fill_stroke_3d": + continue + _activate("fill_stroke_3d", fd.render_bg) + arr = fd.fs_parts[idx] + rp.set_vertex_buffer(0, fd.fs_buf, fd.fs_byte_offsets[idx], arr.nbytes) + rp.draw(len(arr), 1, 0, 0) + # 3. Opaque parametric surfaces. + if fd.surface_buf is not None: + for cmd, idx in fd.draw_plan: + if cmd != "surface_opaque": + continue + _activate("surface_opaque", cam_bg) + arr = fd.surface_parts[idx] + rp.set_vertex_buffer(0, fd.surface_buf, fd.surface_byte_offsets[idx], arr.nbytes) + rp.draw(len(arr), 1, 0, 0) -def render_webgpu_vmobject_stroke( - renderer: WebGPURenderer, - mobject: VMobject, -) -> None: - """Record stroke draw calls for *mobject* and all its descendants.""" - for submob in mobject.family_members_with_points(): - _draw_vmobject_stroke(renderer, submob) + # 4. Surface mesh strokes (depth-biased). + if fd.stroke_surface_buf is not None: + for cmd, idx in fd.draw_plan: + if cmd != "stroke_surface": + continue + _activate("stroke_surface", cam_bg) + arr = fd.stroke_surface_parts[idx] + rp.set_vertex_buffer( + 0, fd.stroke_surface_buf, + fd.stroke_surface_byte_offsets[idx], arr.nbytes, + ) + rp.draw(len(arr), 1, 0, 0) # --------------------------------------------------------------------------- @@ -513,11 +561,7 @@ def _batch_upload( device: wgpu_t.GPUDevice, arrays: list[np.ndarray], ) -> tuple[wgpu_t.GPUBuffer, list[int]]: - """Concatenate *arrays* into one bytes blob and upload as a single VERTEX buffer. - - Returns ``(gpu_buffer, byte_offsets)`` where ``byte_offsets[i]`` is the - byte position of ``arrays[i]`` within the buffer. - """ + """Concatenate *arrays* into one bytes blob and upload as a VERTEX buffer.""" import wgpu byte_offsets: list[int] = [] @@ -537,95 +581,189 @@ def _batch_upload( # --------------------------------------------------------------------------- -# Geometry collectors — CPU only, no GPU calls +# VMobject cubic collector — geometry cache # --------------------------------------------------------------------------- -def _collect_stroke_geometry(vmobject: VMobject) -> np.ndarray | None: - """Return a ``_STROKE_DTYPE`` array for *vmobject*'s stroke, or ``None``.""" - stroke_rgba = vmobject.get_stroke_rgbas() - stroke_width = float(vmobject.get_stroke_width()) - if stroke_rgba.shape[0] == 0 or stroke_rgba[0, 3] == 0 or stroke_width == 0: - return None +def _collect_cubics( + vmobject: VMobject, +) -> tuple[np.ndarray, np.ndarray] | None: + """Return ``(fill_cubics, stroke_cubics)`` for the GPU compute shader. - color = stroke_rgba[0].astype(np.float32) - nppcc = vmobject.n_points_per_cubic_curve + *fill_cubics* — ``(N, 4, 3)`` float32. All subpath cubics **plus** one + linear closing cubic per open subpath (required for correct winding- + number coverage during partial animations such as ``Create``). + + *stroke_cubics* — ``(M, 4, 3)`` float32. Only the actual subpath cubics, + no closing segment — the stroke should follow the visible curve only. + + Colors are NOT stored here; they are fetched fresh every frame in + ``collect_frame_data`` so that opacity animations work correctly. + + Returns ``None`` if the vmobject has no usable bezier curves. + """ + nppcc = vmobject.n_points_per_cubic_curve + + fill_cubics_list: list[np.ndarray] = [] + stroke_cubics_list: list[np.ndarray] = [] - curve_list: list[np.ndarray] = [] for subpath in vmobject.get_subpaths(): n_curves = len(subpath) // nppcc if n_curves == 0: continue pts = subpath[: n_curves * nppcc] - b0s = pts[0::nppcc] - h0s = pts[1::nppcc] - h1s = pts[2::nppcc] - b2s = pts[3::nppcc] - curve_list.append(np.stack([b0s, h0s, h1s, b2s], axis=1)) + b0s = pts[0::nppcc].astype(np.float32) + h0s = pts[1::nppcc].astype(np.float32) + h1s = pts[2::nppcc].astype(np.float32) + b3s = pts[3::nppcc].astype(np.float32) + + cubics = np.stack([b0s, h0s, h1s, b3s], axis=1) # (n, 4, 3) + stroke_cubics_list.append(cubics) + fill_cubics_list.append(cubics) + + # Closing segment: linear cubic from the last anchor back to the first. + # Degree-elevation from a line (last→first) to a cubic: + # b0 = last, b1 = last + (first-last)/3, + # b2 = last + 2*(first-last)/3, b3 = first. + first = b0s[0] + last = b3s[-1] + if not np.allclose(first, last, atol=1e-6): + diff = first - last + closing = np.array( + [[last, last + diff * (1.0 / 3.0), last + diff * (2.0 / 3.0), first]], + dtype=np.float32, + ) + fill_cubics_list.append(closing) - if not curve_list: + if not fill_cubics_list and not stroke_cubics_list: return None - all_curves = np.concatenate(curve_list, axis=0).astype(np.float32) # (N, 4, 3) - n_total = len(all_curves) + fill_cubics = (np.concatenate(fill_cubics_list, axis=0) + if fill_cubics_list + else np.empty((0, 4, 3), dtype=np.float32)) + stroke_cubics = (np.concatenate(stroke_cubics_list, axis=0) + if stroke_cubics_list + else np.empty((0, 4, 3), dtype=np.float32)) + return fill_cubics, stroke_cubics - base = np.zeros(n_total * 3, dtype=_STROKE_DTYPE) - base["current_curve"] = np.repeat(all_curves, 3, axis=0) - base["in_color"] = color - base["in_width"] = stroke_width - stroke_data = np.tile(base, 2) - n_half = n_total * 3 - stroke_data["tile_coordinate"][:n_half] = np.tile( - [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0]], (n_total, 1) - ) - stroke_data["tile_coordinate"][n_half:] = np.tile( - [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]], (n_total, 1) - ) - return stroke_data +# --------------------------------------------------------------------------- +# Bounding-quad builder +# --------------------------------------------------------------------------- -def _smooth_surface_normals(surface_parts: list[np.ndarray]) -> None: - """Average normals at shared vertex positions to produce smooth shading. +def _build_fill_stroke_quad( + fill_cubics: np.ndarray, + stroke_cubics: np.ndarray, + fill_color: np.ndarray, + stroke_color: np.ndarray, + stroke_width: float, + fill_curve_start: int, + stroke_curve_start: int, + view_matrix: np.ndarray, + proj_matrix: np.ndarray, +) -> np.ndarray: + """Build a ``_FILL_STROKE_DTYPE`` bounding quad (6 vertices) for one object. - Modifies ``surface_parts`` in-place. Vertices whose positions match - (within 1e-5 world-space units) share a common averaged normal, removing - the hard crease lines produced by per-face flat normals. + The bounding box is computed in NDC space (clip.xy / clip.w) from the + anchor points of both fill and stroke cubics, then mapped back to world + space at the average view-space Z. This is correct for both orthographic + (w = 1) and perspective projections. + + *stroke_half_ndc* is the stroke half-width in NDC units, computed from + the current projection matrix and average clip-w so that stroke width is + consistent across perspective depths. """ - if not surface_parts: - return + # Gather all anchor points (b0 and b3 of every cubic). + anchor_lists: list[np.ndarray] = [] + if len(fill_cubics) > 0: + anchor_lists.append(fill_cubics[:, 0]) + anchor_lists.append(fill_cubics[:, 3]) + if len(stroke_cubics) > 0: + anchor_lists.append(stroke_cubics[:, 0]) + anchor_lists.append(stroke_cubics[:, 3]) - # Stack all vertex positions and flat face normals. - all_verts = np.concatenate([p["in_vert"] for p in surface_parts], axis=0) # (T, 3) f32 - all_norms = np.concatenate([p["in_normal"] for p in surface_parts], axis=0) # (T, 3) f32 + if not anchor_lists: + return np.empty(0, dtype=_FILL_STROKE_DTYPE) - # Quantise positions so that vertices within 1e-5 units map to the same key. - PREC = 1e-5 - quantized = np.round(all_verts.astype(np.float64) / PREC).astype(np.int64) # (T, 3) + anchors = np.concatenate(anchor_lists, axis=0).astype(np.float32) # (N, 3) - # np.unique on a 2-D array of int64 rows → unique vertex groups. - _, inverse = np.unique(quantized, axis=0, return_inverse=True) # inverse: (T,) + vm = view_matrix.astype(np.float32) + pm = proj_matrix.astype(np.float32) + R, t = vm[:3, :3], vm[:3, 3] - # Accumulate face normals per unique position. - n_unique = int(inverse.max()) + 1 - smooth = np.zeros((n_unique, 3), dtype=np.float64) - np.add.at(smooth, inverse, all_norms.astype(np.float64)) + pts_v = (R @ anchors.T).T + t # (N, 3) view space + avg_z_v = float(pts_v[:, 2].mean()) - # Normalise. - lengths = np.linalg.norm(smooth, axis=1, keepdims=True) - lengths = np.where(lengths < 1e-9, 1.0, lengths) - smooth = (smooth / lengths).astype(np.float32) + # Perspective divide → NDC. + ones = np.ones((len(pts_v), 1), dtype=np.float32) + clips = (pm @ np.hstack([pts_v, ones]).T).T # (N, 4) + w = clips[:, 3:4] + w_s = np.where(np.abs(w) > 1e-8, w, np.sign(w + 1e-38) * 1e-8) + ndcs = clips[:, :2] / w_s # (N, 2) NDC - # Write smoothed normals back into each part's structured array. - idx = 0 - for part in surface_parts: - n = len(part) - part["in_normal"] = smooth[inverse[idx : idx + n]] - idx += n + PAD = 0.05 + ndc_min = ndcs.min(axis=0) - PAD + ndc_max = ndcs.max(axis=0) + PAD + + # Stroke half-width in NDC. + # v_thickness = 0.004 * stroke_width (view-space, matching vmobject_stroke.wgsl) + # stroke_half_ndc = v_thickness * pm[0,0] / avg_clip_w + # where avg_clip_w = pm[3,2]*avg_z + pm[3,3] + avg_clip_w = float(pm[3, 2] * avg_z_v + pm[3, 3]) + avg_clip_w = avg_clip_w if abs(avg_clip_w) > 1e-8 else 1.0 + stroke_half_ndc = 0.0 + if stroke_width > 0.0 and float(stroke_color[3]) > 0.001: + stroke_half_ndc = float(0.004 * stroke_width * abs(pm[0, 0]) / abs(avg_clip_w)) + # Add stroke padding so the bounding quad covers the stroke edges. + ndc_min -= stroke_half_ndc * 2.0 + ndc_max += stroke_half_ndc * 2.0 + + # Invert NDC bounding corners to view space. + inv_px = 1.0 / (pm[0, 0] if abs(pm[0, 0]) > 1e-8 else 1.0) + inv_py = 1.0 / (pm[1, 1] if abs(pm[1, 1]) > 1e-8 else 1.0) + x0_v = (float(ndc_min[0]) * avg_clip_w - float(pm[0, 3])) * inv_px + x1_v = (float(ndc_max[0]) * avg_clip_w - float(pm[0, 3])) * inv_px + y0_v = (float(ndc_min[1]) * avg_clip_w - float(pm[1, 3])) * inv_py + y1_v = (float(ndc_max[1]) * avg_clip_w - float(pm[1, 3])) * inv_py + + corners_v = np.array( + [[x0_v, y0_v, avg_z_v], [x1_v, y0_v, avg_z_v], + [x0_v, y1_v, avg_z_v], [x1_v, y1_v, avg_z_v]], + dtype=np.float32, + ) + R_inv = R.T + t_inv = -(R_inv @ t) + corners_w = (R_inv @ corners_v.T).T + t_inv # (4, 3) world space + quad_pos = corners_w[[0, 1, 2, 1, 3, 2]] # (6, 3) two CCW triangles + + n_fill_quads = len(fill_cubics) * 4 # 4 quadratics per cubic + n_stroke_quads = len(stroke_cubics) * 4 + + verts = np.empty(6, dtype=_FILL_STROKE_DTYPE) + verts["in_pos"] = quad_pos + verts["in_fill_color"] = fill_color + verts["in_stroke_color"] = stroke_color + verts["stroke_half_ndc"] = stroke_half_ndc + verts["fill_curve_start"] = fill_curve_start + verts["n_fill_curves"] = n_fill_quads + verts["stroke_curve_start"] = stroke_curve_start + verts["n_stroke_curves"] = n_stroke_quads + return verts + + +# --------------------------------------------------------------------------- +# Surface geometry collectors (unchanged from original) +# --------------------------------------------------------------------------- + + +def _surface_opacity_class(part: np.ndarray) -> str: + alphas = part["in_color"][:, 3] + return "opaque" if float(alphas.min()) >= 0.99 else "oit" def _collect_surface_geometry(vmobject: VMobject) -> np.ndarray | None: - """Return a ``_SURFACE_DTYPE`` array for a shade_in_3d VMobject, or ``None``.""" + """Return a ``_SURFACE_DTYPE`` array for a shade_in_3d VMobject.""" fill_rgba = vmobject.get_fill_rgbas() if fill_rgba.shape[0] == 0 or fill_rgba[0, 3] == 0: return None @@ -649,22 +787,9 @@ def _collect_surface_geometry(vmobject: VMobject) -> np.ndarray | None: if n_pts < 3: continue - centroid = anchors.mean(axis=0) - v0 = anchors[0] - centroid - v1 = anchors[1] - centroid - # WebGPU evaluates front_face="ccw" in framebuffer space (Y points DOWN). - # Clip space has Y pointing UP, so Y is negated going clip → framebuffer, - # which reverses the apparent winding. A triangle that is CW in clip/ - # world Y-up space becomes CCW in framebuffer space = FRONT FACE. - # - # Manim's anchor ordering is CCW in world Y-up space when viewed from - # outside the surface (confirmed analytically for standard sphere/torus - # parameterisations). Therefore: - # (centroid, curr, next) → CW in world Y-up → CCW in framebuffer → FRONT FACE ✓ - # (centroid, next, curr) → CCW in world Y-up → CW in framebuffer → BACK FACE (culled) ✗ - # - # Normal: v1 × v0 with CCW-from-outside anchors gives the outward-pointing - # normal (v0 × v1 would be inward). + centroid = anchors.mean(axis=0) + v0 = anchors[0] - centroid + v1 = anchors[1] - centroid raw_normal = np.cross(v1, v0).astype(np.float64) norm_len = np.linalg.norm(raw_normal) normal = ( @@ -675,8 +800,8 @@ def _collect_surface_geometry(vmobject: VMobject) -> np.ndarray | None: fan_verts = np.empty((n_pts * 3, 3), dtype=np.float32) fan_verts[0::3] = centroid.astype(np.float32) - fan_verts[1::3] = anchors.astype(np.float32) # curr - fan_verts[2::3] = np.roll(anchors, -1, axis=0).astype(np.float32) # next + fan_verts[1::3] = anchors.astype(np.float32) + fan_verts[2::3] = np.roll(anchors, -1, axis=0).astype(np.float32) all_verts.append(fan_verts) all_normals.append(np.tile(normal, (n_pts * 3, 1))) @@ -695,245 +820,77 @@ def _collect_surface_geometry(vmobject: VMobject) -> np.ndarray | None: return attrs -# --------------------------------------------------------------------------- -# Slug fill geometry collector -# --------------------------------------------------------------------------- +def _smooth_surface_normals(surface_parts: list[np.ndarray]) -> None: + """Average normals at shared vertex positions (modifies in-place).""" + if not surface_parts: + return + all_verts = np.concatenate([p["in_vert"] for p in surface_parts], axis=0) + all_norms = np.concatenate([p["in_normal"] for p in surface_parts], axis=0) -def _collect_slug_fill_geometry( - vmobject: VMobject, -) -> tuple[np.ndarray, np.ndarray] | None: - """Return ``(color, curves_flat)`` for the Slug fill pipeline, or ``None``. + PREC = 1e-5 + quantized = np.round(all_verts.astype(np.float64) / PREC).astype(np.int64) + _, inverse = np.unique(quantized, axis=0, return_inverse=True) - Only the camera-independent data is returned here so the result can be - cached purely on the vmobject's geometry. The caller builds the - per-frame bounding quad via ``_build_slug_quad()`` using the current - view and projection matrices. + n_unique = int(inverse.max()) + 1 + smooth = np.zeros((n_unique, 3), dtype=np.float64) + np.add.at(smooth, inverse, all_norms.astype(np.float64)) - *color* is a float32 RGBA array. + lengths = np.linalg.norm(smooth, axis=1, keepdims=True) + lengths = np.where(lengths < 1e-9, 1.0, lengths) + smooth = (smooth / lengths).astype(np.float32) - *curves_flat* is a ``float32`` array of shape ``(N * 3, 3)`` - containing the world-space XYZ of every quadratic bezier control point - — three consecutive entries (p1, p2, p3) per curve. - """ - fill_rgba = vmobject.get_fill_rgbas() - if fill_rgba.shape[0] == 0 or fill_rgba[0, 3] == 0: - return None + idx = 0 + for part in surface_parts: + n = len(part) + part["in_normal"] = smooth[inverse[idx : idx + n]] + idx += n - color = fill_rgba[0].astype(np.float32) - subpaths = vmobject.get_subpaths() - if not subpaths: - return None - nppcc = vmobject.n_points_per_cubic_curve +# --------------------------------------------------------------------------- +# Surface mesh stroke collector (cubic stroke pipeline, surface only) +# --------------------------------------------------------------------------- - per_subpath: list[np.ndarray] = [] # each: (n, 3, 3) world-space XYZ - for subpath in subpaths: +def _collect_surface_stroke_geometry(vmobject: VMobject) -> np.ndarray | None: + """Return a ``_STROKE_DTYPE`` array for a Surface sub-face stroke.""" + stroke_rgba = vmobject.get_stroke_rgbas() + stroke_width = float(vmobject.get_stroke_width()) + if stroke_rgba.shape[0] == 0 or stroke_rgba[0, 3] == 0 or stroke_width == 0: + return None + + color = stroke_rgba[0].astype(np.float32) + nppcc = vmobject.n_points_per_cubic_curve + + curve_list: list[np.ndarray] = [] + for subpath in vmobject.get_subpaths(): n_curves = len(subpath) // nppcc if n_curves == 0: continue pts = subpath[: n_curves * nppcc] - b0s = pts[0::nppcc] h0s = pts[1::nppcc] h1s = pts[2::nppcc] - b2s = pts[3::nppcc] - - # Subdivide cubics into quadratics (2 de Casteljau levels → 4 per cubic). - qb0s, qmids, qb2s = _cubic_to_quadratics(b0s, h0s, h1s, b2s) - - # Stack to (n_quads, 3, 3): [p1, p2, p3] in world-space XYZ. - curves_xyz = np.stack([qb0s, qmids, qb2s], axis=1) # (n, 3, 3) - - # Implicit close: if the subpath is open (last anchor ≠ first anchor), - # add a degenerate quadratic representing the straight closing line from - # the last anchor back to the first anchor. This matches Cairo's - # implicit-close behaviour during partial animations (e.g. Create): - # without this segment the winding-number integral is wrong for open - # paths and the fill spills outside the intended region. - first_anchor = b0s[0].astype(np.float32) - last_anchor = b2s[-1].astype(np.float32) - if not np.allclose(first_anchor, last_anchor, atol=1e-6): - mid_pt = ((first_anchor + last_anchor) * 0.5).astype(np.float32) - closing = np.array( - [[last_anchor, mid_pt, first_anchor]], dtype=np.float32 - ) # (1, 3, 3) - curves_xyz = np.concatenate([curves_xyz, closing], axis=0) + b3s = pts[3::nppcc] + curve_list.append(np.stack([b0s, h0s, h1s, b3s], axis=1)) - per_subpath.append(curves_xyz.astype(np.float32)) - - if not per_subpath: + if not curve_list: return None - curves_stacked = np.concatenate(per_subpath, axis=0) # (N_total, 3, 3) - curves_flat = curves_stacked.reshape(-1, 3) # (N_total * 3, 3) - - # Return only the camera-independent curve data. The caller builds the - # bounding quad each frame via _build_slug_quad() so it always reflects - # the current view and projection matrices. - return color, curves_flat - - -# --------------------------------------------------------------------------- -# Single-mobject draw helpers (used by the explicit public helpers above) -# --------------------------------------------------------------------------- - - -def _build_slug_quad( - color: np.ndarray, - curves_flat: np.ndarray, - view_matrix: np.ndarray, - proj_matrix: np.ndarray, -) -> np.ndarray: - """Build a ``_SLUG_FILL_DTYPE`` bounding-quad array from cached curve data. - - Called every frame so the quad always reflects the current view and - projection matrices. The bounding box is computed in NDC space - (clip.xy / clip.w), which is correct for both orthographic (w=1) and - perspective projections, then mapped back to world space at avg_z. - - Parameters - ---------- - color : float32 RGBA fill colour. - curves_flat : (N*3, 3) world-space control points from _collect_slug_fill_geometry. - view_matrix : current 4×4 view matrix. - proj_matrix : current 4×4 projection matrix. - """ - n_quads = len(curves_flat) // 3 - vm = view_matrix.astype(np.float32) - pm = proj_matrix.astype(np.float32) - R, t = vm[:3, :3], vm[:3, 3] - - pts_v = (R @ curves_flat.T).T + t # (N*3, 3) view space - avg_z_v = float(pts_v[:, 2].mean()) - - # Perspective divide in NDC: works for both ortho (w=1) and perspective. - ones = np.ones((len(pts_v), 1), dtype=np.float32) - pts_vh = np.hstack([pts_v, ones]) # (N*3, 4) - clips = (pm @ pts_vh.T).T # (N*3, 4) clip space - w = clips[:, 3:4] - w_safe = np.where(np.abs(w) > 1e-8, w, np.sign(w + 1e-38) * 1e-8) - ndcs = clips[:, :2] / w_safe # (N*3, 2) NDC XY - - PAD = 0.05 - ndc_min = ndcs.min(axis=0) - PAD - ndc_max = ndcs.max(axis=0) + PAD - x0_n, y0_n = float(ndc_min[0]), float(ndc_min[1]) - x1_n, y1_n = float(ndc_max[0]), float(ndc_max[1]) - - # Invert NDC → view space at avg_z_v. - # ndc_x = (pm[0,0]*x_v + pm[0,3]) / w_clip where w_clip = pm[3,2]*z + pm[3,3] - # Ortho: w_clip=1 | Perspective: w_clip = -avg_z_v - avg_clip_w = float(pm[3, 2] * avg_z_v + pm[3, 3]) - avg_clip_w = avg_clip_w if abs(avg_clip_w) > 1e-8 else 1.0 - inv_px = 1.0 / (pm[0, 0] if abs(pm[0, 0]) > 1e-8 else 1.0) - inv_py = 1.0 / (pm[1, 1] if abs(pm[1, 1]) > 1e-8 else 1.0) - x0_v = (x0_n * avg_clip_w - float(pm[0, 3])) * inv_px - x1_v = (x1_n * avg_clip_w - float(pm[0, 3])) * inv_px - y0_v = (y0_n * avg_clip_w - float(pm[1, 3])) * inv_py - y1_v = (y1_n * avg_clip_w - float(pm[1, 3])) * inv_py - - corners_v = np.array( - [[x0_v, y0_v, avg_z_v], [x1_v, y0_v, avg_z_v], - [x0_v, y1_v, avg_z_v], [x1_v, y1_v, avg_z_v]], - dtype=np.float32, - ) - R_inv = R.T - t_inv = -(R_inv @ t) - corners_w = (R_inv @ corners_v.T).T + t_inv # (4, 3) world space - quad_pos = corners_w[[0, 1, 2, 1, 3, 2]] # (6, 3) two CCW triangles - - quad_verts = np.empty(6, dtype=_SLUG_FILL_DTYPE) - quad_verts["in_pos"] = quad_pos - quad_verts["in_color"] = color - quad_verts["curve_start"] = 0 - quad_verts["n_curves"] = n_quads - return quad_verts - - -def _draw_vmobject_stroke(renderer: WebGPURenderer, vmobject: VMobject) -> None: - data = _collect_stroke_geometry(vmobject) - if data is None: - return + all_curves = np.concatenate(curve_list, axis=0).astype(np.float32) # (N, 4, 3) + n_total = len(all_curves) - import wgpu + base = np.zeros(n_total * 3, dtype=_STROKE_DTYPE) + base["current_curve"] = np.repeat(all_curves, 3, axis=0) + base["in_color"] = color + base["in_width"] = stroke_width - device: wgpu_t.GPUDevice = renderer.device - vbo = device.create_buffer_with_data( - data=data.tobytes(), usage=wgpu.BufferUsage.VERTEX + stroke_data = np.tile(base, 2) + n_half = n_total * 3 + stroke_data["tile_coordinate"][:n_half] = np.tile( + [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0]], (n_total, 1) ) - renderer.frame_vbos.append(vbo) - - rp = renderer.current_render_pass - rp.set_pipeline(renderer.stroke_pipeline) - rp.set_bind_group(0, renderer.camera_bind_group, [], 0, 0) - rp.set_vertex_buffer(0, vbo) - rp.draw(len(data), 1, 0, 0) - - -def _draw_surface_face(renderer: WebGPURenderer, vmobject: VMobject) -> None: - data = _collect_surface_geometry(vmobject) - if data is None: - return - - import wgpu - - device: wgpu_t.GPUDevice = renderer.device - vbo = device.create_buffer_with_data( - data=data.tobytes(), usage=wgpu.BufferUsage.VERTEX + stroke_data["tile_coordinate"][n_half:] = np.tile( + [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]], (n_total, 1) ) - renderer.frame_vbos.append(vbo) - - rp = renderer.current_render_pass - rp.set_pipeline(renderer.surface_pipeline) - rp.set_bind_group(0, renderer.camera_bind_group, [], 0, 0) - rp.set_vertex_buffer(0, vbo) - rp.draw(len(data), 1, 0, 0) - - -# --------------------------------------------------------------------------- -# Cubic → quadratic subdivision (used by Slug fill geometry collector) -# --------------------------------------------------------------------------- - -_CUBIC_SUBDIVISION_LEVELS: int = 2 # 4 quadratic pieces per cubic bezier - - -def _cubic_to_quadratics( - b0s: np.ndarray, - h0s: np.ndarray, - h1s: np.ndarray, - b2s: np.ndarray, - levels: int = _CUBIC_SUBDIVISION_LEVELS, -) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """Subdivide n cubic beziers into n*2^levels quadratic approximations. - - Each cubic (b0, h0, h1, b3) is split by de Casteljau at t=0.5 `levels` - times, then each sub-cubic is approximated by the quadratic whose single - control point is the midpoint of its two handles. - - Returns (qb0s, qmids, qb2s), each shaped (n * 2^levels, 3). - """ - curves = np.stack([b0s, h0s, h1s, b2s], axis=1).astype(np.float64) # (n, 4, 3) - - for _ in range(levels): - n_cur = len(curves) - c0, c1, c2, c3 = curves[:, 0], curves[:, 1], curves[:, 2], curves[:, 3] - m01 = (c0 + c1) * 0.5 - m12 = (c1 + c2) * 0.5 - m23 = (c2 + c3) * 0.5 - m012 = (m01 + m12) * 0.5 - m123 = (m12 + m23) * 0.5 - m0123 = (m012 + m123) * 0.5 - - new_curves = np.empty((n_cur * 2, 4, 3), dtype=np.float64) - new_curves[0::2, 0] = c0; new_curves[0::2, 1] = m01 - new_curves[0::2, 2] = m012; new_curves[0::2, 3] = m0123 - new_curves[1::2, 0] = m0123; new_curves[1::2, 1] = m123 - new_curves[1::2, 2] = m23; new_curves[1::2, 3] = c3 - curves = new_curves - - qb0s = curves[:, 0] - qmids = (curves[:, 1] + curves[:, 2]) * 0.5 # midpoint of sub-handles - qb2s = curves[:, 3] - return qb0s, qmids, qb2s + return stroke_data From da6906092a577b1e13e8590d60f167cf612358e6 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Wed, 8 Apr 2026 15:21:15 +0530 Subject: [PATCH 12/33] Optimization: Surface shader and mesh shader is now combined into one shader --- .../webgpu/shaders/surface_combined.wgsl | 117 ++++++++ .../renderer/webgpu/shaders/surface_oit.wgsl | 114 ++++---- manim/renderer/webgpu/webgpu_renderer.py | 102 +------ .../webgpu/webgpu_vmobject_rendering.py | 267 +++++++----------- 4 files changed, 283 insertions(+), 317 deletions(-) create mode 100644 manim/renderer/webgpu/shaders/surface_combined.wgsl diff --git a/manim/renderer/webgpu/shaders/surface_combined.wgsl b/manim/renderer/webgpu/shaders/surface_combined.wgsl new file mode 100644 index 0000000000..302b519465 --- /dev/null +++ b/manim/renderer/webgpu/shaders/surface_combined.wgsl @@ -0,0 +1,117 @@ +// Combined opaque surface fill + barycentric wireframe shader. +// +// One draw call per surface face renders both Phong-lit fill and mesh-grid +// lines without a separate stroke pass. +// +// Technique +// --------- +// Each triangle (centroid, anchor_i, anchor_{i+1}) in the centroid fan +// carries barycentric coordinates: +// centroid → bary = (1, 0, 0) bary.x = 0 on the outer edge +// anchor_i → bary = (0, 1, 0) +// anchor_{i+1} → bary = (0, 0, 1) +// +// bary.x is 0 on the "outer" edge (anchor_i ↔ anchor_{i+1}), which is the +// visible mesh-grid edge. The inner spoke edges (centroid ↔ anchor_*) have +// bary.y = 0 or bary.z = 0 but are NOT rendered as wireframe. +// +// fwidth(bary.x) gives the screen-space derivative of bary.x, so +// edge_dist_px = bary.x / fwidth(bary.x) +// is approximately the distance from the outer edge in screen pixels. +// A smooth SDF step at stroke_half_px produces anti-aliased grid lines. +// +// Compositing: wireframe stroke "over" Phong fill (Porter-Duff). +// +// Vertex layout (must match _SURFACE_COMBINED_DTYPE, stride 72 bytes): +// location 0 — in_vert float32x3 offset 0 +// location 1 — in_normal float32x3 offset 12 +// location 2 — in_fill_color float32x4 offset 24 +// location 3 — in_stroke_color float32x4 offset 40 +// location 4 — in_bary float32x3 offset 56 +// location 5 — stroke_half_px float32 offset 68 + +struct Uniforms { + projection : mat4x4, + view : mat4x4, + light_pos : vec3, + light_intensity : f32, + light_color : vec3, + ambient_intensity : f32, + ambient_color : vec3, + _pad : f32, +}; +@group(0) @binding(0) var u : Uniforms; + +struct VertexInput { + @location(0) in_vert : vec3, + @location(1) in_normal : vec3, + @location(2) in_fill_color : vec4, + @location(3) in_stroke_color : vec4, + @location(4) in_bary : vec3, + @location(5) stroke_half_px : f32, +}; + +struct VertexOutput { + @builtin(position) clip_position : vec4, + @location(0) v_fill_color : vec4, + @location(1) v_stroke_color : vec4, + @location(2) v_view_normal : vec3, + @location(3) v_view_pos : vec3, + @location(4) v_view_light : vec3, + @location(5) v_bary : vec3, + @location(6) @interpolate(flat) v_stroke_half : f32, +}; + +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + var out: VertexOutput; + let view_pos = u.view * vec4(in.in_vert, 1.0); + out.clip_position = u.projection * view_pos; + out.v_view_pos = view_pos.xyz; + let view3 = mat3x3(u.view[0].xyz, u.view[1].xyz, u.view[2].xyz); + out.v_view_normal = view3 * in.in_normal; + out.v_view_light = (u.view * vec4(u.light_pos, 1.0)).xyz; + out.v_fill_color = in.in_fill_color; + out.v_stroke_color = in.in_stroke_color; + out.v_bary = in.in_bary; + out.v_stroke_half = in.stroke_half_px; + return out; +} + +@fragment +fn fs_main(in: VertexOutput, @builtin(front_facing) front_facing: bool) -> @location(0) vec4 { + let diffuse_strength = 0.9; + let specular_strength = 0.8; + let specular_exp = 16.0; + + let raw_normal = select(-in.v_view_normal, in.v_view_normal, front_facing); + let norm = normalize(raw_normal); + let light_dir_v = in.v_view_light - in.v_view_pos; + let light_dir = normalize(light_dir_v); + let view_dir = normalize(-in.v_view_pos); + let half_vec = normalize(light_dir + view_dir); + + let diff = clamp(dot(norm, light_dir), 0.0, 1.0); + let spec = pow(max(dot(norm, half_vec), 0.0), specular_exp); + let attenuation = u.light_intensity / dot(light_dir_v, light_dir_v); + + let ambient_rgb = in.v_fill_color.rgb * u.ambient_color * u.ambient_intensity; + let diffuse_rgb = in.v_fill_color.rgb * u.light_color * (diffuse_strength * diff * attenuation); + let specular_rgb = u.light_color * (specular_strength * spec * attenuation); + let lit_rgb = clamp(ambient_rgb + diffuse_rgb + specular_rgb, vec3(0.0), vec3(1.0)); + let fill_a = in.v_fill_color.a; + + // ── Barycentric wireframe ────────────────────────────────────────────── + // bary.x is the barycentric weight of the centroid vertex, which equals 0 + // on the outer (mesh-grid) edge. fwidth converts bary.x to pixel units. + let edge_dist_px = in.v_bary.x / max(fwidth(in.v_bary.x), 1e-6); + let stroke_cov = clamp(in.v_stroke_half + 0.5 - edge_dist_px, 0.0, 1.0); + let stroke_a = in.v_stroke_color.a * stroke_cov; + + // ── Porter-Duff "over": stroke on top of fill ───────────────────────── + let total_a = stroke_a + fill_a * (1.0 - stroke_a); + if total_a <= 0.001 { discard; } + + let out_rgb = (stroke_a * in.v_stroke_color.rgb + fill_a * (1.0 - stroke_a) * lit_rgb) / total_a; + return vec4(out_rgb, total_a); +} diff --git a/manim/renderer/webgpu/shaders/surface_oit.wgsl b/manim/renderer/webgpu/shaders/surface_oit.wgsl index 22904a22a9..3a94585566 100644 --- a/manim/renderer/webgpu/shaders/surface_oit.wgsl +++ b/manim/renderer/webgpu/shaders/surface_oit.wgsl @@ -13,19 +13,13 @@ // A subsequent full-screen composition pass reads both textures and composites // the result onto the opaque framebuffer. // -// Lighting model and uniform layout are identical to surface.wgsl so that -// opaque and transparent surfaces are lit consistently. -// -// Uniform layout (group 0, binding 0): -// offset 0 — projection mat4x4 (64 bytes) -// offset 64 — view mat4x4 (64 bytes) -// offset 128 — light_pos vec3 (12 bytes) -// offset 140 — light_intensity f32 ( 4 bytes) -// offset 144 — light_color vec3 (12 bytes) -// offset 156 — ambient_intensity f32 ( 4 bytes) -// offset 160 — ambient_color vec3 (12 bytes) -// offset 172 — _pad f32 ( 4 bytes) -// total: 176 bytes +// Vertex layout matches surface_combined.wgsl (stride 72 bytes): +// location 0 — in_vert float32x3 offset 0 +// location 1 — in_normal float32x3 offset 12 +// location 2 — in_fill_color float32x4 offset 24 +// location 3 — in_stroke_color float32x4 offset 40 +// location 4 — in_bary float32x3 offset 56 +// location 5 — stroke_half_px float32 offset 68 struct Uniforms { projection : mat4x4, @@ -40,29 +34,38 @@ struct Uniforms { @group(0) @binding(0) var u : Uniforms; struct VertexInput { - @location(0) in_vert : vec3, - @location(1) in_normal : vec3, - @location(2) in_color : vec4, + @location(0) in_vert : vec3, + @location(1) in_normal : vec3, + @location(2) in_fill_color : vec4, + @location(3) in_stroke_color : vec4, + @location(4) in_bary : vec3, + @location(5) stroke_half_px : f32, }; struct VertexOutput { - @builtin(position) clip_position : vec4, - @location(0) v_color : vec4, - @location(1) v_view_normal : vec3, - @location(2) v_view_pos : vec3, - @location(3) v_view_light : vec3, + @builtin(position) clip_position : vec4, + @location(0) v_fill_color : vec4, + @location(1) v_stroke_color : vec4, + @location(2) v_view_normal : vec3, + @location(3) v_view_pos : vec3, + @location(4) v_view_light : vec3, + @location(5) v_bary : vec3, + @location(6) @interpolate(flat) v_stroke_half : f32, }; @vertex fn vs_main(in: VertexInput) -> VertexOutput { var out: VertexOutput; - let view_pos = u.view * vec4(in.in_vert, 1.0); - out.clip_position = u.projection * view_pos; - out.v_view_pos = view_pos.xyz; - let view3 = mat3x3(u.view[0].xyz, u.view[1].xyz, u.view[2].xyz); - out.v_view_normal = view3 * in.in_normal; - out.v_view_light = (u.view * vec4(u.light_pos, 1.0)).xyz; - out.v_color = in.in_color; + let view_pos = u.view * vec4(in.in_vert, 1.0); + out.clip_position = u.projection * view_pos; + out.v_view_pos = view_pos.xyz; + let view3 = mat3x3(u.view[0].xyz, u.view[1].xyz, u.view[2].xyz); + out.v_view_normal = view3 * in.in_normal; + out.v_view_light = (u.view * vec4(u.light_pos, 1.0)).xyz; + out.v_fill_color = in.in_fill_color; + out.v_stroke_color = in.in_stroke_color; + out.v_bary = in.in_bary; + out.v_stroke_half = in.stroke_half_px; return out; } @@ -73,48 +76,47 @@ struct FragOutput { @fragment fn fs_main(in: VertexOutput, @builtin(front_facing) front_facing: bool) -> FragOutput { - // Per-material diffuse and specular strengths — identical to surface.wgsl. - // Will be replaced by per-surface gloss/shadow when LightSource system lands. let diffuse_strength = 0.9; let specular_strength = 0.8; let specular_exp = 16.0; - // Two-sided lighting: flip the normal for back-facing fragments so that - // both sides of open surfaces are correctly lit from either direction. - // Transparent surfaces (cull_mode="none") commonly show both sides — a - // semi-transparent sphere's inner hemisphere is visible through the front. - let raw_normal = select(-in.v_view_normal, in.v_view_normal, front_facing); - let norm = normalize(raw_normal); - let light_dir_vec = in.v_view_light - in.v_view_pos; - let light_distance2 = dot(light_dir_vec, light_dir_vec); - let light_dir = normalize(light_dir_vec); - let view_dir = normalize(-in.v_view_pos); + // Two-sided lighting: flip normal for back-facing fragments. + let raw_normal = select(-in.v_view_normal, in.v_view_normal, front_facing); + let norm = normalize(raw_normal); + let light_dir_v = in.v_view_light - in.v_view_pos; + let light_dir = normalize(light_dir_v); + let view_dir = normalize(-in.v_view_pos); + let half_vec = normalize(light_dir + view_dir); - let diff = clamp(dot(norm, light_dir), 0.0, 1.0); - let half_vec = normalize(light_dir + view_dir); - let spec = pow(max(dot(norm, half_vec), 0.0), specular_exp); + let diff = clamp(dot(norm, light_dir), 0.0, 1.0); + let spec = pow(max(dot(norm, half_vec), 0.0), specular_exp); + let attenuation = u.light_intensity / dot(light_dir_v, light_dir_v); - // Identical lighting formula to surface.wgsl. - let attenuation = u.light_intensity / light_distance2; + let ambient_rgb = in.v_fill_color.rgb * u.ambient_color * u.ambient_intensity; + let diffuse_rgb = in.v_fill_color.rgb * u.light_color * (diffuse_strength * diff * attenuation); + let specular_rgb = u.light_color * (specular_strength * spec * attenuation); + let lit_rgb = clamp(ambient_rgb + diffuse_rgb + specular_rgb, vec3(0.0), vec3(1.0)); + let fill_a = in.v_fill_color.a; - let ambient_rgb = in.v_color.rgb * u.ambient_color * u.ambient_intensity; - let diffuse_rgb = in.v_color.rgb * u.light_color * (diffuse_strength * diff * attenuation); - let specular_rgb = u.light_color * (specular_strength * spec * attenuation); + // ── Barycentric wireframe ────────────────────────────────────────────── + let edge_dist_px = in.v_bary.x / max(fwidth(in.v_bary.x), 1e-6); + let stroke_cov = clamp(in.v_stroke_half + 0.5 - edge_dist_px, 0.0, 1.0); + let stroke_a = in.v_stroke_color.a * stroke_cov; - let rgb = clamp(ambient_rgb + diffuse_rgb + specular_rgb, vec3(0.0), vec3(1.0)); - let alpha = in.v_color.a; + // ── Porter-Duff "over": stroke on top of fill ───────────────────────── + let total_a = stroke_a + fill_a * (1.0 - stroke_a); + if total_a <= 0.001 { discard; } + let out_rgb = (stroke_a * in.v_stroke_color.rgb + fill_a * (1.0 - stroke_a) * lit_rgb) / total_a; - // Depth-based weight that balances contributions from front and back layers. + // ── Weighted Blended OIT ─────────────────────────────────────────────── let z = in.v_view_pos.z; let w = clamp( - pow(alpha, 3.0) / (1e-5 + pow(abs(z) / 5.0, 4.0)), + pow(total_a, 3.0) / (1e-5 + pow(abs(z) / 5.0, 4.0)), 1e-2, 3e3 ); var out: FragOutput; - // accum: additive blend (pipeline: src=one, dst=one) - out.accum = vec4(rgb * alpha * w, alpha * w); - // reveal: multiplicative blend (pipeline: src=zero, dst=one-minus-src-alpha) - out.reveal = vec4(alpha, alpha, alpha, alpha); + out.accum = vec4(out_rgb * total_a * w, total_a * w); + out.reveal = vec4(total_a, total_a, total_a, total_a); return out; } diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index ae3c241c91..10041e1998 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -45,8 +45,7 @@ from .webgpu_vmobject_rendering import ( FILL_STROKE_VERTEX_LAYOUT, - STROKE_VERTEX_LAYOUT, - SURFACE_VERTEX_LAYOUT, + SURFACE_COMBINED_VERTEX_LAYOUT, _FrameData, collect_frame_data, draw_frame_data, @@ -480,10 +479,7 @@ def __init__( self._compute_bgl: wgpu_t.GPUBindGroupLayout | None = None self._cubic_to_quads_pipeline: wgpu_t.GPUComputePipeline | None = None - # Surface mesh stroke (depth-biased cubic stroke pipeline). - self._stroke_3d_surface_pipeline: wgpu_t.GPURenderPipeline | None = None - - # Surface pipelines: opaque (depth write + backface cull) and OIT. + # Surface pipelines: opaque (depth write, combined fill+wireframe) and OIT. self._surface_pipeline: wgpu_t.GPURenderPipeline | None = None # opaque self._surface_oit_pipeline: wgpu_t.GPURenderPipeline | None = None # OIT accumulation textures (rgba16float each). @@ -573,13 +569,6 @@ def init_scene(self, scene: Scene) -> None: # (depth24plus unit ≈ 6e-8), which is large enough to reliably beat # floating-point depth jitter on flat/low-slope surface regions where # depth_bias_slope_scale alone contributes nearly zero. - self._stroke_3d_surface_pipeline = self._create_stroke_pipeline( - self._proj_bgl, - depth_test=True, - depth_bias=-10000, - depth_bias_slope_scale=-1.0, - depth_bias_clamp=0.00001, - ) self._surface_pipeline = self._create_surface_pipeline(self._proj_bgl, cull_mode="none", depth_write=True) # Combined fill+stroke pipeline (replaces separate slug + stroke pipelines). @@ -627,81 +616,6 @@ def _create_camera_bgl(self) -> wgpu_t.GPUBindGroupLayout: ] ) - def _create_stroke_pipeline( - self, - proj_bgl: wgpu_t.GPUBindGroupLayout, - depth_test: bool = False, - depth_bias: int = 0, - depth_bias_slope_scale: float = 0.0, - depth_bias_clamp: float = 0.0, - ) -> wgpu_t.GPURenderPipeline: - """Create a stroke pipeline. - - depth_test controls depth *writing* only — both 2-D and 3-D strokes - always depth-test (depth_compare="less") so they are correctly occluded - by opaque geometry. - - depth_test=False — 2-D strokes: depth-read-only. Occluded by any - opaque surface in front of them, but do not themselves - occlude later geometry. - depth_test=True — 3-D strokes (shade_in_3d): depth-write + depth-test - so they occlude geometry drawn behind them. - depth_bias / depth_bias_slope_scale / depth_bias_clamp - — WebGPU depth bias applied to every fragment. Use - negative values to push geometry toward the camera, - which prevents z-fighting when strokes lie exactly - on a surface (e.g. Surface mesh lines). - """ - assert self._device is not None - shader_path = Path(__file__).parent / "shaders" / "vmobject_stroke.wgsl" - shader_module = self._device.create_shader_module( - code=shader_path.read_text(encoding="utf-8") - ) - _blend = { - "color": { - "src_factor": "src-alpha", - "dst_factor": "one-minus-src-alpha", - "operation": "add", - }, - "alpha": { - "src_factor": "one", - "dst_factor": "one", - "operation": "add", - }, - } - return self._device.create_render_pipeline( - layout=self._device.create_pipeline_layout( - bind_group_layouts=[proj_bgl] - ), - vertex={ - "module": shader_module, - "entry_point": "vs_main", - "buffers": [STROKE_VERTEX_LAYOUT], - }, - fragment={ - "module": shader_module, - "entry_point": "fs_main", - "targets": [{"format": wgpu.TextureFormat.bgra8unorm, "blend": _blend}], - }, - primitive={"topology": "triangle-list", "cull_mode": "none"}, - depth_stencil={ - "format": wgpu.TextureFormat.depth24plus, - "depth_write_enabled": depth_test, - "depth_compare": "less", # always depth-test; write only for 3-D strokes - "depth_bias": depth_bias, - "depth_bias_slope_scale": depth_bias_slope_scale, - "depth_bias_clamp": depth_bias_clamp, - "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, - "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, - "stencil_read_mask": 0, - "stencil_write_mask": 0, - }, - multisample={ - "count": 1, - "mask": 0xFFFF_FFFF, - "alpha_to_coverage_enabled": False, - }, - ) def _create_fill_stroke_pipeline( self, @@ -839,7 +753,7 @@ def _create_surface_pipeline( each other (they still depth-test against opaque geometry). """ assert self._device is not None - shader_path = Path(__file__).parent / "shaders" / "surface.wgsl" + shader_path = Path(__file__).parent / "shaders" / "surface_combined.wgsl" shader_module = self._device.create_shader_module( code=shader_path.read_text(encoding="utf-8") ) @@ -862,7 +776,7 @@ def _create_surface_pipeline( vertex={ "module": shader_module, "entry_point": "vs_main", - "buffers": [SURFACE_VERTEX_LAYOUT], + "buffers": [SURFACE_COMBINED_VERTEX_LAYOUT], }, fragment={ "module": shader_module, @@ -927,7 +841,7 @@ def _create_oit_resources(self, width: int, height: int) -> None: vertex={ "module": oit_shader, "entry_point": "vs_main", - "buffers": [SURFACE_VERTEX_LAYOUT], + "buffers": [SURFACE_COMBINED_VERTEX_LAYOUT], }, fragment={ "module": oit_shader, @@ -1127,12 +1041,6 @@ def fill_stroke_3d_pipeline(self) -> wgpu_t.GPURenderPipeline: assert self._fill_stroke_3d_pipeline is not None, "init_scene() has not been called" return self._fill_stroke_3d_pipeline - @property - def stroke_3d_surface_pipeline(self) -> wgpu_t.GPURenderPipeline: - """Cubic stroke pipeline with depth bias — for surface mesh lines.""" - assert self._stroke_3d_surface_pipeline is not None, "init_scene() has not been called" - return self._stroke_3d_surface_pipeline - @property def surface_pipeline(self) -> wgpu_t.GPURenderPipeline: """Opaque surface pipeline (depth_write=True).""" diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py index b62511ede7..8583e959ea 100644 --- a/manim/renderer/webgpu/webgpu_vmobject_rendering.py +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -21,10 +21,12 @@ Surfaces -------- -Parametric surfaces use a triangle-mesh pipeline (``surface.wgsl``) with -Phong lighting. Transparent surfaces go through the separate OIT -accumulation + composition passes (``surface_oit.wgsl`` + ``oit_compose.wgsl``). -Surface mesh grid lines use the old cubic-stroke pipeline with depth bias. +Parametric surfaces use a combined triangle-mesh pipeline +(``surface_combined.wgsl`` / ``surface_oit.wgsl``) with Phong lighting and +barycentric wireframe in a single draw call. The centroid vertex of each +triangle fan carries bary=(1,0,0); the outer edge (anchor_i ↔ anchor_{i+1}) +has bary.x=0 — this is the visible mesh-grid edge. Transparent surfaces go +through the OIT accumulation + composition passes. Batching -------- @@ -53,65 +55,45 @@ # --------------------------------------------------------------------------- -# Surface vertex layout — must match surface.wgsl locations +# Combined surface vertex layout — must match surface_combined.wgsl / +# surface_oit.wgsl locations. +# +# location 0 — in_vert float32x3 offset 0 (12 B) +# location 1 — in_normal float32x3 offset 12 (12 B) +# location 2 — in_fill_color float32x4 offset 24 (16 B) +# location 3 — in_stroke_color float32x4 offset 40 (16 B) +# location 4 — in_bary float32x3 offset 56 (12 B) +# location 5 — stroke_half_px float32 offset 68 ( 4 B) +# stride: 72 bytes # --------------------------------------------------------------------------- -_SURFACE_DTYPE = np.dtype( +_SURFACE_COMBINED_DTYPE = np.dtype( [ - ("in_vert", np.float32, (3,)), - ("in_normal", np.float32, (3,)), - ("in_color", np.float32, (4,)), + ("in_vert", np.float32, (3,)), + ("in_normal", np.float32, (3,)), + ("in_fill_color", np.float32, (4,)), + ("in_stroke_color", np.float32, (4,)), + ("in_bary", np.float32, (3,)), + ("stroke_half_px", np.float32), ] ) -_SURFACE_STRIDE: int = _SURFACE_DTYPE.itemsize # 40 bytes - -_SURFACE_OFFSETS: dict[str, int] = { - name: _SURFACE_DTYPE.fields[name][1] # type: ignore[index] - for name in _SURFACE_DTYPE.names -} +_SURFACE_COMBINED_STRIDE: int = _SURFACE_COMBINED_DTYPE.itemsize # 72 bytes -SURFACE_VERTEX_LAYOUT: dict = { - "array_stride": _SURFACE_STRIDE, - "step_mode": "vertex", - "attributes": [ - {"format": "float32x3", "offset": _SURFACE_OFFSETS["in_vert"], "shader_location": 0}, - {"format": "float32x3", "offset": _SURFACE_OFFSETS["in_normal"], "shader_location": 1}, - {"format": "float32x4", "offset": _SURFACE_OFFSETS["in_color"], "shader_location": 2}, - ], +_SURFACE_COMBINED_OFFSETS: dict[str, int] = { + name: _SURFACE_COMBINED_DTYPE.fields[name][1] # type: ignore[index] + for name in _SURFACE_COMBINED_DTYPE.names } - -# --------------------------------------------------------------------------- -# Stroke vertex layout — used only for Surface mesh lines (stroke_surface). -# Must match vmobject_stroke.wgsl locations. -# --------------------------------------------------------------------------- - -_STROKE_DTYPE = np.dtype( - [ - ("current_curve", np.float32, (4, 3)), - ("tile_coordinate", np.float32, (2,)), - ("in_color", np.float32, (4,)), - ("in_width", np.float32), - ] -) -_STROKE_STRIDE: int = _STROKE_DTYPE.itemsize # 76 bytes - - -def _stroke_field_offset(name: str) -> int: - return _STROKE_DTYPE.fields[name][1] # type: ignore[index] - - -STROKE_VERTEX_LAYOUT: dict = { - "array_stride": _STROKE_STRIDE, +SURFACE_COMBINED_VERTEX_LAYOUT: dict = { + "array_stride": _SURFACE_COMBINED_STRIDE, "step_mode": "vertex", "attributes": [ - {"format": "float32x3", "offset": _stroke_field_offset("current_curve"), "shader_location": 0}, - {"format": "float32x3", "offset": _stroke_field_offset("current_curve") + 12, "shader_location": 1}, - {"format": "float32x3", "offset": _stroke_field_offset("current_curve") + 24, "shader_location": 2}, - {"format": "float32x3", "offset": _stroke_field_offset("current_curve") + 36, "shader_location": 3}, - {"format": "float32x2", "offset": _stroke_field_offset("tile_coordinate"), "shader_location": 4}, - {"format": "float32x4", "offset": _stroke_field_offset("in_color"), "shader_location": 5}, - {"format": "float32", "offset": _stroke_field_offset("in_width"), "shader_location": 6}, + {"format": "float32x3", "offset": _SURFACE_COMBINED_OFFSETS["in_vert"], "shader_location": 0}, + {"format": "float32x3", "offset": _SURFACE_COMBINED_OFFSETS["in_normal"], "shader_location": 1}, + {"format": "float32x4", "offset": _SURFACE_COMBINED_OFFSETS["in_fill_color"], "shader_location": 2}, + {"format": "float32x4", "offset": _SURFACE_COMBINED_OFFSETS["in_stroke_color"], "shader_location": 3}, + {"format": "float32x3", "offset": _SURFACE_COMBINED_OFFSETS["in_bary"], "shader_location": 4}, + {"format": "float32", "offset": _SURFACE_COMBINED_OFFSETS["stroke_half_px"], "shader_location": 5}, ], } @@ -190,22 +172,16 @@ class _FrameData: compute_bg: wgpu_t.GPUBindGroup | None # compute pass bind group render_bg: wgpu_t.GPUBindGroup | None # fragment bind group (camera + quads) - # Parametric surfaces (unchanged pipeline) + # Parametric surfaces (combined fill + barycentric wireframe pipeline) surface_parts: list[np.ndarray] surface_buf: wgpu_t.GPUBuffer | None surface_byte_offsets: list[int] - # Surface mesh strokes (depth-biased cubic stroke pipeline) - stroke_surface_parts: list[np.ndarray] - stroke_surface_buf: wgpu_t.GPUBuffer | None - stroke_surface_byte_offsets: list[int] - # Ordered draw commands: # "fill_stroke_2d" — 2-D VMobject (no depth write) # "fill_stroke_3d" — shade_in_3d VMobject (depth write + test) # "surface_opaque" — opaque parametric surface # "surface_oit" — transparent parametric surface (OIT pass, caller handles) - # "stroke_surface" — surface mesh grid lines (depth-biased) draw_plan: list[tuple[str, int]] # Indices into surface_parts that need OIT (handled by the caller). @@ -222,10 +198,6 @@ class _FrameData: # Geometry only; colors/widths are fetched fresh every frame. _fill_stroke_cache: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() -# _surface_stroke_cache: vmobject → (points_hash, stroke_data) -# Used for surface mesh grid lines only (the old cubic-stroke pipeline). -_surface_stroke_cache: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() - def _points_hash(vmobject: VMobject) -> int: pts = vmobject.points @@ -267,8 +239,7 @@ def collect_frame_data( n_fill_cubics_per: list[int] = [] # Ni per draw call n_stroke_cubics_per: list[int] = [] # Mi per draw call - surface_parts: list[np.ndarray] = [] - stroke_surface_parts: list[np.ndarray] = [] + surface_parts: list[np.ndarray] = [] draw_plan: list[tuple[str, int]] = [] for mob in mobjects: @@ -278,25 +249,14 @@ def collect_frame_data( # ── Parametric Surface ──────────────────────────────────────────── if isinstance(mob, Surface): for submob in mob.family_members_with_points(): - data = _collect_surface_geometry(submob) + data = _collect_surface_geometry( + submob, view_matrix, proj_matrix + ) if data is not None: cls = _surface_opacity_class(data) cmd = "surface_opaque" if cls == "opaque" else "surface_oit" draw_plan.append((cmd, len(surface_parts))) surface_parts.append(data) - - # Surface mesh strokes (old cubic stroke pipeline). - phash = _points_hash(submob) - scached = _surface_stroke_cache.get(submob) - if scached is not None and scached[0] == phash: - draw_plan.append(("stroke_surface", len(stroke_surface_parts))) - stroke_surface_parts.append(scached[1]) - else: - sdata = _collect_surface_stroke_geometry(submob) - if sdata is not None: - _surface_stroke_cache[submob] = (phash, sdata) - draw_plan.append(("stroke_surface", len(stroke_surface_parts))) - stroke_surface_parts.append(sdata) continue # ── Regular VMobject (2-D or shade_in_3d) ──────────────────────── @@ -436,18 +396,13 @@ def collect_frame_data( ], ) - # ── Upload surface and surface-stroke data ─────────────────────────── + # ── Upload surface data ────────────────────────────────────────────── surface_buf, surface_byte_offsets = None, [] if surface_parts: _smooth_surface_normals(surface_parts) surface_buf, surface_byte_offsets = _batch_upload(device, surface_parts) renderer.frame_vbos.append(surface_buf) - stroke_surface_buf, stroke_surface_byte_offsets = None, [] - if stroke_surface_parts: - stroke_surface_buf, stroke_surface_byte_offsets = _batch_upload(device, stroke_surface_parts) - renderer.frame_vbos.append(stroke_surface_buf) - oit_indices = [idx for cmd, idx in draw_plan if cmd == "surface_oit"] return _FrameData( @@ -462,9 +417,6 @@ def collect_frame_data( surface_parts=surface_parts, surface_buf=surface_buf, surface_byte_offsets=surface_byte_offsets, - stroke_surface_parts=stroke_surface_parts, - stroke_surface_buf=stroke_surface_buf, - stroke_surface_byte_offsets=stroke_surface_byte_offsets, draw_plan=draw_plan, oit_indices=oit_indices, ) @@ -482,8 +434,7 @@ def draw_frame_data( 1. 2-D fill+stroke objects — interleaved in ``draw_plan`` order (painter's algorithm; no depth write so objects paint over each other correctly). 2. 3-D fill+stroke objects — depth write + test (shade_in_3d). - 3. Opaque parametric surfaces — depth write. - 4. Surface mesh strokes — depth-biased to avoid z-fighting. + 3. Opaque parametric surfaces — depth write (includes barycentric wireframe). OIT surfaces are NOT drawn here; the caller reads ``fd.oit_indices`` and handles them in a separate accumulation pass. @@ -501,8 +452,6 @@ def _activate(name: str, bg: wgpu_t.GPUBindGroup) -> None: rp.set_pipeline(renderer.fill_stroke_3d_pipeline) elif name == "surface_opaque": rp.set_pipeline(renderer.surface_pipeline) - elif name == "stroke_surface": - rp.set_pipeline(renderer.stroke_3d_surface_pipeline) cur_pipeline[0] = name if cur_bg[0] is not bg: rp.set_bind_group(0, bg, [], 0, 0) @@ -528,7 +477,7 @@ def _activate(name: str, bg: wgpu_t.GPUBindGroup) -> None: rp.set_vertex_buffer(0, fd.fs_buf, fd.fs_byte_offsets[idx], arr.nbytes) rp.draw(len(arr), 1, 0, 0) - # 3. Opaque parametric surfaces. + # 3. Opaque parametric surfaces (combined fill + barycentric wireframe). if fd.surface_buf is not None: for cmd, idx in fd.draw_plan: if cmd != "surface_opaque": @@ -538,19 +487,6 @@ def _activate(name: str, bg: wgpu_t.GPUBindGroup) -> None: rp.set_vertex_buffer(0, fd.surface_buf, fd.surface_byte_offsets[idx], arr.nbytes) rp.draw(len(arr), 1, 0, 0) - # 4. Surface mesh strokes (depth-biased). - if fd.stroke_surface_buf is not None: - for cmd, idx in fd.draw_plan: - if cmd != "stroke_surface": - continue - _activate("stroke_surface", cam_bg) - arr = fd.stroke_surface_parts[idx] - rp.set_vertex_buffer( - 0, fd.stroke_surface_buf, - fd.stroke_surface_byte_offsets[idx], arr.nbytes, - ) - rp.draw(len(arr), 1, 0, 0) - # --------------------------------------------------------------------------- # GPU upload helpers @@ -758,21 +694,44 @@ def _build_fill_stroke_quad( def _surface_opacity_class(part: np.ndarray) -> str: - alphas = part["in_color"][:, 3] + alphas = part["in_fill_color"][:, 3] return "opaque" if float(alphas.min()) >= 0.99 else "oit" -def _collect_surface_geometry(vmobject: VMobject) -> np.ndarray | None: - """Return a ``_SURFACE_DTYPE`` array for a shade_in_3d VMobject.""" +def _collect_surface_geometry( + vmobject: VMobject, + view_matrix: np.ndarray, + proj_matrix: np.ndarray, +) -> np.ndarray | None: + """Return a ``_SURFACE_COMBINED_DTYPE`` array for a shade_in_3d VMobject. + + Barycentric coordinates are assigned per triangle in the centroid fan: + centroid → bary = (1, 0, 0) (bary.x = 0 on outer edge) + anchor_i → bary = (0, 1, 0) + anchor_{i+1} → bary = (0, 0, 1) + + ``stroke_half_px`` is computed from the stroke width, projection matrix + and average clip-w of the surface anchors so that wireframe line width + is consistent across perspective depths. + """ + from manim import config + fill_rgba = vmobject.get_fill_rgbas() if fill_rgba.shape[0] == 0 or fill_rgba[0, 3] == 0: return None - color = fill_rgba[0].astype(np.float32) + fill_color = fill_rgba[0].astype(np.float32) + stroke_rgba = vmobject.get_stroke_rgbas() + stroke_color = (stroke_rgba[0].astype(np.float32) + if stroke_rgba.shape[0] > 0 + else np.zeros(4, dtype=np.float32)) + stroke_width = float(vmobject.get_stroke_width()) if stroke_rgba.shape[0] > 0 else 0.0 + nppcc = vmobject.n_points_per_cubic_curve all_verts: list[np.ndarray] = [] all_normals: list[np.ndarray] = [] + all_bary: list[np.ndarray] = [] for subpath in vmobject.get_subpaths(): n_curves = len(subpath) // nppcc @@ -798,25 +757,52 @@ def _collect_surface_geometry(vmobject: VMobject) -> np.ndarray | None: else np.array([0.0, 0.0, 1.0], dtype=np.float32) ) + # Triangle fan: (centroid, anchor_i, anchor_{i+1}) fan_verts = np.empty((n_pts * 3, 3), dtype=np.float32) fan_verts[0::3] = centroid.astype(np.float32) fan_verts[1::3] = anchors.astype(np.float32) fan_verts[2::3] = np.roll(anchors, -1, axis=0).astype(np.float32) + # Barycentric coords: centroid=(1,0,0), anchor_i=(0,1,0), next=(0,0,1) + bary_block = np.zeros((n_pts * 3, 3), dtype=np.float32) + bary_block[0::3] = [1.0, 0.0, 0.0] + bary_block[1::3] = [0.0, 1.0, 0.0] + bary_block[2::3] = [0.0, 0.0, 1.0] + all_verts.append(fan_verts) all_normals.append(np.tile(normal, (n_pts * 3, 1))) + all_bary.append(bary_block) if not all_verts: return None verts = np.concatenate(all_verts, axis=0) normals = np.concatenate(all_normals, axis=0) + bary = np.concatenate(all_bary, axis=0) n_total = len(verts) - attrs = np.empty(n_total, dtype=_SURFACE_DTYPE) - attrs["in_vert"] = verts - attrs["in_normal"] = normals - attrs["in_color"] = color + # Compute stroke_half_px: half the wireframe line width in screen pixels. + # Formula matches _build_fill_stroke_quad: 0.004 * width * |pm[0,0]| / |avg_clip_w| + # then multiplied by pixel_width/2 to convert NDC to pixels. + stroke_half_px = 0.0 + if stroke_width > 0.0 and float(stroke_color[3]) > 0.001: + pm = proj_matrix.astype(np.float32) + vm = view_matrix.astype(np.float32) + R, t = vm[:3, :3], vm[:3, 3] + pts_v = (R @ verts.T).T + t # (N, 3) view space + avg_z_v = float(pts_v[:, 2].mean()) + avg_clip_w = float(pm[3, 2] * avg_z_v + pm[3, 3]) + avg_clip_w = avg_clip_w if abs(avg_clip_w) > 1e-8 else 1.0 + stroke_half_ndc = float(0.004 * stroke_width * abs(pm[0, 0]) / abs(avg_clip_w)) + stroke_half_px = stroke_half_ndc * config.pixel_width * 0.5 + + attrs = np.empty(n_total, dtype=_SURFACE_COMBINED_DTYPE) + attrs["in_vert"] = verts + attrs["in_normal"] = normals + attrs["in_fill_color"] = fill_color + attrs["in_stroke_color"] = stroke_color + attrs["in_bary"] = bary + attrs["stroke_half_px"] = stroke_half_px return attrs @@ -825,8 +811,8 @@ def _smooth_surface_normals(surface_parts: list[np.ndarray]) -> None: if not surface_parts: return - all_verts = np.concatenate([p["in_vert"] for p in surface_parts], axis=0) - all_norms = np.concatenate([p["in_normal"] for p in surface_parts], axis=0) + all_verts = np.concatenate([p["in_vert"] for p in surface_parts], axis=0) + all_norms = np.concatenate([p["in_normal"] for p in surface_parts], axis=0) PREC = 1e-5 quantized = np.round(all_verts.astype(np.float64) / PREC).astype(np.int64) @@ -847,50 +833,3 @@ def _smooth_surface_normals(surface_parts: list[np.ndarray]) -> None: idx += n -# --------------------------------------------------------------------------- -# Surface mesh stroke collector (cubic stroke pipeline, surface only) -# --------------------------------------------------------------------------- - - -def _collect_surface_stroke_geometry(vmobject: VMobject) -> np.ndarray | None: - """Return a ``_STROKE_DTYPE`` array for a Surface sub-face stroke.""" - stroke_rgba = vmobject.get_stroke_rgbas() - stroke_width = float(vmobject.get_stroke_width()) - if stroke_rgba.shape[0] == 0 or stroke_rgba[0, 3] == 0 or stroke_width == 0: - return None - - color = stroke_rgba[0].astype(np.float32) - nppcc = vmobject.n_points_per_cubic_curve - - curve_list: list[np.ndarray] = [] - for subpath in vmobject.get_subpaths(): - n_curves = len(subpath) // nppcc - if n_curves == 0: - continue - pts = subpath[: n_curves * nppcc] - b0s = pts[0::nppcc] - h0s = pts[1::nppcc] - h1s = pts[2::nppcc] - b3s = pts[3::nppcc] - curve_list.append(np.stack([b0s, h0s, h1s, b3s], axis=1)) - - if not curve_list: - return None - - all_curves = np.concatenate(curve_list, axis=0).astype(np.float32) # (N, 4, 3) - n_total = len(all_curves) - - base = np.zeros(n_total * 3, dtype=_STROKE_DTYPE) - base["current_curve"] = np.repeat(all_curves, 3, axis=0) - base["in_color"] = color - base["in_width"] = stroke_width - - stroke_data = np.tile(base, 2) - n_half = n_total * 3 - stroke_data["tile_coordinate"][:n_half] = np.tile( - [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0]], (n_total, 1) - ) - stroke_data["tile_coordinate"][n_half:] = np.tile( - [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]], (n_total, 1) - ) - return stroke_data From bde0b2c70c12498fb0b4f6c88e782dda876a6a2f Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Wed, 8 Apr 2026 19:38:00 +0530 Subject: [PATCH 13/33] Removed unused WebGPU Shaders --- manim/renderer/webgpu/shaders/slug_fill.wgsl | 226 -------------- manim/renderer/webgpu/shaders/surface.wgsl | 107 ------- .../webgpu/shaders/vmobject_stroke.wgsl | 275 ------------------ manim/renderer/webgpu/webgpu_renderer.py | 2 +- 4 files changed, 1 insertion(+), 609 deletions(-) delete mode 100644 manim/renderer/webgpu/shaders/slug_fill.wgsl delete mode 100644 manim/renderer/webgpu/shaders/surface.wgsl delete mode 100644 manim/renderer/webgpu/shaders/vmobject_stroke.wgsl diff --git a/manim/renderer/webgpu/shaders/slug_fill.wgsl b/manim/renderer/webgpu/shaders/slug_fill.wgsl deleted file mode 100644 index 279fc0c396..0000000000 --- a/manim/renderer/webgpu/shaders/slug_fill.wgsl +++ /dev/null @@ -1,226 +0,0 @@ -// WebGPU fill shader using the Slug algorithm. -// -// Supports both 2-D and 3-D VMobjects, orthographic and perspective cameras. -// Coverage is computed in NDC space (clip.xy / clip.w), which is the correct -// 2-D space for all projection types: -// - Orthographic: clip.w = 1, so NDC = clip.xy (a uniform scale of view XY). -// - Perspective: NDC accounts for the depth-dependent scale, so fills and -// strokes rendered on tilted 3-D objects stay aligned. -// -// Reference: -// E. Lengyel, "GPU-Centered Font Rendering Directly from Glyph Outlines", -// JCGT Vol. 6 No. 2, 2017. https://github.com/EricLengyel/Slug -// Patent dedicated to public domain. Code: MIT license. -// -// Uniform layout (group 0, binding 0) — 144 bytes: -// offset 0 — projection mat4x4 (64 bytes) -// offset 64 — view mat4x4 (64 bytes) -// offset 128 — light_pos vec3 (12 bytes, padded to 16) -// -// Storage buffer (group 0, binding 1) — tightly packed array: -// For quadratic bezier i: floats at indices [i*9 .. i*9+8] -// [0,1,2] = p1 XYZ (start anchor) -// [3,4,5] = p2 XYZ (control point) -// [6,7,8] = p3 XYZ (end anchor) -// Using array rather than array> because WGSL gives vec3 -// a 16-byte stride in storage buffers, while Python packs them at 12 bytes. -// -// Vertex attributes: -// location 0 — in_pos vec3 world-space 3-D position of bounding-quad corner -// location 1 — in_color vec4 RGBA fill colour -// location 2 — curve_start u32 first curve index in storage buffer -// location 3 — n_curves u32 number of quadratic bezier curves - -struct Uniforms { - projection : mat4x4, - view : mat4x4, - light_pos : vec3, - _pad : f32, -}; -@group(0) @binding(0) var u : Uniforms; - -// Tightly packed floats: 9 floats per quadratic bezier (3 points × 3 floats). -@group(0) @binding(1) var curves : array; - -struct VertexInput { - @location(0) in_pos : vec3, - @location(1) in_color : vec4, - @location(2) curve_start : u32, - @location(3) n_curves : u32, -}; - -struct VertexOutput { - @builtin(position) clip_pos : vec4, - @location(0) ndc_xy : vec2, // NDC XY = clip.xy / clip.w - @location(1) v_color : vec4, - @location(2) @interpolate(flat) curve_start : u32, - @location(3) @interpolate(flat) n_curves : u32, -}; - -@vertex -fn vs_main(in: VertexInput) -> VertexOutput { - var out: VertexOutput; - let view_pos = u.view * vec4(in.in_pos, 1.0); - let clip = u.projection * view_pos; - out.clip_pos = clip; - // Perspective divide: under ortho w=1 (no change), under perspective this - // maps the vertex to the correct screen-proportional 2-D position. - out.ndc_xy = clip.xy / clip.w; - out.v_color = in.in_color; - out.curve_start = in.curve_start; - out.n_curves = in.n_curves; - return out; -} - -// --------------------------------------------------------------------------- -// Slug algorithm — adapted from Lengyel 2017 (HLSL → WGSL) -// --------------------------------------------------------------------------- - -// Return root eligibility code for a sample-relative quadratic bezier. -// Extracts the sign bits of the three y-coordinates and maps them through -// a lookup table to determine which roots of the quadratic cross y = 0 in -// a winding-compatible direction. -// Result: bit 0 = root 1 eligible, bit 8 = root 2 eligible. -fn calc_root_code(y1: f32, y2: f32, y3: f32) -> u32 { - let i1 = (bitcast(y1) >> 31u) & 1u; - let i2 = (bitcast(y2) >> 30u) & 2u; - let i3 = (bitcast(y3) >> 29u) & 4u; - let shift = i3 | i2 | i1; - return (0x2E74u >> shift) & 0x0101u; -} - -// Solve quadratic bezier for y = 0 crossings; return x-coordinates. -// C(t) = (1-t)^2 p1 + 2t(1-t) p2 + t^2 p3, t in [0,1]. -// Polynomial: a*t^2 - 2*b*t + c = 0 -// a = p1.y - 2*p2.y + p3.y -// b = p1.y - p2.y -// c = p1.y (the sample has already been subtracted) -fn solve_horiz(p1: vec2, p2: vec2, p3: vec2) -> vec2 { - let ay = p1.y - 2.0 * p2.y + p3.y; - let by = p1.y - p2.y; - let ax = p1.x - 2.0 * p2.x + p3.x; - let bx = p1.x - p2.x; - - var t1: f32; - var t2: f32; - - if abs(ay) < (1.0 / 65536.0) { - // Nearly linear — solve -2*by*t + p1.y = 0. - let denom = select(1.0, by, abs(by) > 1e-10); - t1 = p1.y * 0.5 / denom; - t2 = t1; - } else { - let ra = 1.0 / ay; - let d = sqrt(max(by * by - ay * p1.y, 0.0)); - t1 = (by - d) * ra; - t2 = (by + d) * ra; - } - - let x1 = (ax * t1 - bx * 2.0) * t1 + p1.x; - let x2 = (ax * t2 - bx * 2.0) * t2 + p1.x; - return vec2(x1, x2); -} - -// Solve quadratic bezier for x = 0 crossings; return y-coordinates. -fn solve_vert(p1: vec2, p2: vec2, p3: vec2) -> vec2 { - let ax = p1.x - 2.0 * p2.x + p3.x; - let bx = p1.x - p2.x; - let ay = p1.y - 2.0 * p2.y + p3.y; - let by = p1.y - p2.y; - - var t1: f32; - var t2: f32; - - if abs(ax) < (1.0 / 65536.0) { - let denom = select(1.0, bx, abs(bx) > 1e-10); - t1 = p1.x * 0.5 / denom; - t2 = t1; - } else { - let ra = 1.0 / ax; - let d = sqrt(max(bx * bx - ax * p1.x, 0.0)); - t1 = (bx - d) * ra; - t2 = (bx + d) * ra; - } - - let y1 = (ay * t1 - by * 2.0) * t1 + p1.y; - let y2 = (ay * t2 - by * 2.0) * t2 + p1.y; - return vec2(y1, y2); -} - -// Combine horizontal and vertical winding coverage into [0, 1]. -// The weighted blend handles pixels where one ray's result is more reliable. -fn calc_coverage(xcov: f32, ycov: f32, xwgt: f32, ywgt: f32) -> f32 { - let blended = abs(xcov * xwgt + ycov * ywgt) / max(xwgt + ywgt, 1.0 / 65536.0); - let fallback = min(abs(xcov), abs(ycov)); - return clamp(max(blended, fallback), 0.0, 1.0); -} - -// --------------------------------------------------------------------------- -// Fragment shader -// --------------------------------------------------------------------------- - -@fragment -fn fs_main(in: VertexOutput) -> @location(0) vec4 { - // NDC units per screen pixel — coverage math works in these units for both - // orthographic and perspective projections. - let ndc_per_pixel = fwidth(in.ndc_xy); - let pixels_per_ndc = 1.0 / max(ndc_per_pixel, vec2(1e-9)); - - var xcov = 0.0; var xwgt = 0.0; - var ycov = 0.0; var ywgt = 0.0; - - let pv = u.projection * u.view; // combined matrix — avoids recomputing per curve - - for (var i = 0u; i < in.n_curves; i = i + 1u) { - // 9 floats per quadratic: p1 (xyz), p2 (xyz), p3 (xyz). - let f = (in.curve_start + i) * 9u; - let p1w = vec3(curves[f ], curves[f + 1u], curves[f + 2u]); - let p2w = vec3(curves[f + 3u], curves[f + 4u], curves[f + 5u]); - let p3w = vec3(curves[f + 6u], curves[f + 7u], curves[f + 8u]); - - // Transform world-space control points to NDC, then shift so the - // current fragment (in.ndc_xy) is the origin. - // NDC = clip.xy / clip.w handles both orthographic (w=1) and - // perspective (w = -z_view) correctly. - let c1 = pv * vec4(p1w, 1.0); - let c2 = pv * vec4(p2w, 1.0); - let c3 = pv * vec4(p3w, 1.0); - let p1 = c1.xy / c1.w - in.ndc_xy; - let p2 = c2.xy / c2.w - in.ndc_xy; - let p3 = c3.xy / c3.w - in.ndc_xy; - - // ── Horizontal ray: accumulate x-coverage ──────────────────────── - let hcode = calc_root_code(p1.y, p2.y, p3.y); - if hcode != 0u { - let r = solve_horiz(p1, p2, p3) * pixels_per_ndc.x; - if (hcode & 1u) != 0u { - xcov += clamp(r.x + 0.5, 0.0, 1.0); - xwgt = max(xwgt, clamp(1.0 - abs(r.x) * 2.0, 0.0, 1.0)); - } - if hcode > 1u { - xcov -= clamp(r.y + 0.5, 0.0, 1.0); - xwgt = max(xwgt, clamp(1.0 - abs(r.y) * 2.0, 0.0, 1.0)); - } - } - - // ── Vertical ray: accumulate y-coverage ────────────────────────── - let vcode = calc_root_code(p1.x, p2.x, p3.x); - if vcode != 0u { - let r = solve_vert(p1, p2, p3) * pixels_per_ndc.y; - if (vcode & 1u) != 0u { - ycov -= clamp(r.x + 0.5, 0.0, 1.0); - ywgt = max(ywgt, clamp(1.0 - abs(r.x) * 2.0, 0.0, 1.0)); - } - if vcode > 1u { - ycov += clamp(r.y + 0.5, 0.0, 1.0); - ywgt = max(ywgt, clamp(1.0 - abs(r.y) * 2.0, 0.0, 1.0)); - } - } - } - - let coverage = calc_coverage(xcov, ycov, xwgt, ywgt); - if coverage <= 0.0 { - discard; - } - return vec4(in.v_color.rgb, in.v_color.a * coverage); -} diff --git a/manim/renderer/webgpu/shaders/surface.wgsl b/manim/renderer/webgpu/shaders/surface.wgsl deleted file mode 100644 index 10433f22ce..0000000000 --- a/manim/renderer/webgpu/shaders/surface.wgsl +++ /dev/null @@ -1,107 +0,0 @@ -// WebGPU surface shader for Manim — Phase 3. -// -// Blinn-Phong ambient + diffuse + specular lighting computed in view space. -// -// Uniform layout (group 0, binding 0) — shared with fill/stroke: -// offset 0 — projection mat4x4 (64 bytes) -// offset 64 — view mat4x4 (64 bytes) -// offset 128 — light_pos vec3 (12 bytes) -// offset 140 — light_intensity f32 ( 4 bytes) -// offset 144 — light_color vec3 (12 bytes) -// offset 156 — ambient_intensity f32 ( 4 bytes) -// offset 160 — ambient_color vec3 (12 bytes) -// offset 172 — _pad f32 ( 4 bytes) -// total: 176 bytes -// -// Vertex attributes: -// location 0 — in_vert vec3 world-space position -// location 1 — in_normal vec3 world-space face normal -// location 2 — in_color vec4 RGBA fill colour - -struct Uniforms { - projection : mat4x4, - view : mat4x4, - light_pos : vec3, - light_intensity : f32, - light_color : vec3, - ambient_intensity : f32, - ambient_color : vec3, - _pad : f32, -}; -@group(0) @binding(0) var u : Uniforms; - -struct VertexInput { - @location(0) in_vert : vec3, - @location(1) in_normal : vec3, - @location(2) in_color : vec4, -}; - -struct VertexOutput { - @builtin(position) clip_position : vec4, - @location(0) v_color : vec4, - @location(1) v_view_normal : vec3, // normal in view space - @location(2) v_view_pos : vec3, // position in view space - @location(3) v_view_light : vec3, // light position in view space -}; - -@vertex -fn vs_main(in: VertexInput) -> VertexOutput { - var out: VertexOutput; - - let view_pos = u.view * vec4(in.in_vert, 1.0); - out.clip_position = u.projection * view_pos; - out.v_view_pos = view_pos.xyz; - - // Normal transform: use the upper-left 3×3 of the view matrix. - // Assumes uniform scaling (no shear), which holds for Manim cameras. - let view3 = mat3x3(u.view[0].xyz, u.view[1].xyz, u.view[2].xyz); - out.v_view_normal = view3 * in.in_normal; - - out.v_view_light = (u.view * vec4(u.light_pos, 1.0)).xyz; - out.v_color = in.in_color; - return out; -} - -@fragment -fn fs_main(in: VertexOutput, @builtin(front_facing) front_facing: bool) -> @location(0) vec4 { - // Per-material diffuse and specular strengths. - // Will be replaced by per-surface gloss/shadow when LightSource system lands. - let diffuse_strength = 0.9; - let specular_strength = 0.8; - let specular_exp = 16.0; - - // Two-sided lighting: flip the normal for back-facing fragments so that - // both sides of open surfaces (flat planes, shade_in_3d VMobjects) are - // correctly lit when seen from either direction. - // For closed opaque surfaces (sphere, torus) the back faces lose the depth - // test before shading, so this select is a no-op in that case. - let raw_normal = select(-in.v_view_normal, in.v_view_normal, front_facing); - let norm = normalize(raw_normal); - let light_dir_vec = in.v_view_light - in.v_view_pos; - let light_distance2 = dot(light_dir_vec, light_dir_vec); - let light_dir = normalize(light_dir_vec); - let view_dir = normalize(-in.v_view_pos); // camera at origin in view space - - // Diffuse — one-sided: surfaces facing away from the light are dark. - let diff = clamp(dot(norm, light_dir), 0.0, 1.0); - - // Blinn-Phong specular. - let half_vec = normalize(light_dir + view_dir); - let spec = pow(max(dot(norm, half_vec), 0.0), specular_exp); - - let attenuation = u.light_intensity / light_distance2; - - // Ambient: object color × ambient light color. - let ambient_rgb = in.v_color.rgb * u.ambient_color * u.ambient_intensity; - - // Diffuse: object color × light color (pigment modulates incoming light). - let diffuse_rgb = in.v_color.rgb * u.light_color * (diffuse_strength * diff * attenuation); - - // Specular: light color only — highlight is the light's color, not the object's. - // Correct for dielectrics (plastic, paint); metals would tint by object color, - // but that requires a metalness parameter (future work). - let specular_rgb = u.light_color * (specular_strength * spec * attenuation); - - let lit_rgb = clamp(ambient_rgb + diffuse_rgb + specular_rgb, vec3(0.0), vec3(1.0)); - return vec4(lit_rgb, in.v_color.a); -} diff --git a/manim/renderer/webgpu/shaders/vmobject_stroke.wgsl b/manim/renderer/webgpu/shaders/vmobject_stroke.wgsl deleted file mode 100644 index 573e6a1072..0000000000 --- a/manim/renderer/webgpu/shaders/vmobject_stroke.wgsl +++ /dev/null @@ -1,275 +0,0 @@ -// WebGPU stroke shader for VMobject — 2-D and 3-D curves. -// -// The vertex shader transforms control points to VIEW space first (camera -// looks along −Z, so view-space XY is the screen plane), then builds the -// bounding tile in that 2-D screen space. This correctly handles 3-D curves -// such as the world Z-axis whose world-XY chord collapses to zero length. -// -// Uniform layout (group 0, binding 0): -// offset 0 — projection mat4x4 (64 bytes) -// offset 64 — view mat4x4 (64 bytes) -// offset 128 — light_pos vec3 (12 bytes, padded to 16) -// -// Vertex attributes: -// location 0-3 — current_curve_{0-3} vec3 cubic bezier control points -// location 4 — tile_coordinate vec2 quad corner ∈ [0,1] -// location 5 — in_color vec4 RGBA stroke colour -// location 6 — in_width f32 stroke width (Manim units) - -struct Uniforms { - projection : mat4x4, - view : mat4x4, - light_pos : vec3, - _pad : f32, -}; -@group(0) @binding(0) var u : Uniforms; - -// ---- Cubic bezier helpers ------------------------------------------------ - -fn cubic_eval( - p0: vec2, p1: vec2, p2: vec2, p3: vec2, t: f32 -) -> vec2 { - let s = 1.0 - t; - return s*s*s*p0 + 3.0*s*s*t*p1 + 3.0*s*t*t*p2 + t*t*t*p3; -} - -fn cubic_deriv1( - p0: vec2, p1: vec2, p2: vec2, p3: vec2, t: f32 -) -> vec2 { - let s = 1.0 - t; - return 3.0 * (s*s*(p1 - p0) + 2.0*s*t*(p2 - p1) + t*t*(p3 - p2)); -} - -fn cubic_deriv2( - p0: vec2, p1: vec2, p2: vec2, p3: vec2, t: f32 -) -> vec2 { - return 6.0 * ((1.0 - t)*(p2 - 2.0*p1 + p0) + t*(p3 - 2.0*p2 + p1)); -} - -fn ud_cubic_bezier( - p0: vec2, p1: vec2, p2: vec2, p3: vec2, - pos: vec2, -) -> f32 { - var best_t : f32 = 0.0; - var best_d2 : f32 = 1e18; - for (var i = 0u; i <= 8u; i = i + 1u) { - let t = f32(i) * (1.0 / 8.0); - let pt = cubic_eval(p0, p1, p2, p3, t); - let d2 = dot(pt - pos, pt - pos); - if (d2 < best_d2) { best_d2 = d2; best_t = t; } - } - for (var k = 0u; k < 4u; k = k + 1u) { - let t = clamp(best_t, 0.0, 1.0); - let pt = cubic_eval(p0, p1, p2, p3, t); - let dp = cubic_deriv1(p0, p1, p2, p3, t); - let d2p = cubic_deriv2(p0, p1, p2, p3, t); - let diff = pt - pos; - let denom = dot(dp, dp) + dot(diff, d2p); - if (abs(denom) > 1e-10) { best_t = t - dot(diff, dp) / denom; } - } - let closest = cubic_eval(p0, p1, p2, p3, clamp(best_t, 0.0, 1.0)); - return length(closest - pos); -} - -// ---- Bounding-box helpers ------------------------------------------------ - -fn cubic_eval_1d(p0: f32, p1: f32, p2: f32, p3: f32, t: f32) -> f32 { - let s = 1.0 - t; - return s*s*s*p0 + 3.0*s*s*t*p1 + 3.0*s*t*t*p2 + t*t*t*p3; -} - -fn bbox_cubic( - p0: vec2, p1: vec2, p2: vec2, p3: vec2 -) -> vec4 { - var mi = min(p0, p3); - var ma = max(p0, p3); - - let a_v = p1 - p0; - let b_v = p2 - p1; - let c_v = p3 - p2; - let A = a_v - 2.0*b_v + c_v; - let B = 2.0 * (b_v - a_v); - let C = a_v; - - if (abs(A.x) > 1e-8) { - let disc = B.x*B.x - 4.0*A.x*C.x; - if (disc >= 0.0) { - let sq = sqrt(disc); - let t1 = (-B.x + sq) / (2.0*A.x); - let t2 = (-B.x - sq) / (2.0*A.x); - if (t1 > 0.0 && t1 < 1.0) { - let v = cubic_eval_1d(p0.x, p1.x, p2.x, p3.x, t1); - mi.x = min(mi.x, v); ma.x = max(ma.x, v); - } - if (t2 > 0.0 && t2 < 1.0) { - let v = cubic_eval_1d(p0.x, p1.x, p2.x, p3.x, t2); - mi.x = min(mi.x, v); ma.x = max(ma.x, v); - } - } - } else if (abs(B.x) > 1e-8) { - let t = -C.x / B.x; - if (t > 0.0 && t < 1.0) { - let v = cubic_eval_1d(p0.x, p1.x, p2.x, p3.x, t); - mi.x = min(mi.x, v); ma.x = max(ma.x, v); - } - } - - if (abs(A.y) > 1e-8) { - let disc = B.y*B.y - 4.0*A.y*C.y; - if (disc >= 0.0) { - let sq = sqrt(disc); - let t1 = (-B.y + sq) / (2.0*A.y); - let t2 = (-B.y - sq) / (2.0*A.y); - if (t1 > 0.0 && t1 < 1.0) { - let v = cubic_eval_1d(p0.y, p1.y, p2.y, p3.y, t1); - mi.y = min(mi.y, v); ma.y = max(ma.y, v); - } - if (t2 > 0.0 && t2 < 1.0) { - let v = cubic_eval_1d(p0.y, p1.y, p2.y, p3.y, t2); - mi.y = min(mi.y, v); ma.y = max(ma.y, v); - } - } - } else if (abs(B.y) > 1e-8) { - let t = -C.y / B.y; - if (t > 0.0 && t < 1.0) { - let v = cubic_eval_1d(p0.y, p1.y, p2.y, p3.y, t); - mi.y = min(mi.y, v); ma.y = max(ma.y, v); - } - } - - return vec4(mi, ma); -} - -// ---- Vertex I/O ----------------------------------------------------------- - -struct VertexInput { - @location(0) current_curve_0 : vec3, - @location(1) current_curve_1 : vec3, - @location(2) current_curve_2 : vec3, - @location(3) current_curve_3 : vec3, - @location(4) tile_coordinate : vec2, - @location(5) in_color : vec4, - @location(6) in_width : f32, -}; - -struct VertexOutput { - @builtin(position) clip_position : vec4, - @location(0) v_thickness : f32, - @location(1) uv_point : vec2, - @location(2) uv_curve_0 : vec2, - @location(3) uv_curve_1 : vec2, - @location(4) uv_curve_2 : vec2, - @location(5) uv_curve_3 : vec2, - @location(6) v_color : vec4, -}; - -// ---- Vertex shader --------------------------------------------------------- -// -// Strategy: transform control points to VIEW space first. In view space the -// camera looks along −Z, so the XY plane is the screen plane. The chord XY -// always has the correct 2-D screen-space direction regardless of the 3-D -// curve orientation. The bounding tile is built in view-space XY, then the -// tile corners are projected to clip space using the projection matrix. -// Using avg-Z for all tile corners is a valid approximation for curves that -// are short relative to the viewing distance. - -@vertex -fn vs_main(in: VertexInput) -> VertexOutput { - let thickness_multiplier = 0.004; - var out: VertexOutput; - out.v_color = in.in_color; - out.v_thickness = thickness_multiplier * in.in_width; - - // Transform all 4 control points to view space. - let vs0 = (u.view * vec4(in.current_curve_0, 1.0)).xyz; - let vs1 = (u.view * vec4(in.current_curve_1, 1.0)).xyz; - let vs2 = (u.view * vec4(in.current_curve_2, 1.0)).xyz; - let vs3 = (u.view * vec4(in.current_curve_3, 1.0)).xyz; - - // Tile x-axis: chord direction in view-space XY (= screen plane). - let chord_xy = vs3.xy - vs0.xy; - let chord_len = length(chord_xy); - - var tile_x : vec2; - if (chord_len > 1e-6) { - tile_x = chord_xy / chord_len; - } else { - let alt = vs1.xy - vs0.xy; - let alt_len = length(alt); - if (alt_len > 1e-6) { - tile_x = alt / alt_len; - } else { - // Curve points directly toward / away from camera — collapse quad. - out.clip_position = u.projection * vec4(vs0, 1.0); - out.uv_point = vec2(0.0); - out.uv_curve_0 = vec2(0.0); - out.uv_curve_1 = vec2(0.0); - out.uv_curve_2 = vec2(0.0); - out.uv_curve_3 = vec2(0.0); - return out; - } - } - let tile_y = vec2(-tile_x.y, tile_x.x); // 90° CCW in screen plane - - // Project control points into the 2-D tile UV space (view-space XY). - // The view matrix is a rigid-body transform, so UV distances == world distances - // and the thickness comparison in the fragment shader remains correct. - let uv0 = vec2(dot(vs0.xy, tile_x), dot(vs0.xy, tile_y)); - let uv1 = vec2(dot(vs1.xy, tile_x), dot(vs1.xy, tile_y)); - let uv2 = vec2(dot(vs2.xy, tile_x), dot(vs2.xy, tile_y)); - let uv3 = vec2(dot(vs3.xy, tile_x), dot(vs3.xy, tile_y)); - out.uv_curve_0 = uv0; - out.uv_curve_1 = uv1; - out.uv_curve_2 = uv2; - out.uv_curve_3 = uv3; - - let t = out.v_thickness; - let aa_pad = thickness_multiplier * 2.0; - let uv_bb = bbox_cubic(uv0, uv1, uv2, uv3); - let uv_min = uv_bb.xy - vec2(t + aa_pad); - let uv_max = uv_bb.zw + vec2(t + aa_pad); - - let uv_tile = mix(uv_min, uv_max, in.tile_coordinate); - out.uv_point = uv_tile; - - // Reconstruct the 3-D view-space position: XY from tile UV, Z from - // the average depth of the 4 control points. - // Reconstruct the 3-D view-space position: XY from tile UV. - // For Z, interpolate strictly along tile_x to maintain proper 3-D depth - // for perspective projection and correct occlusion with 3-D surfaces. - var vs_z : f32; - if (chord_len > 1e-6) { - let t = (uv_tile.x - uv0.x) / chord_len; - vs_z = mix(vs0.z, vs3.z, t); - } else { - vs_z = 0.5 * (vs0.z + vs3.z); - } - - let vs_tile = vec3( - uv_tile.x * tile_x.x + uv_tile.y * tile_y.x, - uv_tile.x * tile_x.y + uv_tile.y * tile_y.y, - vs_z, - ); - out.clip_position = u.projection * vec4(vs_tile, 1.0); - return out; -} - -// ---- Fragment shader ------------------------------------------------------- - -@fragment -fn fs_main(in: VertexOutput) -> @location(0) vec4 { - let dist = ud_cubic_bezier( - in.uv_curve_0, in.uv_curve_1, in.uv_curve_2, in.uv_curve_3, - in.uv_point, - ); - - let px = fwidthFine(dist); - let half_px = 0.5 * px; - let edge_low = in.v_thickness - half_px; - let edge_high = in.v_thickness + half_px; - let coverage = 1.0 - smoothstep(edge_low, edge_high, dist); - - if (coverage <= 0.0) { discard; } - - return vec4(in.v_color.rgb, in.v_color.a * coverage); -} diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index 10041e1998..d8389eb753 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -927,7 +927,7 @@ def _pack_camera_uniforms( ) -> wgpu_t.GPUBuffer: """Pack a 176-byte camera+lighting uniform buffer from explicit proj/view matrices. - Layout (matches Uniforms struct in surface.wgsl / surface_oit.wgsl): + Layout (matches Uniforms struct in surface_combined.wgsl / surface_oit.wgsl): offset 0 — projection mat4x4 64 B offset 64 — view mat4x4 64 B offset 128 — light_pos vec3 12 B From fec7583ab6a6addb4aa9063301919b69feabf630 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Wed, 8 Apr 2026 19:39:35 +0530 Subject: [PATCH 14/33] Added uv.lock file to .gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index abec5da495..b294075283 100644 --- a/.gitignore +++ b/.gitignore @@ -132,5 +132,9 @@ dist/ /media_dir.txt # ^TODO: Remove the need for this with a proper config file +#uv lock file +uv.lock +*.lock + # Ignore the built dependencies third_party/* From bc8463e8fddd9488e82583f78e00ab0b08663a94 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Wed, 8 Apr 2026 20:14:54 +0530 Subject: [PATCH 15/33] WebGPU Renderer can not render ManimBanner scene --- manim/renderer/webgpu/webgpu_renderer.py | 30 +++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index d8389eb753..ca0b4798c2 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -1092,9 +1092,33 @@ def update_frame(self, scene: Scene) -> None: fixed_in_frame = cam._fixed_in_frame_mobjects fixed_orient = cam._fixed_orientation_mobjects - normal_mobs = [m for m in scene.mobjects if m not in fixed_in_frame and m not in fixed_orient] - fixed_orient_mobs = [m for m in scene.mobjects if m in fixed_orient] - fixed_frame_mobs = [m for m in scene.mobjects if m in fixed_in_frame] + # Expand non-VMobject containers (e.g. Group) to their VMobject children. + # This handles the case where scene.add(Group(vmobject)) causes + # restructure_mobjects to replace the vmobject in scene.mobjects with the + # Group wrapper, which is not a VMobject and would otherwise be skipped. + def _flatten_to_vmobjects(mob_list: list) -> list: + result: list = [] + seen: set[int] = set() + + def _add(mob: Any) -> None: + if id(mob) in seen: + return + seen.add(id(mob)) + if isinstance(mob, VMobject): + result.append(mob) + else: + for sub in mob.submobjects: + _add(sub) + + for mob in mob_list: + _add(mob) + return result + + scene_mobs = _flatten_to_vmobjects(list(scene.mobjects)) + + normal_mobs = [m for m in scene_mobs if m not in fixed_in_frame and m not in fixed_orient] + fixed_orient_mobs = [m for m in scene_mobs if m in fixed_orient] + fixed_frame_mobs = [m for m in scene_mobs if m in fixed_in_frame] # ── CPU tessellation + GPU buffer upload (no commands yet) ──────── assert self._camera_uniform_buf is not None From 18044414f120a9733ca0979a2d29ed1a1453f65a Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Thu, 9 Apr 2026 00:17:28 +0530 Subject: [PATCH 16/33] Static Frame Optimization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cairo and OpenGL composite a static background image with only the moving_mobjects each frame. For scenes with many static elements this is a major perf win — only the animated subset is re-drawn. --- manim/renderer/webgpu/webgpu_renderer.py | 182 ++++++++++++++++-- .../webgpu/webgpu_vmobject_rendering.py | 20 +- 2 files changed, 181 insertions(+), 21 deletions(-) diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index ca0b4798c2..4a2cbe8310 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -439,9 +439,20 @@ def __init__( self.camera: WebGPUCamera = WebGPUCamera() self.window: WebGPUWindow | None = None self.pressed_keys: set[int] = set() - self.static_image: Any = None + self._static_image: Any = None self.file_writer: SceneFileWriter | None = None # set by init_scene() + # Static-frame compositing (WP1). + # save_static_frame_data() renders static mobjects once into + # _static_texture; update_frame() blits it as the background and only + # re-draws the moving subset each animation frame. + self._static_texture: wgpu_t.GPUTexture | None = None + self._static_texture_view: wgpu_t.GPUTextureView | None = None + self._has_static_frame: bool = False + # IDs (id()) of the top-level mobjects that belong to the static layer. + # Used in render() to partition scene.mobjects into static vs dynamic. + self._static_mob_ids: set[int] = set() + # SpecialThreeDScene reads renderer.camera_config["pixel_width"] to decide # whether to apply low-quality overrides. Mirrors the pattern used by # OpenGLRenderer so that SpecialThreeDScene works unchanged with WebGPU. @@ -514,6 +525,21 @@ def __init__( self._fixed_frame_uniform_buf: wgpu_t.GPUBuffer | None = None self.frame_vbos: list[wgpu_t.GPUBuffer] = [] + # ------------------------------------------------------------------ + # static_image property — scene.py sets this to None at end of play() + # ------------------------------------------------------------------ + + @property + def static_image(self) -> Any: + return self._static_image + + @static_image.setter + def static_image(self, value: Any) -> None: + self._static_image = value + if value is None: + self._has_static_frame = False + self._static_mob_ids = set() + # ------------------------------------------------------------------ # Initialisation # ------------------------------------------------------------------ @@ -548,11 +574,25 @@ def init_scene(self, scene: Scene) -> None: usage=( wgpu.TextureUsage.RENDER_ATTACHMENT | wgpu.TextureUsage.COPY_SRC + | wgpu.TextureUsage.COPY_DST # receives blit from _static_texture | wgpu.TextureUsage.TEXTURE_BINDING # read by compact-readback compute shader ), ) self._render_texture_view = self._render_texture.create_view() + # Static-frame texture: stores the pre-rendered static layer. + # Populated once per animation by save_static_frame_data(); blitted + # back into _render_texture each frame by update_frame(blit_static=True). + self._static_texture = self._device.create_texture( + size=(width, height, 1), + format=wgpu.TextureFormat.bgra8unorm, + usage=( + wgpu.TextureUsage.COPY_DST # written by copy from _render_texture + | wgpu.TextureUsage.COPY_SRC # read back into _render_texture each frame + ), + ) + self._static_texture_view = self._static_texture.create_view() + self._depth_texture = self._device.create_texture( size=(width, height, 1), format=wgpu.TextureFormat.depth24plus, @@ -1056,24 +1096,44 @@ def surface_oit_pipeline(self) -> wgpu_t.GPURenderPipeline: # Frame rendering # ------------------------------------------------------------------ - def update_frame(self, scene: Scene) -> None: + def update_frame( + self, + scene: Scene, + mob_list: list | None = None, + blit_static: bool = False, + ) -> None: """Render one frame into the offscreen texture. + Parameters + ---------- + mob_list: + When provided, render only these top-level mobjects instead of + all of ``scene.mobjects``. Used by ``save_static_frame_data`` + (static subset) and by ``render`` (moving subset). + blit_static: + When True, blit ``_static_texture`` → ``_render_texture`` before + the main render pass so the static background is preserved. The + main pass then uses ``load_op="load"`` to composite moving mobs + on top. If False (the default), the frame is cleared to the + background colour first. + Pass structure -------------- - 0. **Compute pass** — cubic_to_quads.wgsl converts raw cubic Bezier + 0. **Texture blit** (when *blit_static*) — copies the pre-rendered + static layer into the render texture before any render passes. + 1. **Compute pass** — cubic_to_quads.wgsl converts raw cubic Bezier control points to quadratic approximations for all three mobject groups (normal, fixed-orientation, fixed-in-frame). This runs before any render pass in the same command encoder, so WebGPU's implicit pass ordering provides the barrier. - 1. **Main pass** — clears the frame; draws normal and fixed-orientation - mobjects (shared depth buffer; fixed-orient uses a rotation-stripped - camera bind group). - 2. **OIT accumulation pass** — transparent surfaces use Weighted + 2. **Main pass** — clears (or loads) the frame; draws normal and + fixed-orientation mobjects (shared depth buffer; fixed-orient uses + a rotation-stripped camera bind group). + 3. **OIT accumulation pass** — transparent surfaces use Weighted Blended OIT into two rgba16float textures. - 3. **OIT composition pass** — full-screen triangle composites the OIT + 4. **OIT composition pass** — full-screen triangle composites the OIT result onto the main texture. - 4. **Fixed-in-frame overlay pass** — 2-D overlays rendered with a + 5. **Fixed-in-frame overlay pass** — 2-D overlays rendered with a fresh depth buffer so they always appear on top. """ assert self._device is not None @@ -1096,7 +1156,7 @@ def update_frame(self, scene: Scene) -> None: # This handles the case where scene.add(Group(vmobject)) causes # restructure_mobjects to replace the vmobject in scene.mobjects with the # Group wrapper, which is not a VMobject and would otherwise be skipped. - def _flatten_to_vmobjects(mob_list: list) -> list: + def _flatten_to_vmobjects(source: list) -> list: result: list = [] seen: set[int] = set() @@ -1110,11 +1170,12 @@ def _add(mob: Any) -> None: for sub in mob.submobjects: _add(sub) - for mob in mob_list: + for mob in source: _add(mob) return result - scene_mobs = _flatten_to_vmobjects(list(scene.mobjects)) + source = mob_list if mob_list is not None else list(scene.mobjects) + scene_mobs = _flatten_to_vmobjects(source) normal_mobs = [m for m in scene_mobs if m not in fixed_in_frame and m not in fixed_orient] fixed_orient_mobs = [m for m in scene_mobs if m in fixed_orient] @@ -1125,12 +1186,39 @@ def _add(mob: Any) -> None: assert self._fixed_orient_uniform_buf is not None assert self._fixed_frame_uniform_buf is not None - normal_fd = collect_frame_data(self, normal_mobs, self._camera_uniform_buf) - fixed_orient_fd = collect_frame_data(self, fixed_orient_mobs, self._fixed_orient_uniform_buf) - fixed_frame_fd = collect_frame_data(self, fixed_frame_mobs, self._fixed_frame_uniform_buf) + normal_fd = collect_frame_data(self, normal_mobs, self._camera_uniform_buf) + + # Fixed-orientation: identity rotation, same projection as scene. + # Pass the stripped view matrix so CPU bounding quads are computed in the + # same space as the GPU will rasterise them. + fixed_view = self.camera.fixed_view_matrix + fixed_orient_fd = collect_frame_data( + self, fixed_orient_mobs, self._fixed_orient_uniform_buf, + view_matrix_override=fixed_view, + ) + + # Fixed-in-frame: identity rotation + orthographic projection. + # Both overrides are needed so the CPU quad positions match the GPU output. + fixed_frame_fd = collect_frame_data( + self, fixed_frame_mobs, self._fixed_frame_uniform_buf, + view_matrix_override=fixed_view, + proj_matrix_override=self.camera.ortho_projection_matrix, + ) encoder = self._device.create_command_encoder() + # ── Pre-pass: blit static background ───────────────────────────── + # When compositing moving mobs on top of the pre-rendered static layer, + # copy the static texture into the render texture before any render + # passes. The subsequent main pass uses load_op="load" so the static + # pixels are preserved under the newly drawn moving mobs. + if blit_static and self._has_static_frame and self._static_texture is not None: + encoder.copy_texture_to_texture( + {"texture": self._static_texture, "mip_level": 0, "origin": (0, 0, 0)}, + {"texture": self._render_texture, "mip_level": 0, "origin": (0, 0, 0)}, + (config.pixel_width, config.pixel_height, 1), + ) + # ── Pass 0: compute — cubic → quadratic conversion ──────────────── # Runs before any render pass; WebGPU guarantees the output buffer is # ready by the time the fragment shader reads it in Pass 1. @@ -1143,11 +1231,17 @@ def _add(mob: Any) -> None: cp.end() # ── Pass 1: main render ─────────────────────────────────────────── + # When blit_static, the render texture already has the static background + # from the pre-pass blit, so we use load_op="load" to preserve it. + # The depth buffer is always cleared: moving mobs composite on top + # regardless of their world-space depth relative to static objects, + # which matches Cairo's static-image compositing behaviour. + color_load_op = "load" if blit_static else "clear" main_pass = encoder.begin_render_pass( color_attachments=[ { "view": self._render_texture_view, - "load_op": "clear", + "load_op": color_load_op, "store_op": "store", "clear_value": tuple(float(c) for c in bg), } @@ -1447,7 +1541,14 @@ def pixel_coords_to_space_coords( # ------------------------------------------------------------------ def render(self, scene: Scene, frame_offset: float, moving_mobjects: list) -> None: - self.update_frame(scene) + if self._has_static_frame: + # Composite only the moving top-level mobjects on top of the + # pre-rendered static background. We derive the "top-level moving" + # set by excluding the static mob IDs from scene.mobjects. + top_moving = [m for m in scene.mobjects if id(m) not in self._static_mob_ids] + self.update_frame(scene, mob_list=top_moving, blit_static=True) + else: + self.update_frame(scene) if self.skip_animations: return self.file_writer.write_frame(self) @@ -1456,7 +1557,11 @@ def render(self, scene: Scene, frame_offset: float, moving_mobjects: list) -> No while self.animation_elapsed_time < frame_offset: if self.window.is_closing: break - self.update_frame(scene) + if self._has_static_frame: + top_moving = [m for m in scene.mobjects if id(m) not in self._static_mob_ids] + self.update_frame(scene, mob_list=top_moving, blit_static=True) + else: + self.update_frame(scene) self.window.present() def play(self, scene: Scene, *animations: Any, **kwargs: Any) -> None: @@ -1492,6 +1597,10 @@ def play(self, scene: Scene, *animations: Any, **kwargs: Any) -> None: self.file_writer.begin_animation(not self.skip_animations) scene.begin_animations() + # Pre-render static mobjects once, matching Cairo's optimisation. + # scene.static_mobjects is populated by begin_animations() above. + self.save_static_frame_data(scene, scene.static_mobjects) + if scene.is_current_animation_frozen_frame(): self.update_frame(scene) if not self.skip_animations: @@ -1525,7 +1634,42 @@ def scene_finished(self, scene: Scene) -> None: self.file_writer.save_image(self.get_image()) def save_static_frame_data(self, scene: Scene, static_mobjects: Any) -> None: - pass # not implemented in Phase 1 + """Render *static_mobjects* once and cache the result in ``_static_texture``. + + Called by ``play()`` after ``begin_animations()``, before the per-frame + loop starts. Subsequent calls to ``render()`` blit this cached texture + as the background and only re-draw the moving subset, matching Cairo's + static-image compositing optimisation. + + When *static_mobjects* is empty (all mobs are moving, or no mobs at all), + the static frame is cleared so that ``render()`` falls back to a full + redraw each frame. + """ + assert self._device is not None + assert self._static_texture is not None + + static_list = list(static_mobjects) if static_mobjects else [] + + if not static_list: + self._has_static_frame = False + self._static_mob_ids = set() + return + + self._static_mob_ids = set(id(m) for m in static_list) + + # Render the static mob list into _render_texture (full clear + draw). + self.update_frame(scene, mob_list=static_list, blit_static=False) + + # Copy _render_texture → _static_texture for later per-frame blits. + encoder = self._device.create_command_encoder() + encoder.copy_texture_to_texture( + {"texture": self._render_texture, "mip_level": 0, "origin": (0, 0, 0)}, + {"texture": self._static_texture, "mip_level": 0, "origin": (0, 0, 0)}, + (config.pixel_width, config.pixel_height, 1), + ) + self._device.queue.submit([encoder.finish()]) + + self._has_static_frame = True def clear_screen(self) -> None: if self.window is not None: diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py index 8583e959ea..44a72926c0 100644 --- a/manim/renderer/webgpu/webgpu_vmobject_rendering.py +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -215,6 +215,8 @@ def collect_frame_data( renderer: WebGPURenderer, mobjects: list, camera_uniform_buf: wgpu_t.GPUBuffer, + view_matrix_override: np.ndarray | None = None, + proj_matrix_override: np.ndarray | None = None, ) -> _FrameData | None: """Tessellate *mobjects*, upload to GPU, return a ``_FrameData``. @@ -225,11 +227,25 @@ def collect_frame_data( *camera_uniform_buf* is the 176-byte uniform buffer for this camera group. It is stored in the render bind group so the fragment shader can project world-space curve data into the correct NDC space. + + *view_matrix_override* / *proj_matrix_override* replace the camera's normal + view and projection matrices for CPU-side bounding-quad computation. Use + these for fixed-in-frame and fixed-orientation mobjects so that the + world-space quad vertices are consistent with the bind group the GPU will + use to rasterise them. """ import wgpu - view_matrix: np.ndarray = renderer.camera.view_matrix - proj_matrix: np.ndarray = renderer.camera.projection_matrix + view_matrix: np.ndarray = ( + view_matrix_override + if view_matrix_override is not None + else renderer.camera.view_matrix + ) + proj_matrix: np.ndarray = ( + proj_matrix_override + if proj_matrix_override is not None + else renderer.camera.projection_matrix + ) # Per-draw-call data collected across all mobjects. fs_parts: list[np.ndarray] = [] From 40d9241321214bb2bcbcb868da65306b82b1d45c Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Thu, 9 Apr 2026 21:22:47 +0530 Subject: [PATCH 17/33] Added image support to WebGPU Renderer --- manim/renderer/webgpu/shaders/image.wgsl | 50 + .../webgpu/shaders/surface_combined.wgsl | 4 +- manim/renderer/webgpu/webgpu_renderer.py | 761 +++- .../webgpu/webgpu_vmobject_rendering.py | 266 +- manim/scene/three_d_scene.py | 2 - uv.lock | 3484 ----------------- 6 files changed, 880 insertions(+), 3687 deletions(-) create mode 100644 manim/renderer/webgpu/shaders/image.wgsl delete mode 100644 uv.lock diff --git a/manim/renderer/webgpu/shaders/image.wgsl b/manim/renderer/webgpu/shaders/image.wgsl new file mode 100644 index 0000000000..09e2d5719a --- /dev/null +++ b/manim/renderer/webgpu/shaders/image.wgsl @@ -0,0 +1,50 @@ +// Image quad shader. +// +// Renders a textured quad from four world-space corner vertices. +// Used by WebGPURenderer to draw ImageMobject instances. +// +// Uniform layout (group 0, binding 0) — same 176-byte block as the VMobject +// shader; only projection and view are used here: +// offset 0 — projection mat4x4 64 B +// offset 64 — view mat4x4 64 B +// (remaining 48 bytes are lighting fields, unused by this shader) +// +// Texture / sampler (group 1): +// binding 0 — texture_2d (rgba8unorm uploaded as f32 [0,1] per channel) +// binding 1 — sampler (linear, clamp-to-edge) +// +// Vertex attributes (stride 20 bytes): +// location 0 — in_pos float32x3 offset 0 +// location 1 — in_uv float32x2 offset 12 + +struct Uniforms { + projection : mat4x4, + view : mat4x4, +}; +@group(0) @binding(0) var u : Uniforms; + +@group(1) @binding(0) var img_texture : texture_2d; +@group(1) @binding(1) var img_sampler : sampler; + +struct VertexInput { + @location(0) in_pos : vec3, + @location(1) in_uv : vec2, +}; + +struct VertexOutput { + @builtin(position) position : vec4, + @location(0) uv : vec2, +}; + +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + var out: VertexOutput; + out.position = u.projection * u.view * vec4(in.in_pos, 1.0); + out.uv = in.in_uv; + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + return textureSample(img_texture, img_sampler, in.uv); +} diff --git a/manim/renderer/webgpu/shaders/surface_combined.wgsl b/manim/renderer/webgpu/shaders/surface_combined.wgsl index 302b519465..bad4aa51ac 100644 --- a/manim/renderer/webgpu/shaders/surface_combined.wgsl +++ b/manim/renderer/webgpu/shaders/surface_combined.wgsl @@ -80,8 +80,8 @@ fn vs_main(in: VertexInput) -> VertexOutput { @fragment fn fs_main(in: VertexOutput, @builtin(front_facing) front_facing: bool) -> @location(0) vec4 { - let diffuse_strength = 0.9; - let specular_strength = 0.8; + let diffuse_strength = 0.8; + let specular_strength = 0.9; let specular_exp = 16.0; let raw_normal = select(-in.v_view_normal, in.v_view_normal, front_facing); diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index 4a2cbe8310..791eab097c 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -22,6 +22,7 @@ from __future__ import annotations import time +import weakref from pathlib import Path from typing import TYPE_CHECKING, Any @@ -31,11 +32,13 @@ from manim import config, logger from manim.constants import IN, OUT, PI, RIGHT, DOWN, LEFT from manim.mobject.mobject import Mobject +from manim.mobject.types.image_mobject import AbstractImageMobject from manim.mobject.types.vectorized_mobject import VMobject from manim.scene.scene_file_writer import SceneFileWriter from manim.utils.color import color_to_rgba from manim.utils.exceptions import EndSceneEarlyException from manim.utils.hashing import get_hash_from_play_call +from manim.utils.iterables import list_update from manim.utils.simple_functions import clip from manim.utils.space_ops import ( quaternion_from_angle_axis, @@ -93,7 +96,7 @@ class WebGPUCamera(Mobject): frame_shape (width, height) of the rendered frame. Defaults to ``(config.frame_width, config.frame_height)``. - center_point + frame_center World-space origin of the camera frame. Defaults to the origin. euler_angles (theta, phi, gamma) camera orientation angles in radians. @@ -115,9 +118,9 @@ class WebGPUCamera(Mobject): def __init__( self, frame_shape: tuple[float, float] | None = None, - center_point: np.ndarray | None = None, + frame_center: np.ndarray | None = None, euler_angles: np.ndarray | None = None, - focal_distance: float = 2.0, + focal_distance: float = 20.0, orthographic: bool = False, minimum_polar_angle: float = -PI / 2, maximum_polar_angle: float = PI / 2, @@ -135,24 +138,26 @@ def __init__( if frame_shape is not None else (float(config["frame_width"]), float(config["frame_height"])) ) - self.center_point: np.ndarray = ( - np.asarray(center_point, dtype=float) - if center_point is not None - else np.array([0.0, 0.0, 11.0], dtype=float) + self.frame_center: np.ndarray = ( + np.asarray(frame_center, dtype=float) + if frame_center is not None + else np.array([0.0, 0.0, focal_distance], dtype=float) ) + # Default theta matches Cairo's default (-90°) so that the initial + # rotation formula (theta + 90°) gives identity for 2-D scenes. self.euler_angles: np.ndarray = np.asarray( - euler_angles if euler_angles is not None else [0.0, 0.0, 0.0], + euler_angles if euler_angles is not None else [-PI / 2, 0.0, 0.0], dtype=float, ) - self.refresh_rotation_matrix() + self.reset_rotation_matrix() # Fixed-mobject registries — populated by ThreeDScene helpers. # fixed_in_frame: objects rendered with identity rotation + ortho # projection as a 2-D overlay on top of the 3-D scene (e.g. title text). # fixed_orientation: objects rendered with identity rotation + current # projection so they don't tilt as the camera orbits (e.g. 3-D labels). - self._fixed_in_frame_mobjects: set[Mobject] = set() - self._fixed_orientation_mobjects: set[Mobject] = set() + self.fixed_in_frame_mobjects: set[Mobject] = set() + self.fixed_orientation_mobjects: set[Mobject] = set() # ThreeDScene.get_moving_mobjects() checks _frame_center and # get_value_trackers() to detect camera-driven animation. @@ -161,8 +166,15 @@ def __init__( self._frame_center: Mobject = Mobject() def get_value_trackers(self) -> list: - """Required by ThreeDScene.get_moving_mobjects.""" - return [] + """Required by ThreeDScene.get_moving_mobjects. + + Returning ``[self]`` ensures that when the camera has updaters (e.g. + ambient rotation), ThreeDScene.get_moving_mobjects() detects the camera + in ``moving_mobjects`` and returns all scene mobjects — preventing the + static-frame optimisation from freezing the 3-D scene under camera + motion. + """ + return [self] # ------------------------------------------------------------------ # Frame geometry helpers (mirrors OpenGLCamera) @@ -182,7 +194,7 @@ def get_shape(self) -> tuple[float, float]: def get_center(self) -> np.ndarray: """World-space centre of the camera frame.""" - return self.center_point.copy() + return self.frame_center.copy() def get_focal_distance(self) -> float: """Perspective focal distance in scene units.""" @@ -198,24 +210,39 @@ def to_default_state(self) -> WebGPUCamera: float(config["frame_width"]), float(config["frame_height"]), ) - self.center_point = np.zeros(3) - self.euler_angles = np.zeros(3) - self.refresh_rotation_matrix() + self.frame_center = np.array([0.0, 0.0, self.focal_distance], dtype=float) + self.euler_angles = np.array([-PI / 2, 0.0, 0.0]) + self.reset_rotation_matrix() return self # ------------------------------------------------------------------ # Rotation — matches OpenGLCamera.set/increment_* interface # ------------------------------------------------------------------ - def refresh_rotation_matrix(self) -> None: + def reset_rotation_matrix(self) -> None: """Refresh the camera's inverse rotation matrix based on its Euler angles. - Matches Cairo's orientation. + + The formula replicates Cairo's ThreeDCamera so that the same (theta, phi, + gamma) values produce the same view in both renderers. + + Cairo's generate_rotation_matrix builds: + R = R_z(gamma) @ R_x(-phi) @ R_z(-theta - 90°) (np.dot loop order) + and applies it to world column vectors in project_points. + + The WebGPU view matrix stores ``inverse_rotation_matrix`` and applies it + directly. ``rotation_matrix_transpose_from_quaternion(q)`` returns R_q^T + where R_q is the rotation for quaternion q. To get R_q^T == R_cairo we + need: + R_q = R_cairo^T = R_z(theta + 90°) @ R_x(phi) @ R_z(-gamma) + i.e. the quaternion that rotates: first by -gamma around Z, then by phi + around X, then by (theta + 90°) around Z: + q = q(theta + PI/2, OUT) * q(phi, RIGHT) * q(-gamma, OUT) """ theta, phi, gamma = self.euler_angles quat = quaternion_mult( - quaternion_from_angle_axis(theta, IN, axis_normalized=True), + quaternion_from_angle_axis(theta + PI / 2, OUT, axis_normalized=True), quaternion_from_angle_axis(phi, RIGHT, axis_normalized=True), - quaternion_from_angle_axis(gamma, OUT, axis_normalized=True), + quaternion_from_angle_axis(-gamma, OUT, axis_normalized=True), ) self.inverse_rotation_matrix: np.ndarray = np.array( rotation_matrix_transpose_from_quaternion(np.asarray(quat, dtype=float)), @@ -234,7 +261,7 @@ def set_euler_angles( self.euler_angles[1] = phi if gamma is not None: self.euler_angles[2] = gamma - self.refresh_rotation_matrix() + self.reset_rotation_matrix() return self def set_theta(self, theta: float) -> WebGPUCamera: @@ -246,20 +273,25 @@ def set_phi(self, phi: float) -> WebGPUCamera: def set_gamma(self, gamma: float) -> WebGPUCamera: return self.set_euler_angles(gamma=gamma) - _PERSPECTIVE_FAR: float = 50.0 # must match projection_matrix + _PERSPECTIVE_FAR: float = 200.0 def set_focal_distance(self, focal_distance: float) -> WebGPUCamera: - """Set the perspective focal distance (= near plane distance). + """Set the perspective focal distance. - Larger values zoom in (more telephoto); smaller values zoom out. - Only has an effect when ``orthographic=False``. - Matches ``OpenGLCamera.focal_distance`` convention. + Matches Cairo's ``ThreeDCamera.focal_distance`` convention: larger + values push the camera further from the scene (same FOV, objects + appear further away); smaller values pull it closer. - ``focal_distance`` must be positive and strictly less than the far - plane (50.0). Values outside that range are clamped. + The near plane is derived as ``focal_distance / 6`` so that the + frustum height at depth ``focal_distance`` exactly equals the frame + height — matching Cairo's perspective formula + ``factor = focal_distance / (focal_distance - z_cam)``. + + ``focal_distance`` must be positive and less than ``_PERSPECTIVE_FAR``. + Values outside that range are clamped. """ - max_near = self._PERSPECTIVE_FAR * (1.0 - 1e-4) - clamped = float(np.clip(focal_distance, 1e-4, max_near)) + max_fd = self._PERSPECTIVE_FAR * (1.0 - 1e-4) + clamped = float(np.clip(focal_distance, 1e-4, max_fd)) if clamped != focal_distance: logger.warning( "WebGPUCamera.set_focal_distance: value %.4g clamped to %.4g " @@ -267,11 +299,14 @@ def set_focal_distance(self, focal_distance: float) -> WebGPUCamera: focal_distance, clamped, self._PERSPECTIVE_FAR, ) self.focal_distance = clamped + # Keep the virtual camera position (frame_center z) in sync so that + # the perspective projection exactly matches Cairo's formula at all depths. + self.frame_center[2] = clamped return self def increment_theta(self, dtheta: float) -> WebGPUCamera: self.euler_angles[0] += dtheta - self.refresh_rotation_matrix() + self.reset_rotation_matrix() return self def increment_phi(self, dphi: float) -> WebGPUCamera: @@ -280,12 +315,12 @@ def increment_phi(self, dphi: float) -> WebGPUCamera: self.minimum_polar_angle, self.maximum_polar_angle, ) - self.refresh_rotation_matrix() + self.reset_rotation_matrix() return self def increment_gamma(self, dgamma: float) -> WebGPUCamera: self.euler_angles[2] += dgamma - self.refresh_rotation_matrix() + self.reset_rotation_matrix() return self # ------------------------------------------------------------------ @@ -301,7 +336,7 @@ def view_matrix(self) -> np.ndarray: OpenGLCamera behavior where the camera orbits the focal point). """ R = np.asarray(self.inverse_rotation_matrix, dtype=np.float32) # 3×3 - c = self.center_point.astype(np.float32) + c = self.frame_center.astype(np.float32) view = np.eye(4, dtype=np.float32) view[:3, :3] = R # Translation in camera space: T(-c) followed by rotation R is equivalent @@ -320,7 +355,7 @@ def fixed_view_matrix(self) -> np.ndarray: depth ordering within the fixed layer is consistent with the main scene. """ view = np.eye(4, dtype=np.float32) - view[2, 3] = -float(self.center_point[2]) + view[2, 3] = -float(self.frame_center[2]) return view @property @@ -355,21 +390,26 @@ def projection_matrix(self) -> np.ndarray: """4×4 float32 projection matrix in WebGPU NDC convention (z ∈ [0, 1]). Perspective when ``self.orthographic`` is False (default). - Perspective otherwise — focal distance drives the field of view. + + Design: the near plane is derived as ``focal_distance / 6`` so that the + visible height at camera depth ``focal_distance`` (where world_z = 0 + maps to) exactly equals the frame height. Combined with + ``frame_center_z = focal_distance``, this exactly replicates Cairo's + perspective formula ``factor = focal_distance / (focal_distance - z_cam)`` + (for all depths, not just at world_z = 0). """ fw, fh = self.frame_shape - near, far = self.near, self.far if self.orthographic: return self.ortho_projection_matrix else: - # Perspective mapping for WebGPU: W_clip = -z_view, z_clip ∈ [0, 1]. - # near = focal_distance (matches OpenGLCamera's implicit convention where - # the default focal_distance=2.0 equals OpenGL's hardcoded near=2). - # Changing focal_distance zooms the scene: larger → more telephoto. - # FOV is set by w=fw/6, h=fh/6 (same as opengl.perspective_projection_matrix). + # n = fd/6, w = fw/6, h = fh/6 → 2n/w = 2*fd/fw, 2n/h = 2*fd/fh + # → NDC_y = (2*fd/fh) * y / (-z_view) + # = (2*fd/fh) * y / (fd - z_cairo) [with z_view = z_cairo - fd] + # Cairo: NDC_y = fd/(fd-z_cairo) * y / (fh/2) = 2*fd/fh * y / (fd-z_cairo) ✓ f = self._PERSPECTIVE_FAR - n = float(np.clip(self.focal_distance, 1e-4, f * (1.0 - 1e-4))) + fd = self.focal_distance + n = float(np.clip(fd / 6.0, 1e-6, f * (1.0 - 1e-4))) w, h = fw / 6.0, fh / 6.0 return np.array( [ @@ -393,11 +433,11 @@ def add_fixed_in_frame_mobjects(self, *mobjects: Mobject) -> None: identity camera rotation, and an orthographic projection so they always appear on top at their 2-D screen-space coordinates. """ - self._fixed_in_frame_mobjects.update(mobjects) + self.fixed_in_frame_mobjects.update(mobjects) def remove_fixed_in_frame_mobjects(self, *mobjects: Mobject) -> None: """Unregister mobjects previously added with add_fixed_in_frame_mobjects.""" - self._fixed_in_frame_mobjects.difference_update(mobjects) + self.fixed_in_frame_mobjects.difference_update(mobjects) def add_fixed_orientation_mobjects(self, *mobjects: Mobject) -> None: """Register mobjects whose orientation is frozen relative to the camera. @@ -406,11 +446,11 @@ def add_fixed_orientation_mobjects(self, *mobjects: Mobject) -> None: normally) but the camera rotation is not applied — they remain upright as the camera orbits. Useful for 3-D labels that should always face forward. """ - self._fixed_orientation_mobjects.update(mobjects) + self.fixed_orientation_mobjects.update(mobjects) def remove_fixed_orientation_mobjects(self, *mobjects: Mobject) -> None: """Unregister mobjects previously added with add_fixed_orientation_mobjects.""" - self._fixed_orientation_mobjects.difference_update(mobjects) + self.fixed_orientation_mobjects.difference_update(mobjects) # --------------------------------------------------------------------------- @@ -465,11 +505,11 @@ def __init__( # Scene-wide lighting — read by _build_camera_uniform_buf() each frame. # light_color / ambient_color are RGB floats in [0, 1]. - self.light_source_position: np.ndarray = np.array([-10.0, 10.0, 5.0]) + self.light_source_position: np.ndarray = np.array([10.0, 10.0, -10.0]) self.light_color: np.ndarray = np.array([1.0, 1.0, 1.0]) - self.light_intensity: float = 100.0 + self.light_intensity: float = 300.0 self.ambient_color: np.ndarray = np.array([1.0, 1.0, 1.0]) - self.ambient_intensity: float = 0.4 + self.ambient_intensity: float = 0.5 # Filled by init_scene(): self._device: wgpu_t.GPUDevice | None = None @@ -503,6 +543,14 @@ def __init__( self._oit_compose_bgl: wgpu_t.GPUBindGroupLayout | None = None self._oit_compose_bind_group: wgpu_t.GPUBindGroup | None = None + # Image pipeline (image.wgsl) — renders ImageMobject pixel arrays as + # textured quads before the VMobject pass (painter's algorithm). + self._image_pipeline: wgpu_t.GPURenderPipeline | None = None + self._image_tex_bgl: wgpu_t.GPUBindGroupLayout | None = None + # Cache: ImageMobject → (fingerprint, GPUTexture, GPUBindGroup). + # Keyed weakly so destroyed mobs release their GPU textures. + self._image_cache: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() + # Compact readback compute pipeline (GPU row-depadding + B↔R fix). self._readback_compute_pipeline: wgpu_t.GPUComputePipeline | None = None self._readback_compute_bgl: wgpu_t.GPUBindGroupLayout | None = None @@ -525,6 +573,11 @@ def __init__( self._fixed_frame_uniform_buf: wgpu_t.GPUBuffer | None = None self.frame_vbos: list[wgpu_t.GPUBuffer] = [] + # _FrameData cache: keyed by cache slot name ("normal", "orient", "frame"). + # Each entry is (fingerprint_bytes, _FrameData). On a fingerprint hit we + # return the cached _FrameData, skipping all tessellation and buffer uploads. + self._fd_cache: dict[str, tuple[bytes, Any]] = {} + # ------------------------------------------------------------------ # static_image property — scene.py sets this to None at end of play() # ------------------------------------------------------------------ @@ -623,6 +676,37 @@ def init_scene(self, scene: Scene) -> None: self._create_oit_resources(width, height) self._create_readback_pipeline(width, height) + self._image_tex_bgl, self._image_pipeline = self._create_image_pipeline() + + # Persistent camera uniform buffers — created once, updated each frame via + # write_buffer. Using COPY_DST so queue.write_buffer can write into them. + # These stable GPU objects let cached _FrameData bind groups remain valid + # across frames: the bind group references the same buffer; write_buffer + # updates its contents so the shader always sees the current camera. + self._camera_uniform_buf = self._device.create_buffer( + size=176, + usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST, + ) + self._fixed_orient_uniform_buf = self._device.create_buffer( + size=176, + usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST, + ) + self._fixed_frame_uniform_buf = self._device.create_buffer( + size=176, + usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST, + ) + + # Persistent camera bind groups — constant layout + constant buffer objects, + # so they never need to be recreated. + def _make_persistent_bg(buf: wgpu_t.GPUBuffer) -> wgpu_t.GPUBindGroup: + return self._device.create_bind_group( + layout=self._proj_bgl, + entries=[{"binding": 0, "resource": {"buffer": buf, "offset": 0, "size": 176}}], + ) + + self.camera_bind_group = _make_persistent_bg(self._camera_uniform_buf) + self.fixed_camera_bind_group = _make_persistent_bg(self._fixed_orient_uniform_buf) + self.fixed_frame_bind_group = _make_persistent_bg(self._fixed_frame_uniform_buf) if self.should_create_window(): from .webgpu_renderer_window import WebGPUWindow @@ -840,6 +924,249 @@ def _create_surface_pipeline( }, ) + def _create_image_pipeline( + self, + ) -> tuple[wgpu_t.GPUBindGroupLayout, wgpu_t.GPURenderPipeline]: + """Create the render pipeline for ImageMobject textured quads. + + Layout + ------ + group 0 — camera uniform (reuses ``_proj_bgl``, same as VMobject shaders) + group 1 — texture_2d at binding 0, sampler at binding 1 + + Vertex buffer (stride 20 B): + location 0 — in_pos float32x3 (12 B) + location 1 — in_uv float32x2 ( 8 B) + """ + assert self._device is not None + assert self._proj_bgl is not None + + shader_path = Path(__file__).parent / "shaders" / "image.wgsl" + shader = self._device.create_shader_module( + code=shader_path.read_text(encoding="utf-8") + ) + + # Group 1: texture + sampler + tex_bgl = self._device.create_bind_group_layout( + entries=[ + { + "binding": 0, + "visibility": wgpu.ShaderStage.FRAGMENT, + "texture": { + "sample_type": "float", + "view_dimension": "2d", + "multisampled": False, + }, + }, + { + "binding": 1, + "visibility": wgpu.ShaderStage.FRAGMENT, + "sampler": {"type": "filtering"}, + }, + ] + ) + + layout = self._device.create_pipeline_layout( + bind_group_layouts=[self._proj_bgl, tex_bgl] + ) + + blend = { + "color": { + "src_factor": "src-alpha", + "dst_factor": "one-minus-src-alpha", + "operation": "add", + }, + "alpha": { + "src_factor": "one", + "dst_factor": "one-minus-src-alpha", + "operation": "add", + }, + } + + pipeline = self._device.create_render_pipeline( + layout=layout, + vertex={ + "module": shader, + "entry_point": "vs_main", + "buffers": [ + { + "array_stride": 20, # 3+2 floats × 4 B + "step_mode": "vertex", + "attributes": [ + {"format": "float32x3", "offset": 0, "shader_location": 0}, + {"format": "float32x2", "offset": 12, "shader_location": 1}, + ], + } + ], + }, + fragment={ + "module": shader, + "entry_point": "fs_main", + "targets": [ + { + "format": wgpu.TextureFormat.bgra8unorm, + "blend": blend, + } + ], + }, + primitive={"topology": "triangle-list", "cull_mode": "none"}, + depth_stencil={ + # Must match the render pass's depth format. + # Images use painter's algorithm (draw order), not depth test. + "format": wgpu.TextureFormat.depth24plus, + "depth_write_enabled": False, + "depth_compare": "always", + "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_read_mask": 0, + "stencil_write_mask": 0, + }, + multisample={"count": 1, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, + ) + + return tex_bgl, pipeline + + def _image_fingerprint(self, pixel_array: np.ndarray) -> int: + """Cheap dirty-check fingerprint for a pixel array. + + Samples the first 64, middle 64, and last 64 bytes of the flattened + array plus the shape tuple — fast enough for typical image sizes and + reliably detects in-place changes such as set_opacity(). + """ + flat = pixel_array.ravel() + n = len(flat) + if n <= 192: + return hash((pixel_array.shape, flat.tobytes())) + mid = n // 2 + sample = np.concatenate([flat[:64], flat[mid : mid + 64], flat[-64:]]) + return hash((pixel_array.shape, sample.tobytes())) + + def _get_image_gpu_resources( + self, mob: Any + ) -> tuple[wgpu_t.GPUTexture, wgpu_t.GPUBindGroup] | None: + """Return (texture, bind_group) for *mob*, re-uploading if pixel_array changed. + + Returns None if the mob has no valid pixel array. + """ + assert self._device is not None + assert self._image_tex_bgl is not None + + pixel_array: np.ndarray | None = getattr(mob, "pixel_array", None) + if pixel_array is None or pixel_array.ndim != 3 or pixel_array.shape[2] < 4: + return None + + fp = self._image_fingerprint(pixel_array) + cached = self._image_cache.get(mob) + if cached is not None and cached[0] == fp: + return cached[1], cached[2] + + # (Re-)upload texture. + h, w = pixel_array.shape[:2] + # Ensure RGBA uint8. + if pixel_array.dtype != np.uint8: + pixel_array = pixel_array.astype(np.uint8) + + # bytes_per_row must be a multiple of 256. + bytes_per_row = w * 4 + aligned_bpr = (bytes_per_row + 255) & ~255 + if aligned_bpr == bytes_per_row: + data = pixel_array.tobytes() + else: + rows = [ + pixel_array[r].ravel().tobytes() + b"\x00" * (aligned_bpr - bytes_per_row) + for r in range(h) + ] + data = b"".join(rows) + + tex = self._device.create_texture( + size=(w, h, 1), + format=wgpu.TextureFormat.rgba8unorm, + usage=wgpu.TextureUsage.TEXTURE_BINDING | wgpu.TextureUsage.COPY_DST, + ) + self._device.queue.write_texture( + {"texture": tex, "mip_level": 0, "origin": (0, 0, 0)}, + data, + {"bytes_per_row": aligned_bpr, "rows_per_image": h}, + (w, h, 1), + ) + + sampler = self._device.create_sampler( + min_filter="linear", + mag_filter="linear", + address_mode_u="clamp-to-edge", + address_mode_v="clamp-to-edge", + ) + + bg = self._device.create_bind_group( + layout=self._image_tex_bgl, + entries=[ + {"binding": 0, "resource": tex.create_view()}, + {"binding": 1, "resource": sampler}, + ], + ) + + self._image_cache[mob] = (fp, tex, bg) + return tex, bg + + def _build_image_vbo(self, mob: Any) -> wgpu_t.GPUBuffer | None: + """Build a 6-vertex (20 B/vertex) VBO for *mob*'s bounding quad. + + Corner layout from AbstractImageMobject.reset_points(): + points[0] = UP + LEFT → UV (0, 0) + points[1] = UP + RIGHT → UV (1, 0) + points[2] = DOWN + LEFT → UV (0, 1) + points[3] = DOWN + RIGHT→ UV (1, 1) + + Two CCW triangles: [0,1,2] and [1,3,2]. + """ + assert self._device is not None + + pts = getattr(mob, "points", None) + if pts is None or len(pts) < 4: + return None + + corners = pts[:4].astype(np.float32) # (4, 3) + uvs = np.array( + [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]], dtype=np.float32 + ) + # Index order: 0,1,2, 1,3,2 + idx = [0, 1, 2, 1, 3, 2] + data = np.empty((6, 5), dtype=np.float32) + data[:, :3] = corners[idx] + data[:, 3:] = uvs[idx] + + buf = self._device.create_buffer_with_data( + data=data.tobytes(), + usage=wgpu.BufferUsage.VERTEX, + ) + self.frame_vbos.append(buf) + return buf + + def _draw_images_in_pass( + self, + render_pass: Any, + image_mobs: list, + camera_bind_group: Any, + ) -> None: + """Draw all *image_mobs* into *render_pass* using the image pipeline.""" + if not image_mobs or self._image_pipeline is None: + return + + render_pass.set_pipeline(self._image_pipeline) + render_pass.set_bind_group(0, camera_bind_group, [], 0, 0) + + for mob in image_mobs: + resources = self._get_image_gpu_resources(mob) + if resources is None: + continue + _, tex_bg = resources + vbo = self._build_image_vbo(mob) + if vbo is None: + continue + render_pass.set_bind_group(1, tex_bg, [], 0, 0) + render_pass.set_vertex_buffer(0, vbo) + render_pass.draw(6) + def _create_oit_resources(self, width: int, height: int) -> None: """Create OIT accumulation textures, pipelines, and bind groups.""" assert self._device is not None @@ -960,12 +1287,12 @@ def _create_oit_resources(self, width: int, height: int) -> None: # Camera bind group (rebuilt each frame when projection changes) # ------------------------------------------------------------------ - def _pack_camera_uniforms( + def _pack_camera_uniforms_bytes( self, proj: np.ndarray, view: np.ndarray, - ) -> wgpu_t.GPUBuffer: - """Pack a 176-byte camera+lighting uniform buffer from explicit proj/view matrices. + ) -> bytes: + """Return a 176-byte camera+lighting uniform payload from explicit proj/view. Layout (matches Uniforms struct in surface_combined.wgsl / surface_oit.wgsl): offset 0 — projection mat4x4 64 B @@ -976,12 +1303,7 @@ def _pack_camera_uniforms( offset 156 — ambient_intensity f32 4 B offset 160 — ambient_color vec3 12 B offset 172 — _pad f32 4 B - - Called by _build_camera_bind_group for each of the three per-frame - variants: normal, fixed-orientation, and fixed-in-frame. """ - assert self._device is not None - proj_bytes = proj.T.flatten().astype(np.float32).tobytes() view_bytes = view.T.flatten().astype(np.float32).tobytes() @@ -999,23 +1321,26 @@ def _pack_camera_uniforms( block_c = np.zeros(4, dtype=np.float32) block_c[:3] = np.asarray(self.ambient_color, dtype=np.float32) + return (proj_bytes + view_bytes + + block_a.tobytes() + block_b.tobytes() + block_c.tobytes()) + + # Keep the old name as a shim so any external callers don't break. + def _pack_camera_uniforms(self, proj: np.ndarray, view: np.ndarray) -> wgpu_t.GPUBuffer: + """Create a throw-away 176-byte uniform buffer (legacy path, rarely used).""" + assert self._device is not None buf = self._device.create_buffer_with_data( - data=(proj_bytes + view_bytes - + block_a.tobytes() + block_b.tobytes() + block_c.tobytes()), + data=self._pack_camera_uniforms_bytes(proj, view), usage=wgpu.BufferUsage.UNIFORM, ) self.frame_vbos.append(buf) return buf - def _build_camera_uniform_buf(self) -> wgpu_t.GPUBuffer: - """Pack the 176-byte camera+lighting uniform with the current view/projection.""" - return self._pack_camera_uniforms( - self.camera.projection_matrix, - self.camera.view_matrix, - ) - def _build_camera_bind_group(self) -> wgpu_t.GPUBindGroup: - """Build all three per-frame camera bind groups. + """Update all three persistent camera uniform buffers for the current frame. + + The three uniform buffers and their bind groups are created once in + init_scene. Each frame we write fresh matrix data into the buffers via + queue.write_buffer so the shaders see the updated camera. normal (camera_bind_group) Full camera rotation + current projection. Used for all regular @@ -1032,33 +1357,27 @@ def _build_camera_bind_group(self) -> wgpu_t.GPUBindGroup: with a fresh depth buffer so they always appear on top. """ assert self._device is not None - assert self._proj_bgl is not None - - def _make_bg(buf: wgpu_t.GPUBuffer) -> wgpu_t.GPUBindGroup: - return self._device.create_bind_group( - layout=self._proj_bgl, - entries=[{"binding": 0, "resource": {"buffer": buf, "offset": 0, "size": 176}}], - ) - - # Normal bind group - self._camera_uniform_buf = self._build_camera_uniform_buf() - normal_bg = _make_bg(self._camera_uniform_buf) + assert self._camera_uniform_buf is not None + assert self._fixed_orient_uniform_buf is not None + assert self._fixed_frame_uniform_buf is not None fixed_view = self.camera.fixed_view_matrix - # Fixed-orientation: rotation-stripped view, same projection as scene - self._fixed_orient_uniform_buf = self._pack_camera_uniforms( - self.camera.projection_matrix, fixed_view + self._device.queue.write_buffer( + self._camera_uniform_buf, 0, + self._pack_camera_uniforms_bytes(self.camera.projection_matrix, self.camera.view_matrix), ) - self.fixed_camera_bind_group = _make_bg(self._fixed_orient_uniform_buf) - - # Fixed-in-frame: rotation-stripped view, always orthographic - self._fixed_frame_uniform_buf = self._pack_camera_uniforms( - self.camera.ortho_projection_matrix, fixed_view + self._device.queue.write_buffer( + self._fixed_orient_uniform_buf, 0, + self._pack_camera_uniforms_bytes(self.camera.projection_matrix, fixed_view), + ) + self._device.queue.write_buffer( + self._fixed_frame_uniform_buf, 0, + self._pack_camera_uniforms_bytes(self.camera.ortho_projection_matrix, fixed_view), ) - self.fixed_frame_bind_group = _make_bg(self._fixed_frame_uniform_buf) - return normal_bg + # Return the persistent normal bind group (unchanged object). + return self.camera_bind_group # ------------------------------------------------------------------ # Pipeline / device accessors (used by webgpu_vmobject_rendering) @@ -1147,64 +1466,159 @@ def update_frame( self.camera_bind_group = self._build_camera_bind_group() self.frame_vbos = [] - # ── Partition mobjects ──────────────────────────────────────────── + # ── Partition and z-sort mobjects ──────────────────────────────── cam = self.camera - fixed_in_frame = cam._fixed_in_frame_mobjects - fixed_orient = cam._fixed_orientation_mobjects - - # Expand non-VMobject containers (e.g. Group) to their VMobject children. - # This handles the case where scene.add(Group(vmobject)) causes - # restructure_mobjects to replace the vmobject in scene.mobjects with the - # Group wrapper, which is not a VMobject and would otherwise be skipped. - def _flatten_to_vmobjects(source: list) -> list: - result: list = [] - seen: set[int] = set() - - def _add(mob: Any) -> None: - if id(mob) in seen: - return - seen.add(id(mob)) - if isinstance(mob, VMobject): - result.append(mob) - else: - for sub in mob.submobjects: - _add(sub) - - for mob in source: - _add(mob) - return result - - source = mob_list if mob_list is not None else list(scene.mobjects) - scene_mobs = _flatten_to_vmobjects(source) - - normal_mobs = [m for m in scene_mobs if m not in fixed_in_frame and m not in fixed_orient] - fixed_orient_mobs = [m for m in scene_mobs if m in fixed_orient] - fixed_frame_mobs = [m for m in scene_mobs if m in fixed_in_frame] + fixed_in_frame = cam.fixed_in_frame_mobjects + fixed_orient = cam.fixed_orientation_mobjects + fixed_view = self.camera.fixed_view_matrix - # ── CPU tessellation + GPU buffer upload (no commands yet) ──────── assert self._camera_uniform_buf is not None assert self._fixed_orient_uniform_buf is not None assert self._fixed_frame_uniform_buf is not None - normal_fd = collect_frame_data(self, normal_mobs, self._camera_uniform_buf) + if mob_list is not None: + # Caller (save_static_frame_data, render) already sorted the list. + source = mob_list + else: + # Full-frame path: merge mobjects + foreground_mobjects and apply + # z_index ordering (Bug 1). foreground_mobjects are already present + # in scene.mobjects (add_foreground_mobjects calls add()), so + # list_update just removes the duplicates from the left side. + # We then sort with a two-key tuple so that: + # key[0] = 0 for normal mobs, 1 for foreground mobs + # key[1] = z_index + # This ensures foreground mobs always draw last (on top) even when + # they share z_index=0 with regular mobs (Bug 3). + all_mobs = list_update(list(scene.mobjects), list(scene.foreground_mobjects)) + if self.camera.use_z_index: + foreground_ids = {id(m) for m in scene.foreground_mobjects} + source = sorted( + all_mobs, + key=lambda m: (1 if id(m) in foreground_ids else 0, m.z_index), + ) + else: + source = all_mobs + + # Build a z-ordered render queue by walking `source` in order. + # + # Rules: + # • fixed_in_frame mobs → skipped here, collected separately below + # • fixed_orient mobs → VMobject batch with stripped-rotation camera + # • normal VMobjects → VMobject batch with full camera + # • ImageMobjects → image draw item, flushing any pending + # VMobject runs first so z-order is respected + # • containers (Group…) → recursed + # + # The resulting queue is a list of items: + # ('vmobs', _FrameData, camera_bind_group) + # ('image', ImageMobject) + # + # Within the main render pass these are drawn in queue order, giving + # correct painter's-algorithm depth for any interleaving of images and + # VMobjects in scene.mobjects. + + render_queue: list[tuple] = [] + _run_normal: list = [] + _run_orient: list = [] + _seen: set[int] = set() + + def _flush_runs() -> None: + if _run_normal: + fd = collect_frame_data( + self, list(_run_normal), self._camera_uniform_buf, + cache_slot="normal", + ) + if fd is not None: + render_queue.append(("vmobs", fd, self.camera_bind_group)) + _run_normal.clear() + if _run_orient: + fd = collect_frame_data( + self, list(_run_orient), self._fixed_orient_uniform_buf, + view_matrix_override=fixed_view, + center_view_matrix=self.camera.view_matrix, + cache_slot="orient", + ) + if fd is not None: + render_queue.append(("vmobs", fd, self.fixed_camera_bind_group)) + _run_orient.clear() + + def _walk(mob: Any) -> None: + if id(mob) in _seen: + return + _seen.add(id(mob)) + if isinstance(mob, AbstractImageMobject): + _flush_runs() + render_queue.append(("image", mob)) + elif isinstance(mob, VMobject): + if mob in fixed_in_frame: + pass # handled in the overlay pass below + elif mob in fixed_orient: + _run_orient.append(mob) + else: + _run_normal.append(mob) + else: + for sub in mob.submobjects: + _walk(sub) + + for mob in source: + _walk(mob) + _flush_runs() + + # Pre-fetch image GPU resources (texture upload, VBO) before the + # command encoder starts. Replace ('image', mob) queue items with + # ('image', vbo, tex_bg) so the render loop has no CPU work left. + resolved_queue: list[tuple] = [] + for item in render_queue: + if item[0] == "image": + mob = item[1] + vbo = self._build_image_vbo(mob) + resources = self._get_image_gpu_resources(mob) + if vbo is not None and resources is not None: + resolved_queue.append(("image", vbo, resources[1])) + else: + resolved_queue.append(item) + + # Fixed-in-frame: always last, separate overlay pass. + fixed_frame_mobs = [ + m for m in _seen + if False # placeholder — rebuilt below from source flatten + ] + # Re-flatten source to get all VMobjects (including those inside containers) + # and filter to the fixed_in_frame set. + def _flatten_vmobjects(src: list) -> list: + out: list = [] + seen2: set[int] = set() + + def _f(m: Any) -> None: + if id(m) in seen2: + return + seen2.add(id(m)) + if isinstance(m, VMobject): + out.append(m) + else: + for s in m.submobjects: + _f(s) - # Fixed-orientation: identity rotation, same projection as scene. - # Pass the stripped view matrix so CPU bounding quads are computed in the - # same space as the GPU will rasterise them. - fixed_view = self.camera.fixed_view_matrix - fixed_orient_fd = collect_frame_data( - self, fixed_orient_mobs, self._fixed_orient_uniform_buf, - view_matrix_override=fixed_view, - ) + for m in src: + _f(m) + return out - # Fixed-in-frame: identity rotation + orthographic projection. - # Both overrides are needed so the CPU quad positions match the GPU output. + fixed_frame_mobs = [ + m for m in _flatten_vmobjects(source) if m in fixed_in_frame + ] fixed_frame_fd = collect_frame_data( self, fixed_frame_mobs, self._fixed_frame_uniform_buf, view_matrix_override=fixed_view, proj_matrix_override=self.camera.ortho_projection_matrix, + cache_slot="frame", ) + # OIT surfaces come from all normal VMobject batches in the queue. + all_normal_fds = [ + item[1] for item in resolved_queue if item[0] == "vmobs" + and item[2] is self.camera_bind_group + ] + encoder = self._device.create_command_encoder() # ── Pre-pass: blit static background ───────────────────────────── @@ -1224,18 +1638,19 @@ def _add(mob: Any) -> None: # ready by the time the fragment shader reads it in Pass 1. cp = encoder.begin_compute_pass() cp.set_pipeline(self._cubic_to_quads_pipeline) - for fd in (normal_fd, fixed_orient_fd, fixed_frame_fd): - if fd is not None and fd.n_cubics_total > 0 and fd.compute_bg is not None: + all_fds = [ + item[1] for item in resolved_queue if item[0] == "vmobs" + ] + ([fixed_frame_fd] if fixed_frame_fd is not None else []) + for fd in all_fds: + if fd.n_cubics_total > 0 and fd.compute_bg is not None: cp.set_bind_group(0, fd.compute_bg, [], 0, 0) cp.dispatch_workgroups((fd.n_cubics_total + 63) // 64, 1, 1) cp.end() # ── Pass 1: main render ─────────────────────────────────────────── - # When blit_static, the render texture already has the static background - # from the pre-pass blit, so we use load_op="load" to preserve it. - # The depth buffer is always cleared: moving mobs composite on top - # regardless of their world-space depth relative to static objects, - # which matches Cairo's static-image compositing behaviour. + # Draw the z-ordered render queue (VMobject batches and images + # interleaved in scene.mobjects order) so painter's-algorithm depth + # is respected for any combination of images and geometry. color_load_op = "load" if blit_static else "clear" main_pass = encoder.begin_render_pass( color_attachments=[ @@ -1255,18 +1670,30 @@ def _add(mob: Any) -> None: ) self.current_render_pass = main_pass - if normal_fd is not None: - draw_frame_data(self, normal_fd, self.camera_bind_group) - - if fixed_orient_fd is not None: - draw_frame_data(self, fixed_orient_fd, self.fixed_camera_bind_group) + for item in resolved_queue: + if item[0] == "image": + _, vbo, tex_bg = item + main_pass.set_pipeline(self._image_pipeline) + main_pass.set_bind_group(0, self.camera_bind_group, [], 0, 0) + main_pass.set_bind_group(1, tex_bg, [], 0, 0) + main_pass.set_vertex_buffer(0, vbo) + main_pass.draw(6) + elif item[0] == "vmobs": + _, fd, cam_bg = item + draw_frame_data(self, fd, cam_bg) main_pass.end() # ── Pass 2: OIT accumulation ────────────────────────────────────── - # Uses the normal (rotated) camera bind group for the surface OIT pass. - oit_fd = normal_fd # fixed-orient surfaces are uncommon; handle normally - if oit_fd is not None and oit_fd.oit_indices: + # Collect OIT surfaces from all normal-camera VMobject batches. + oit_fds = [fd for fd in all_normal_fds if fd.oit_indices] + if oit_fds: + # Use the first fd with OIT surfaces as representative; actual + # OIT draw loops over all of them below. + oit_fd = oit_fds[0] + else: + oit_fd = None + if oit_fds: oit_pass = encoder.begin_render_pass( color_attachments=[ { @@ -1290,16 +1717,17 @@ def _add(mob: Any) -> None: ) oit_pass.set_pipeline(self.surface_oit_pipeline) oit_pass.set_bind_group(0, self.camera_bind_group, [], 0, 0) - for idx in oit_fd.oit_indices: - arr = oit_fd.surface_parts[idx] - oit_pass.set_vertex_buffer( - 0, oit_fd.surface_buf, - oit_fd.surface_byte_offsets[idx], arr.nbytes, - ) - oit_pass.draw(len(arr), 1, 0, 0) + for oit_fd in oit_fds: + for idx in oit_fd.oit_indices: + arr = oit_fd.surface_parts[idx] + oit_pass.set_vertex_buffer( + 0, oit_fd.surface_buf, + oit_fd.surface_byte_offsets[idx], arr.nbytes, + ) + oit_pass.draw(len(arr), 1, 0, 0) oit_pass.end() - # ── Pass 3: OIT composition ─────────────────────────────────── + # ── Pass 3: OIT composition ────────────────────────────────── compose_pass = encoder.begin_render_pass( color_attachments=[ {"view": self._render_texture_view, "load_op": "load", "store_op": "store"} @@ -1332,7 +1760,8 @@ def _add(mob: Any) -> None: self._device.queue.submit([encoder.finish()]) self.current_render_pass = None - self.camera_bind_group = None + # camera_bind_group is now persistent (created once in init_scene) — + # do NOT null it here. self.frame_vbos = [] self.animation_elapsed_time = time.time() - self.animation_start_time @@ -1542,11 +1971,14 @@ def pixel_coords_to_space_coords( def render(self, scene: Scene, frame_offset: float, moving_mobjects: list) -> None: if self._has_static_frame: - # Composite only the moving top-level mobjects on top of the - # pre-rendered static background. We derive the "top-level moving" - # set by excluding the static mob IDs from scene.mobjects. - top_moving = [m for m in scene.mobjects if id(m) not in self._static_mob_ids] - self.update_frame(scene, mob_list=top_moving, blit_static=True) + # Use the family-level moving list produced by begin_animations() + # directly. That list is already z_index-sorted by + # extract_mobject_family_members and is at the correct granularity + # (same as what Cairo passes to its camera). Filtering + # scene.mobjects by static IDs was wrong because it operated at + # top-level container granularity while _static_mob_ids stores + # family-member IDs (Bug 2 fix). + self.update_frame(scene, mob_list=list(moving_mobjects), blit_static=True) else: self.update_frame(scene) if self.skip_animations: @@ -1558,8 +1990,7 @@ def render(self, scene: Scene, frame_offset: float, moving_mobjects: list) -> No if self.window.is_closing: break if self._has_static_frame: - top_moving = [m for m in scene.mobjects if id(m) not in self._static_mob_ids] - self.update_frame(scene, mob_list=top_moving, blit_static=True) + self.update_frame(scene, mob_list=list(moving_mobjects), blit_static=True) else: self.update_frame(scene) self.window.present() diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py index 44a72926c0..b3429500cc 100644 --- a/manim/renderer/webgpu/webgpu_vmobject_rendering.py +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -211,12 +211,50 @@ def _points_hash(vmobject: VMobject) -> int: # --------------------------------------------------------------------------- +def _fd_fingerprint( + mobjects: list, + view_matrix: np.ndarray, + proj_matrix: np.ndarray, + center_view_matrix: np.ndarray | None = None, +) -> bytes: + """Compute a compact fingerprint of the mobject set + camera state. + + Captures: view/projection matrices, per-submobject geometry hash, fill + color, stroke color, and stroke width. Two frames with identical + fingerprints are guaranteed to produce pixel-identical renders. + + *center_view_matrix* — when provided (fixed-orientation path), it is + included in the fingerprint so that camera rotation invalidates the cache + even though *view_matrix* (the stripped fixed_view) is constant. + + Cost: O(n) over all leaf submobjects, but only does scalar reads and bytes + operations — no numpy matrix math or buffer allocations. Much cheaper + than a full tessellation pass. + """ + parts: list[bytes] = [view_matrix.tobytes(), proj_matrix.tobytes()] + if center_view_matrix is not None: + parts.append(center_view_matrix.tobytes()) + for mob in mobjects: + for submob in mob.family_members_with_points(): + phash = _points_hash(submob) + fill_rgba = submob.get_fill_rgbas() + stroke_rgba = submob.get_stroke_rgbas() + sw = float(submob.get_stroke_width()) if stroke_rgba.shape[0] > 0 else 0.0 + parts.append(struct.pack(' _FrameData | None: """Tessellate *mobjects*, upload to GPU, return a ``_FrameData``. @@ -233,6 +271,21 @@ def collect_frame_data( these for fixed-in-frame and fixed-orientation mobjects so that the world-space quad vertices are consistent with the bind group the GPU will use to rasterise them. + + *center_view_matrix* — when provided, enables fixed-orientation rendering. + Each submobject's bezier control points are pre-translated by + ``(R_full - I) @ center_w`` so the object appears at the 3D-projected + position of its world center while its local orientation stays upright + (no camera rotation applied to the local shape). The GPU shader still + uses *view_matrix_override* (typically the rotation-stripped fixed_view), + and the pre-translation makes the combined effect equivalent to Cairo's + ``transform_points_pre_display`` for fixed-orientation objects. + + *cache_slot* — when not None, enables ``_FrameData`` caching for this + call. On a fingerprint hit the cached ``_FrameData`` is returned + immediately, skipping all tessellation and GPU buffer uploads. The + ``camera_uniform_buf`` must be a *persistent* buffer (same Python object + across frames) so that cached ``render_bg`` bind groups remain valid. """ import wgpu @@ -247,6 +300,19 @@ def collect_frame_data( else renderer.camera.projection_matrix ) + # ── _FrameData cache check ──────────────────────────────────────────── + fp: bytes = b"" # populated below on cache-enabled paths + if cache_slot is not None and mobjects: + fp = _fd_fingerprint(mobjects, view_matrix, proj_matrix, center_view_matrix) + cached = renderer._fd_cache.get(cache_slot) + if cached is not None and cached[0] == fp: + # Scene + camera unchanged — return the cached GPU data directly. + # The compute pass will re-dispatch into the same quads_out_buf + # (safe: identical input → identical output; the render pass reads + # it after the compute pass completes within the same encoder). + return cached[1] + # Cache miss — tessellate below, then store result before returning. + # Per-draw-call data collected across all mobjects. fs_parts: list[np.ndarray] = [] # Cubics: fill first (all objects), then stroke (all objects). @@ -258,13 +324,28 @@ def collect_frame_data( surface_parts: list[np.ndarray] = [] draw_plan: list[tuple[str, int]] = [] + # Guard against double-processing the same submobject. This can happen + # when mob_list is a flat family list (e.g. moving_mobjects from + # begin_animations) that contains both a VGroup and its children: without + # the guard, family_members_with_points() on the VGroup would process the + # children, and then those children would be processed again individually. + _seen_submobs: set[int] = set() + + use_z_index: bool = renderer.camera.use_z_index + for mob in mobjects: if not isinstance(mob, VMobject): continue # ── Parametric Surface ──────────────────────────────────────────── if isinstance(mob, Surface): - for submob in mob.family_members_with_points(): + surface_submobs = mob.family_members_with_points() + if use_z_index: + surface_submobs = sorted(surface_submobs, key=lambda m: m.z_index) + for submob in surface_submobs: + if id(submob) in _seen_submobs: + continue + _seen_submobs.add(id(submob)) data = _collect_surface_geometry( submob, view_matrix, proj_matrix ) @@ -276,7 +357,13 @@ def collect_frame_data( continue # ── Regular VMobject (2-D or shade_in_3d) ──────────────────────── - for submob in mob.family_members_with_points(): + vmob_submobs = mob.family_members_with_points() + if use_z_index: + vmob_submobs = sorted(vmob_submobs, key=lambda m: m.z_index) + for submob in vmob_submobs: + if id(submob) in _seen_submobs: + continue + _seen_submobs.add(id(submob)) phash = _points_hash(submob) cached = _fill_stroke_cache.get(submob) if cached is None or cached[0] != phash: @@ -291,6 +378,31 @@ def collect_frame_data( continue fill_cubics, stroke_cubics = cached[1] + # Fixed-orientation pre-transform: translate control points so the + # submob appears at its full 3D-projected center position while + # preserving local orientation (no rotation of the local shape). + # + # Cairo's equivalent: transform_points_pre_display() computes + # new_center = project_point(center) (full camera rotation) + # points = points + (new_center - center) + # i.e. translate all points by the difference between the + # camera-space center and the world-space center. + # + # In WebGPU, with t_full == t_fixed == [0,0,-11], the offset + # simplifies to: + # offset = R_full @ center_w - center_w = (R_full - I) @ center_w + # After adding this offset, the GPU shader applies fixed_view + # (identity rotation + z-translation), giving: + # view_pos = (point_w + offset) + [0,0,-11] + # = (point_w - center_w) + (R_full @ center_w + [0,0,-11]) + # which is the local shape centred at the full-projection center. ✓ + if center_view_matrix is not None: + R_full = center_view_matrix[:3, :3].astype(np.float32) + c_w = submob.get_center().astype(np.float32) + offset = R_full @ c_w - c_w # shape (3,) + fill_cubics = fill_cubics + offset # broadcast (N,4,3)+(3,) + stroke_cubics = stroke_cubics + offset + # Fetch current colors every frame (they change during animations). fill_rgba = submob.get_fill_rgbas() stroke_rgba = submob.get_stroke_rgbas() @@ -421,7 +533,7 @@ def collect_frame_data( oit_indices = [idx for cmd, idx in draw_plan if cmd == "surface_oit"] - return _FrameData( + result = _FrameData( fs_parts=fs_parts, fs_buf=fs_buf, fs_byte_offsets=fs_byte_offsets, @@ -437,6 +549,24 @@ def collect_frame_data( oit_indices=oit_indices, ) + # Store in cache so the NEXT frame can skip tessellation on a fingerprint hit. + # frame_vbos are NOT added for cached buffers — the cache itself is the owner. + # Remove the just-uploaded buffers from frame_vbos so they aren't released at + # end-of-frame (the cache needs them to survive across frames). + if cache_slot is not None: + cached_bufs = { + id(result.fs_buf), + id(result.cubics_buf), + id(result.quads_out_buf), + id(result.surface_buf), + } - {id(None)} + renderer.frame_vbos = [ + b for b in renderer.frame_vbos if id(b) not in cached_bufs + ] + renderer._fd_cache[cache_slot] = (fp, result) + + return result + def draw_frame_data( renderer: WebGPURenderer, @@ -457,51 +587,119 @@ def draw_frame_data( """ rp = renderer.current_render_pass - cur_pipeline: list[str | None] = [None] - cur_bg: list[object | None] = [None] - - def _activate(name: str, bg: wgpu_t.GPUBindGroup) -> None: - if cur_pipeline[0] != name: - if name == "fill_stroke_2d": - rp.set_pipeline(renderer.fill_stroke_pipeline) - elif name == "fill_stroke_3d": - rp.set_pipeline(renderer.fill_stroke_3d_pipeline) - elif name == "surface_opaque": - rp.set_pipeline(renderer.surface_pipeline) - cur_pipeline[0] = name - if cur_bg[0] is not bg: - rp.set_bind_group(0, bg, [], 0, 0) - cur_bg[0] = bg - - # 1. 2-D fill+stroke: interleaved in draw_plan order (painter's algorithm). + # ── 1. 2-D fill+stroke: painter's algorithm ─────────────────────────── + # All 2-D quads live in fs_buf in their original draw_plan order. + # Instead of N separate set_vertex_buffer+draw calls we issue one draw + # per *contiguous run* of "fill_stroke_2d" entries, dramatically reducing + # the number of wgpu API calls (typically from 800 to 1 for a pure-2D scene). if fd.fs_buf is not None and fd.render_bg is not None: + rp.set_pipeline(renderer.fill_stroke_pipeline) + rp.set_bind_group(0, fd.render_bg, [], 0, 0) + rp.set_vertex_buffer(0, fd.fs_buf) + + # Walk draw_plan and batch consecutive fill_stroke_2d entries. + run_first_vertex: int = -1 + run_vertex_count: int = 0 + for cmd, idx in fd.draw_plan: if cmd != "fill_stroke_2d": + # Flush the current 2D run (if any) before breaking the batch. + if run_vertex_count > 0: + rp.draw(run_vertex_count, 1, run_first_vertex, 0) + run_vertex_count = 0 + run_first_vertex = -1 continue - _activate("fill_stroke_2d", fd.render_bg) - arr = fd.fs_parts[idx] - rp.set_vertex_buffer(0, fd.fs_buf, fd.fs_byte_offsets[idx], arr.nbytes) - rp.draw(len(arr), 1, 0, 0) - # 2. 3-D fill+stroke: depth-tested and depth-written. + arr = fd.fs_parts[idx] + byte_offset = fd.fs_byte_offsets[idx] + first_vert = byte_offset // _FILL_STROKE_STRIDE + + if run_first_vertex < 0: + # Start a new run. + run_first_vertex = first_vert + run_vertex_count = len(arr) + elif first_vert == run_first_vertex + run_vertex_count: + # Extend the current contiguous run. + run_vertex_count += len(arr) + else: + # Gap in the buffer (shouldn't happen for pure-2D scenes but + # guard anyway). Flush old run, start new one. + rp.draw(run_vertex_count, 1, run_first_vertex, 0) + run_first_vertex = first_vert + run_vertex_count = len(arr) + + # Flush final run. + if run_vertex_count > 0: + rp.draw(run_vertex_count, 1, run_first_vertex, 0) + + # ── 2. 3-D fill+stroke: depth-tested and depth-written ──────────────── + # Same batching strategy for shade_in_3d VMobjects. if fd.fs_buf is not None and fd.render_bg is not None: + rp.set_pipeline(renderer.fill_stroke_3d_pipeline) + rp.set_bind_group(0, fd.render_bg, [], 0, 0) + rp.set_vertex_buffer(0, fd.fs_buf) + + run_first_vertex = -1 + run_vertex_count = 0 + for cmd, idx in fd.draw_plan: if cmd != "fill_stroke_3d": + if run_vertex_count > 0: + rp.draw(run_vertex_count, 1, run_first_vertex, 0) + run_vertex_count = 0 + run_first_vertex = -1 continue - _activate("fill_stroke_3d", fd.render_bg) - arr = fd.fs_parts[idx] - rp.set_vertex_buffer(0, fd.fs_buf, fd.fs_byte_offsets[idx], arr.nbytes) - rp.draw(len(arr), 1, 0, 0) - # 3. Opaque parametric surfaces (combined fill + barycentric wireframe). + arr = fd.fs_parts[idx] + byte_offset = fd.fs_byte_offsets[idx] + first_vert = byte_offset // _FILL_STROKE_STRIDE + + if run_first_vertex < 0: + run_first_vertex = first_vert + run_vertex_count = len(arr) + elif first_vert == run_first_vertex + run_vertex_count: + run_vertex_count += len(arr) + else: + rp.draw(run_vertex_count, 1, run_first_vertex, 0) + run_first_vertex = first_vert + run_vertex_count = len(arr) + + if run_vertex_count > 0: + rp.draw(run_vertex_count, 1, run_first_vertex, 0) + + # ── 3. Opaque parametric surfaces ───────────────────────────────────── if fd.surface_buf is not None: + rp.set_pipeline(renderer.surface_pipeline) + rp.set_bind_group(0, cam_bg, [], 0, 0) + rp.set_vertex_buffer(0, fd.surface_buf) + + run_first_vertex = -1 + run_vertex_count = 0 + for cmd, idx in fd.draw_plan: if cmd != "surface_opaque": + if run_vertex_count > 0: + rp.draw(run_vertex_count, 1, run_first_vertex, 0) + run_vertex_count = 0 + run_first_vertex = -1 continue - _activate("surface_opaque", cam_bg) - arr = fd.surface_parts[idx] - rp.set_vertex_buffer(0, fd.surface_buf, fd.surface_byte_offsets[idx], arr.nbytes) - rp.draw(len(arr), 1, 0, 0) + + arr = fd.surface_parts[idx] + byte_offset = fd.surface_byte_offsets[idx] + first_vert = byte_offset // _SURFACE_COMBINED_STRIDE + + if run_first_vertex < 0: + run_first_vertex = first_vert + run_vertex_count = len(arr) + elif first_vert == run_first_vertex + run_vertex_count: + run_vertex_count += len(arr) + else: + rp.draw(run_vertex_count, 1, run_first_vertex, 0) + run_first_vertex = first_vert + run_vertex_count = len(arr) + + if run_vertex_count > 0: + rp.draw(run_vertex_count, 1, run_first_vertex, 0) # --------------------------------------------------------------------------- diff --git a/manim/scene/three_d_scene.py b/manim/scene/three_d_scene.py index 5ba0fda97e..062fafbb50 100644 --- a/manim/scene/three_d_scene.py +++ b/manim/scene/three_d_scene.py @@ -466,7 +466,6 @@ def remove_fixed_orientation_mobjects(self, *mobjects: Mobject): self.remove(mob) elif config.renderer == RendererType.WEBGPU: self.renderer.camera.remove_fixed_orientation_mobjects(*mobjects) - self.remove(*mobjects) def remove_fixed_in_frame_mobjects(self, *mobjects: Mobject): """ @@ -488,7 +487,6 @@ def remove_fixed_in_frame_mobjects(self, *mobjects: Mobject): self.remove(mob) elif config.renderer == RendererType.WEBGPU: self.renderer.camera.remove_fixed_in_frame_mobjects(*mobjects) - self.remove(*mobjects) ## def set_to_default_angled_camera_orientation(self, **kwargs): diff --git a/uv.lock b/uv.lock deleted file mode 100644 index d5d2357739..0000000000 --- a/uv.lock +++ /dev/null @@ -1,3484 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version < '3.13'", -] - -[[package]] -name = "accessible-pygments" -version = "0.0.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, -] - -[[package]] -name = "alabaster" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, -] - -[[package]] -name = "anyio" -version = "4.12.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, -] - -[[package]] -name = "appnope" -version = "0.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, -] - -[[package]] -name = "argon2-cffi" -version = "25.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "argon2-cffi-bindings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, -] - -[[package]] -name = "argon2-cffi-bindings" -version = "25.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, - { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, - { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, - { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, - { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, - { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, - { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, - { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, - { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, - { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, - { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, - { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, - { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, - { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, - { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, - { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, - { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, -] - -[[package]] -name = "arrow" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "python-dateutil" }, - { name = "tzdata" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, -] - -[[package]] -name = "asttokens" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, -] - -[[package]] -name = "async-lru" -version = "2.0.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/4d/71ec4d3939dc755264f680f6c2b4906423a304c3d18e96853f0a595dfe97/async_lru-2.0.5.tar.gz", hash = "sha256:481d52ccdd27275f42c43a928b4a50c3bfb2d67af4e78b170e3e0bb39c66e5bb", size = 10380, upload-time = "2025-03-16T17:25:36.919Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/49/d10027df9fce941cb8184e78a02857af36360d33e1721df81c5ed2179a1a/async_lru-2.0.5-py3-none-any.whl", hash = "sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943", size = 6069, upload-time = "2025-03-16T17:25:35.422Z" }, -] - -[[package]] -name = "attrs" -version = "25.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, -] - -[[package]] -name = "audioop-lts" -version = "0.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" }, - { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" }, - { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" }, - { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" }, - { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" }, - { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" }, - { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" }, - { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" }, - { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" }, - { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" }, - { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" }, - { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" }, - { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" }, - { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" }, - { url = "https://files.pythonhosted.org/packages/58/a7/0a764f77b5c4ac58dc13c01a580f5d32ae8c74c92020b961556a43e26d02/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", size = 47096, upload-time = "2025-08-05T16:42:40.684Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ed/ebebedde1a18848b085ad0fa54b66ceb95f1f94a3fc04f1cd1b5ccb0ed42/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", size = 27748, upload-time = "2025-08-05T16:42:41.992Z" }, - { url = "https://files.pythonhosted.org/packages/cb/6e/11ca8c21af79f15dbb1c7f8017952ee8c810c438ce4e2b25638dfef2b02c/audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", size = 27329, upload-time = "2025-08-05T16:42:42.987Z" }, - { url = "https://files.pythonhosted.org/packages/84/52/0022f93d56d85eec5da6b9da6a958a1ef09e80c39f2cc0a590c6af81dcbb/audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", size = 92407, upload-time = "2025-08-05T16:42:44.336Z" }, - { url = "https://files.pythonhosted.org/packages/87/1d/48a889855e67be8718adbc7a01f3c01d5743c325453a5e81cf3717664aad/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", size = 91811, upload-time = "2025-08-05T16:42:45.325Z" }, - { url = "https://files.pythonhosted.org/packages/98/a6/94b7213190e8077547ffae75e13ed05edc488653c85aa5c41472c297d295/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", size = 100470, upload-time = "2025-08-05T16:42:46.468Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e9/78450d7cb921ede0cfc33426d3a8023a3bda755883c95c868ee36db8d48d/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", size = 103878, upload-time = "2025-08-05T16:42:47.576Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e2/cd5439aad4f3e34ae1ee852025dc6aa8f67a82b97641e390bf7bd9891d3e/audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", size = 84867, upload-time = "2025-08-05T16:42:49.003Z" }, - { url = "https://files.pythonhosted.org/packages/68/4b/9d853e9076c43ebba0d411e8d2aa19061083349ac695a7d082540bad64d0/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", size = 90001, upload-time = "2025-08-05T16:42:50.038Z" }, - { url = "https://files.pythonhosted.org/packages/58/26/4bae7f9d2f116ed5593989d0e521d679b0d583973d203384679323d8fa85/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", size = 99046, upload-time = "2025-08-05T16:42:51.111Z" }, - { url = "https://files.pythonhosted.org/packages/b2/67/a9f4fb3e250dda9e9046f8866e9fa7d52664f8985e445c6b4ad6dfb55641/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", size = 84788, upload-time = "2025-08-05T16:42:52.198Z" }, - { url = "https://files.pythonhosted.org/packages/70/f7/3de86562db0121956148bcb0fe5b506615e3bcf6e63c4357a612b910765a/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", size = 94472, upload-time = "2025-08-05T16:42:53.59Z" }, - { url = "https://files.pythonhosted.org/packages/f1/32/fd772bf9078ae1001207d2df1eef3da05bea611a87dd0e8217989b2848fa/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", size = 92279, upload-time = "2025-08-05T16:42:54.632Z" }, - { url = "https://files.pythonhosted.org/packages/4f/41/affea7181592ab0ab560044632571a38edaf9130b84928177823fbf3176a/audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", size = 26568, upload-time = "2025-08-05T16:42:55.627Z" }, - { url = "https://files.pythonhosted.org/packages/28/2b/0372842877016641db8fc54d5c88596b542eec2f8f6c20a36fb6612bf9ee/audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", size = 30942, upload-time = "2025-08-05T16:42:56.674Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/baf2b9cc7e96c179bb4a54f30fcd83e6ecb340031bde68f486403f943768/audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", size = 24603, upload-time = "2025-08-05T16:42:57.571Z" }, - { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" }, - { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" }, - { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" }, - { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" }, - { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" }, - { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" }, - { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" }, - { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" }, - { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" }, - { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" }, - { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" }, - { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" }, - { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, -] - -[[package]] -name = "av" -version = "16.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/cd/3a83ffbc3cc25b39721d174487fb0d51a76582f4a1703f98e46170ce83d4/av-16.1.0.tar.gz", hash = "sha256:a094b4fd87a3721dacf02794d3d2c82b8d712c85b9534437e82a8a978c175ffd", size = 4285203, upload-time = "2026-01-11T07:31:33.772Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/d0/b71b65d1b36520dcb8291a2307d98b7fc12329a45614a303ff92ada4d723/av-16.1.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:e88ad64ee9d2b9c4c5d891f16c22ae78e725188b8926eb88187538d9dd0b232f", size = 26927747, upload-time = "2026-01-09T20:18:16.976Z" }, - { url = "https://files.pythonhosted.org/packages/2f/79/720a5a6ccdee06eafa211b945b0a450e3a0b8fc3d12922f0f3c454d870d2/av-16.1.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:cb296073fa6935724de72593800ba86ae49ed48af03960a4aee34f8a611f442b", size = 21492232, upload-time = "2026-01-09T20:18:19.266Z" }, - { url = "https://files.pythonhosted.org/packages/8e/4f/a1ba8d922f2f6d1a3d52419463ef26dd6c4d43ee364164a71b424b5ae204/av-16.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:720edd4d25aa73723c1532bb0597806d7b9af5ee34fc02358782c358cfe2f879", size = 39291737, upload-time = "2026-01-09T20:18:21.513Z" }, - { url = "https://files.pythonhosted.org/packages/1a/31/fc62b9fe8738d2693e18d99f040b219e26e8df894c10d065f27c6b4f07e3/av-16.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c7f2bc703d0df260a1fdf4de4253c7f5500ca9fc57772ea241b0cb241bcf972e", size = 40846822, upload-time = "2026-01-09T20:18:24.275Z" }, - { url = "https://files.pythonhosted.org/packages/53/10/ab446583dbce730000e8e6beec6ec3c2753e628c7f78f334a35cad0317f4/av-16.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d69c393809babada7d54964d56099e4b30a3e1f8b5736ca5e27bd7be0e0f3c83", size = 40675604, upload-time = "2026-01-09T20:18:26.866Z" }, - { url = "https://files.pythonhosted.org/packages/31/d7/1003be685277005f6d63fd9e64904ee222fe1f7a0ea70af313468bb597db/av-16.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:441892be28582356d53f282873c5a951592daaf71642c7f20165e3ddcb0b4c63", size = 42015955, upload-time = "2026-01-09T20:18:29.461Z" }, - { url = "https://files.pythonhosted.org/packages/2f/4a/fa2a38ee9306bf4579f556f94ecbc757520652eb91294d2a99c7cf7623b9/av-16.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:273a3e32de64819e4a1cd96341824299fe06f70c46f2288b5dc4173944f0fd62", size = 31750339, upload-time = "2026-01-09T20:18:32.249Z" }, - { url = "https://files.pythonhosted.org/packages/9c/84/2535f55edcd426cebec02eb37b811b1b0c163f26b8d3f53b059e2ec32665/av-16.1.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:640f57b93f927fba8689f6966c956737ee95388a91bd0b8c8b5e0481f73513d6", size = 26945785, upload-time = "2026-01-09T20:18:34.486Z" }, - { url = "https://files.pythonhosted.org/packages/b6/17/ffb940c9e490bf42e86db4db1ff426ee1559cd355a69609ec1efe4d3a9eb/av-16.1.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:ae3fb658eec00852ebd7412fdc141f17f3ddce8afee2d2e1cf366263ad2a3b35", size = 21481147, upload-time = "2026-01-09T20:18:36.716Z" }, - { url = "https://files.pythonhosted.org/packages/15/c1/e0d58003d2d83c3921887d5c8c9b8f5f7de9b58dc2194356a2656a45cfdc/av-16.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:27ee558d9c02a142eebcbe55578a6d817fedfde42ff5676275504e16d07a7f86", size = 39517197, upload-time = "2026-01-11T09:57:31.937Z" }, - { url = "https://files.pythonhosted.org/packages/32/77/787797b43475d1b90626af76f80bfb0c12cfec5e11eafcfc4151b8c80218/av-16.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7ae547f6d5fa31763f73900d43901e8c5fa6367bb9a9840978d57b5a7ae14ed2", size = 41174337, upload-time = "2026-01-11T09:57:35.792Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/d90df7f1e3b97fc5554cf45076df5045f1e0a6adf13899e10121229b826c/av-16.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8cf065f9d438e1921dc31fc7aa045790b58aee71736897866420d80b5450f62a", size = 40817720, upload-time = "2026-01-11T09:57:39.039Z" }, - { url = "https://files.pythonhosted.org/packages/80/6f/13c3a35f9dbcebafd03fe0c4cbd075d71ac8968ec849a3cfce406c35a9d2/av-16.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a345877a9d3cc0f08e2bc4ec163ee83176864b92587afb9d08dff50f37a9a829", size = 42267396, upload-time = "2026-01-11T09:57:42.115Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b9/275df9607f7fb44317ccb1d4be74827185c0d410f52b6e2cd770fe209118/av-16.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:f49243b1d27c91cd8c66fdba90a674e344eb8eb917264f36117bf2b6879118fd", size = 31752045, upload-time = "2026-01-11T09:57:45.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/2a/63797a4dde34283dd8054219fcb29294ba1c25d68ba8c8c8a6ae53c62c45/av-16.1.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:ce2a1b3d8bf619f6c47a9f28cfa7518ff75ddd516c234a4ee351037b05e6a587", size = 26916715, upload-time = "2026-01-11T09:57:47.682Z" }, - { url = "https://files.pythonhosted.org/packages/d2/c4/0b49cf730d0ae8cda925402f18ae814aef351f5772d14da72dd87ff66448/av-16.1.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:408dbe6a2573ca58a855eb8cd854112b33ea598651902c36709f5f84c991ed8e", size = 21452167, upload-time = "2026-01-11T09:57:50.606Z" }, - { url = "https://files.pythonhosted.org/packages/51/23/408806503e8d5d840975aad5699b153aaa21eb6de41ade75248a79b7a37f/av-16.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:57f657f86652a160a8a01887aaab82282f9e629abf94c780bbdbb01595d6f0f7", size = 39215659, upload-time = "2026-01-11T09:57:53.757Z" }, - { url = "https://files.pythonhosted.org/packages/c4/19/a8528d5bba592b3903f44c28dab9cc653c95fcf7393f382d2751a1d1523e/av-16.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:adbad2b355c2ee4552cac59762809d791bda90586d134a33c6f13727fb86cb3a", size = 40874970, upload-time = "2026-01-11T09:57:56.802Z" }, - { url = "https://files.pythonhosted.org/packages/e8/24/2dbcdf0e929ad56b7df078e514e7bd4ca0d45cba798aff3c8caac097d2f7/av-16.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f42e1a68ec2aebd21f7eb6895be69efa6aa27eec1670536876399725bbda4b99", size = 40530345, upload-time = "2026-01-11T09:58:00.421Z" }, - { url = "https://files.pythonhosted.org/packages/54/27/ae91b41207f34e99602d1c72ab6ffd9c51d7c67e3fbcd4e3a6c0e54f882c/av-16.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58fe47aeaef0f100c40ec8a5de9abbd37f118d3ca03829a1009cf288e9aef67c", size = 41972163, upload-time = "2026-01-11T09:58:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7a/22158fb923b2a9a00dfab0e96ef2e8a1763a94dd89e666a5858412383d46/av-16.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:565093ebc93b2f4b76782589564869dadfa83af5b852edebedd8fee746457d06", size = 31729230, upload-time = "2026-01-11T09:58:07.254Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f1/878f8687d801d6c4565d57ebec08449c46f75126ebca8e0fed6986599627/av-16.1.0-cp313-cp313t-macosx_11_0_x86_64.whl", hash = "sha256:574081a24edb98343fd9f473e21ae155bf61443d4ec9d7708987fa597d6b04b2", size = 27008769, upload-time = "2026-01-11T09:58:10.266Z" }, - { url = "https://files.pythonhosted.org/packages/30/f1/bd4ce8c8b5cbf1d43e27048e436cbc9de628d48ede088a1d0a993768eb86/av-16.1.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:9ab00ea29c25ebf2ea1d1e928d7babb3532d562481c5d96c0829212b70756ad0", size = 21590588, upload-time = "2026-01-11T09:58:12.629Z" }, - { url = "https://files.pythonhosted.org/packages/1d/dd/c81f6f9209201ff0b5d5bed6da6c6e641eef52d8fbc930d738c3f4f6f75d/av-16.1.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:a84a91188c1071f238a9523fd42dbe567fb2e2607b22b779851b2ce0eac1b560", size = 40638029, upload-time = "2026-01-11T09:58:15.399Z" }, - { url = "https://files.pythonhosted.org/packages/15/4d/07edff82b78d0459a6e807e01cd280d3180ce832efc1543de80d77676722/av-16.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c2cd0de4dd022a7225ff224fde8e7971496d700be41c50adaaa26c07bb50bf97", size = 41970776, upload-time = "2026-01-11T09:58:19.075Z" }, - { url = "https://files.pythonhosted.org/packages/da/9d/1f48b354b82fa135d388477cd1b11b81bdd4384bd6a42a60808e2ec2d66b/av-16.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0816143530624a5a93bc5494f8c6eeaf77549b9366709c2ac8566c1e9bff6df5", size = 41764751, upload-time = "2026-01-11T09:58:22.788Z" }, - { url = "https://files.pythonhosted.org/packages/2f/c7/a509801e98db35ec552dd79da7bdbcff7104044bfeb4c7d196c1ce121593/av-16.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e3a28053af29644696d0c007e897d19b1197585834660a54773e12a40b16974c", size = 43034355, upload-time = "2026-01-11T09:58:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/36/8b/e5f530d9e8f640da5f5c5f681a424c65f9dd171c871cd255d8a861785a6e/av-16.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e3e67144a202b95ed299d165232533989390a9ea3119d37eccec697dc6dbb0c", size = 31947047, upload-time = "2026-01-11T09:58:31.867Z" }, - { url = "https://files.pythonhosted.org/packages/df/18/8812221108c27d19f7e5f486a82c827923061edf55f906824ee0fcaadf50/av-16.1.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:39a634d8e5a87e78ea80772774bfd20c0721f0d633837ff185f36c9d14ffede4", size = 26916179, upload-time = "2026-01-11T09:58:36.506Z" }, - { url = "https://files.pythonhosted.org/packages/38/ef/49d128a9ddce42a2766fe2b6595bd9c49e067ad8937a560f7838a541464e/av-16.1.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:0ba32fb9e9300948a7fa9f8a3fc686e6f7f77599a665c71eb2118fdfd2c743f9", size = 21460168, upload-time = "2026-01-11T09:58:39.231Z" }, - { url = "https://files.pythonhosted.org/packages/e6/a9/b310d390844656fa74eeb8c2750e98030877c75b97551a23a77d3f982741/av-16.1.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:ca04d17815182d34ce3edc53cbda78a4f36e956c0fd73e3bab249872a831c4d7", size = 39210194, upload-time = "2026-01-11T09:58:42.138Z" }, - { url = "https://files.pythonhosted.org/packages/0c/7b/e65aae179929d0f173af6e474ad1489b5b5ad4c968a62c42758d619e54cf/av-16.1.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ee0e8de2e124a9ef53c955fe2add6ee7c56cc8fd83318265549e44057db77142", size = 40811675, upload-time = "2026-01-11T09:58:45.871Z" }, - { url = "https://files.pythonhosted.org/packages/54/3f/5d7edefd26b6a5187d6fac0f5065ee286109934f3dea607ef05e53f05b31/av-16.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22bf77a2f658827043a1e184b479c3bf25c4c43ab32353677df2d119f080e28f", size = 40543942, upload-time = "2026-01-11T09:58:49.759Z" }, - { url = "https://files.pythonhosted.org/packages/1b/24/f8b17897b67be0900a211142f5646a99d896168f54d57c81f3e018853796/av-16.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2dd419d262e6a71cab206d80bbf28e0a10d0f227b671cdf5e854c028faa2d043", size = 41924336, upload-time = "2026-01-11T09:58:53.344Z" }, - { url = "https://files.pythonhosted.org/packages/1c/cf/d32bc6bbbcf60b65f6510c54690ed3ae1c4ca5d9fafbce835b6056858686/av-16.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:53585986fd431cd436f290fba662cfb44d9494fbc2949a183de00acc5b33fa88", size = 31735077, upload-time = "2026-01-11T09:58:56.684Z" }, - { url = "https://files.pythonhosted.org/packages/53/f4/9b63dc70af8636399bd933e9df4f3025a0294609510239782c1b746fc796/av-16.1.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:76f5ed8495cf41e1209a5775d3699dc63fdc1740b94a095e2485f13586593205", size = 27014423, upload-time = "2026-01-11T09:58:59.703Z" }, - { url = "https://files.pythonhosted.org/packages/d1/da/787a07a0d6ed35a0888d7e5cfb8c2ffa202f38b7ad2c657299fac08eb046/av-16.1.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:8d55397190f12a1a3ae7538be58c356cceb2bf50df1b33523817587748ce89e5", size = 21595536, upload-time = "2026-01-11T09:59:02.508Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f4/9a7d8651a611be6e7e3ab7b30bb43779899c8cac5f7293b9fb634c44a3f3/av-16.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:9d51d9037437218261b4bbf9df78a95e216f83d7774fbfe8d289230b5b2e28e2", size = 40642490, upload-time = "2026-01-11T09:59:05.842Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e4/eb79bc538a94b4ff93cd4237d00939cba797579f3272490dd0144c165a21/av-16.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0ce07a89c15644407f49d942111ca046e323bbab0a9078ff43ee57c9b4a50dad", size = 41976905, upload-time = "2026-01-11T09:59:09.169Z" }, - { url = "https://files.pythonhosted.org/packages/5e/f5/f6db0dd86b70167a4d55ee0d9d9640983c570d25504f2bde42599f38241e/av-16.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cac0c074892ea97113b53556ff41c99562db7b9f09f098adac1f08318c2acad5", size = 41770481, upload-time = "2026-01-11T09:59:12.74Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/33651d658e45e16ab7671ea5fcf3d20980ea7983234f4d8d0c63c65581a5/av-16.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7dec3dcbc35a187ce450f65a2e0dda820d5a9e6553eea8344a1459af11c98649", size = 43036824, upload-time = "2026-01-11T09:59:16.507Z" }, - { url = "https://files.pythonhosted.org/packages/83/41/7f13361db54d7e02f11552575c0384dadaf0918138f4eaa82ea03a9f9580/av-16.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6f90dc082ff2068ddbe77618400b44d698d25d9c4edac57459e250c16b33d700", size = 31948164, upload-time = "2026-01-11T09:59:19.501Z" }, -] - -[[package]] -name = "babel" -version = "2.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, -] - -[[package]] -name = "beautifulsoup4" -version = "4.14.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "soupsieve" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, -] - -[[package]] -name = "bleach" -version = "6.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" }, -] - -[package.optional-dependencies] -css = [ - { name = "tinycss2" }, -] - -[[package]] -name = "certifi" -version = "2026.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - -[[package]] -name = "cfgv" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, - { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, - { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, - { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, - { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, - { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, - { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, - { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, - { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, - { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, - { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, - { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, - { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, -] - -[[package]] -name = "click" -version = "8.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, -] - -[[package]] -name = "cloup" -version = "3.0.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/46/cf/09a31f0f51b5c8ef2343baf37c35a5feb4f6dfdcbd0592a014baf837f2e4/cloup-3.0.8.tar.gz", hash = "sha256:f91c080a725196ddf74feabd6250266f466e97fc16dfe21a762cf6bc6beb3ecb", size = 229657, upload-time = "2025-08-05T02:25:02.83Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/0a/494a923f90cd97cdf4fb989cfd06ac0c6745f6dfb8adcef1b7f99d3c7834/cloup-3.0.8-py2.py3-none-any.whl", hash = "sha256:6fe9474dc44fa06f8870e9c797f005de1e3ef891ddc1a9612d9b58598a038323", size = 54647, upload-time = "2025-08-05T02:25:01.536Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "comm" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, -] - -[[package]] -name = "contourpy" -version = "1.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, - { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, - { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, - { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, - { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, - { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, - { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, - { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, - { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, - { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, - { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, - { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, - { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, - { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, - { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, - { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, - { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, - { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, - { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, - { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, - { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, - { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, - { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, - { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, - { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, - { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, - { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, - { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, - { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, - { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, - { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, - { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, - { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, - { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, - { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, - { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, - { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, - { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, - { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, - { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, - { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, - { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, - { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, - { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, - { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, - { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, - { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, -] - -[[package]] -name = "coverage" -version = "7.13.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" }, - { url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" }, - { url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" }, - { url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" }, - { url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" }, - { url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" }, - { url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" }, - { url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" }, - { url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" }, - { url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" }, - { url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, - { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, - { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, - { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, - { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, - { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, - { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, - { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, - { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, - { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, - { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, - { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, - { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, - { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, - { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, - { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, - { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, - { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, - { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, - { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, - { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, - { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, - { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, - { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, - { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, - { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, - { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, - { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, - { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, - { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, - { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, - { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, - { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, - { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, - { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, - { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, - { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, - { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, - { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, - { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, - { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, - { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, - { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, - { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, - { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, - { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, - { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, - { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, -] - -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, -] - -[[package]] -name = "cycler" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, -] - -[[package]] -name = "cython" -version = "3.2.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/85/7574c9cd44b69a27210444b6650f6477f56c75fee1b70d7672d3e4166167/cython-3.2.4.tar.gz", hash = "sha256:84226ecd313b233da27dc2eb3601b4f222b8209c3a7216d8733b031da1dc64e6", size = 3280291, upload-time = "2026-01-04T14:14:14.473Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/85/cc/8f06145ec3efa121c8b1b67f06a640386ddacd77ee3e574da582a21b14ee/cython-3.2.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff9af2134c05e3734064808db95b4dd7341a39af06e8945d05ea358e1741aaed", size = 2953769, upload-time = "2026-01-04T14:15:00.361Z" }, - { url = "https://files.pythonhosted.org/packages/91/4d/1eb0c7c196a136b1926f4d7f0492a96c6fabd604d77e6cd43b56a3a16d83/cython-3.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64d7f71be3dd6d6d4a4c575bb3a4674ea06d1e1e5e4cd1b9882a2bc40ed3c4c9", size = 2970064, upload-time = "2026-01-04T14:15:08.567Z" }, - { url = "https://files.pythonhosted.org/packages/18/b5/1cfca43b7d20a0fdb1eac67313d6bb6b18d18897f82dd0f17436bdd2ba7f/cython-3.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:28e8075087a59756f2d059273184b8b639fe0f16cf17470bd91c39921bc154e0", size = 2960506, upload-time = "2026-01-04T14:15:16.733Z" }, - { url = "https://files.pythonhosted.org/packages/ee/d7/3bda3efce0c5c6ce79cc21285dbe6f60369c20364e112f5a506ee8a1b067/cython-3.2.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d4b4fd5332ab093131fa6172e8362f16adef3eac3179fd24bbdc392531cb82fa", size = 2971496, upload-time = "2026-01-04T14:15:25.038Z" }, - { url = "https://files.pythonhosted.org/packages/0a/8b/fd393f0923c82be4ec0db712fffb2ff0a7a131707b842c99bf24b549274d/cython-3.2.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:36bf3f5eb56d5281aafabecbaa6ed288bc11db87547bba4e1e52943ae6961ccf", size = 2875622, upload-time = "2026-01-04T14:15:39.749Z" }, - { url = "https://files.pythonhosted.org/packages/ff/fa/d3c15189f7c52aaefbaea76fb012119b04b9013f4bf446cb4eb4c26c4e6b/cython-3.2.4-py3-none-any.whl", hash = "sha256:732fc93bc33ae4b14f6afaca663b916c2fdd5dcbfad7114e17fb2434eeaea45c", size = 1257078, upload-time = "2026-01-04T14:14:12.373Z" }, -] - -[[package]] -name = "dearpygui" -version = "2.1.1" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/57/b5/d5bae262633d05f82aeeb3f84ae6240fe3f2955ab6fb4509ebf8048a0b9e/dearpygui-2.1.1-cp311-cp311-macosx_10_6_x86_64.whl", hash = "sha256:8a7c8608b365f4b380b7326679023595fecd04b78c514f2cfd349b0a1108bd0e", size = 2100934, upload-time = "2025-11-14T14:47:38.172Z" }, - { url = "https://files.pythonhosted.org/packages/93/86/75fa9a0f0a7b4b62810cc6f1e8ebaea3df0a825c0adf27d2024aaac2d178/dearpygui-2.1.1-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:ee87153fdd494ccead8c2345553acd7a9ee61c031e3223f4160aa560709248a3", size = 1895473, upload-time = "2025-11-14T14:47:47.064Z" }, - { url = "https://files.pythonhosted.org/packages/ab/7a/e109e06f8f4379d41a4e672c49aba42e7fcf0eec88056fa06185f4e52c98/dearpygui-2.1.1-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:964fbb3735017b919efa58104b2d7e9b84a168ff5c1031ae0652d5bc0a48bf5b", size = 2640408, upload-time = "2025-11-14T14:47:53.124Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b5/2ec29d9b47c30ecee96c6f6a0cf229f2898ce3e133a1a0e5b0cd5db82e6b/dearpygui-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:6141184ff59fa4b8df1b81b077cb8cc2b2ef9c0ff92e69c6063062b6d251f426", size = 1808736, upload-time = "2025-11-14T14:47:26.46Z" }, - { url = "https://files.pythonhosted.org/packages/79/41/2146e8d03d28b5a66d5282beb26ffd9ab68a729a29d31e2fe91809271bf5/dearpygui-2.1.1-cp312-cp312-macosx_10_6_x86_64.whl", hash = "sha256:238aea7b4be7376f564dae8edd563b280ec1483a03786022969938507691e017", size = 2101529, upload-time = "2025-11-14T14:47:39.646Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c5/fcc37ef834fe225241aa4f18d77aaa2903134f283077978d65a901c624c6/dearpygui-2.1.1-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:c27ca6ecd4913555b717f3bb341c0b6a27d6c9fdc9932f0b3c31ae2ef893ae35", size = 1895555, upload-time = "2025-11-14T14:47:48.149Z" }, - { url = "https://files.pythonhosted.org/packages/74/66/19f454ba02d5f03a847cc1dfee4a849cd2307d97add5ba26fecdca318adb/dearpygui-2.1.1-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:8c071e9c165d89217bdcdaf769c6069252fcaee50bf369489add524107932273", size = 2641509, upload-time = "2025-11-14T14:47:54.581Z" }, - { url = "https://files.pythonhosted.org/packages/5e/58/d01538556103d544a5a5b4cbcb00646ff92d8a97f0a6283a56bede4307c8/dearpygui-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:9f2291313d2035f8a4108e13f60d8c1a0e7c19af7554a7739a3fd15b3d5af8f7", size = 1808971, upload-time = "2025-11-14T14:47:28.15Z" }, - { url = "https://files.pythonhosted.org/packages/68/3d/69e1204f84e7153b52483c48f28bbc5d6a3996dca5de1a1d5e4904059b6b/dearpygui-2.1.1-cp313-cp313-macosx_10_6_x86_64.whl", hash = "sha256:5bab324eb6d61213e74a1937b501eff8ca713e7478d5bd6465b6456921194ca0", size = 2101382, upload-time = "2025-11-14T14:47:40.666Z" }, - { url = "https://files.pythonhosted.org/packages/3c/79/f28074174bd4ea2193213fcc099fa576568129ad805fc51a066f8502dedc/dearpygui-2.1.1-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:958a794ed9c622842c70d80548fa943162f8b9f56c27f9a9a32eff5beef10945", size = 1895619, upload-time = "2025-11-14T14:47:49.231Z" }, - { url = "https://files.pythonhosted.org/packages/0c/46/afdbc98c35b1bb6bcac815489955c1a891dc44c5b6a00ac859cc11bd0ff5/dearpygui-2.1.1-cp313-cp313-manylinux1_x86_64.whl", hash = "sha256:ce692d9531d797cf31c85439e774d27fa67c5918e21e7e18d64987d1cb65ff2c", size = 2641406, upload-time = "2025-11-14T14:47:56.269Z" }, - { url = "https://files.pythonhosted.org/packages/6b/3a/5a71ab350e580cf88e1669f2d27bdc698df7bcda665edc459a9fe217234d/dearpygui-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:0901c6ba9c717599df9469576184ca851de6cde242b2d9f9ab10bc35a7219656", size = 1808916, upload-time = "2025-11-14T14:47:30.363Z" }, - { url = "https://files.pythonhosted.org/packages/2c/bd/d41500cae7d4b204f4474df1953a1735a7be232711fa33692fe176a88824/dearpygui-2.1.1-cp314-cp314-macosx_10_6_x86_64.whl", hash = "sha256:6d8f807ea0c75d64407926db442255b609c6883dfcc85559acfd8f66569f7d61", size = 2101737, upload-time = "2025-11-14T14:47:41.67Z" }, - { url = "https://files.pythonhosted.org/packages/97/52/00cd59bf2cca1f76dff39370fd47dffe0cd116639e9a957bbf7dbedea369/dearpygui-2.1.1-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:16cba0c621914c28abc056940143900677d964c60fe2432aeb45c4054cb84adc", size = 1895685, upload-time = "2025-11-14T14:47:50.303Z" }, - { url = "https://files.pythonhosted.org/packages/62/f8/305f9edc94601924f399d8381509763b3398300b012389ff4d1ebcdbdb9b/dearpygui-2.1.1-cp314-cp314-manylinux1_x86_64.whl", hash = "sha256:8a71dfb779772cd36b56858c15e333fb2487fb19a9319d796896cb0cd0efcd2c", size = 2641463, upload-time = "2025-11-14T14:47:57.792Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/1780c1165c435afe4b35aa664ae48881101bdb32476efeae5588d520bcb2/dearpygui-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:7c719adbc382f688c84c576e8d752cfe861b41b9d83ed1fd065e20944c11afbf", size = 1866074, upload-time = "2025-11-14T14:47:31.897Z" }, -] - -[[package]] -name = "debugpy" -version = "1.8.19" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/75/9e12d4d42349b817cd545b89247696c67917aab907012ae5b64bbfea3199/debugpy-1.8.19.tar.gz", hash = "sha256:eea7e5987445ab0b5ed258093722d5ecb8bb72217c5c9b1e21f64efe23ddebdb", size = 1644590, upload-time = "2025-12-15T21:53:28.044Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/80/e2/48531a609b5a2aa94c6b6853afdfec8da05630ab9aaa96f1349e772119e9/debugpy-1.8.19-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:c5dcfa21de1f735a4f7ced4556339a109aa0f618d366ede9da0a3600f2516d8b", size = 2207620, upload-time = "2025-12-15T21:53:37.1Z" }, - { url = "https://files.pythonhosted.org/packages/1b/d4/97775c01d56071969f57d93928899e5616a4cfbbf4c8cc75390d3a51c4a4/debugpy-1.8.19-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:806d6800246244004625d5222d7765874ab2d22f3ba5f615416cf1342d61c488", size = 3170796, upload-time = "2025-12-15T21:53:38.513Z" }, - { url = "https://files.pythonhosted.org/packages/8d/7e/8c7681bdb05be9ec972bbb1245eb7c4c7b0679bb6a9e6408d808bc876d3d/debugpy-1.8.19-cp311-cp311-win32.whl", hash = "sha256:783a519e6dfb1f3cd773a9bda592f4887a65040cb0c7bd38dde410f4e53c40d4", size = 5164287, upload-time = "2025-12-15T21:53:40.857Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a8/aaac7ff12ddf5d68a39e13a423a8490426f5f661384f5ad8d9062761bd8e/debugpy-1.8.19-cp311-cp311-win_amd64.whl", hash = "sha256:14035cbdbb1fe4b642babcdcb5935c2da3b1067ac211c5c5a8fdc0bb31adbcaa", size = 5188269, upload-time = "2025-12-15T21:53:42.359Z" }, - { url = "https://files.pythonhosted.org/packages/4a/15/d762e5263d9e25b763b78be72dc084c7a32113a0bac119e2f7acae7700ed/debugpy-1.8.19-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:bccb1540a49cde77edc7ce7d9d075c1dbeb2414751bc0048c7a11e1b597a4c2e", size = 2549995, upload-time = "2025-12-15T21:53:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/a7/88/f7d25c68b18873b7c53d7c156ca7a7ffd8e77073aa0eac170a9b679cf786/debugpy-1.8.19-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:e9c68d9a382ec754dc05ed1d1b4ed5bd824b9f7c1a8cd1083adb84b3c93501de", size = 4309891, upload-time = "2025-12-15T21:53:45.26Z" }, - { url = "https://files.pythonhosted.org/packages/c5/4f/a65e973aba3865794da65f71971dca01ae66666132c7b2647182d5be0c5f/debugpy-1.8.19-cp312-cp312-win32.whl", hash = "sha256:6599cab8a783d1496ae9984c52cb13b7c4a3bd06a8e6c33446832a5d97ce0bee", size = 5286355, upload-time = "2025-12-15T21:53:46.763Z" }, - { url = "https://files.pythonhosted.org/packages/d8/3a/d3d8b48fec96e3d824e404bf428276fb8419dfa766f78f10b08da1cb2986/debugpy-1.8.19-cp312-cp312-win_amd64.whl", hash = "sha256:66e3d2fd8f2035a8f111eb127fa508469dfa40928a89b460b41fd988684dc83d", size = 5328239, upload-time = "2025-12-15T21:53:48.868Z" }, - { url = "https://files.pythonhosted.org/packages/71/3d/388035a31a59c26f1ecc8d86af607d0c42e20ef80074147cd07b180c4349/debugpy-1.8.19-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:91e35db2672a0abaf325f4868fcac9c1674a0d9ad9bb8a8c849c03a5ebba3e6d", size = 2538859, upload-time = "2025-12-15T21:53:50.478Z" }, - { url = "https://files.pythonhosted.org/packages/4a/19/c93a0772d0962294f083dbdb113af1a7427bb632d36e5314297068f55db7/debugpy-1.8.19-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:85016a73ab84dea1c1f1dcd88ec692993bcbe4532d1b49ecb5f3c688ae50c606", size = 4292575, upload-time = "2025-12-15T21:53:51.821Z" }, - { url = "https://files.pythonhosted.org/packages/5c/56/09e48ab796b0a77e3d7dc250f95251832b8bf6838c9632f6100c98bdf426/debugpy-1.8.19-cp313-cp313-win32.whl", hash = "sha256:b605f17e89ba0ecee994391194285fada89cee111cfcd29d6f2ee11cbdc40976", size = 5286209, upload-time = "2025-12-15T21:53:53.602Z" }, - { url = "https://files.pythonhosted.org/packages/fb/4e/931480b9552c7d0feebe40c73725dd7703dcc578ba9efc14fe0e6d31cfd1/debugpy-1.8.19-cp313-cp313-win_amd64.whl", hash = "sha256:c30639998a9f9cd9699b4b621942c0179a6527f083c72351f95c6ab1728d5b73", size = 5328206, upload-time = "2025-12-15T21:53:55.433Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b9/cbec520c3a00508327476c7fce26fbafef98f412707e511eb9d19a2ef467/debugpy-1.8.19-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:1e8c4d1bd230067bf1bbcdbd6032e5a57068638eb28b9153d008ecde288152af", size = 2537372, upload-time = "2025-12-15T21:53:57.318Z" }, - { url = "https://files.pythonhosted.org/packages/88/5e/cf4e4dc712a141e10d58405c58c8268554aec3c35c09cdcda7535ff13f76/debugpy-1.8.19-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d40c016c1f538dbf1762936e3aeb43a89b965069d9f60f9e39d35d9d25e6b809", size = 4268729, upload-time = "2025-12-15T21:53:58.712Z" }, - { url = "https://files.pythonhosted.org/packages/82/a3/c91a087ab21f1047db328c1d3eb5d1ff0e52de9e74f9f6f6fa14cdd93d58/debugpy-1.8.19-cp314-cp314-win32.whl", hash = "sha256:0601708223fe1cd0e27c6cce67a899d92c7d68e73690211e6788a4b0e1903f5b", size = 5286388, upload-time = "2025-12-15T21:54:00.687Z" }, - { url = "https://files.pythonhosted.org/packages/17/b8/bfdc30b6e94f1eff09f2dc9cc1f9cd1c6cde3d996bcbd36ce2d9a4956e99/debugpy-1.8.19-cp314-cp314-win_amd64.whl", hash = "sha256:8e19a725f5d486f20e53a1dde2ab8bb2c9607c40c00a42ab646def962b41125f", size = 5327741, upload-time = "2025-12-15T21:54:02.148Z" }, - { url = "https://files.pythonhosted.org/packages/25/3e/e27078370414ef35fafad2c06d182110073daaeb5d3bf734b0b1eeefe452/debugpy-1.8.19-py2.py3-none-any.whl", hash = "sha256:360ffd231a780abbc414ba0f005dad409e71c78637efe8f2bd75837132a41d38", size = 5292321, upload-time = "2025-12-15T21:54:16.024Z" }, -] - -[[package]] -name = "decorator" -version = "5.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, -] - -[[package]] -name = "defusedxml" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, -] - -[[package]] -name = "distlib" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, -] - -[[package]] -name = "docutils" -version = "0.21.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, -] - -[[package]] -name = "execnet" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, -] - -[[package]] -name = "executing" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, -] - -[[package]] -name = "fastjsonschema" -version = "2.21.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, -] - -[[package]] -name = "filelock" -version = "3.20.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, -] - -[[package]] -name = "fonttools" -version = "4.61.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213, upload-time = "2025-12-12T17:29:46.675Z" }, - { url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689, upload-time = "2025-12-12T17:29:48.769Z" }, - { url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809, upload-time = "2025-12-12T17:29:51.701Z" }, - { url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039, upload-time = "2025-12-12T17:29:53.659Z" }, - { url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714, upload-time = "2025-12-12T17:29:55.592Z" }, - { url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648, upload-time = "2025-12-12T17:29:57.861Z" }, - { url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681, upload-time = "2025-12-12T17:29:59.943Z" }, - { url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951, upload-time = "2025-12-12T17:30:02.254Z" }, - { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" }, - { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" }, - { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" }, - { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" }, - { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" }, - { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" }, - { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454, upload-time = "2025-12-12T17:30:24.938Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191, upload-time = "2025-12-12T17:30:27.343Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410, upload-time = "2025-12-12T17:30:29.771Z" }, - { url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460, upload-time = "2025-12-12T17:30:32.073Z" }, - { url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800, upload-time = "2025-12-12T17:30:34.339Z" }, - { url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859, upload-time = "2025-12-12T17:30:36.593Z" }, - { url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821, upload-time = "2025-12-12T17:30:38.478Z" }, - { url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169, upload-time = "2025-12-12T17:30:40.951Z" }, - { url = "https://files.pythonhosted.org/packages/32/8f/4e7bf82c0cbb738d3c2206c920ca34ca74ef9dabde779030145d28665104/fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd", size = 2846094, upload-time = "2025-12-12T17:30:43.511Z" }, - { url = "https://files.pythonhosted.org/packages/71/09/d44e45d0a4f3a651f23a1e9d42de43bc643cce2971b19e784cc67d823676/fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e", size = 2396589, upload-time = "2025-12-12T17:30:45.681Z" }, - { url = "https://files.pythonhosted.org/packages/89/18/58c64cafcf8eb677a99ef593121f719e6dcbdb7d1c594ae5a10d4997ca8a/fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c", size = 4877892, upload-time = "2025-12-12T17:30:47.709Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ec/9e6b38c7ba1e09eb51db849d5450f4c05b7e78481f662c3b79dbde6f3d04/fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75", size = 4972884, upload-time = "2025-12-12T17:30:49.656Z" }, - { url = "https://files.pythonhosted.org/packages/5e/87/b5339da8e0256734ba0dbbf5b6cdebb1dd79b01dc8c270989b7bcd465541/fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063", size = 4924405, upload-time = "2025-12-12T17:30:51.735Z" }, - { url = "https://files.pythonhosted.org/packages/0b/47/e3409f1e1e69c073a3a6fd8cb886eb18c0bae0ee13db2c8d5e7f8495e8b7/fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2", size = 5035553, upload-time = "2025-12-12T17:30:54.823Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b6/1f6600161b1073a984294c6c031e1a56ebf95b6164249eecf30012bb2e38/fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c", size = 2271915, upload-time = "2025-12-12T17:30:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/52/7b/91e7b01e37cc8eb0e1f770d08305b3655e4f002fc160fb82b3390eabacf5/fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c", size = 2323487, upload-time = "2025-12-12T17:30:59.804Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/908ad78e46c61c3e3ed70c3b58ff82ab48437faf84ec84f109592cabbd9f/fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa", size = 2929571, upload-time = "2025-12-12T17:31:02.574Z" }, - { url = "https://files.pythonhosted.org/packages/bd/41/975804132c6dea64cdbfbaa59f3518a21c137a10cccf962805b301ac6ab2/fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91", size = 2435317, upload-time = "2025-12-12T17:31:04.974Z" }, - { url = "https://files.pythonhosted.org/packages/b0/5a/aef2a0a8daf1ebaae4cfd83f84186d4a72ee08fd6a8451289fcd03ffa8a4/fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19", size = 4882124, upload-time = "2025-12-12T17:31:07.456Z" }, - { url = "https://files.pythonhosted.org/packages/80/33/d6db3485b645b81cea538c9d1c9219d5805f0877fda18777add4671c5240/fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba", size = 5100391, upload-time = "2025-12-12T17:31:09.732Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d6/675ba631454043c75fcf76f0ca5463eac8eb0666ea1d7badae5fea001155/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7", size = 4978800, upload-time = "2025-12-12T17:31:11.681Z" }, - { url = "https://files.pythonhosted.org/packages/7f/33/d3ec753d547a8d2bdaedd390d4a814e8d5b45a093d558f025c6b990b554c/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118", size = 5006426, upload-time = "2025-12-12T17:31:13.764Z" }, - { url = "https://files.pythonhosted.org/packages/b4/40/cc11f378b561a67bea850ab50063366a0d1dd3f6d0a30ce0f874b0ad5664/fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5", size = 2335377, upload-time = "2025-12-12T17:31:16.49Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ff/c9a2b66b39f8628531ea58b320d66d951267c98c6a38684daa8f50fb02f8/fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b", size = 2400613, upload-time = "2025-12-12T17:31:18.769Z" }, - { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, -] - -[[package]] -name = "fqdn" -version = "1.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, -] - -[[package]] -name = "furo" -version = "2025.12.19" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "accessible-pygments" }, - { name = "beautifulsoup4" }, - { name = "pygments" }, - { name = "sphinx" }, - { name = "sphinx-basic-ng" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz", hash = "sha256:188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7", size = 1661473, upload-time = "2025-12-19T17:34:40.889Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" }, -] - -[[package]] -name = "glcontext" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/80/8238a0e6e972292061176141c1028b5e670aa8c94cf4c2f819bd730d314e/glcontext-3.0.0.tar.gz", hash = "sha256:57168edcd38df2fc0d70c318edf6f7e59091fba1cd3dadb289d0aa50449211ef", size = 16422, upload-time = "2024-08-10T20:01:20.004Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/36/3d0d09f7352b179a7ecc5fc3322beb85c8995c66db780acf791853e49043/glcontext-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3a9e56fa3597cc709cfd0fdf2ae682cda36510a13faac2b3142f401e823b64f4", size = 9328, upload-time = "2024-08-10T20:00:09.43Z" }, - { url = "https://files.pythonhosted.org/packages/44/9d/0c8fd9c660db000071ebace14a40bd381a41775c10faa262fedfae8227e3/glcontext-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a0484308af75e04b0e56066dc2324a8fb9f1443b76ddb98833439982322b2a39", size = 9738, upload-time = "2024-08-10T20:00:10.831Z" }, - { url = "https://files.pythonhosted.org/packages/78/cf/7bcadb995830cdd6a1a31f0527a52b2c441a499feabed9749106f7e41e67/glcontext-3.0.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:983231394396aa2a1e2b96df49404cc8f8aa729d462ed40e605a74b079c46342", size = 50663, upload-time = "2024-08-10T20:00:12.301Z" }, - { url = "https://files.pythonhosted.org/packages/43/fb/646c2773cb097b914afe1f06c95e65deb8a544d770389bf29c76a8f3a8fd/glcontext-3.0.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa413f4420abff2bbb5aa5770a3e1deffcdc13e0ef2f459b145fa79c36909e7", size = 51680, upload-time = "2024-08-10T20:00:13.646Z" }, - { url = "https://files.pythonhosted.org/packages/89/b7/04aac6c50071b858cfd02a9bccafb42a21f567992fa448c8ad8aa62939b9/glcontext-3.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7d0ac35ac07fc91eccea093beb9d1c1a4eae250bc33836047deff01a3b5f4757", size = 45063, upload-time = "2024-08-10T20:00:15.137Z" }, - { url = "https://files.pythonhosted.org/packages/3c/6e/6e398492b55f3c453cc6a2ecc2886a01b2a465623aa0c476d3e115be85c4/glcontext-3.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7145d17a70adc5784ca59ebbe19a56435ba21816070b8b433f43aa2dfb8be71a", size = 47281, upload-time = "2024-08-10T20:00:16.679Z" }, - { url = "https://files.pythonhosted.org/packages/b5/fd/4f59118e5067a3c88217862d8672463aff29f29c1ea9f47971f8ca67e83c/glcontext-3.0.0-cp311-cp311-win32.whl", hash = "sha256:b31808ca2517fedcac8ca5b296ff46c8af012911eaa2080889a1f244d329ef9a", size = 12222, upload-time = "2024-08-10T20:00:18.108Z" }, - { url = "https://files.pythonhosted.org/packages/20/1b/f402574ae4644e7fb389b9534de9d1be575b8a4da901a9023d1cec72c4aa/glcontext-3.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:ef4b4ec35e2b720f4cd250bb92cf6417add445490bf780345596da5a796a0e6f", size = 12975, upload-time = "2024-08-10T20:00:19.236Z" }, - { url = "https://files.pythonhosted.org/packages/6e/61/d8f77d44bbf477b235ecbff8d421a5511d4b4f6dc676dacd84d012348516/glcontext-3.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:848f870a2bc72a29de7ab6756b9e8f2e6ce052e17873ebc6b3f25129b6e0d58a", size = 9360, upload-time = "2024-08-10T20:00:20.42Z" }, - { url = "https://files.pythonhosted.org/packages/de/46/680a97d974cfe7af798542918b65bd8e65a5f8f7647edfd9fdb91a95df6c/glcontext-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b3b12a66f57379566dd4d36899ac265abdbe040f3fc3293f50cd6678a1dcc9b", size = 9733, upload-time = "2024-08-10T20:00:21.624Z" }, - { url = "https://files.pythonhosted.org/packages/74/2c/be188c4eb63b4d0cc74c644a1519b5e3a37488da3cbda724570cd5fed8d3/glcontext-3.0.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:449eaefd89c0519900715b8363ead59ac4aa32457722ca521ce01297441edb34", size = 50409, upload-time = "2024-08-10T20:00:22.827Z" }, - { url = "https://files.pythonhosted.org/packages/32/ba/9ccb80650e5bd61e739f16f33aec3bb290a80f314631be8f7dc0a2b22b5f/glcontext-3.0.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04921720740438ceea8fb8a38b5665963520c7c8f27bef03df8aeb3ea3cfbfb6", size = 51440, upload-time = "2024-08-10T20:00:23.987Z" }, - { url = "https://files.pythonhosted.org/packages/c0/99/f6c9a0e614809ba5b83bd8403c475b788bd574d694bd5bc6b6ae2e2cefdf/glcontext-3.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:25538bdb106f673638d70e8a16a0c037a92a24c4cf40a05f0d3fa14b483d6194", size = 44820, upload-time = "2024-08-10T20:00:25.436Z" }, - { url = "https://files.pythonhosted.org/packages/a2/29/fdbb94e4c9374390639b741774384f7413efcd7634cc9c4baacc2cf00a1f/glcontext-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d11f7701b900a5a34c994e1d91c547be1cc469b73f881471460fd905f69f9e4c", size = 47055, upload-time = "2024-08-10T20:00:26.723Z" }, - { url = "https://files.pythonhosted.org/packages/5d/fe/25b5348fe5e856697dacad34fc07e80f48eecfb38bd09806679fe0e62769/glcontext-3.0.0-cp312-cp312-win32.whl", hash = "sha256:5d2b567eaf34adb016aadce81fd2f1d4c8e4a39e3d6f2a395ce528e2a350dd3f", size = 12219, upload-time = "2024-08-10T20:00:27.76Z" }, - { url = "https://files.pythonhosted.org/packages/17/d3/6619693ddad97011ca1c9aaeb82216ab2bfd54757be752b12f4e9a2fc489/glcontext-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:e80bb37ba727bd20c192f2754aea40c437a7665005c1001c10752f91913964e9", size = 12971, upload-time = "2024-08-10T20:00:29.575Z" }, - { url = "https://files.pythonhosted.org/packages/12/be/0ef6a6710164fde818040238b041b02a082a2b9d210f18632ab2354d863e/glcontext-3.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5bd37089570d3cdb01c6c0b315c49ce8a4dcdab2c431f5ba9f37a8b633cebfdf", size = 9366, upload-time = "2024-08-10T20:00:30.531Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d7/c3220898d72fbf938660ba5789c19cf245d21b45802a5d86cbcc67d66413/glcontext-3.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:857fd83e60f15580afd369dfb651a10d84a70ec35995622d253551bfb3ff9477", size = 9735, upload-time = "2024-08-10T20:00:31.708Z" }, - { url = "https://files.pythonhosted.org/packages/74/c1/2d57062d2f2f6e55c58b12bdeab2a39b209f959df32039d099ecdfe96bf7/glcontext-3.0.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93fda9b378ce6d91f366e83e71ebdafdd167280a9834d1d6341ce6457c4e42ed", size = 50166, upload-time = "2024-08-10T20:00:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/03/4d/7fee00c76d678b06529e939ab6f3e3190af30208fe2e984526899026a437/glcontext-3.0.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89ad50d34aa62f03f6aaf6ae39fc27afd1b0eaefb0281aac51f686dc5672d473", size = 51186, upload-time = "2024-08-10T20:00:33.718Z" }, - { url = "https://files.pythonhosted.org/packages/f0/58/dc9a56192b889587e51ea511804bad5dec816de81ce16831db9fe19d5c40/glcontext-3.0.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2634d5e9647a6d7b0c5a5c0c57e91ac98aa79759bffb42459af4374b049fab01", size = 44658, upload-time = "2024-08-10T20:00:35.232Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ed/acb12e67589deaa96ad29d6994c2b9383afd18700f4f2a42ff342628aac5/glcontext-3.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0140c5df37cb48271527355062d35589dc3e1e7e73b51adf9962ed5048115f69", size = 46882, upload-time = "2024-08-10T20:00:36.33Z" }, - { url = "https://files.pythonhosted.org/packages/e0/6d/033ba23466d596c945f5f31f5c4e50cba3bc6664fdee58f6ceab54b76f4b/glcontext-3.0.0-cp313-cp313-win32.whl", hash = "sha256:6678e0552b516fa8fe62f500ef2b953bec991e82a003be2a9840d16556d03d2e", size = 12220, upload-time = "2024-08-10T20:00:37.874Z" }, - { url = "https://files.pythonhosted.org/packages/53/b4/f0e0860526b8661ec6ae2b25a15b61100e551f57f488613c564752173a56/glcontext-3.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:18aa4b1df50e8c8ea39bd0f775f39bcc987521f92c4ed019ec7d70078471354d", size = 12971, upload-time = "2024-08-10T20:00:39.233Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "identify" -version = "2.6.16" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - -[[package]] -name = "imagesize" -version = "1.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "ipykernel" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "appnope", marker = "sys_platform == 'darwin'" }, - { name = "comm" }, - { name = "debugpy" }, - { name = "ipython" }, - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "matplotlib-inline" }, - { name = "nest-asyncio" }, - { name = "packaging" }, - { name = "psutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/a4/4948be6eb88628505b83a1f2f40d90254cab66abf2043b3c40fa07dfce0f/ipykernel-7.1.0.tar.gz", hash = "sha256:58a3fc88533d5930c3546dc7eac66c6d288acde4f801e2001e65edc5dc9cf0db", size = 174579, upload-time = "2025-10-27T09:46:39.471Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/17/20c2552266728ceba271967b87919664ecc0e33efca29c3efc6baf88c5f9/ipykernel-7.1.0-py3-none-any.whl", hash = "sha256:763b5ec6c5b7776f6a8d7ce09b267693b4e5ce75cb50ae696aaefb3c85e1ea4c", size = 117968, upload-time = "2025-10-27T09:46:37.805Z" }, -] - -[[package]] -name = "ipython" -version = "9.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/46/dd/fb08d22ec0c27e73c8bc8f71810709870d51cadaf27b7ddd3f011236c100/ipython-9.9.0.tar.gz", hash = "sha256:48fbed1b2de5e2c7177eefa144aba7fcb82dac514f09b57e2ac9da34ddb54220", size = 4425043, upload-time = "2026-01-05T12:36:46.233Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/86/92/162cfaee4ccf370465c5af1ce36a9eacec1becb552f2033bb3584e6f640a/ipython-9.9.0-py3-none-any.whl", hash = "sha256:b457fe9165df2b84e8ec909a97abcf2ed88f565970efba16b1f7229c283d252b", size = 621431, upload-time = "2026-01-05T12:36:44.669Z" }, -] - -[[package]] -name = "ipython-pygments-lexers" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, -] - -[[package]] -name = "isoduration" -version = "20.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "arrow" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, -] - -[[package]] -name = "isosurfaces" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/cf/bd7e70bb7b8dfd77afdc79aba8d83afd4a9263f045861cd4ddd34b7f6a12/isosurfaces-0.1.2.tar.gz", hash = "sha256:fa51ebe864ea9355b26830e27fdd6a41d5a58b419fa8d4b47e3b8b80718d6e21", size = 11348, upload-time = "2024-02-26T00:20:52.066Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/68/d5e9e6e0d6e43107d8393d2ee3d231dbb597bf93052c6f3117b313724980/isosurfaces-0.1.2-py3-none-any.whl", hash = "sha256:525a49ba93f4dbc35303cd2faf30976af0f99d9274cfa2787aec016b8ef96c64", size = 11649, upload-time = "2024-02-26T00:20:41.308Z" }, -] - -[[package]] -name = "jedi" -version = "0.19.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "parso" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, -] - -[[package]] -name = "json5" -version = "0.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/e8/a3f261a66e4663f22700bc8a17c08cb83e91fbf086726e7a228398968981/json5-0.13.0.tar.gz", hash = "sha256:b1edf8d487721c0bf64d83c28e91280781f6e21f4a797d3261c7c828d4c165bf", size = 52441, upload-time = "2026-01-01T19:42:14.99Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/9e/038522f50ceb7e74f1f991bf1b699f24b0c2bbe7c390dd36ad69f4582258/json5-0.13.0-py3-none-any.whl", hash = "sha256:9a08e1dd65f6a4d4c6fa82d216cf2477349ec2346a38fd70cc11d2557499fbcc", size = 36163, upload-time = "2026-01-01T19:42:13.962Z" }, -] - -[[package]] -name = "jsonpointer" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, -] - -[[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, -] - -[package.optional-dependencies] -format-nongpl = [ - { name = "fqdn" }, - { name = "idna" }, - { name = "isoduration" }, - { name = "jsonpointer" }, - { name = "rfc3339-validator" }, - { name = "rfc3986-validator" }, - { name = "rfc3987-syntax" }, - { name = "uri-template" }, - { name = "webcolors" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, -] - -[[package]] -name = "jupyter-client" -version = "8.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jupyter-core" }, - { name = "python-dateutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/05/e4/ba649102a3bc3fbca54e7239fb924fd434c766f855693d86de0b1f2bec81/jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e", size = 348020, upload-time = "2026-01-08T13:55:47.938Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/0b/ceb7694d864abc0a047649aec263878acb9f792e1fec3e676f22dc9015e3/jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a", size = 107371, upload-time = "2026-01-08T13:55:45.562Z" }, -] - -[[package]] -name = "jupyter-core" -version = "5.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "platformdirs" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, -] - -[[package]] -name = "jupyter-events" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jsonschema", extra = ["format-nongpl"] }, - { name = "packaging" }, - { name = "python-json-logger" }, - { name = "pyyaml" }, - { name = "referencing" }, - { name = "rfc3339-validator" }, - { name = "rfc3986-validator" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9d/c3/306d090461e4cf3cd91eceaff84bede12a8e52cd821c2d20c9a4fd728385/jupyter_events-0.12.0.tar.gz", hash = "sha256:fc3fce98865f6784c9cd0a56a20644fc6098f21c8c33834a8d9fe383c17e554b", size = 62196, upload-time = "2025-02-03T17:23:41.485Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/48/577993f1f99c552f18a0428731a755e06171f9902fa118c379eb7c04ea22/jupyter_events-0.12.0-py3-none-any.whl", hash = "sha256:6464b2fa5ad10451c3d35fabc75eab39556ae1e2853ad0c0cc31b656731a97fb", size = 19430, upload-time = "2025-02-03T17:23:38.643Z" }, -] - -[[package]] -name = "jupyter-lsp" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jupyter-server" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/5a/9066c9f8e94ee517133cd98dba393459a16cd48bba71a82f16a65415206c/jupyter_lsp-2.3.0.tar.gz", hash = "sha256:458aa59339dc868fb784d73364f17dbce8836e906cd75fd471a325cba02e0245", size = 54823, upload-time = "2025-08-27T17:47:34.671Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/60/1f6cee0c46263de1173894f0fafcb3475ded276c472c14d25e0280c18d6d/jupyter_lsp-2.3.0-py3-none-any.whl", hash = "sha256:e914a3cb2addf48b1c7710914771aaf1819d46b2e5a79b0f917b5478ec93f34f", size = 76687, upload-time = "2025-08-27T17:47:33.15Z" }, -] - -[[package]] -name = "jupyter-server" -version = "2.17.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "argon2-cffi" }, - { name = "jinja2" }, - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "jupyter-events" }, - { name = "jupyter-server-terminals" }, - { name = "nbconvert" }, - { name = "nbformat" }, - { name = "overrides", marker = "python_full_version < '3.12'" }, - { name = "packaging" }, - { name = "prometheus-client" }, - { name = "pywinpty", marker = "os_name == 'nt'" }, - { name = "pyzmq" }, - { name = "send2trash" }, - { name = "terminado" }, - { name = "tornado" }, - { name = "traitlets" }, - { name = "websocket-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/ac/e040ec363d7b6b1f11304cc9f209dac4517ece5d5e01821366b924a64a50/jupyter_server-2.17.0.tar.gz", hash = "sha256:c38ea898566964c888b4772ae1ed58eca84592e88251d2cfc4d171f81f7e99d5", size = 731949, upload-time = "2025-08-21T14:42:54.042Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/80/a24767e6ca280f5a49525d987bf3e4d7552bf67c8be07e8ccf20271f8568/jupyter_server-2.17.0-py3-none-any.whl", hash = "sha256:e8cb9c7db4251f51ed307e329b81b72ccf2056ff82d50524debde1ee1870e13f", size = 388221, upload-time = "2025-08-21T14:42:52.034Z" }, -] - -[[package]] -name = "jupyter-server-terminals" -version = "0.5.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pywinpty", marker = "os_name == 'nt'" }, - { name = "terminado" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f4/a7/bcd0a9b0cbba88986fe944aaaf91bfda603e5a50bda8ed15123f381a3b2f/jupyter_server_terminals-0.5.4.tar.gz", hash = "sha256:bbda128ed41d0be9020349f9f1f2a4ab9952a73ed5f5ac9f1419794761fb87f5", size = 31770, upload-time = "2026-01-14T16:53:20.213Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/2d/6674563f71c6320841fc300911a55143925112a72a883e2ca71fba4c618d/jupyter_server_terminals-0.5.4-py3-none-any.whl", hash = "sha256:55be353fc74a80bc7f3b20e6be50a55a61cd525626f578dcb66a5708e2007d14", size = 13704, upload-time = "2026-01-14T16:53:18.738Z" }, -] - -[[package]] -name = "jupyterlab" -version = "4.5.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "async-lru" }, - { name = "httpx" }, - { name = "ipykernel" }, - { name = "jinja2" }, - { name = "jupyter-core" }, - { name = "jupyter-lsp" }, - { name = "jupyter-server" }, - { name = "jupyterlab-server" }, - { name = "notebook-shim" }, - { name = "packaging" }, - { name = "setuptools" }, - { name = "tornado" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/93/dc/2c8c4ff1aee27ac999ba04c373c5d0d7c6c181b391640d7b916b884d5985/jupyterlab-4.5.2.tar.gz", hash = "sha256:c80a6b9f6dace96a566d590c65ee2785f61e7cd4aac5b4d453dcc7d0d5e069b7", size = 23990371, upload-time = "2026-01-12T12:27:08.493Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/78/7e455920f104ef2aa94a4c0d2b40e5b44334ee7057eae1aa1fb97b9631ad/jupyterlab-4.5.2-py3-none-any.whl", hash = "sha256:76466ebcfdb7a9bb7e2fbd6459c0e2c032ccf75be673634a84bee4b3e6b13ab6", size = 12385807, upload-time = "2026-01-12T12:27:03.923Z" }, -] - -[[package]] -name = "jupyterlab-pygments" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, -] - -[[package]] -name = "jupyterlab-server" -version = "2.28.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "babel" }, - { name = "jinja2" }, - { name = "json5" }, - { name = "jsonschema" }, - { name = "jupyter-server" }, - { name = "packaging" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d6/2c/90153f189e421e93c4bb4f9e3f59802a1f01abd2ac5cf40b152d7f735232/jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c", size = 76996, upload-time = "2025-10-22T13:59:18.37Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830, upload-time = "2025-10-22T13:59:16.767Z" }, -] - -[[package]] -name = "kiwisolver" -version = "1.4.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167, upload-time = "2025-08-10T21:25:53.403Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579, upload-time = "2025-08-10T21:25:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309, upload-time = "2025-08-10T21:25:55.76Z" }, - { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596, upload-time = "2025-08-10T21:25:56.861Z" }, - { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548, upload-time = "2025-08-10T21:25:58.246Z" }, - { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618, upload-time = "2025-08-10T21:25:59.857Z" }, - { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437, upload-time = "2025-08-10T21:26:01.105Z" }, - { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742, upload-time = "2025-08-10T21:26:02.675Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810, upload-time = "2025-08-10T21:26:04.009Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579, upload-time = "2025-08-10T21:26:05.317Z" }, - { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071, upload-time = "2025-08-10T21:26:06.686Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840, upload-time = "2025-08-10T21:26:07.94Z" }, - { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159, upload-time = "2025-08-10T21:26:09.048Z" }, - { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, - { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, - { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, - { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, - { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, - { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, - { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, - { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, - { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, - { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, - { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, - { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, - { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681, upload-time = "2025-08-10T21:26:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464, upload-time = "2025-08-10T21:26:27.733Z" }, - { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961, upload-time = "2025-08-10T21:26:28.729Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607, upload-time = "2025-08-10T21:26:29.798Z" }, - { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546, upload-time = "2025-08-10T21:26:31.401Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482, upload-time = "2025-08-10T21:26:32.721Z" }, - { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720, upload-time = "2025-08-10T21:26:34.032Z" }, - { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907, upload-time = "2025-08-10T21:26:35.824Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334, upload-time = "2025-08-10T21:26:37.534Z" }, - { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313, upload-time = "2025-08-10T21:26:39.191Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970, upload-time = "2025-08-10T21:26:40.828Z" }, - { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894, upload-time = "2025-08-10T21:26:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995, upload-time = "2025-08-10T21:26:43.889Z" }, - { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510, upload-time = "2025-08-10T21:26:44.915Z" }, - { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903, upload-time = "2025-08-10T21:26:45.934Z" }, - { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402, upload-time = "2025-08-10T21:26:47.101Z" }, - { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135, upload-time = "2025-08-10T21:26:48.665Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409, upload-time = "2025-08-10T21:26:50.335Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763, upload-time = "2025-08-10T21:26:51.867Z" }, - { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643, upload-time = "2025-08-10T21:26:53.592Z" }, - { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818, upload-time = "2025-08-10T21:26:55.051Z" }, - { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963, upload-time = "2025-08-10T21:26:56.421Z" }, - { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639, upload-time = "2025-08-10T21:26:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741, upload-time = "2025-08-10T21:26:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" }, - { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" }, - { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" }, - { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" }, - { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" }, - { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" }, - { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" }, - { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" }, - { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" }, - { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" }, - { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" }, - { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" }, - { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" }, - { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" }, - { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" }, - { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" }, - { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, - { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104, upload-time = "2025-08-10T21:27:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592, upload-time = "2025-08-10T21:27:44.314Z" }, - { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281, upload-time = "2025-08-10T21:27:45.369Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009, upload-time = "2025-08-10T21:27:46.376Z" }, - { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" }, -] - -[[package]] -name = "lark" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, -] - -[[package]] -name = "manim" -version = "0.20.1" -source = { editable = "." } -dependencies = [ - { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, - { name = "av" }, - { name = "beautifulsoup4" }, - { name = "click" }, - { name = "cloup" }, - { name = "decorator" }, - { name = "isosurfaces" }, - { name = "manimpango" }, - { name = "mapbox-earcut" }, - { name = "moderngl" }, - { name = "moderngl-window" }, - { name = "networkx" }, - { name = "numpy" }, - { name = "pillow" }, - { name = "pycairo" }, - { name = "pydub" }, - { name = "pygments" }, - { name = "rich" }, - { name = "scipy" }, - { name = "screeninfo" }, - { name = "skia-pathops" }, - { name = "srt" }, - { name = "svgelements" }, - { name = "tqdm" }, - { name = "typing-extensions" }, - { name = "watchdog" }, -] - -[package.optional-dependencies] -gui = [ - { name = "dearpygui" }, -] -jupyterlab = [ - { name = "jupyterlab" }, - { name = "notebook" }, -] - -[package.dev-dependencies] -dev = [ - { name = "furo" }, - { name = "matplotlib" }, - { name = "myst-parser" }, - { name = "pre-commit" }, - { name = "psutil" }, - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "pytest-xdist" }, - { name = "requests" }, - { name = "ruff" }, - { name = "sphinx" }, - { name = "sphinx-copybutton" }, - { name = "sphinx-design" }, - { name = "sphinx-reredirects" }, - { name = "sphinxcontrib-programoutput" }, - { name = "sphinxext-opengraph" }, - { name = "types-decorator" }, - { name = "types-pillow" }, - { name = "types-pygments" }, -] - -[package.metadata] -requires-dist = [ - { name = "audioop-lts", marker = "python_full_version >= '3.13'", specifier = ">=0.2.1" }, - { name = "av", specifier = ">=15.0" }, - { name = "beautifulsoup4", specifier = ">=4.12" }, - { name = "click", specifier = ">=8.0" }, - { name = "cloup", specifier = ">=2.0.0" }, - { name = "dearpygui", marker = "extra == 'gui'", specifier = ">=1.0.0" }, - { name = "decorator", specifier = ">=4.3.2" }, - { name = "isosurfaces", specifier = ">=0.1.1" }, - { name = "jupyterlab", marker = "extra == 'jupyterlab'", specifier = ">=4.3.4" }, - { name = "manimpango", specifier = ">=0.6.1,<1.0.0" }, - { name = "mapbox-earcut", specifier = ">=1.0.0" }, - { name = "moderngl", specifier = ">=5.7.0,<6.0.0" }, - { name = "moderngl-window", specifier = ">=2.0.0" }, - { name = "networkx", specifier = ">=2.6" }, - { name = "notebook", marker = "extra == 'jupyterlab'", specifier = ">=7.3.2" }, - { name = "numpy", specifier = ">=2.1" }, - { name = "pillow", specifier = ">=11.0" }, - { name = "pycairo", specifier = ">=1.14,<2.0.0" }, - { name = "pydub", specifier = ">=0.22.0" }, - { name = "pygments", specifier = ">=2.17" }, - { name = "rich", specifier = ">=12.0.0" }, - { name = "scipy", specifier = ">=1.13.0" }, - { name = "scipy", marker = "python_full_version >= '3.13'", specifier = ">=1.15.0" }, - { name = "screeninfo", specifier = ">=0.7.0" }, - { name = "skia-pathops", specifier = ">=0.9.0" }, - { name = "srt", specifier = ">=3.0.0" }, - { name = "svgelements", specifier = ">=1.9.0" }, - { name = "tqdm", specifier = ">=4.21.0" }, - { name = "typing-extensions", specifier = ">=4.12.0" }, - { name = "watchdog", specifier = ">=2.0.0" }, -] -provides-extras = ["gui", "jupyterlab"] - -[package.metadata.requires-dev] -dev = [ - { name = "furo", specifier = ">=2024.8.6" }, - { name = "matplotlib", specifier = ">=3.9.4" }, - { name = "myst-parser", specifier = ">=3.0.1" }, - { name = "pre-commit", specifier = ">=4.1.0" }, - { name = "psutil", specifier = ">=6.1.1" }, - { name = "pytest", specifier = ">=8.3.4" }, - { name = "pytest-cov", specifier = ">=6.0.0" }, - { name = "pytest-xdist", specifier = ">=2.2,<3.0" }, - { name = "requests", specifier = ">=2.32.3" }, - { name = "ruff", specifier = ">=0.14.7" }, - { name = "sphinx", specifier = ">=7.4.7" }, - { name = "sphinx-copybutton", specifier = ">=0.5.2" }, - { name = "sphinx-design", specifier = ">=0.6.1" }, - { name = "sphinx-reredirects", specifier = ">=0.1.5" }, - { name = "sphinxcontrib-programoutput", specifier = ">=0.18" }, - { name = "sphinxext-opengraph", specifier = ">=0.9.1" }, - { name = "types-decorator", specifier = ">=5.1.8.20250121" }, - { name = "types-pillow", specifier = ">=10.2.0.20240822" }, - { name = "types-pygments", specifier = ">=2.19.0.20250107" }, -] - -[[package]] -name = "manimpango" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/55/d360e73eb4d04b102cef399ddebcba486a9b6c1977a26fe710beffd52e95/manimpango-0.6.1.tar.gz", hash = "sha256:59a00bbf8e99dab5f94341087c88e609fe946e79724627429cf59da84cbd40bf", size = 4080834, upload-time = "2025-10-23T06:04:48.564Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/fb/3aaddb9eeff88c9b4343faae54a0188f090c1806b64cd5fc15286fe4683b/manimpango-0.6.1-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:6c9e43b38d516287116e1a95577366d338e3b895491707783fef9d0dcce910a3", size = 7753953, upload-time = "2025-10-23T06:04:14.135Z" }, - { url = "https://files.pythonhosted.org/packages/4a/c7/0c9296ffa7770ddcb24c88b9c4aebc45dd2971d47e31df2fa810d99e3fca/manimpango-0.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c2c7c04dc080f64fa8f22e33b8085050e04aa89ca147b0350ddb277a7a620cf5", size = 7064562, upload-time = "2025-10-23T06:04:15.865Z" }, - { url = "https://files.pythonhosted.org/packages/78/72/91bf645cd71dff74d7a3e3d650465ce956024d3eadbb202ff6dab0c8feb7/manimpango-0.6.1-cp311-cp311-win32.whl", hash = "sha256:87500d54800e85bf9ccec0beee4cc26c7e9b62ae58e53e41e1155f3b0d5dce66", size = 3626993, upload-time = "2025-10-23T06:04:17.299Z" }, - { url = "https://files.pythonhosted.org/packages/62/c1/fc116c73e6b1b2dabb9c70996d8b01b3dfa70b06f4c6ac28e21e157d9c4d/manimpango-0.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:c9576c8fd8072e9a204baf67bcf609ed94677a0a824a06dcfab508759bbb89cb", size = 4203812, upload-time = "2025-10-23T06:04:19.2Z" }, - { url = "https://files.pythonhosted.org/packages/27/1c/440a6cb359b2b0fd2ba54f615af3b8d84d26f66a1e3a72ed70e812ffeedc/manimpango-0.6.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2039787f156a6ce2135c2ebccd4199a1859b45945e7b8d2bf46423ae1c3bcf11", size = 7754764, upload-time = "2025-10-23T06:04:21.075Z" }, - { url = "https://files.pythonhosted.org/packages/d3/6b/14c90dd8bce1f98fc4f4bbc708985b10106a793b25cfac9df3d7bd26396e/manimpango-0.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:412a876be8221aaf76a80401746ed7f42865779ac6f5ad5a220ff935bbc7e455", size = 7065699, upload-time = "2025-10-23T06:04:23.003Z" }, - { url = "https://files.pythonhosted.org/packages/ce/85/ddf7bdb96660c63b07306c8b074444f30d7f233dbec206a99e74828f926d/manimpango-0.6.1-cp312-cp312-win32.whl", hash = "sha256:81dd186c5cabd682afe56d43e88f7438bffd093061f858eb95bf8a773bfc2000", size = 3626854, upload-time = "2025-10-23T06:04:24.65Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ef/dc48832fa8d2d867ba88a9dade038a148aaf9c3e4bf5f4539e3969f79560/manimpango-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:5bf98ec864c3ade022211f88dd22e6da9d72f1b1ba7b20091b149e45a5e0d5ba", size = 4203288, upload-time = "2025-10-23T06:04:26.129Z" }, - { url = "https://files.pythonhosted.org/packages/23/ee/01cd6b3087a5ead9483add3023f46c135bddd7773d506b771b44bbe0830b/manimpango-0.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:79aafe6373cb31dcdd1ee505407ec2bea18e74aed877f93dcd27ec9d88584472", size = 7751685, upload-time = "2025-10-23T06:04:28.457Z" }, - { url = "https://files.pythonhosted.org/packages/70/93/905ac20a9190655870131940dadb5121645cf8a23e6ea0f04be7f4604f2e/manimpango-0.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1049bda27ca504d24b48bec21756f4d8a3310ea7bc690167c54d05e97b8f7639", size = 7062629, upload-time = "2025-10-23T06:04:30.059Z" }, - { url = "https://files.pythonhosted.org/packages/76/88/b2de7d2d3a0331cf79992f8c6da0f0abd873d4ec9f024cad5992f1cf7a6d/manimpango-0.6.1-cp313-cp313-win32.whl", hash = "sha256:8da8238147b96737b36725b0fc386dc1dd7901516dfd7e73b2e7d34cd5d934f1", size = 3626705, upload-time = "2025-10-23T06:04:31.527Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ed/a7a57491b26e8fb85ac0be9c39b53a6690f3c39beaf06d3715cb6916932b/manimpango-0.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:e90d6b926d0e673ce624963d44cc28c55a368b6074108ab885a43305baeae26c", size = 4203874, upload-time = "2025-10-23T06:04:32.981Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c0/318410215b409465a237c126a3e1f612c432d33b037b2f997870df327434/manimpango-0.6.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:318d14087901ee27ed6d0cfef0335aed57db217f8de831c8ca4e4c52b7805b45", size = 7751895, upload-time = "2025-10-23T06:04:34.874Z" }, - { url = "https://files.pythonhosted.org/packages/d1/83/f0a05e3e6312ca9673bc8b470c6a743cf2d0bd7f08b218efc993226e72e4/manimpango-0.6.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffe2f8353857f0ca9a16894430d871051da450bc2d19beb969cf8b57b032f580", size = 7063387, upload-time = "2025-10-23T06:04:36.877Z" }, - { url = "https://files.pythonhosted.org/packages/13/76/0748323341647e8c338bd7af6fb527ffdc43d676003b57743740bdfcffcc/manimpango-0.6.1-cp314-cp314-win32.whl", hash = "sha256:c294ab801c8ffc217342dfeff7aeca97cbea86cd8b4e6e8c9a8d7da020b820e1", size = 3737933, upload-time = "2025-10-23T06:04:38.775Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e9/42b995549cfd137780045e44b3bfba2cb1247b18bd2fb9f97079048e78ac/manimpango-0.6.1-cp314-cp314-win_amd64.whl", hash = "sha256:d8b0ee675eb30bbb67097cecb2be469847c3ccf574291f2bd43360caeb5f62ed", size = 4357908, upload-time = "2025-10-23T06:04:40.641Z" }, -] - -[[package]] -name = "mapbox-earcut" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bc/7b/bbf6b00488662be5d2eb7a188222c264b6f713bac10dc4a77bf37a4cb4b6/mapbox_earcut-2.0.0.tar.gz", hash = "sha256:81eab6b86cf99551deb698b98e3f7502c57900e5c479df15e1bdaf1a57f0f9d6", size = 39934, upload-time = "2025-11-16T18:41:27.251Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/9f/fbd15d9e348e75e986d6912c4eab99888106b7e5fb0a01e765422f7cd464/mapbox_earcut-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:9b5040e79e3783295e99c90277f31c1cbaddd3335297275331995ba5680e3649", size = 55773, upload-time = "2025-11-16T18:40:20.045Z" }, - { url = "https://files.pythonhosted.org/packages/72/40/be761298704fbbaa81c5618bb306f1510fb068e482f6a1c8b3b6c1b31479/mapbox_earcut-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1cf43baafec3ef1e967319d9b5da96bc6ddf3dbb204b6f3535275eda4b519a72", size = 52444, upload-time = "2025-11-16T18:40:21.501Z" }, - { url = "https://files.pythonhosted.org/packages/5a/0b/0c0c08db9663238ffb82c48259582dc0047a3255d98c0ac83c48026b7544/mapbox_earcut-2.0.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a283531847f603dd9d69afb75b21bd009d385ca9485fcd3e5a7fa5db1ccd913", size = 56803, upload-time = "2025-11-16T18:40:22.891Z" }, - { url = "https://files.pythonhosted.org/packages/f0/4a/86796859383d7d11fa5d4bcf1983f94c6cbb9eeb60fb3bab527fec4b32fa/mapbox_earcut-2.0.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab697676f4cec4572d4e941b7a3429a6687bf2ac6e8db3f3781024e3239ae3a0", size = 59403, upload-time = "2025-11-16T18:40:24.021Z" }, - { url = "https://files.pythonhosted.org/packages/6c/db/adaf981ab3bcfcf993ef317636b1f27210d6834bb1e8d63db6ad7c08214a/mapbox_earcut-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1bdac76e048f4299accf4eaf797079ddfc330442e7231c15535ed198100d6c5", size = 152876, upload-time = "2025-11-16T18:40:25.588Z" }, - { url = "https://files.pythonhosted.org/packages/d2/83/86417974039e7554c9e1e55c852a7e9c2a1390d64675eb85d70e5fa7eb37/mapbox_earcut-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a6945b23f859bef11ce3194303d17bd371c86b637e7029f81b1feaff3db3758", size = 157548, upload-time = "2025-11-16T18:40:27.202Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4c/c82a292bb21e5c651d81334123db2d654c5c9d19b2197080d3429dc1e49a/mapbox_earcut-2.0.0-cp311-cp311-win32.whl", hash = "sha256:8e119524c29406afb5eaa15e933f297d35679293a3ca62ced22f97a14c484cb5", size = 51424, upload-time = "2025-11-16T18:40:28.415Z" }, - { url = "https://files.pythonhosted.org/packages/30/57/6c39d7db81f72a3e4814ef152c8fb8dfe275dc4b03c9bfa073d251e3755f/mapbox_earcut-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:378bbbb3304e446023752db8f44ecd6e7ef965bcbda36541d2ae64442ba94254", size = 56662, upload-time = "2025-11-16T18:40:29.863Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d6/a1ef6e196b3d6968bf6546d4f7e54c559f9cff8991fdb880df0ba1618f52/mapbox_earcut-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:6d249a431abd6bbff36f1fd0493247a86de962244cc4081b4d5050b02ed48fb1", size = 50505, upload-time = "2025-11-16T18:40:30.992Z" }, - { url = "https://files.pythonhosted.org/packages/8d/93/846804029d955c3c841d8efff77c2b0e8d9aab057d3a077dc8e3f88b5ea4/mapbox_earcut-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db55ce18e698bc9d90914ee7d4f8c3e4d23827456ece7c5d7a1ec91e90c7122b", size = 55623, upload-time = "2025-11-16T18:40:32.113Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f6/cc9ece104bc3876b350dba6fef7f34fb7b20ecc028d2cdbdbecb436b1ed1/mapbox_earcut-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01dd6099d16123baf582a11b2bd1d59ce848498cf0cdca3812fd1f8b20ff33b7", size = 52028, upload-time = "2025-11-16T18:40:33.516Z" }, - { url = "https://files.pythonhosted.org/packages/88/6e/230da4aabcc56c99e9bddb4c43ce7d4ba3609c0caf2d316fb26535d7c60c/mapbox_earcut-2.0.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d5a098aae26a52282bc981a38e7bf6b889d2ea7442f2cd1903d2ba842f4ff07", size = 56351, upload-time = "2025-11-16T18:40:35.217Z" }, - { url = "https://files.pythonhosted.org/packages/1a/f7/5cdd3752526e91d91336c7263af7767b291d21e63c89d7190a60051f0f87/mapbox_earcut-2.0.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de35f241d0b9110ad9260f295acedd9d7cc0d7acfe30d36b1b3ee8419c2caba1", size = 59209, upload-time = "2025-11-16T18:40:36.634Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a2/b7781416cb93b37b95d0444e03f87184de8815e57ff202ce4105fa921325/mapbox_earcut-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cb63ab85e2e430c350f93e75c13f8b91cb8c8a045f3cd714c390b69a720368a", size = 152316, upload-time = "2025-11-16T18:40:38.147Z" }, - { url = "https://files.pythonhosted.org/packages/c1/74/396338e3d345e4e36fb23a0380921098b6a95ce7fb19c4777f4185a5974e/mapbox_earcut-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fb3c9f069fc3795306db87f8139f70c4f047532f897a3de05f54dc1faebc97f6", size = 157268, upload-time = "2025-11-16T18:40:39.753Z" }, - { url = "https://files.pythonhosted.org/packages/56/2c/66fd137ea86c508f6cd7247f7f6e2d1dabffc9f0e9ccf14c71406b197af1/mapbox_earcut-2.0.0-cp312-cp312-win32.whl", hash = "sha256:eb290e6676217707ed238dd55e07b0a8ca3ab928f6a27c4afefb2ff3af08d7cb", size = 51226, upload-time = "2025-11-16T18:40:41.018Z" }, - { url = "https://files.pythonhosted.org/packages/b8/84/7b78e37b0c2109243c0dad7d9ba9774b02fcee228bf61cf727a5aa1702e2/mapbox_earcut-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:5ef5b3319a43375272ad2cad9333ed16e569b5102e32a4241451358897e6f6ee", size = 56417, upload-time = "2025-11-16T18:40:42.173Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/cd7195aa27c1c8f2b9d38025a5a8663f32cd01c07b648a54b1308ab26c15/mapbox_earcut-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:a4a3706feb5cc8c782d8f68bb0110c8d551304043f680a87a54b0651a2c208c3", size = 50111, upload-time = "2025-11-16T18:40:43.334Z" }, - { url = "https://files.pythonhosted.org/packages/8b/7c/c5dd5b255b9828ba5df729e62fdd470a322c938f07ef392ca03c0592bb3a/mapbox_earcut-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:582329a81bd36cf0f82e443c395bcb8cfdb10caddafec76acaebac7c20bf1c31", size = 55619, upload-time = "2025-11-16T18:40:44.44Z" }, - { url = "https://files.pythonhosted.org/packages/1a/3f/03f23eac9831e7d0d8da3d6993695a9a3724659c94e9997f6b7aaccc199d/mapbox_earcut-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d2ac5f610b3e44a3a0c4df06b5552d503b4f1c2c409eeca20dbe05112bd60955", size = 52023, upload-time = "2025-11-16T18:40:45.857Z" }, - { url = "https://files.pythonhosted.org/packages/39/f3/a92ccee494b3e437e4bd81ecd358e39d231dc90af010d6c43930506c10ad/mapbox_earcut-2.0.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58cc88513b87734b243d86f0d3fb87e96e0a78d9abd8fd615c55f766dd63f949", size = 56357, upload-time = "2025-11-16T18:40:47.27Z" }, - { url = "https://files.pythonhosted.org/packages/03/30/e54ececd0403a5495c340b693075abec92a6d17dc44283b6cb059534f7ed/mapbox_earcut-2.0.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40218d887798451932f3c335992834aa807c35cd497c6e0733470fdbd77f9521", size = 59215, upload-time = "2025-11-16T18:40:48.682Z" }, - { url = "https://files.pythonhosted.org/packages/6e/e1/8fbff13a074c1fbf702b30ce7ec4d878bc664d659c1c2b1697831f4ea3a8/mapbox_earcut-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:39fa5cfa0e855b028ec9b0200c88ebfa252448f343ce2f67b6fc07fe1f22a3ae", size = 152304, upload-time = "2025-11-16T18:40:49.85Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d5/c757030b3cb3a9f2278ded6f7312d2b9d3761db6f3da8d395f7f7303dd66/mapbox_earcut-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:476b558473b8a43f238d46e819bc0f830c427842ec5feb19e23b4dcac8ad2455", size = 157270, upload-time = "2025-11-16T18:40:51.093Z" }, - { url = "https://files.pythonhosted.org/packages/96/63/589c6decb1f032d8811f1066da552f0a718830f592e6d6539fa4c3c766b8/mapbox_earcut-2.0.0-cp313-cp313-win32.whl", hash = "sha256:8c2d125c182acbc490b39503c0dec4f937bae180d0849a26bcea0ee4a76024bd", size = 51207, upload-time = "2025-11-16T18:40:52.285Z" }, - { url = "https://files.pythonhosted.org/packages/76/75/a79a6020c46d4f07731e88ec5cc9324f6b43343aba835def1dc0bf59fecf/mapbox_earcut-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e049e6a37c228d7a9cb2f54ae405aa21d35c5175d849530fb32064ddb38ad5ab", size = 56416, upload-time = "2025-11-16T18:40:53.474Z" }, - { url = "https://files.pythonhosted.org/packages/ce/5f/83e878c2b3e9e6db1f60b598a2cc5ed4c2b5bc8d281575c964869414a159/mapbox_earcut-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:8a833d73d63d4b6291bbd8b4d2f551e87f663282cdc547ecbbd9b423849ee996", size = 50103, upload-time = "2025-11-16T18:40:54.954Z" }, - { url = "https://files.pythonhosted.org/packages/96/fc/f1b74324c83f510213ff91eb8b1d2697ad5a12418c5fba966e80f1104a5f/mapbox_earcut-2.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad1dc141797037b7d4c9d8d2e52b9665b36294913a8ec31008b282d1a95b9bdc", size = 55728, upload-time = "2025-11-16T18:40:56.098Z" }, - { url = "https://files.pythonhosted.org/packages/7b/59/053c04e29c4bd22157d3b6255f1e5c19c46cb7a594c4314298bdcbca723f/mapbox_earcut-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0f0f5c6f5ed8ffdce8efe6a003ba598089d0ee07eabd41868db183be50484f9f", size = 52063, upload-time = "2025-11-16T18:40:57.227Z" }, - { url = "https://files.pythonhosted.org/packages/a6/77/acc2d553c3bb8c769535a280545bb7d9608141e90511a2e6215a54611776/mapbox_earcut-2.0.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82cd92775f37fd1e4b8464c5e74a00e87130eecc55ee3df2492b8ca2bdf6ef3e", size = 56522, upload-time = "2025-11-16T18:40:58.349Z" }, - { url = "https://files.pythonhosted.org/packages/1a/f5/627dd6defd3c1a2b3069e9e27482aa04d268c841735e576c1e22848a34f6/mapbox_earcut-2.0.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:626ffc1310e0cc8910283e4ac3139e5fb0458f18f2c4874162f66159951933ff", size = 59204, upload-time = "2025-11-16T18:41:00.095Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3e/819185542ab095ba1244ad65ececb3edcde6fd0111248a0f9318d695bfcf/mapbox_earcut-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ea951d764a356cad95b23fef950d8aa3b44b933795ad09d977fea7d4dbe377c3", size = 152550, upload-time = "2025-11-16T18:41:01.233Z" }, - { url = "https://files.pythonhosted.org/packages/a9/ad/85e0f815e4774b90ad6761bce55c80d13ee21b2a24014b0be0d5010b0049/mapbox_earcut-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:df1f217624abb5e02ecabcbd84369de970b8d8bc1e4e7c164c1cfcaddad76ca3", size = 157322, upload-time = "2025-11-16T18:41:02.866Z" }, - { url = "https://files.pythonhosted.org/packages/27/4c/0f56369e7a000d2f3177d17baf34263559b206ae524fcd0c4c5d1d960dab/mapbox_earcut-2.0.0-cp314-cp314-win32.whl", hash = "sha256:6fa61307d38b50fc9bd5449c00dbae46d270a32b372c6fc3b8af4b85c85746e4", size = 52916, upload-time = "2025-11-16T18:41:04.122Z" }, - { url = "https://files.pythonhosted.org/packages/c2/9d/8c557dd9b3d9fe2344f5bd5ff3bb0b2a42ed6addb7e43ca4358051743b04/mapbox_earcut-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:0da20ed3c81b240450118773bcedfac34e70a56998f66147222c46f4356fff67", size = 57713, upload-time = "2025-11-16T18:41:05.204Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ec/678c5553938d3a29d02dd41dd898672267f054afc4e2821958dee6ec86ce/mapbox_earcut-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:847e74bd5878e4c64793dc100f9288f5443f87c55c3fe391fd90509029136ff6", size = 51872, upload-time = "2025-11-16T18:41:06.323Z" }, - { url = "https://files.pythonhosted.org/packages/18/37/94f2d973669cbfef811e536713fe56ec012ba74e5f8795a832337b1866a3/mapbox_earcut-2.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ddc9e7175fc903185c64afbbf91febee56b50787dd0962fce2bfb4f20cf80d27", size = 56447, upload-time = "2025-11-16T18:41:07.443Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1c/e0afcc82659cc1727a7e59c4f9e9880bbc3f048a4a5325772b44d4a91dfd/mapbox_earcut-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6dc8a7568066af9a858018d6d92b7e77e164578f9fcd79093f1cbe4ec203461b", size = 53154, upload-time = "2025-11-16T18:41:08.618Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2d/9845281c8c35da2bea733b8c2df5b9fe694e73e7b05fe8a1d4c3c439a1bc/mapbox_earcut-2.0.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6abc5340edd9b433ab2dab2ee033082a199d5c51cce445124626c0040ec0d81b", size = 56285, upload-time = "2025-11-16T18:41:09.728Z" }, - { url = "https://files.pythonhosted.org/packages/97/8e/eeea762a519490662b8f480e2b35bf03701b0bcc5a446b62a4c5a1500b06/mapbox_earcut-2.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df7afdd8078a9aa28f469d9242531d304e09a4b14e514f048e021a949f3777b4", size = 58601, upload-time = "2025-11-16T18:41:10.872Z" }, - { url = "https://files.pythonhosted.org/packages/b9/67/932f80aa6af9bc1a317b6119052c74f327d81e00b457003a049e324b810c/mapbox_earcut-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a286f73e612a46cafd6d6c843365265090517af16823e2f37277c13cd8b6f09", size = 154924, upload-time = "2025-11-16T18:41:12.104Z" }, - { url = "https://files.pythonhosted.org/packages/87/38/5db4a91f9f90cbb447be61da5468a2955fad3a840ae4c7dbde789b09d45a/mapbox_earcut-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8d081fe1d00dc553e3e68c02fc395324aad0d8ed955f3ff59289264c9b21ace4", size = 159194, upload-time = "2025-11-16T18:41:13.364Z" }, - { url = "https://files.pythonhosted.org/packages/6b/03/de3843b13fe854a010fb2f8b25551d4d5fe1c879ff2e7c8d7d8d7d735a8e/mapbox_earcut-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:13049ca96431bbc7ef7fd7780dd1872209ca11a5c1977f7aa91a1b574a8af863", size = 54143, upload-time = "2025-11-16T18:41:14.564Z" }, - { url = "https://files.pythonhosted.org/packages/9a/89/fbdee5a56ba51df9be6098b5428636ad75aa994e98d8bec6113d5cba401e/mapbox_earcut-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ace78e4fdba3b8cbb7768d44d77a981698305862a07f94bbb6f5cc16659adb4", size = 60833, upload-time = "2025-11-16T18:41:15.694Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, -] - -[[package]] -name = "matplotlib" -version = "3.10.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "contourpy" }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "kiwisolver" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215, upload-time = "2025-12-10T22:55:16.175Z" }, - { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625, upload-time = "2025-12-10T22:55:17.712Z" }, - { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614, upload-time = "2025-12-10T22:55:20.8Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997, upload-time = "2025-12-10T22:55:23.258Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825, upload-time = "2025-12-10T22:55:25.217Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090, upload-time = "2025-12-10T22:55:27.162Z" }, - { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377, upload-time = "2025-12-10T22:55:29.185Z" }, - { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, - { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, - { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, - { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, - { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, - { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, - { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, - { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, - { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, - { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, - { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, - { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, - { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, - { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, - { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, - { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, - { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, - { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, - { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, - { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, - { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, - { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198, upload-time = "2025-12-10T22:56:45.584Z" }, - { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817, upload-time = "2025-12-10T22:56:47.339Z" }, - { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, -] - -[[package]] -name = "matplotlib-inline" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, -] - -[[package]] -name = "mdit-py-plugins" -version = "0.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - -[[package]] -name = "mistune" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, -] - -[[package]] -name = "moderngl" -version = "5.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "glcontext" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/52/540e2f8c45060bb2709f56eb5a44ae828dfcc97ccecb342c1a7deb467889/moderngl-5.12.0.tar.gz", hash = "sha256:52936a98ccb2f2e1d6e3cb18528b2919f6831e7e3f924e788b5873badce5129b", size = 193232, upload-time = "2024-10-17T12:36:28.002Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/ea/569c2c08bfef84f4acf633a8e6d956f4f75cfaa8832d7d812dbf2ff6843a/moderngl-5.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28cdba5dcf2d03c89bb25dc3b2f5770ac4104470ed5bbe680a15494fa52a537d", size = 111802, upload-time = "2024-10-17T12:36:47.377Z" }, - { url = "https://files.pythonhosted.org/packages/00/ef/98f36133ab010ce9831b75a16e75a627c12a4c1d6ef2e353eca1769a1e09/moderngl-5.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dad93893e3fcb2410bfd31e854f20e1370b4fbafa07a737f1046f5fbd29ba0f4", size = 109227, upload-time = "2024-10-17T12:36:49.24Z" }, - { url = "https://files.pythonhosted.org/packages/c2/bb/ab371acacd2497bddb5f02b209e3bfae452b2be59d0cf8fa728a3b87de1f/moderngl-5.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fc0f8788bc84433d2124e9a4893adbe40f93c7d213abb8ad7b909540cb0161f", size = 293468, upload-time = "2024-10-17T12:36:50.8Z" }, - { url = "https://files.pythonhosted.org/packages/0d/9e/7ebf2b98da310c90c2b295e91b6c25f864f0f5583ce86bee72d387cb577a/moderngl-5.12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6efd3fe0d2c9652af21e2c1f5a936a2b971abac5bdd777da7182a54962466cab", size = 267348, upload-time = "2024-10-17T12:36:52.526Z" }, - { url = "https://files.pythonhosted.org/packages/61/0a/87fb24f4cd2aa07150b84fbf400edf4d8a8f71784cbf064f1ed92b756fea/moderngl-5.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6f3bd2d534fc081cde30545b84ebca63aef847ba8bd533217b9a37f565614ade", size = 1343953, upload-time = "2024-10-17T12:36:54.601Z" }, - { url = "https://files.pythonhosted.org/packages/83/42/11b0306e630d9a38b8bac20563b326f6d5fba4dfc45cc90b0666ed3e7141/moderngl-5.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eaa3de9446c6febec4d5f888e6f1a4e9398bc5a5ea70b1570ea447213641d4a6", size = 1271832, upload-time = "2024-10-17T12:36:56.383Z" }, - { url = "https://files.pythonhosted.org/packages/6b/dd/74d300275fe4834e63b8d70450801f206d005f2047898d4eb2a30efc3913/moderngl-5.12.0-cp311-cp311-win32.whl", hash = "sha256:9fdb76f1fd890db67727c8cdee4db2ee6319068c7ce92be0308366f8745e28ab", size = 101043, upload-time = "2024-10-17T12:36:58.281Z" }, - { url = "https://files.pythonhosted.org/packages/7f/08/5f615a4605d343cd5c1112d2b175270e6a5586008bc10a85b822c340cf86/moderngl-5.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:0c210e8d52a60025f6586ca015c39feb1e57e6dc792c3ff44800f6493a541b1a", size = 108279, upload-time = "2024-10-17T12:37:01.125Z" }, - { url = "https://files.pythonhosted.org/packages/3c/66/31161e81bc85ca3cdbb9d94f703f21575e4ae9a2919e9d1af98fc7fdb1ba/moderngl-5.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2750547707c1ec3790dfbeb9c90fb808672ff13f61cac392c706ba09fda10db0", size = 112101, upload-time = "2024-10-17T12:37:02.267Z" }, - { url = "https://files.pythonhosted.org/packages/84/b2/7229a89a40d33a95119a7c64c7ee36a6a6e376c57c39fb577ea513602f37/moderngl-5.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c5c2a5fe06c7021183d9274df798f25516409c8d55898c324dae8a0b2de10144", size = 109377, upload-time = "2024-10-17T12:37:03.843Z" }, - { url = "https://files.pythonhosted.org/packages/d7/96/bcb5141eae24474d80b8157b0c3055d25fa75f9804d4abb4a514695bbba9/moderngl-5.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6c4972f3ddd10a3de6c30311da2c25bc493d023796e16c5d4e0f8bd6d5770be", size = 296394, upload-time = "2024-10-17T12:37:04.967Z" }, - { url = "https://files.pythonhosted.org/packages/b8/79/a9998ddf6757f4f15888b0a106d80a64a8c8991a8ce5c14047830704b9e6/moderngl-5.12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4d497ec6a3f6afa9ebd0be816d9bfe2fe20fec2105acfb88d956619c3ed8eb4", size = 270548, upload-time = "2024-10-17T12:37:07.57Z" }, - { url = "https://files.pythonhosted.org/packages/17/f4/313dc301db936b231035b961e004f1914c2954bdcdf4985e24bff15e7ed5/moderngl-5.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2f3d240e9bc5d83257378bae59f8f35638b89d22bb003cf674b88fd7932161ce", size = 1345817, upload-time = "2024-10-17T12:37:09.105Z" }, - { url = "https://files.pythonhosted.org/packages/8c/6f/7b3587e7e3ae633b8c85038f035dfeb348ebf805de4beb01241c59c6b97c/moderngl-5.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6fa667d560d842e778e2a5968305fb78f9781616a11b1b93acd2562f97262ccf", size = 1274968, upload-time = "2024-10-17T12:37:11.092Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e8/0f3b3cd1be7b0a93f33dc613f76a42021d1393b4949c5b6a1ca2a01c6772/moderngl-5.12.0-cp312-cp312-win32.whl", hash = "sha256:0a02fddd54dccee1ca6060bfed75a2e6a17dd3ee06920fac418506d8a8233849", size = 101221, upload-time = "2024-10-17T12:37:12.642Z" }, - { url = "https://files.pythonhosted.org/packages/56/85/35498b1821cf31c731b1882168db8924207ff3c06d8f0da53e1cc373a89d/moderngl-5.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:8698a59ad03539a2982125b7998efc1c107ba31d5d03437b6fcd72cb2c226922", size = 108525, upload-time = "2024-10-17T12:37:13.816Z" }, - { url = "https://files.pythonhosted.org/packages/39/13/cf493bdc3cb4f7a6b4fb357e683404dc8a97d19f53d501e4afdd679538e2/moderngl-5.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f6efb432f5164f871471d1da36e3a4be9dc3efd7a1e48d0ac6b751e556af5d02", size = 112118, upload-time = "2024-10-17T12:37:14.892Z" }, - { url = "https://files.pythonhosted.org/packages/a9/1f/1d84bba5f42fb19ce240d08d5434fe8e2f34341e68bb9fa89336b6cbdcfc/moderngl-5.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9b09d8d15b2eaab41c8646a664429ec86af225fa25096758497cd212489d2e1e", size = 109371, upload-time = "2024-10-17T12:37:15.992Z" }, - { url = "https://files.pythonhosted.org/packages/be/ae/bda0b95878e2b36eac66f64d88c08e6c8ea759607f7d40e843a21c2f4f32/moderngl-5.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:071042dd4846e58cbe204cf49341b62cd209fdcb6d48018feb5a61c66707fcb2", size = 296206, upload-time = "2024-10-17T12:37:17.204Z" }, - { url = "https://files.pythonhosted.org/packages/28/bc/93dc73251bcdb9c0f6f5c9a1d97ec2134672c307c68e6106948eab1f73d4/moderngl-5.12.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91db8302ac7f5d7a82a967388677e1378ff078f1e16d05da37ce77f4633b93b1", size = 270443, upload-time = "2024-10-17T12:37:18.44Z" }, - { url = "https://files.pythonhosted.org/packages/2e/a1/bd72c788b16c2392d3e1ebb570e56d7f871eaa1854f57917c0f131acb365/moderngl-5.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:51971d65ec96a212a814350c8b324ae0754353e1b61826d1a06aa2d060df170e", size = 1345675, upload-time = "2024-10-17T12:37:20.077Z" }, - { url = "https://files.pythonhosted.org/packages/b2/ec/6aff8fa267d9f80e4d32b7a606fdf9f0563103441a7dbaa4f53e272a4ada/moderngl-5.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d56827360c19e831e986243b5daaf6a51006f1ec0d5372084ad446308763d19f", size = 1274906, upload-time = "2024-10-17T12:37:22.372Z" }, - { url = "https://files.pythonhosted.org/packages/9f/4d/dc3ff763c125080e71b1095875f5dcc80949402019abc073bdfdbed1f4c2/moderngl-5.12.0-cp313-cp313-win32.whl", hash = "sha256:caa432c12b138a6c9571719075c4d103bdc2504cd31aeda38a00ad10fcf268cb", size = 101215, upload-time = "2024-10-17T12:37:23.833Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8b/0a264732e0ee49fca109e98ec28f4d0c326ffc31466aa6e9668e8961aabb/moderngl-5.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:e34d1cd38f7998258f76a08bb5e87f351ec653b7ea1928b2711f8719c10cefd1", size = 108514, upload-time = "2024-10-17T12:37:25.304Z" }, -] - -[[package]] -name = "moderngl-window" -version = "3.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "moderngl" }, - { name = "numpy" }, - { name = "pillow" }, - { name = "pyglet" }, - { name = "pyglm" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/90/02/92e235891300c901f59647112a0267a07454f58aeb2041aa44f6b85f9cb3/moderngl_window-3.1.1.tar.gz", hash = "sha256:29c2827505f87399f3461d480b2778910fddeebe44ea803301215cf212a6c1bc", size = 353495, upload-time = "2025-01-19T10:07:56.133Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/41/d3503f07de8ad3597165f88b07e1064629f82e65385c4307875b2f6b137f/moderngl_window-3.1.1-py3-none-any.whl", hash = "sha256:e3b3ac2b4e23afcbfdac1971318a4db893bed3ba6a8fbde3367b1226af39b2e5", size = 382381, upload-time = "2025-01-19T10:07:53.606Z" }, -] - -[[package]] -name = "myst-parser" -version = "4.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docutils" }, - { name = "jinja2" }, - { name = "markdown-it-py" }, - { name = "mdit-py-plugins" }, - { name = "pyyaml" }, - { name = "sphinx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, -] - -[[package]] -name = "nbclient" -version = "0.10.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "nbformat" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/56/91/1c1d5a4b9a9ebba2b4e32b8c852c2975c872aec1fe42ab5e516b2cecd193/nbclient-0.10.4.tar.gz", hash = "sha256:1e54091b16e6da39e297b0ece3e10f6f29f4ac4e8ee515d29f8a7099bd6553c9", size = 62554, upload-time = "2025-12-23T07:45:46.369Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" }, -] - -[[package]] -name = "nbconvert" -version = "7.17.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beautifulsoup4" }, - { name = "bleach", extra = ["css"] }, - { name = "defusedxml" }, - { name = "jinja2" }, - { name = "jupyter-core" }, - { name = "jupyterlab-pygments" }, - { name = "markupsafe" }, - { name = "mistune" }, - { name = "nbclient" }, - { name = "nbformat" }, - { name = "packaging" }, - { name = "pandocfilters" }, - { name = "pygments" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/47/81f886b699450d0569f7bc551df2b1673d18df7ff25cc0c21ca36ed8a5ff/nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78", size = 862855, upload-time = "2026-01-29T16:37:48.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/4b/8d5f796a792f8a25f6925a96032f098789f448571eb92011df1ae59e8ea8/nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518", size = 261510, upload-time = "2026-01-29T16:37:46.322Z" }, -] - -[[package]] -name = "nbformat" -version = "5.10.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "fastjsonschema" }, - { name = "jsonschema" }, - { name = "jupyter-core" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, -] - -[[package]] -name = "nest-asyncio" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, -] - -[[package]] -name = "networkx" -version = "3.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, -] - -[[package]] -name = "nodeenv" -version = "1.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, -] - -[[package]] -name = "notebook" -version = "7.5.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jupyter-server" }, - { name = "jupyterlab" }, - { name = "jupyterlab-server" }, - { name = "notebook-shim" }, - { name = "tornado" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3b/b6/6b2c653570b02e4ec2a94c0646a4a25132be0749617776d0b72a2bcedb9b/notebook-7.5.2.tar.gz", hash = "sha256:83e82f93c199ca730313bea1bb24bc279ea96f74816d038a92d26b6b9d5f3e4a", size = 14059605, upload-time = "2026-01-12T14:56:53.483Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/55/b754cd51c6011d90ef03e3f06136f1ebd44658b9529dbcf0c15fc0d6a0b7/notebook-7.5.2-py3-none-any.whl", hash = "sha256:17d078a98603d70d62b6b4b3fcb67e87d7a68c398a7ae9b447eb2d7d9aec9979", size = 14468915, upload-time = "2026-01-12T14:56:47.87Z" }, -] - -[[package]] -name = "notebook-shim" -version = "0.2.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jupyter-server" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/54/d2/92fa3243712b9a3e8bafaf60aac366da1cada3639ca767ff4b5b3654ec28/notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb", size = 13167, upload-time = "2024-02-14T23:35:18.353Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307, upload-time = "2024-02-14T23:35:16.286Z" }, -] - -[[package]] -name = "numpy" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/34/2b1bc18424f3ad9af577f6ce23600319968a70575bd7db31ce66731bbef9/numpy-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0cce2a669e3c8ba02ee563c7835f92c153cf02edff1ae05e1823f1dde21b16a5", size = 16944563, upload-time = "2026-01-10T06:42:14.615Z" }, - { url = "https://files.pythonhosted.org/packages/2c/57/26e5f97d075aef3794045a6ca9eada6a4ed70eb9a40e7a4a93f9ac80d704/numpy-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:899d2c18024984814ac7e83f8f49d8e8180e2fbe1b2e252f2e7f1d06bea92425", size = 12645658, upload-time = "2026-01-10T06:42:17.298Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ba/80fc0b1e3cb2fd5c6143f00f42eb67762aa043eaa05ca924ecc3222a7849/numpy-2.4.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:09aa8a87e45b55a1c2c205d42e2808849ece5c484b2aab11fecabec3841cafba", size = 5474132, upload-time = "2026-01-10T06:42:19.637Z" }, - { url = "https://files.pythonhosted.org/packages/40/ae/0a5b9a397f0e865ec171187c78d9b57e5588afc439a04ba9cab1ebb2c945/numpy-2.4.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:edee228f76ee2dab4579fad6f51f6a305de09d444280109e0f75df247ff21501", size = 6804159, upload-time = "2026-01-10T06:42:21.44Z" }, - { url = "https://files.pythonhosted.org/packages/86/9c/841c15e691c7085caa6fd162f063eff494099c8327aeccd509d1ab1e36ab/numpy-2.4.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a92f227dbcdc9e4c3e193add1a189a9909947d4f8504c576f4a732fd0b54240a", size = 14708058, upload-time = "2026-01-10T06:42:23.546Z" }, - { url = "https://files.pythonhosted.org/packages/5d/9d/7862db06743f489e6a502a3b93136d73aea27d97b2cf91504f70a27501d6/numpy-2.4.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:538bf4ec353709c765ff75ae616c34d3c3dca1a68312727e8f2676ea644f8509", size = 16651501, upload-time = "2026-01-10T06:42:25.909Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9c/6fc34ebcbd4015c6e5f0c0ce38264010ce8a546cb6beacb457b84a75dfc8/numpy-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ac08c63cb7779b85e9d5318e6c3518b424bc1f364ac4cb2c6136f12e5ff2dccc", size = 16492627, upload-time = "2026-01-10T06:42:28.938Z" }, - { url = "https://files.pythonhosted.org/packages/aa/63/2494a8597502dacda439f61b3c0db4da59928150e62be0e99395c3ad23c5/numpy-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f9c360ecef085e5841c539a9a12b883dff005fbd7ce46722f5e9cef52634d82", size = 18585052, upload-time = "2026-01-10T06:42:31.312Z" }, - { url = "https://files.pythonhosted.org/packages/6a/93/098e1162ae7522fc9b618d6272b77404c4656c72432ecee3abc029aa3de0/numpy-2.4.1-cp311-cp311-win32.whl", hash = "sha256:0f118ce6b972080ba0758c6087c3617b5ba243d806268623dc34216d69099ba0", size = 6236575, upload-time = "2026-01-10T06:42:33.872Z" }, - { url = "https://files.pythonhosted.org/packages/8c/de/f5e79650d23d9e12f38a7bc6b03ea0835b9575494f8ec94c11c6e773b1b1/numpy-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:18e14c4d09d55eef39a6ab5b08406e84bc6869c1e34eef45564804f90b7e0574", size = 12604479, upload-time = "2026-01-10T06:42:35.778Z" }, - { url = "https://files.pythonhosted.org/packages/dd/65/e1097a7047cff12ce3369bd003811516b20ba1078dbdec135e1cd7c16c56/numpy-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:6461de5113088b399d655d45c3897fa188766415d0f568f175ab071c8873bd73", size = 10578325, upload-time = "2026-01-10T06:42:38.518Z" }, - { url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888, upload-time = "2026-01-10T06:42:40.913Z" }, - { url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956, upload-time = "2026-01-10T06:42:43.091Z" }, - { url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567, upload-time = "2026-01-10T06:42:45.107Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ee/34b7930eb61e79feb4478800a4b95b46566969d837546aa7c034c742ef98/numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", size = 6549459, upload-time = "2026-01-10T06:42:48.152Z" }, - { url = "https://files.pythonhosted.org/packages/79/e3/5f115fae982565771be994867c89bcd8d7208dbfe9469185497d70de5ddf/numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", size = 14404859, upload-time = "2026-01-10T06:42:49.947Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7d/9c8a781c88933725445a859cac5d01b5871588a15969ee6aeb618ba99eee/numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", size = 16371419, upload-time = "2026-01-10T06:42:52.409Z" }, - { url = "https://files.pythonhosted.org/packages/a6/d2/8aa084818554543f17cf4162c42f162acbd3bb42688aefdba6628a859f77/numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", size = 16182131, upload-time = "2026-01-10T06:42:54.694Z" }, - { url = "https://files.pythonhosted.org/packages/60/db/0425216684297c58a8df35f3284ef56ec4a043e6d283f8a59c53562caf1b/numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", size = 18295342, upload-time = "2026-01-10T06:42:56.991Z" }, - { url = "https://files.pythonhosted.org/packages/31/4c/14cb9d86240bd8c386c881bafbe43f001284b7cce3bc01623ac9475da163/numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", size = 5959015, upload-time = "2026-01-10T06:42:59.631Z" }, - { url = "https://files.pythonhosted.org/packages/51/cf/52a703dbeb0c65807540d29699fef5fda073434ff61846a564d5c296420f/numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", size = 12310730, upload-time = "2026-01-10T06:43:01.627Z" }, - { url = "https://files.pythonhosted.org/packages/69/80/a828b2d0ade5e74a9fe0f4e0a17c30fdc26232ad2bc8c9f8b3197cf7cf18/numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", size = 10312166, upload-time = "2026-01-10T06:43:03.673Z" }, - { url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495, upload-time = "2026-01-10T06:43:06.283Z" }, - { url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657, upload-time = "2026-01-10T06:43:09.094Z" }, - { url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256, upload-time = "2026-01-10T06:43:13.634Z" }, - { url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212, upload-time = "2026-01-10T06:43:15.661Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871, upload-time = "2026-01-10T06:43:17.324Z" }, - { url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" }, - { url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" }, - { url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" }, - { url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" }, - { url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" }, - { url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" }, - { url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" }, - { url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" }, - { url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" }, - { url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352, upload-time = "2026-01-10T06:43:39.479Z" }, - { url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" }, - { url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" }, - { url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" }, - { url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" }, - { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a7/ef08d25698e0e4b4efbad8d55251d20fe2a15f6d9aa7c9b30cd03c165e6f/numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", size = 16652046, upload-time = "2026-01-10T06:43:54.797Z" }, - { url = "https://files.pythonhosted.org/packages/8f/39/e378b3e3ca13477e5ac70293ec027c438d1927f18637e396fe90b1addd72/numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", size = 12378858, upload-time = "2026-01-10T06:43:57.099Z" }, - { url = "https://files.pythonhosted.org/packages/c3/74/7ec6154f0006910ed1fdbb7591cf4432307033102b8a22041599935f8969/numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", size = 5207417, upload-time = "2026-01-10T06:43:59.037Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b7/053ac11820d84e42f8feea5cb81cc4fcd1091499b45b1ed8c7415b1bf831/numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", size = 6542643, upload-time = "2026-01-10T06:44:01.852Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c4/2e7908915c0e32ca636b92e4e4a3bdec4cb1e7eb0f8aedf1ed3c68a0d8cd/numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", size = 14418963, upload-time = "2026-01-10T06:44:04.047Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c0/3ed5083d94e7ffd7c404e54619c088e11f2e1939a9544f5397f4adb1b8ba/numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", size = 16363811, upload-time = "2026-01-10T06:44:06.207Z" }, - { url = "https://files.pythonhosted.org/packages/0e/68/42b66f1852bf525050a67315a4fb94586ab7e9eaa541b1bef530fab0c5dd/numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", size = 16197643, upload-time = "2026-01-10T06:44:08.33Z" }, - { url = "https://files.pythonhosted.org/packages/d2/40/e8714fc933d85f82c6bfc7b998a0649ad9769a32f3494ba86598aaf18a48/numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", size = 18289601, upload-time = "2026-01-10T06:44:10.841Z" }, - { url = "https://files.pythonhosted.org/packages/80/9a/0d44b468cad50315127e884802351723daca7cf1c98d102929468c81d439/numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", size = 6005722, upload-time = "2026-01-10T06:44:13.332Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bb/c6513edcce5a831810e2dddc0d3452ce84d208af92405a0c2e58fd8e7881/numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", size = 12438590, upload-time = "2026-01-10T06:44:15.006Z" }, - { url = "https://files.pythonhosted.org/packages/e9/da/a598d5cb260780cf4d255102deba35c1d072dc028c4547832f45dd3323a8/numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", size = 10596180, upload-time = "2026-01-10T06:44:17.386Z" }, - { url = "https://files.pythonhosted.org/packages/de/bc/ea3f2c96fcb382311827231f911723aeff596364eb6e1b6d1d91128aa29b/numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", size = 12498774, upload-time = "2026-01-10T06:44:19.467Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ab/ef9d939fe4a812648c7a712610b2ca6140b0853c5efea361301006c02ae5/numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", size = 5327274, upload-time = "2026-01-10T06:44:23.189Z" }, - { url = "https://files.pythonhosted.org/packages/bd/31/d381368e2a95c3b08b8cf7faac6004849e960f4a042d920337f71cef0cae/numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c", size = 6648306, upload-time = "2026-01-10T06:44:25.012Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e5/0989b44ade47430be6323d05c23207636d67d7362a1796ccbccac6773dd2/numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", size = 14464653, upload-time = "2026-01-10T06:44:26.706Z" }, - { url = "https://files.pythonhosted.org/packages/10/a7/cfbe475c35371cae1358e61f20c5f075badc18c4797ab4354140e1d283cf/numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", size = 16405144, upload-time = "2026-01-10T06:44:29.378Z" }, - { url = "https://files.pythonhosted.org/packages/f8/a3/0c63fe66b534888fa5177cc7cef061541064dbe2b4b60dcc60ffaf0d2157/numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", size = 16247425, upload-time = "2026-01-10T06:44:31.721Z" }, - { url = "https://files.pythonhosted.org/packages/6b/2b/55d980cfa2c93bd40ff4c290bf824d792bd41d2fe3487b07707559071760/numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", size = 18330053, upload-time = "2026-01-10T06:44:34.617Z" }, - { url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482, upload-time = "2026-01-10T06:44:36.798Z" }, - { url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117, upload-time = "2026-01-10T06:44:38.828Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" }, - { url = "https://files.pythonhosted.org/packages/1e/48/d86f97919e79314a1cdee4c832178763e6e98e623e123d0bada19e92c15a/numpy-2.4.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ad35f20be147a204e28b6a0575fbf3540c5e5f802634d4258d55b1ff5facce1", size = 16822202, upload-time = "2026-01-10T06:44:43.738Z" }, - { url = "https://files.pythonhosted.org/packages/51/e9/1e62a7f77e0f37dcfb0ad6a9744e65df00242b6ea37dfafb55debcbf5b55/numpy-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8097529164c0f3e32bb89412a0905d9100bf434d9692d9fc275e18dcf53c9344", size = 12569985, upload-time = "2026-01-10T06:44:45.945Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7e/914d54f0c801342306fdcdce3e994a56476f1b818c46c47fc21ae968088c/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:ea66d2b41ca4a1630aae5507ee0a71647d3124d1741980138aa8f28f44dac36e", size = 5398484, upload-time = "2026-01-10T06:44:48.012Z" }, - { url = "https://files.pythonhosted.org/packages/1c/d8/9570b68584e293a33474e7b5a77ca404f1dcc655e40050a600dee81d27fb/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d3f8f0df9f4b8be57b3bf74a1d087fec68f927a2fab68231fdb442bf2c12e426", size = 6713216, upload-time = "2026-01-10T06:44:49.725Z" }, - { url = "https://files.pythonhosted.org/packages/33/9b/9dd6e2db8d49eb24f86acaaa5258e5f4c8ed38209a4ee9de2d1a0ca25045/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2023ef86243690c2791fd6353e5b4848eedaa88ca8a2d129f462049f6d484696", size = 14538937, upload-time = "2026-01-10T06:44:51.498Z" }, - { url = "https://files.pythonhosted.org/packages/53/87/d5bd995b0f798a37105b876350d346eea5838bd8f77ea3d7a48392f3812b/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8361ea4220d763e54cff2fbe7d8c93526b744f7cd9ddab47afeff7e14e8503be", size = 16479830, upload-time = "2026-01-10T06:44:53.931Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c7/b801bf98514b6ae6475e941ac05c58e6411dd863ea92916bfd6d510b08c1/numpy-2.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4f1b68ff47680c2925f8063402a693ede215f0257f02596b1318ecdfb1d79e33", size = 12492579, upload-time = "2026-01-10T06:44:57.094Z" }, -] - -[[package]] -name = "overrides" -version = "7.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, -] - -[[package]] -name = "packaging" -version = "25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, -] - -[[package]] -name = "pandocfilters" -version = "1.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, -] - -[[package]] -name = "parso" -version = "0.8.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205, upload-time = "2025-08-23T15:15:28.028Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, -] - -[[package]] -name = "pexpect" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ptyprocess" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, -] - -[[package]] -name = "pillow" -version = "12.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, - { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, - { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, - { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, - { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, - { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, - { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, - { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, - { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, - { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, - { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, - { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, - { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, - { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, - { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, - { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, - { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pre-commit" -version = "4.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cfgv" }, - { name = "identify" }, - { name = "nodeenv" }, - { name = "pyyaml" }, - { name = "virtualenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, -] - -[[package]] -name = "prometheus-client" -version = "0.24.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, -] - -[[package]] -name = "prompt-toolkit" -version = "3.0.52" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, -] - -[[package]] -name = "psutil" -version = "7.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/cb/09e5184fb5fc0358d110fc3ca7f6b1d033800734d34cac10f4136cfac10e/psutil-7.2.1.tar.gz", hash = "sha256:f7583aec590485b43ca601dd9cea0dcd65bd7bb21d30ef4ddbf4ea6b5ed1bdd3", size = 490253, upload-time = "2025-12-29T08:26:00.169Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/8e/f0c242053a368c2aa89584ecd1b054a18683f13d6e5a318fc9ec36582c94/psutil-7.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9f33bb525b14c3ea563b2fd521a84d2fa214ec59e3e6a2858f78d0844dd60d", size = 129624, upload-time = "2025-12-29T08:26:04.255Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/a58a4968f8990617decee234258a2b4fc7cd9e35668387646c1963e69f26/psutil-7.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:81442dac7abfc2f4f4385ea9e12ddf5a796721c0f6133260687fec5c3780fa49", size = 130132, upload-time = "2025-12-29T08:26:06.228Z" }, - { url = "https://files.pythonhosted.org/packages/db/6d/ed44901e830739af5f72a85fa7ec5ff1edea7f81bfbf4875e409007149bd/psutil-7.2.1-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea46c0d060491051d39f0d2cff4f98d5c72b288289f57a21556cc7d504db37fc", size = 180612, upload-time = "2025-12-29T08:26:08.276Z" }, - { url = "https://files.pythonhosted.org/packages/c7/65/b628f8459bca4efbfae50d4bf3feaab803de9a160b9d5f3bd9295a33f0c2/psutil-7.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35630d5af80d5d0d49cfc4d64c1c13838baf6717a13effb35869a5919b854cdf", size = 183201, upload-time = "2025-12-29T08:26:10.622Z" }, - { url = "https://files.pythonhosted.org/packages/fb/23/851cadc9764edcc18f0effe7d0bf69f727d4cf2442deb4a9f78d4e4f30f2/psutil-7.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:923f8653416604e356073e6e0bccbe7c09990acef442def2f5640dd0faa9689f", size = 139081, upload-time = "2025-12-29T08:26:12.483Z" }, - { url = "https://files.pythonhosted.org/packages/59/82/d63e8494ec5758029f31c6cb06d7d161175d8281e91d011a4a441c8a43b5/psutil-7.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cfbe6b40ca48019a51827f20d830887b3107a74a79b01ceb8cc8de4ccb17b672", size = 134767, upload-time = "2025-12-29T08:26:14.528Z" }, - { url = "https://files.pythonhosted.org/packages/05/c2/5fb764bd61e40e1fe756a44bd4c21827228394c17414ade348e28f83cd79/psutil-7.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:494c513ccc53225ae23eec7fe6e1482f1b8a44674241b54561f755a898650679", size = 129716, upload-time = "2025-12-29T08:26:16.017Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d2/935039c20e06f615d9ca6ca0ab756cf8408a19d298ffaa08666bc18dc805/psutil-7.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fce5f92c22b00cdefd1645aa58ab4877a01679e901555067b1bd77039aa589f", size = 130133, upload-time = "2025-12-29T08:26:18.009Z" }, - { url = "https://files.pythonhosted.org/packages/77/69/19f1eb0e01d24c2b3eacbc2f78d3b5add8a89bf0bb69465bc8d563cc33de/psutil-7.2.1-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93f3f7b0bb07711b49626e7940d6fe52aa9940ad86e8f7e74842e73189712129", size = 181518, upload-time = "2025-12-29T08:26:20.241Z" }, - { url = "https://files.pythonhosted.org/packages/e1/6d/7e18b1b4fa13ad370787626c95887b027656ad4829c156bb6569d02f3262/psutil-7.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d34d2ca888208eea2b5c68186841336a7f5e0b990edec929be909353a202768a", size = 184348, upload-time = "2025-12-29T08:26:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/98/60/1672114392dd879586d60dd97896325df47d9a130ac7401318005aab28ec/psutil-7.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2ceae842a78d1603753561132d5ad1b2f8a7979cb0c283f5b52fb4e6e14b1a79", size = 140400, upload-time = "2025-12-29T08:26:23.993Z" }, - { url = "https://files.pythonhosted.org/packages/fb/7b/d0e9d4513c46e46897b46bcfc410d51fc65735837ea57a25170f298326e6/psutil-7.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:08a2f175e48a898c8eb8eace45ce01777f4785bc744c90aa2cc7f2fa5462a266", size = 135430, upload-time = "2025-12-29T08:26:25.999Z" }, - { url = "https://files.pythonhosted.org/packages/c5/cf/5180eb8c8bdf6a503c6919f1da28328bd1e6b3b1b5b9d5b01ae64f019616/psutil-7.2.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2e953fcfaedcfbc952b44744f22d16575d3aa78eb4f51ae74165b4e96e55f42", size = 128137, upload-time = "2025-12-29T08:26:27.759Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2c/78e4a789306a92ade5000da4f5de3255202c534acdadc3aac7b5458fadef/psutil-7.2.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:05cc68dbb8c174828624062e73078e7e35406f4ca2d0866c272c2410d8ef06d1", size = 128947, upload-time = "2025-12-29T08:26:29.548Z" }, - { url = "https://files.pythonhosted.org/packages/29/f8/40e01c350ad9a2b3cb4e6adbcc8a83b17ee50dd5792102b6142385937db5/psutil-7.2.1-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e38404ca2bb30ed7267a46c02f06ff842e92da3bb8c5bfdadbd35a5722314d8", size = 154694, upload-time = "2025-12-29T08:26:32.147Z" }, - { url = "https://files.pythonhosted.org/packages/06/e4/b751cdf839c011a9714a783f120e6a86b7494eb70044d7d81a25a5cd295f/psutil-7.2.1-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab2b98c9fc19f13f59628d94df5cc4cc4844bc572467d113a8b517d634e362c6", size = 156136, upload-time = "2025-12-29T08:26:34.079Z" }, - { url = "https://files.pythonhosted.org/packages/44/ad/bbf6595a8134ee1e94a4487af3f132cef7fce43aef4a93b49912a48c3af7/psutil-7.2.1-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f78baafb38436d5a128f837fab2d92c276dfb48af01a240b861ae02b2413ada8", size = 148108, upload-time = "2025-12-29T08:26:36.225Z" }, - { url = "https://files.pythonhosted.org/packages/1c/15/dd6fd869753ce82ff64dcbc18356093471a5a5adf4f77ed1f805d473d859/psutil-7.2.1-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:99a4cd17a5fdd1f3d014396502daa70b5ec21bf4ffe38393e152f8e449757d67", size = 147402, upload-time = "2025-12-29T08:26:39.21Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/d9317542e3f2b180c4306e3f45d3c922d7e86d8ce39f941bb9e2e9d8599e/psutil-7.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:b1b0671619343aa71c20ff9767eced0483e4fc9e1f489d50923738caf6a03c17", size = 136938, upload-time = "2025-12-29T08:26:41.036Z" }, - { url = "https://files.pythonhosted.org/packages/3e/73/2ce007f4198c80fcf2cb24c169884f833fe93fbc03d55d302627b094ee91/psutil-7.2.1-cp37-abi3-win_arm64.whl", hash = "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442", size = 133836, upload-time = "2025-12-29T08:26:43.086Z" }, -] - -[[package]] -name = "ptyprocess" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, -] - -[[package]] -name = "pure-eval" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, -] - -[[package]] -name = "py" -version = "1.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/ff/fec109ceb715d2a6b4c4a85a61af3b40c723a961e8828319fbcb15b868dc/py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", size = 207796, upload-time = "2021-11-04T17:17:01.377Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", size = 98708, upload-time = "2021-11-04T17:17:00.152Z" }, -] - -[[package]] -name = "pycairo" -version = "1.29.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/92/1b904087e831806a449502786d47d3a468e5edb8f65755f6bd88e8038e53/pycairo-1.29.0-cp311-cp311-win32.whl", hash = "sha256:12757ebfb304b645861283c20585c9204c3430671fad925419cba04844d6dfed", size = 751342, upload-time = "2025-11-11T19:11:37.386Z" }, - { url = "https://files.pythonhosted.org/packages/db/09/a0ab6a246a7ede89e817d749a941df34f27a74bedf15551da51e86ae105e/pycairo-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:3391532db03f9601c1cee9ebfa15b7d1db183c6020f3e75c1348cee16825934f", size = 845036, upload-time = "2025-11-11T19:11:43.408Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/bf455454bac50baef553e7356d36b9d16e482403bf132cfb12960d2dc2e7/pycairo-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:b69be8bb65c46b680771dc6a1a422b1cdd0cffb17be548f223e8cbbb6205567c", size = 694644, upload-time = "2025-11-11T19:11:48.599Z" }, - { url = "https://files.pythonhosted.org/packages/f6/28/6363087b9e60af031398a6ee5c248639eefc6cc742884fa2789411b1f73b/pycairo-1.29.0-cp312-cp312-win32.whl", hash = "sha256:91bcd7b5835764c616a615d9948a9afea29237b34d2ed013526807c3d79bb1d0", size = 751486, upload-time = "2025-11-11T19:11:54.451Z" }, - { url = "https://files.pythonhosted.org/packages/3a/d2/d146f1dd4ef81007686ac52231dd8f15ad54cf0aa432adaefc825475f286/pycairo-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f01c3b5e49ef9411fff6bc7db1e765f542dc1c9cfed4542958a5afa3a8b8e76", size = 845383, upload-time = "2025-11-11T19:12:01.551Z" }, - { url = "https://files.pythonhosted.org/packages/01/16/6e6f33bb79ec4a527c9e633915c16dc55a60be26b31118dbd0d5859e8c51/pycairo-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:eafe3d2076f3533535ad4a361fa0754e0ee66b90e548a3a0f558fed00b1248f2", size = 694518, upload-time = "2025-11-11T19:12:06.561Z" }, - { url = "https://files.pythonhosted.org/packages/f0/21/3f477dc318dd4e84a5ae6301e67284199d7e5a2384f3063714041086b65d/pycairo-1.29.0-cp313-cp313-win32.whl", hash = "sha256:3eb382a4141591807073274522f7aecab9e8fa2f14feafd11ac03a13a58141d7", size = 750949, upload-time = "2025-11-11T19:12:12.198Z" }, - { url = "https://files.pythonhosted.org/packages/43/34/7d27a333c558d6ac16dbc12a35061d389735e99e494ee4effa4ec6d99bed/pycairo-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:91114e4b3fbf4287c2b0788f83e1f566ce031bda49cf1c3c3c19c3e986e95c38", size = 844149, upload-time = "2025-11-11T19:12:19.171Z" }, - { url = "https://files.pythonhosted.org/packages/15/43/e782131e23df69e5c8e631a016ed84f94bbc4981bf6411079f57af730a23/pycairo-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:09b7f69a5ff6881e151354ea092137b97b0b1f0b2ab4eb81c92a02cc4a08e335", size = 693595, upload-time = "2025-11-11T19:12:23.445Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fa/87eaeeb9d53344c769839d7b2854db7ff2cd596211e00dd1b702eeb1838f/pycairo-1.29.0-cp314-cp314-win32.whl", hash = "sha256:69e2a7968a3fbb839736257bae153f547bca787113cc8d21e9e08ca4526e0b6b", size = 767198, upload-time = "2025-11-11T19:12:42.336Z" }, - { url = "https://files.pythonhosted.org/packages/3c/90/3564d0f64d0a00926ab863dc3c4a129b1065133128e96900772e1c4421f8/pycairo-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:e91243437a21cc4c67c401eff4433eadc45745275fa3ade1a0d877e50ffb90da", size = 871579, upload-time = "2025-11-11T19:12:48.982Z" }, - { url = "https://files.pythonhosted.org/packages/5e/91/93632b6ba12ad69c61991e3208bde88486fdfc152be8cfdd13444e9bc650/pycairo-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:b72200ea0e5f73ae4c788cd2028a750062221385eb0e6d8f1ecc714d0b4fdf82", size = 719537, upload-time = "2025-11-11T19:12:55.016Z" }, - { url = "https://files.pythonhosted.org/packages/93/23/37053c039f8d3b9b5017af9bc64d27b680c48a898d48b72e6d6583cf0155/pycairo-1.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5e45fce6185f553e79e4ef1722b8e98e6cde9900dbc48cb2637a9ccba86f627a", size = 874015, upload-time = "2025-11-11T19:12:28.47Z" }, - { url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" }, -] - -[[package]] -name = "pycparser" -version = "2.23" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, -] - -[[package]] -name = "pydub" -version = "0.25.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326, upload-time = "2021-03-10T02:09:54.659Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, -] - -[[package]] -name = "pyglet" -version = "2.1.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/07/6c/4bf476a1522d8293565f801ef305f2932148950b552df866a771c884ddaf/pyglet-2.1.12.tar.gz", hash = "sha256:bd7a750b2a5beaf0d2dd4bf4052d96e711ecd00ad29dada889b1f8374285b5f6", size = 6594600, upload-time = "2026-01-07T11:45:23.453Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/eb/872c18852bc1b9f39e7a14e992ebdc0bb6535227b2828a2bb737f6aa81b3/pyglet-2.1.12-py3-none-any.whl", hash = "sha256:875052fcfe1fbdd32272b0f57c4b3da908e727da7c98cf29485f10672607d327", size = 1032686, upload-time = "2026-01-07T11:45:18.585Z" }, -] - -[[package]] -name = "pyglm" -version = "2.8.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/8b/bdaf7b9cacecd28f7b4c6fc2d7d136824c506ad38cfdb37a05ea7ec88694/pyglm-2.8.3.tar.gz", hash = "sha256:161781ea4d1267f796b645f85ebff53aeb8ee4f13b4e993c04d64c96d286e534", size = 584038, upload-time = "2025-11-26T12:12:59.47Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/08/3a3e227515a7e4511699bb467379e8184fe883ba17a96adfa8b246e4a7b1/pyglm-2.8.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:94c7eb9c967423a123306f3a1bf66691cd66ed5ef17165c29424c096a2f96537", size = 1624376, upload-time = "2025-11-26T12:11:13.161Z" }, - { url = "https://files.pythonhosted.org/packages/f7/23/beab60070ef7dcfbac7de7e6512e42eea8ffad0a11ff59ab6b1c82849ef5/pyglm-2.8.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:43ac57b7ec33f0ec1e7224a4a8833c0f1405c446177e5abcd6d87867d286f8b3", size = 1359776, upload-time = "2025-11-26T12:11:14.27Z" }, - { url = "https://files.pythonhosted.org/packages/5d/39/8449cc2901a6693e89ed2f5d6913d99cc16816e8b539662700c11e341487/pyglm-2.8.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ccdc19eb1b432297aebc60e8f72f7870186f2defdaf2e0d0a5bc449057dad01", size = 12081814, upload-time = "2025-11-26T12:11:15.601Z" }, - { url = "https://files.pythonhosted.org/packages/a5/52/a3e9b3e91b312e0b21e21ba587bca9601e032e33e2693be7a9c864660e72/pyglm-2.8.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2f1424cd0a5d49bc0d8c43a9028a471dcb90a22ba1954baad9158659726f256c", size = 12703254, upload-time = "2025-11-26T12:11:17.402Z" }, - { url = "https://files.pythonhosted.org/packages/c6/c7/f76f2dd862a00e1a34e9ef76a6d5ccf7454224f734b9ff5d5780dd6ddf16/pyglm-2.8.3-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:32e7a2815f39a49f5434d66d2d109c020d3e7125044905d3afebcb597d6a583e", size = 10827408, upload-time = "2025-11-26T12:11:19.066Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5d/73cfdde12032bf741cb2e3de5467814b57611d32f5e42c4196847a75e777/pyglm-2.8.3-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:96da922be7d7d754f2ea269687c9215906ecc651b5f197ff92dcb497230d5f57", size = 11646495, upload-time = "2025-11-26T12:11:20.895Z" }, - { url = "https://files.pythonhosted.org/packages/4b/42/ef3cddd57fb8e0ab5b44bd0146704efa1e651015bf85296b16e0413890e7/pyglm-2.8.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:71132521c65b43badb3338edc12102eafacc4bf8597378ea7bb57a700975ce77", size = 11876783, upload-time = "2025-11-26T12:11:22.723Z" }, - { url = "https://files.pythonhosted.org/packages/73/70/55c2c56c9b29e9862ddafabfc90b97615c0470424fdddfaff7c56092e79c/pyglm-2.8.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:957816050c3bded151be3183e3160e7fc6b31f708b17d282f6c401ba1536f2a4", size = 12711844, upload-time = "2025-11-26T12:11:24.948Z" }, - { url = "https://files.pythonhosted.org/packages/ee/1e/891cede4878d7b0644b96af3999c5417c50bfccce18b2653ffc02e7483fe/pyglm-2.8.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae0dd576e89638654079f554d3f41b5d463ff6fee68961e4cd8b23069bff7e55", size = 1659367, upload-time = "2025-11-26T12:11:27.071Z" }, - { url = "https://files.pythonhosted.org/packages/e4/7b/2042502444d7d823e1e5f8c8776f8fe029b90da57ae76ec245c4f2263e39/pyglm-2.8.3-cp311-cp311-win_arm64.whl", hash = "sha256:28dba29d5b80cd9e81cf9875290bcedb451c83ca532de90c451d5b3a79727fcc", size = 1240884, upload-time = "2025-11-26T12:11:28.177Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a0/8759ed290b8d9830a6beda947c48cfb4e7ac9a14c04c8b612e1954dd0cf8/pyglm-2.8.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72c75bd76ade851bf5c2b12cbe384193ea901a73474d57e4d7044bbbcd7d08fd", size = 1608534, upload-time = "2025-11-26T12:11:29.277Z" }, - { url = "https://files.pythonhosted.org/packages/77/36/5feaf47a5f105cf478505cde5e32b4ce19649a11f802afaee1063ad3adaf/pyglm-2.8.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:570da7032ff1185c842c5864d080ee5eab091fa53ec217b8d4d4034c22ba744f", size = 1362644, upload-time = "2025-11-26T12:11:30.774Z" }, - { url = "https://files.pythonhosted.org/packages/f9/6d/840566e3cbadb4d66d60c112f2c507260b46aa674e6fd22913493cfb7cd4/pyglm-2.8.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a14f1ffdc30a6f53334d16651888f60a2f8a63e05613357234c5592747b906bf", size = 12155701, upload-time = "2025-11-26T12:11:32.54Z" }, - { url = "https://files.pythonhosted.org/packages/f1/97/3e6648727f597885f4e6d92b77b49dc2fa2b65a2b9ea3ab35caaff06e072/pyglm-2.8.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d2305b258bebc358e00b8c85c3bc2d6f78d64f9b3f8802e1699c3e8dae54361", size = 12785652, upload-time = "2025-11-26T12:11:34.449Z" }, - { url = "https://files.pythonhosted.org/packages/e4/9c/04c525d78356e6493d0abc3ea945802befd5b813b1820e5b78dd5ca2f072/pyglm-2.8.3-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:f80d208e558a024a3dc83300bd661a71aa4cda884a256c5b87179059a1c55373", size = 10886479, upload-time = "2025-11-26T12:11:36.324Z" }, - { url = "https://files.pythonhosted.org/packages/eb/20/d4003e53a590b8dd8fa9ab2e6848a1acaeb8d239c95161dc2e05f5972c9c/pyglm-2.8.3-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:8abf221111db95e34620e446a853d43c887ced10d5c217e2df18471c9ac683ef", size = 11711522, upload-time = "2025-11-26T12:11:38.639Z" }, - { url = "https://files.pythonhosted.org/packages/f0/15/2b1a7761b33c309f4c14ce505f671fb75cc2c4b285af8fdcfb6d580cc1be/pyglm-2.8.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71ace7b15dd6d2ea9dbeadd43738ce69e7134547fc4674617084df5096e9b866", size = 11938446, upload-time = "2025-11-26T12:11:40.36Z" }, - { url = "https://files.pythonhosted.org/packages/9b/e7/9cbee60fbd66592a0cc46ad4e26c4bd26d55f45411323a5b03e3bd9a33d5/pyglm-2.8.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:04be7de24547130b8d413bc96ab9998d95db89cbaf224f401624ac1fa62bc431", size = 12773288, upload-time = "2025-11-26T12:11:42.44Z" }, - { url = "https://files.pythonhosted.org/packages/70/11/c80ffa11495b5788cfa5d021ae3bdbef20c941ead87911b2d50d534b2054/pyglm-2.8.3-cp312-cp312-win_amd64.whl", hash = "sha256:5f7ff6f4ac14b092d897931b9ef76bb8108b5faa6f5118963c9de86c6fa18efc", size = 1663004, upload-time = "2025-11-26T12:11:44.1Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d6/a24ac1280fc1cd03fe1ff0fa2632fbafdee1a4abe9cd319cb0ae6aed99bf/pyglm-2.8.3-cp312-cp312-win_arm64.whl", hash = "sha256:ba2de300dbaf39cb6cd82ce7db05a8dcf85bb7dacf1ec65db18b1ebc34438a0d", size = 1239976, upload-time = "2025-11-26T12:11:45.196Z" }, - { url = "https://files.pythonhosted.org/packages/2d/22/ee11dff20adfc6aac3e0482ed275843e0b9854dbc2e812ab56cdacbed8d6/pyglm-2.8.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a6d07b6b73e55a36e1b6daa63acb53f4912f2ddf88f434af4cc70441435aef8", size = 1608547, upload-time = "2025-11-26T12:11:46.426Z" }, - { url = "https://files.pythonhosted.org/packages/1e/5f/10c4cd636c3e6c63328a476f84bb03501d8016f12053c3a82ba588fc61ce/pyglm-2.8.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eaf6241f7c3ff169575e11da78f4439422517e42558961332db4cd09e9599267", size = 1362651, upload-time = "2025-11-26T12:11:47.555Z" }, - { url = "https://files.pythonhosted.org/packages/ff/70/2c7fe768900ee9d0f87e7a89375fa7d83b5b0a8f0eee8d0ad06b22a96e37/pyglm-2.8.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e1ec0dfb8f2c848c4ee6330c70a1ca9333004776c8e5ec76096e0b67c739f688", size = 12157160, upload-time = "2025-11-26T12:11:49.042Z" }, - { url = "https://files.pythonhosted.org/packages/b0/a6/befefccf1c8a0a66f09a7a1a1d324a3f988cb5e6b634026280518e5a83cd/pyglm-2.8.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3eea9093210afb946769c84fe31f17a9f73696a1161ee84fb42e9255f8c5cc8a", size = 12787899, upload-time = "2025-11-26T12:11:50.946Z" }, - { url = "https://files.pythonhosted.org/packages/7c/d2/5475d0791b585ae26dc0d690862a0e1e6fda243552a1718628dd937e5543/pyglm-2.8.3-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:0c652650912dbd88994fa02ec9dba2f3b35dc3995427b6ae8056ae542d5a060a", size = 10886936, upload-time = "2025-11-26T12:11:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/e1/a1/035068410f60ed53007e0488af96f7e4634b679e9156d1090e70732b2159/pyglm-2.8.3-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:e3bdda68f75ad270e66b0fc7a1883749489cca14c44cbe22bac038fa170a8a1c", size = 11711601, upload-time = "2025-11-26T12:11:55.367Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e6/286d1879eae87199f086136b08c70a3bff27880417f98dadc0cbcdb65e50/pyglm-2.8.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:08f8b1bd0d80ce396ee9cd5d3d4c7aeb4bbfa2a54dc73413924e3c9982412528", size = 11939755, upload-time = "2025-11-26T12:11:57.23Z" }, - { url = "https://files.pythonhosted.org/packages/72/58/f40f109ac025bb18412db78e6e73ffc20a17b6b495de90352f5aabfb9982/pyglm-2.8.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:12fa61feefa5a255097d0887415bce9dd72c1dd5e5a8c6577121ee5348336a16", size = 12774242, upload-time = "2025-11-26T12:11:59.051Z" }, - { url = "https://files.pythonhosted.org/packages/17/fd/71b44ee5ac341e9979731a7b868e237f3775e140a9481cea79bae7abb83c/pyglm-2.8.3-cp313-cp313-win_amd64.whl", hash = "sha256:33118ef1d678ab573546757dee7f0a1ca2fba8e8d7760c9fe6320fe0cfa3deb7", size = 1663010, upload-time = "2025-11-26T12:12:00.914Z" }, - { url = "https://files.pythonhosted.org/packages/fa/1e/9b8ba9d4627585797d8bda952412c93a4c09b70cb25e64756a52accddc1e/pyglm-2.8.3-cp313-cp313-win_arm64.whl", hash = "sha256:73ff3785dfc4ce017626d7ab56d6711a7119c29e2e71294efed73810c1d307f9", size = 1239893, upload-time = "2025-11-26T12:12:02.032Z" }, - { url = "https://files.pythonhosted.org/packages/65/fe/494d7dce3fcaf0123e787320b4a558d8fe733d6d0e23b7fe51e687f88dc2/pyglm-2.8.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b312012458d0537b3d84f24f4ba51fd3930df7a0773fc643e36f8df27b807c7a", size = 1610593, upload-time = "2025-11-26T12:12:03.136Z" }, - { url = "https://files.pythonhosted.org/packages/27/c0/37cced4a1b29957a29baebc1f0034be5d5419adf12da4b99140c56152cdb/pyglm-2.8.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3249bfe5352e18cc777fac591665679a99f270e1ff27cd11dc349af07684f007", size = 1366295, upload-time = "2025-11-26T12:12:04.176Z" }, - { url = "https://files.pythonhosted.org/packages/a7/ba/186176d1c3e26196ae4bec4b228bdbb1304576f2038a7f1635070923ee48/pyglm-2.8.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb8ea0d6721c763a26eacde59b2c9165719050dcf49c99a2857f6e1e5a5f30bb", size = 12140715, upload-time = "2025-11-26T12:12:05.429Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3e/8d9f307649e9b79b34ae51303e46e7012cc2550d60e6dd00ea8d3d8c9cf2/pyglm-2.8.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:83effb89e2cf6dd79cf9ebecf2f9fbda3d25a92b61af264de3cadad408911de6", size = 12764551, upload-time = "2025-11-26T12:12:07.403Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d6/2481433abe537d7019d9ce75eba86b57e1731e3e0352fd1ac7f31f4d1883/pyglm-2.8.3-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:e958d65ed55f2716fd8a3a2ef872cc52893ea7300d7feec62dccb27ec25fbc2f", size = 10869966, upload-time = "2025-11-26T12:12:09.198Z" }, - { url = "https://files.pythonhosted.org/packages/fe/bf/3c78a9718e1d26c5b6ec468a584eb30cd797c46b6edd08e79cb1d67e8bf0/pyglm-2.8.3-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:ec7cc14d2eb9f46a18012ee7c1a164e0395b058ceb6e341bf6d986316b698574", size = 11693025, upload-time = "2025-11-26T12:12:10.948Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0d/00ef293153b6ca7e56d1be6facbd9d0a8e331d0c6d3114f47f2cf2913eda/pyglm-2.8.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4beb0ada21e7641a577f274496451befcde79965467ef4027bd933334b3de39b", size = 11920727, upload-time = "2025-11-26T12:12:12.845Z" }, - { url = "https://files.pythonhosted.org/packages/4e/10/d8ecf9b5ac3b6fc1b179b33dfbd087b8f4d4b12bab99b5794f569a15a99a/pyglm-2.8.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77878c1b8c713b9e39fe32f870d82e20c864da3b11a3b875eca06c270b04cdbe", size = 12759345, upload-time = "2025-11-26T12:12:14.705Z" }, - { url = "https://files.pythonhosted.org/packages/a1/d1/cc1f75ee77fd2f9bcb122bc7f3a628710fe3f0bc517333fa9f38268a7065/pyglm-2.8.3-cp314-cp314-win_amd64.whl", hash = "sha256:15c77bc46ff69d945565309e13ca99c4a001d6a941a80c45f26fbdec80fa16c4", size = 1710524, upload-time = "2025-11-26T12:12:16.486Z" }, - { url = "https://files.pythonhosted.org/packages/35/51/74c0a3107567dc769c06b6c04c7bc828f33792e089036c637334b7e4c573/pyglm-2.8.3-cp314-cp314-win_arm64.whl", hash = "sha256:2b16ec33bd43c514502bae8de2b319d168259090e101e1cde79cd0a7d33e1185", size = 1271521, upload-time = "2025-11-26T12:12:17.611Z" }, - { url = "https://files.pythonhosted.org/packages/32/8e/867045b54da8257b21c71c0255ba7a231c251aae4b5f26b12eb50b651cc7/pyglm-2.8.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6a1f1ab8debc06e0fdedb3f4285ded4bec38bf075652c393039838504767e6cf", size = 1633871, upload-time = "2025-11-26T12:12:18.791Z" }, - { url = "https://files.pythonhosted.org/packages/93/f5/81bb8b52e132dc1ecc87b7ffb50c714b4fd2f71f98b801dd376e036f942b/pyglm-2.8.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a7835d18747ab9f8e736e343cc35bee0a514f18add282f1fc8035945fcf9d9bd", size = 1387757, upload-time = "2025-11-26T12:12:20.497Z" }, - { url = "https://files.pythonhosted.org/packages/32/40/8581283c00e2a18a6bb20a9192e25a792b605c53dee1c3abb8871eb0d3ca/pyglm-2.8.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:af3ddee3d150bbef68ee7338ccd3e0710b75b08121b8efd52d786b0d2b6731be", size = 12724921, upload-time = "2025-11-26T12:12:21.802Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0e/bc3c03038da822d1a66c38e166d4c89b6bdab846e576ac226442813af7f4/pyglm-2.8.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a76c8eaba0c58f5738e87be5efcd16c4e75540fe6ebfdf15c236a799a61358e", size = 13317946, upload-time = "2025-11-26T12:12:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/3b/21/b1f52dc73d610e36aa3ccf3ef61634530e6f97c2ca809a057839bf578667/pyglm-2.8.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:879ee9ab3c8ab47b1de59fe7e593eda854b8349f274ca60f057b83f3a405b84d", size = 11371582, upload-time = "2025-11-26T12:12:25.768Z" }, - { url = "https://files.pythonhosted.org/packages/16/aa/e03cc7a2daceb1bbdf98780a991a323e4078254584cf6664984a626ecfe1/pyglm-2.8.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:3e37c15b6c3e08f960b34ff9ec42e73469dfd868aec214e8a347da6c9d0245d6", size = 12192848, upload-time = "2025-11-26T12:12:28.606Z" }, - { url = "https://files.pythonhosted.org/packages/69/07/be893aee24a9a8b5400feda4d032fad9d281047d4379aa9f5ef4bea1f6a4/pyglm-2.8.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd34a8670debef4a55bc756b14cbe8b0a4daa49f8f6850c86c5e11d20554927f", size = 12414214, upload-time = "2025-11-26T12:12:30.438Z" }, - { url = "https://files.pythonhosted.org/packages/0c/06/6bb4e8a09f7dd2bf8dc1f4cc5edc938fc1247a1b2770833515fb418fc695/pyglm-2.8.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:699f852e0335b79d0b664ba1c2d02cb4689256cda786e7780e821f60b0824c46", size = 13237516, upload-time = "2025-11-26T12:12:32.412Z" }, - { url = "https://files.pythonhosted.org/packages/2b/5c/7f15edd05020540748dad8f5eae0a07ed7f14f699bff530172b8850bd998/pyglm-2.8.3-cp314-cp314t-win_amd64.whl", hash = "sha256:78caadaf9cc2ddea1c55b0d44fa8032f35c9f821a6f152b72422e5657d38f01d", size = 1764614, upload-time = "2025-11-26T12:12:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f9/8bc8d010503a250319c55d2ceec5f90b0e807c7f85d581638c5ecd0a81de/pyglm-2.8.3-cp314-cp314t-win_arm64.whl", hash = "sha256:69400ad1852ca0972e4d9cbef9d9510941d4b81dc0fffebc5ac796a85440a119", size = 1292992, upload-time = "2025-11-26T12:12:35.681Z" }, -] - -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - -[[package]] -name = "pyobjc-core" -version = "12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263, upload-time = "2025-11-14T09:31:35.231Z" }, - { url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" }, - { url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" }, - { url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" }, - { url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" }, -] - -[[package]] -name = "pyobjc-framework-cocoa" -version = "12.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyobjc-core" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812, upload-time = "2025-11-14T09:40:53.169Z" }, - { url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" }, - { url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" }, - { url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" }, - { url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" }, -] - -[[package]] -name = "pyparsing" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/c1/1d9de9aeaa1b89b0186e5fe23294ff6517fce1bc69149185577cd31016b2/pyparsing-3.3.1.tar.gz", hash = "sha256:47fad0f17ac1e2cad3de3b458570fbc9b03560aa029ed5e16ee5554da9a2251c", size = 1550512, upload-time = "2025-12-23T03:14:04.391Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793, upload-time = "2025-12-23T03:14:02.103Z" }, -] - -[[package]] -name = "pytest" -version = "9.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, -] - -[[package]] -name = "pytest-cov" -version = "7.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "coverage", extra = ["toml"] }, - { name = "pluggy" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, -] - -[[package]] -name = "pytest-forked" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "py" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8c/c9/93ad2ba2413057ee694884b88cf7467a46c50c438977720aeac26e73fdb7/pytest-forked-1.6.0.tar.gz", hash = "sha256:4dafd46a9a600f65d822b8f605133ecf5b3e1941ebb3588e943b4e3eb71a5a3f", size = 9977, upload-time = "2023-02-12T23:22:27.544Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/af/9c0bda43e486a3c9bf1e0f876d0f241bc3f229d7d65d09331a0868db9629/pytest_forked-1.6.0-py3-none-any.whl", hash = "sha256:810958f66a91afb1a1e2ae83089d8dc1cd2437ac96b12963042fbb9fb4d16af0", size = 4897, upload-time = "2023-02-12T23:22:26.022Z" }, -] - -[[package]] -name = "pytest-xdist" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "execnet" }, - { name = "pytest" }, - { name = "pytest-forked" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5d/43/9dbc32d297d6eae85d6c05dc8e8d3371061bd6cbe56a2f645d9ea4b53d9b/pytest-xdist-2.5.0.tar.gz", hash = "sha256:4580deca3ff04ddb2ac53eba39d76cb5dd5edeac050cb6fbc768b0dd712b4edf", size = 72455, upload-time = "2021-12-10T11:41:56.849Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/21/08/b1945d4b4986eb1aa10cf84efc5293bba39da80a2f95db3573dd90678408/pytest_xdist-2.5.0-py3-none-any.whl", hash = "sha256:6fe5c74fec98906deb8f2d2b616b5c782022744978e7bd4695d39c8f42d0ce65", size = 41698, upload-time = "2021-12-10T11:41:55.441Z" }, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - -[[package]] -name = "python-json-logger" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, -] - -[[package]] -name = "pywinpty" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/bb/a7cc2967c5c4eceb6cc49cfe39447d4bfc56e6c865e7c2249b6eb978935f/pywinpty-3.0.2.tar.gz", hash = "sha256:1505cc4cb248af42cb6285a65c9c2086ee9e7e574078ee60933d5d7fa86fb004", size = 30669, upload-time = "2025-10-03T21:16:29.205Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/a1/409c1651c9f874d598c10f51ff586c416625601df4bca315d08baec4c3e3/pywinpty-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:327790d70e4c841ebd9d0f295a780177149aeb405bca44c7115a3de5c2054b23", size = 2050304, upload-time = "2025-10-03T21:19:29.466Z" }, - { url = "https://files.pythonhosted.org/packages/02/4e/1098484e042c9485f56f16eb2b69b43b874bd526044ee401512234cf9e04/pywinpty-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:99fdd9b455f0ad6419aba6731a7a0d2f88ced83c3c94a80ff9533d95fa8d8a9e", size = 2050391, upload-time = "2025-10-03T21:19:01.642Z" }, - { url = "https://files.pythonhosted.org/packages/fc/19/b757fe28008236a4a713e813283721b8a40aa60cd7d3f83549f2e25a3155/pywinpty-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:18f78b81e4cfee6aabe7ea8688441d30247b73e52cd9657138015c5f4ee13a51", size = 2050057, upload-time = "2025-10-03T21:19:26.732Z" }, - { url = "https://files.pythonhosted.org/packages/cb/44/cbae12ecf6f4fa4129c36871fd09c6bef4f98d5f625ecefb5e2449765508/pywinpty-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:663383ecfab7fc382cc97ea5c4f7f0bb32c2f889259855df6ea34e5df42d305b", size = 2049874, upload-time = "2025-10-03T21:18:53.923Z" }, - { url = "https://files.pythonhosted.org/packages/ca/15/f12c6055e2d7a617d4d5820e8ac4ceaff849da4cb124640ef5116a230771/pywinpty-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:28297cecc37bee9f24d8889e47231972d6e9e84f7b668909de54f36ca785029a", size = 2050386, upload-time = "2025-10-03T21:18:50.477Z" }, - { url = "https://files.pythonhosted.org/packages/de/24/c6907c5bb06043df98ad6a0a0ff5db2e0affcecbc3b15c42404393a3f72a/pywinpty-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:34b55ae9a1b671fe3eae071d86618110538e8eaad18fcb1531c0830b91a82767", size = 2049834, upload-time = "2025-10-03T21:19:25.688Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - -[[package]] -name = "pyzmq" -version = "27.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "implementation_name == 'pypy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, - { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" }, - { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" }, - { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" }, - { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" }, - { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" }, - { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" }, - { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, - { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, - { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, - { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, - { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, - { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, - { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, - { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, - { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, - { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, - { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, - { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, - { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, - { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, - { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, - { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, - { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, - { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, - { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, - { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" }, - { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, -] - -[[package]] -name = "referencing" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, -] - -[[package]] -name = "requests" -version = "2.33.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, -] - -[[package]] -name = "rfc3339-validator" -version = "0.1.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, -] - -[[package]] -name = "rfc3986-validator" -version = "0.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, -] - -[[package]] -name = "rfc3987-syntax" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "lark" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, -] - -[[package]] -name = "rich" -version = "14.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, -] - -[[package]] -name = "roman-numerals" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, -] - -[[package]] -name = "roman-numerals-py" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "roman-numerals" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cb/b5/de96fca640f4f656eb79bbee0e79aeec52e3e0e359f8a3e6a0d366378b64/roman_numerals_py-4.1.0.tar.gz", hash = "sha256:f5d7b2b4ca52dd855ef7ab8eb3590f428c0b1ea480736ce32b01fef2a5f8daf9", size = 4274, upload-time = "2025-12-17T18:25:41.153Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/2c/daca29684cbe9fd4bc711f8246da3c10adca1ccc4d24436b17572eb2590e/roman_numerals_py-4.1.0-py3-none-any.whl", hash = "sha256:553114c1167141c1283a51743759723ecd05604a1b6b507225e91dc1a6df0780", size = 4547, upload-time = "2025-12-17T18:25:40.136Z" }, -] - -[[package]] -name = "rpds-py" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, - { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, - { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, - { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, - { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, - { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, - { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, - { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, - { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, - { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, - { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, - { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, - { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, - { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, - { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, - { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, - { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, - { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, - { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, - { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, - { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, - { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, - { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, - { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, - { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, - { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, - { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, - { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, - { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, - { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, - { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, - { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, - { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, - { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, - { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, - { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, - { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, - { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, - { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, -] - -[[package]] -name = "ruff" -version = "0.14.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/77/9a7fe084d268f8855d493e5031ea03fa0af8cc05887f638bf1c4e3363eb8/ruff-0.14.11.tar.gz", hash = "sha256:f6dc463bfa5c07a59b1ff2c3b9767373e541346ea105503b4c0369c520a66958", size = 5993417, upload-time = "2026-01-08T19:11:58.322Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/a6/a4c40a5aaa7e331f245d2dc1ac8ece306681f52b636b40ef87c88b9f7afd/ruff-0.14.11-py3-none-linux_armv6l.whl", hash = "sha256:f6ff2d95cbd335841a7217bdfd9c1d2e44eac2c584197ab1385579d55ff8830e", size = 12951208, upload-time = "2026-01-08T19:12:09.218Z" }, - { url = "https://files.pythonhosted.org/packages/5c/5c/360a35cb7204b328b685d3129c08aca24765ff92b5a7efedbdd6c150d555/ruff-0.14.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f6eb5c1c8033680f4172ea9c8d3706c156223010b8b97b05e82c59bdc774ee6", size = 13330075, upload-time = "2026-01-08T19:12:02.549Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9e/0cc2f1be7a7d33cae541824cf3f95b4ff40d03557b575912b5b70273c9ec/ruff-0.14.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2fc34cc896f90080fca01259f96c566f74069a04b25b6205d55379d12a6855e", size = 12257809, upload-time = "2026-01-08T19:12:00.366Z" }, - { url = "https://files.pythonhosted.org/packages/a7/e5/5faab97c15bb75228d9f74637e775d26ac703cc2b4898564c01ab3637c02/ruff-0.14.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53386375001773ae812b43205d6064dae49ff0968774e6befe16a994fc233caa", size = 12678447, upload-time = "2026-01-08T19:12:13.899Z" }, - { url = "https://files.pythonhosted.org/packages/1b/33/e9767f60a2bef779fb5855cab0af76c488e0ce90f7bb7b8a45c8a2ba4178/ruff-0.14.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a697737dce1ca97a0a55b5ff0434ee7205943d4874d638fe3ae66166ff46edbe", size = 12758560, upload-time = "2026-01-08T19:11:42.55Z" }, - { url = "https://files.pythonhosted.org/packages/eb/84/4c6cf627a21462bb5102f7be2a320b084228ff26e105510cd2255ea868e5/ruff-0.14.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6845ca1da8ab81ab1dce755a32ad13f1db72e7fba27c486d5d90d65e04d17b8f", size = 13599296, upload-time = "2026-01-08T19:11:30.371Z" }, - { url = "https://files.pythonhosted.org/packages/88/e1/92b5ed7ea66d849f6157e695dc23d5d6d982bd6aa8d077895652c38a7cae/ruff-0.14.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e36ce2fd31b54065ec6f76cb08d60159e1b32bdf08507862e32f47e6dde8bcbf", size = 15048981, upload-time = "2026-01-08T19:12:04.742Z" }, - { url = "https://files.pythonhosted.org/packages/61/df/c1bd30992615ac17c2fb64b8a7376ca22c04a70555b5d05b8f717163cf9f/ruff-0.14.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:590bcc0e2097ecf74e62a5c10a6b71f008ad82eb97b0a0079e85defe19fe74d9", size = 14633183, upload-time = "2026-01-08T19:11:40.069Z" }, - { url = "https://files.pythonhosted.org/packages/04/e9/fe552902f25013dd28a5428a42347d9ad20c4b534834a325a28305747d64/ruff-0.14.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53fe71125fc158210d57fe4da26e622c9c294022988d08d9347ec1cf782adafe", size = 14050453, upload-time = "2026-01-08T19:11:37.555Z" }, - { url = "https://files.pythonhosted.org/packages/ae/93/f36d89fa021543187f98991609ce6e47e24f35f008dfe1af01379d248a41/ruff-0.14.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a35c9da08562f1598ded8470fcfef2afb5cf881996e6c0a502ceb61f4bc9c8a3", size = 13757889, upload-time = "2026-01-08T19:12:07.094Z" }, - { url = "https://files.pythonhosted.org/packages/b7/9f/c7fb6ecf554f28709a6a1f2a7f74750d400979e8cd47ed29feeaa1bd4db8/ruff-0.14.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0f3727189a52179393ecf92ec7057c2210203e6af2676f08d92140d3e1ee72c1", size = 13955832, upload-time = "2026-01-08T19:11:55.064Z" }, - { url = "https://files.pythonhosted.org/packages/db/a0/153315310f250f76900a98278cf878c64dfb6d044e184491dd3289796734/ruff-0.14.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:eb09f849bd37147a789b85995ff734a6c4a095bed5fd1608c4f56afc3634cde2", size = 12586522, upload-time = "2026-01-08T19:11:35.356Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2b/a73a2b6e6d2df1d74bf2b78098be1572191e54bec0e59e29382d13c3adc5/ruff-0.14.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c61782543c1231bf71041461c1f28c64b961d457d0f238ac388e2ab173d7ecb7", size = 12724637, upload-time = "2026-01-08T19:11:47.796Z" }, - { url = "https://files.pythonhosted.org/packages/f0/41/09100590320394401cd3c48fc718a8ba71c7ddb1ffd07e0ad6576b3a3df2/ruff-0.14.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82ff352ea68fb6766140381748e1f67f83c39860b6446966cff48a315c3e2491", size = 13145837, upload-time = "2026-01-08T19:11:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d8/e035db859d1d3edf909381eb8ff3e89a672d6572e9454093538fe6f164b0/ruff-0.14.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:728e56879df4ca5b62a9dde2dd0eb0edda2a55160c0ea28c4025f18c03f86984", size = 13850469, upload-time = "2026-01-08T19:12:11.694Z" }, - { url = "https://files.pythonhosted.org/packages/4e/02/bb3ff8b6e6d02ce9e3740f4c17dfbbfb55f34c789c139e9cd91985f356c7/ruff-0.14.11-py3-none-win32.whl", hash = "sha256:337c5dd11f16ee52ae217757d9b82a26400be7efac883e9e852646f1557ed841", size = 12851094, upload-time = "2026-01-08T19:11:45.163Z" }, - { url = "https://files.pythonhosted.org/packages/58/f1/90ddc533918d3a2ad628bc3044cdfc094949e6d4b929220c3f0eb8a1c998/ruff-0.14.11-py3-none-win_amd64.whl", hash = "sha256:f981cea63d08456b2c070e64b79cb62f951aa1305282974d4d5216e6e0178ae6", size = 14001379, upload-time = "2026-01-08T19:11:52.591Z" }, - { url = "https://files.pythonhosted.org/packages/c4/1c/1dbe51782c0e1e9cfce1d1004752672d2d4629ea46945d19d731ad772b3b/ruff-0.14.11-py3-none-win_arm64.whl", hash = "sha256:649fb6c9edd7f751db276ef42df1f3df41c38d67d199570ae2a7bd6cbc3590f0", size = 12938644, upload-time = "2026-01-08T19:11:50.027Z" }, -] - -[[package]] -name = "scipy" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/4b/c89c131aa87cad2b77a54eb0fb94d633a842420fa7e919dc2f922037c3d8/scipy-1.17.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:2abd71643797bd8a106dff97894ff7869eeeb0af0f7a5ce02e4227c6a2e9d6fd", size = 31381316, upload-time = "2026-01-10T21:24:33.42Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5f/a6b38f79a07d74989224d5f11b55267714707582908a5f1ae854cf9a9b84/scipy-1.17.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:ef28d815f4d2686503e5f4f00edc387ae58dfd7a2f42e348bb53359538f01558", size = 27966760, upload-time = "2026-01-10T21:24:38.911Z" }, - { url = "https://files.pythonhosted.org/packages/c1/20/095ad24e031ee8ed3c5975954d816b8e7e2abd731e04f8be573de8740885/scipy-1.17.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:272a9f16d6bb4667e8b50d25d71eddcc2158a214df1b566319298de0939d2ab7", size = 20138701, upload-time = "2026-01-10T21:24:43.249Z" }, - { url = "https://files.pythonhosted.org/packages/89/11/4aad2b3858d0337756f3323f8960755704e530b27eb2a94386c970c32cbe/scipy-1.17.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:7204fddcbec2fe6598f1c5fdf027e9f259106d05202a959a9f1aecf036adc9f6", size = 22480574, upload-time = "2026-01-10T21:24:47.266Z" }, - { url = "https://files.pythonhosted.org/packages/85/bd/f5af70c28c6da2227e510875cadf64879855193a687fb19951f0f44cfd6b/scipy-1.17.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc02c37a5639ee67d8fb646ffded6d793c06c5622d36b35cfa8fe5ececb8f042", size = 32862414, upload-time = "2026-01-10T21:24:52.566Z" }, - { url = "https://files.pythonhosted.org/packages/ef/df/df1457c4df3826e908879fe3d76bc5b6e60aae45f4ee42539512438cfd5d/scipy-1.17.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dac97a27520d66c12a34fd90a4fe65f43766c18c0d6e1c0a80f114d2260080e4", size = 35112380, upload-time = "2026-01-10T21:24:58.433Z" }, - { url = "https://files.pythonhosted.org/packages/5f/bb/88e2c16bd1dd4de19d80d7c5e238387182993c2fb13b4b8111e3927ad422/scipy-1.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb7446a39b3ae0fe8f416a9a3fdc6fba3f11c634f680f16a239c5187bc487c0", size = 34922676, upload-time = "2026-01-10T21:25:04.287Z" }, - { url = "https://files.pythonhosted.org/packages/02/ba/5120242cc735f71fc002cff0303d536af4405eb265f7c60742851e7ccfe9/scipy-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:474da16199f6af66601a01546144922ce402cb17362e07d82f5a6cf8f963e449", size = 37507599, upload-time = "2026-01-10T21:25:09.851Z" }, - { url = "https://files.pythonhosted.org/packages/52/c8/08629657ac6c0da198487ce8cd3de78e02cfde42b7f34117d56a3fe249dc/scipy-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:255c0da161bd7b32a6c898e7891509e8a9289f0b1c6c7d96142ee0d2b114c2ea", size = 36380284, upload-time = "2026-01-10T21:25:15.632Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4a/465f96d42c6f33ad324a40049dfd63269891db9324aa66c4a1c108c6f994/scipy-1.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:85b0ac3ad17fa3be50abd7e69d583d98792d7edc08367e01445a1e2076005379", size = 24370427, upload-time = "2026-01-10T21:25:20.514Z" }, - { url = "https://files.pythonhosted.org/packages/0b/11/7241a63e73ba5a516f1930ac8d5b44cbbfabd35ac73a2d08ca206df007c4/scipy-1.17.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:0d5018a57c24cb1dd828bcf51d7b10e65986d549f52ef5adb6b4d1ded3e32a57", size = 31364580, upload-time = "2026-01-10T21:25:25.717Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1d/5057f812d4f6adc91a20a2d6f2ebcdb517fdbc87ae3acc5633c9b97c8ba5/scipy-1.17.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:88c22af9e5d5a4f9e027e26772cc7b5922fab8bcc839edb3ae33de404feebd9e", size = 27969012, upload-time = "2026-01-10T21:25:30.921Z" }, - { url = "https://files.pythonhosted.org/packages/e3/21/f6ec556c1e3b6ec4e088da667d9987bb77cc3ab3026511f427dc8451187d/scipy-1.17.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f3cd947f20fe17013d401b64e857c6b2da83cae567adbb75b9dcba865abc66d8", size = 20140691, upload-time = "2026-01-10T21:25:34.802Z" }, - { url = "https://files.pythonhosted.org/packages/7a/fe/5e5ad04784964ba964a96f16c8d4676aa1b51357199014dce58ab7ec5670/scipy-1.17.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e8c0b331c2c1f531eb51f1b4fc9ba709521a712cce58f1aa627bc007421a5306", size = 22463015, upload-time = "2026-01-10T21:25:39.277Z" }, - { url = "https://files.pythonhosted.org/packages/4a/69/7c347e857224fcaf32a34a05183b9d8a7aca25f8f2d10b8a698b8388561a/scipy-1.17.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5194c445d0a1c7a6c1a4a4681b6b7c71baad98ff66d96b949097e7513c9d6742", size = 32724197, upload-time = "2026-01-10T21:25:44.084Z" }, - { url = "https://files.pythonhosted.org/packages/d1/fe/66d73b76d378ba8cc2fe605920c0c75092e3a65ae746e1e767d9d020a75a/scipy-1.17.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9eeb9b5f5997f75507814ed9d298ab23f62cf79f5a3ef90031b1ee2506abdb5b", size = 35009148, upload-time = "2026-01-10T21:25:50.591Z" }, - { url = "https://files.pythonhosted.org/packages/af/07/07dec27d9dc41c18d8c43c69e9e413431d20c53a0339c388bcf72f353c4b/scipy-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:40052543f7bbe921df4408f46003d6f01c6af109b9e2c8a66dd1cf6cf57f7d5d", size = 34798766, upload-time = "2026-01-10T21:25:59.41Z" }, - { url = "https://files.pythonhosted.org/packages/81/61/0470810c8a093cdacd4ba7504b8a218fd49ca070d79eca23a615f5d9a0b0/scipy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0cf46c8013fec9d3694dc572f0b54100c28405d55d3e2cb15e2895b25057996e", size = 37405953, upload-time = "2026-01-10T21:26:07.75Z" }, - { url = "https://files.pythonhosted.org/packages/92/ce/672ed546f96d5d41ae78c4b9b02006cedd0b3d6f2bf5bb76ea455c320c28/scipy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:0937a0b0d8d593a198cededd4c439a0ea216a3f36653901ea1f3e4be949056f8", size = 36328121, upload-time = "2026-01-10T21:26:16.509Z" }, - { url = "https://files.pythonhosted.org/packages/9d/21/38165845392cae67b61843a52c6455d47d0cc2a40dd495c89f4362944654/scipy-1.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:f603d8a5518c7426414d1d8f82e253e454471de682ce5e39c29adb0df1efb86b", size = 24314368, upload-time = "2026-01-10T21:26:23.087Z" }, - { url = "https://files.pythonhosted.org/packages/0c/51/3468fdfd49387ddefee1636f5cf6d03ce603b75205bf439bbf0e62069bfd/scipy-1.17.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:65ec32f3d32dfc48c72df4291345dae4f048749bc8d5203ee0a3f347f96c5ce6", size = 31344101, upload-time = "2026-01-10T21:26:30.25Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/9406aec58268d437636069419e6977af953d1e246df941d42d3720b7277b/scipy-1.17.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1f9586a58039d7229ce77b52f8472c972448cded5736eaf102d5658bbac4c269", size = 27950385, upload-time = "2026-01-10T21:26:36.801Z" }, - { url = "https://files.pythonhosted.org/packages/4f/98/e7342709e17afdfd1b26b56ae499ef4939b45a23a00e471dfb5375eea205/scipy-1.17.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9fad7d3578c877d606b1150135c2639e9de9cecd3705caa37b66862977cc3e72", size = 20122115, upload-time = "2026-01-10T21:26:42.107Z" }, - { url = "https://files.pythonhosted.org/packages/fd/0e/9eeeb5357a64fd157cbe0302c213517c541cc16b8486d82de251f3c68ede/scipy-1.17.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:423ca1f6584fc03936972b5f7c06961670dbba9f234e71676a7c7ccf938a0d61", size = 22442402, upload-time = "2026-01-10T21:26:48.029Z" }, - { url = "https://files.pythonhosted.org/packages/c9/10/be13397a0e434f98e0c79552b2b584ae5bb1c8b2be95db421533bbca5369/scipy-1.17.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe508b5690e9eaaa9467fc047f833af58f1152ae51a0d0aed67aa5801f4dd7d6", size = 32696338, upload-time = "2026-01-10T21:26:55.521Z" }, - { url = "https://files.pythonhosted.org/packages/63/1e/12fbf2a3bb240161651c94bb5cdd0eae5d4e8cc6eaeceb74ab07b12a753d/scipy-1.17.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6680f2dfd4f6182e7d6db161344537da644d1cf85cf293f015c60a17ecf08752", size = 34977201, upload-time = "2026-01-10T21:27:03.501Z" }, - { url = "https://files.pythonhosted.org/packages/19/5b/1a63923e23ccd20bd32156d7dd708af5bbde410daa993aa2500c847ab2d2/scipy-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eec3842ec9ac9de5917899b277428886042a93db0b227ebbe3a333b64ec7643d", size = 34777384, upload-time = "2026-01-10T21:27:11.423Z" }, - { url = "https://files.pythonhosted.org/packages/39/22/b5da95d74edcf81e540e467202a988c50fef41bd2011f46e05f72ba07df6/scipy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d7425fcafbc09a03731e1bc05581f5fad988e48c6a861f441b7ab729a49a55ea", size = 37379586, upload-time = "2026-01-10T21:27:20.171Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b6/8ac583d6da79e7b9e520579f03007cb006f063642afd6b2eeb16b890bf93/scipy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:87b411e42b425b84777718cc41516b8a7e0795abfa8e8e1d573bf0ef014f0812", size = 36287211, upload-time = "2026-01-10T21:28:43.122Z" }, - { url = "https://files.pythonhosted.org/packages/55/fb/7db19e0b3e52f882b420417644ec81dd57eeef1bd1705b6f689d8ff93541/scipy-1.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:357ca001c6e37601066092e7c89cca2f1ce74e2a520ca78d063a6d2201101df2", size = 24312646, upload-time = "2026-01-10T21:28:49.893Z" }, - { url = "https://files.pythonhosted.org/packages/20/b6/7feaa252c21cc7aff335c6c55e1b90ab3e3306da3f048109b8b639b94648/scipy-1.17.0-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:ec0827aa4d36cb79ff1b81de898e948a51ac0b9b1c43e4a372c0508c38c0f9a3", size = 31693194, upload-time = "2026-01-10T21:27:27.454Z" }, - { url = "https://files.pythonhosted.org/packages/76/bb/bbb392005abce039fb7e672cb78ac7d158700e826b0515cab6b5b60c26fb/scipy-1.17.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:819fc26862b4b3c73a60d486dbb919202f3d6d98c87cf20c223511429f2d1a97", size = 28365415, upload-time = "2026-01-10T21:27:34.26Z" }, - { url = "https://files.pythonhosted.org/packages/37/da/9d33196ecc99fba16a409c691ed464a3a283ac454a34a13a3a57c0d66f3a/scipy-1.17.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:363ad4ae2853d88ebcde3ae6ec46ccca903ea9835ee8ba543f12f575e7b07e4e", size = 20537232, upload-time = "2026-01-10T21:27:40.306Z" }, - { url = "https://files.pythonhosted.org/packages/56/9d/f4b184f6ddb28e9a5caea36a6f98e8ecd2a524f9127354087ce780885d83/scipy-1.17.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:979c3a0ff8e5ba254d45d59ebd38cde48fce4f10b5125c680c7a4bfe177aab07", size = 22791051, upload-time = "2026-01-10T21:27:46.539Z" }, - { url = "https://files.pythonhosted.org/packages/9b/9d/025cccdd738a72140efc582b1641d0dd4caf2e86c3fb127568dc80444e6e/scipy-1.17.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:130d12926ae34399d157de777472bf82e9061c60cc081372b3118edacafe1d00", size = 32815098, upload-time = "2026-01-10T21:27:54.389Z" }, - { url = "https://files.pythonhosted.org/packages/48/5f/09b879619f8bca15ce392bfc1894bd9c54377e01d1b3f2f3b595a1b4d945/scipy-1.17.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e886000eb4919eae3a44f035e63f0fd8b651234117e8f6f29bad1cd26e7bc45", size = 35031342, upload-time = "2026-01-10T21:28:03.012Z" }, - { url = "https://files.pythonhosted.org/packages/f2/9a/f0f0a9f0aa079d2f106555b984ff0fbb11a837df280f04f71f056ea9c6e4/scipy-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13c4096ac6bc31d706018f06a49abe0485f96499deb82066b94d19b02f664209", size = 34893199, upload-time = "2026-01-10T21:28:10.832Z" }, - { url = "https://files.pythonhosted.org/packages/90/b8/4f0f5cf0c5ea4d7548424e6533e6b17d164f34a6e2fb2e43ffebb6697b06/scipy-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cacbaddd91fcffde703934897c5cd2c7cb0371fac195d383f4e1f1c5d3f3bd04", size = 37438061, upload-time = "2026-01-10T21:28:19.684Z" }, - { url = "https://files.pythonhosted.org/packages/f9/cc/2bd59140ed3b2fa2882fb15da0a9cb1b5a6443d67cfd0d98d4cec83a57ec/scipy-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:edce1a1cf66298cccdc48a1bdf8fb10a3bf58e8b58d6c3883dd1530e103f87c0", size = 36328593, upload-time = "2026-01-10T21:28:28.007Z" }, - { url = "https://files.pythonhosted.org/packages/13/1b/c87cc44a0d2c7aaf0f003aef2904c3d097b422a96c7e7c07f5efd9073c1b/scipy-1.17.0-cp313-cp313t-win_arm64.whl", hash = "sha256:30509da9dbec1c2ed8f168b8d8aa853bc6723fede1dbc23c7d43a56f5ab72a67", size = 24625083, upload-time = "2026-01-10T21:28:35.188Z" }, - { url = "https://files.pythonhosted.org/packages/1a/2d/51006cd369b8e7879e1c630999a19d1fbf6f8b5ed3e33374f29dc87e53b3/scipy-1.17.0-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:c17514d11b78be8f7e6331b983a65a7f5ca1fd037b95e27b280921fe5606286a", size = 31346803, upload-time = "2026-01-10T21:28:57.24Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2e/2349458c3ce445f53a6c93d4386b1c4c5c0c540917304c01222ff95ff317/scipy-1.17.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:4e00562e519c09da34c31685f6acc3aa384d4d50604db0f245c14e1b4488bfa2", size = 27967182, upload-time = "2026-01-10T21:29:04.107Z" }, - { url = "https://files.pythonhosted.org/packages/5e/7c/df525fbfa77b878d1cfe625249529514dc02f4fd5f45f0f6295676a76528/scipy-1.17.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7df7941d71314e60a481e02d5ebcb3f0185b8d799c70d03d8258f6c80f3d467", size = 20139125, upload-time = "2026-01-10T21:29:10.179Z" }, - { url = "https://files.pythonhosted.org/packages/33/11/fcf9d43a7ed1234d31765ec643b0515a85a30b58eddccc5d5a4d12b5f194/scipy-1.17.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:aabf057c632798832f071a8dde013c2e26284043934f53b00489f1773b33527e", size = 22443554, upload-time = "2026-01-10T21:29:15.888Z" }, - { url = "https://files.pythonhosted.org/packages/80/5c/ea5d239cda2dd3d31399424967a24d556cf409fbea7b5b21412b0fd0a44f/scipy-1.17.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a38c3337e00be6fd8a95b4ed66b5d988bac4ec888fd922c2ea9fe5fb1603dd67", size = 32757834, upload-time = "2026-01-10T21:29:23.406Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7e/8c917cc573310e5dc91cbeead76f1b600d3fb17cf0969db02c9cf92e3cfa/scipy-1.17.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00fb5f8ec8398ad90215008d8b6009c9db9fa924fd4c7d6be307c6f945f9cd73", size = 34995775, upload-time = "2026-01-10T21:29:31.915Z" }, - { url = "https://files.pythonhosted.org/packages/c5/43/176c0c3c07b3f7df324e7cdd933d3e2c4898ca202b090bd5ba122f9fe270/scipy-1.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f2a4942b0f5f7c23c7cd641a0ca1955e2ae83dedcff537e3a0259096635e186b", size = 34841240, upload-time = "2026-01-10T21:29:39.995Z" }, - { url = "https://files.pythonhosted.org/packages/44/8c/d1f5f4b491160592e7f084d997de53a8e896a3ac01cd07e59f43ca222744/scipy-1.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf133ced83889583156566d2bdf7a07ff89228fe0c0cb727f777de92092ec6b", size = 37394463, upload-time = "2026-01-10T21:29:48.723Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ec/42a6657f8d2d087e750e9a5dde0b481fd135657f09eaf1cf5688bb23c338/scipy-1.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3625c631a7acd7cfd929e4e31d2582cf00f42fcf06011f59281271746d77e061", size = 37053015, upload-time = "2026-01-10T21:30:51.418Z" }, - { url = "https://files.pythonhosted.org/packages/27/58/6b89a6afd132787d89a362d443a7bddd511b8f41336a1ae47f9e4f000dc4/scipy-1.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:9244608d27eafe02b20558523ba57f15c689357c85bdcfe920b1828750aa26eb", size = 24951312, upload-time = "2026-01-10T21:30:56.771Z" }, - { url = "https://files.pythonhosted.org/packages/e9/01/f58916b9d9ae0112b86d7c3b10b9e685625ce6e8248df139d0fcb17f7397/scipy-1.17.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:2b531f57e09c946f56ad0b4a3b2abee778789097871fc541e267d2eca081cff1", size = 31706502, upload-time = "2026-01-10T21:29:56.326Z" }, - { url = "https://files.pythonhosted.org/packages/59/8e/2912a87f94a7d1f8b38aabc0faf74b82d3b6c9e22be991c49979f0eceed8/scipy-1.17.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:13e861634a2c480bd237deb69333ac79ea1941b94568d4b0efa5db5e263d4fd1", size = 28380854, upload-time = "2026-01-10T21:30:01.554Z" }, - { url = "https://files.pythonhosted.org/packages/bd/1c/874137a52dddab7d5d595c1887089a2125d27d0601fce8c0026a24a92a0b/scipy-1.17.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:eb2651271135154aa24f6481cbae5cc8af1f0dd46e6533fb7b56aa9727b6a232", size = 20552752, upload-time = "2026-01-10T21:30:05.93Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f0/7518d171cb735f6400f4576cf70f756d5b419a07fe1867da34e2c2c9c11b/scipy-1.17.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:c5e8647f60679790c2f5c76be17e2e9247dc6b98ad0d3b065861e082c56e078d", size = 22803972, upload-time = "2026-01-10T21:30:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/7c/74/3498563a2c619e8a3ebb4d75457486c249b19b5b04a30600dfd9af06bea5/scipy-1.17.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fb10d17e649e1446410895639f3385fd2bf4c3c7dfc9bea937bddcbc3d7b9ba", size = 32829770, upload-time = "2026-01-10T21:30:16.359Z" }, - { url = "https://files.pythonhosted.org/packages/48/d1/7b50cedd8c6c9d6f706b4b36fa8544d829c712a75e370f763b318e9638c1/scipy-1.17.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8547e7c57f932e7354a2319fab613981cde910631979f74c9b542bb167a8b9db", size = 35051093, upload-time = "2026-01-10T21:30:22.987Z" }, - { url = "https://files.pythonhosted.org/packages/e2/82/a2d684dfddb87ba1b3ea325df7c3293496ee9accb3a19abe9429bce94755/scipy-1.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33af70d040e8af9d5e7a38b5ed3b772adddd281e3062ff23fec49e49681c38cf", size = 34909905, upload-time = "2026-01-10T21:30:28.704Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5e/e565bd73991d42023eb82bb99e51c5b3d9e2c588ca9d4b3e2cc1d3ca62a6/scipy-1.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb55bb97d00f8b7ab95cb64f873eb0bf54d9446264d9f3609130381233483f", size = 37457743, upload-time = "2026-01-10T21:30:34.819Z" }, - { url = "https://files.pythonhosted.org/packages/58/a8/a66a75c3d8f1fb2b83f66007d6455a06a6f6cf5618c3dc35bc9b69dd096e/scipy-1.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1ff269abf702f6c7e67a4b7aad981d42871a11b9dd83c58d2d2ea624efbd1088", size = 37098574, upload-time = "2026-01-10T21:30:40.782Z" }, - { url = "https://files.pythonhosted.org/packages/56/a5/df8f46ef7da168f1bc52cd86e09a9de5c6f19cc1da04454d51b7d4f43408/scipy-1.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:031121914e295d9791319a1875444d55079885bbae5bdc9c5e0f2ee5f09d34ff", size = 25246266, upload-time = "2026-01-10T21:30:45.923Z" }, -] - -[[package]] -name = "screeninfo" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cython", marker = "sys_platform == 'darwin'" }, - { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ec/bb/e69e5e628d43f118e0af4fc063c20058faa8635c95a1296764acc8167e27/screeninfo-0.8.1.tar.gz", hash = "sha256:9983076bcc7e34402a1a9e4d7dabf3729411fd2abb3f3b4be7eba73519cd2ed1", size = 10666, upload-time = "2022-09-09T11:35:23.419Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/bf/c5205d480307bef660e56544b9e3d7ff687da776abb30c9cb3f330887570/screeninfo-0.8.1-py3-none-any.whl", hash = "sha256:e97d6b173856edcfa3bd282f81deb528188aff14b11ec3e195584e7641be733c", size = 12907, upload-time = "2022-09-09T11:35:21.351Z" }, -] - -[[package]] -name = "send2trash" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c5/f0/184b4b5f8d00f2a92cf96eec8967a3d550b52cf94362dad1100df9e48d57/send2trash-2.1.0.tar.gz", hash = "sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459", size = 17255, upload-time = "2026-01-14T06:27:36.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/78/504fdd027da3b84ff1aecd9f6957e65f35134534ccc6da8628eb71e76d3f/send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c", size = 17610, upload-time = "2026-01-14T06:27:35.218Z" }, -] - -[[package]] -name = "setuptools" -version = "80.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, -] - -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - -[[package]] -name = "skia-pathops" -version = "0.9.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/e5/2df8c918ffcb4ad847d2571f32a92447ffebe2e9c94d4ea05d9a86f20beb/skia_pathops-0.9.1.tar.gz", hash = "sha256:f1273ef4da23570f33e76e7753908484e5a4a2468f7b1089f9110ccee6293f99", size = 65116011, upload-time = "2025-12-08T11:44:49.152Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/39/4edc7484e2dff1ac43ea62d1df8c8b00e9ed820ca4e5efcd8371e87f4fe7/skia_pathops-0.9.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:54ac44ade7b37d3d67b04c3eea244b5f9b4e7555ccad4b6997a56602cb6f0f48", size = 2897494, upload-time = "2025-12-08T11:44:30.086Z" }, - { url = "https://files.pythonhosted.org/packages/84/7c/0238144453b9b99369e04ecc6393e4236ea6eac2105145cb3dcd02d80645/skia_pathops-0.9.1-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3a5eee648d4acff631b8eaca13984a288886bbf754999b940da85ea3bcb4b9a9", size = 3300295, upload-time = "2025-12-08T11:44:31.844Z" }, - { url = "https://files.pythonhosted.org/packages/87/7f/268de1790fdefb3700d75ec6bb7a73e4cd67af67ce1ab290db04bd06a98b/skia_pathops-0.9.1-cp310-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dab0e9b16c98bf92be384fba2a0578a54a969fd386d995d739c8a541585dc34a", size = 2999241, upload-time = "2025-12-08T11:44:34.149Z" }, - { url = "https://files.pythonhosted.org/packages/9d/53/22ae66ac02476174272539e724565088045462b3591d01d8da66ce009c38/skia_pathops-0.9.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b08bb15347dfdcc91bd62aa5d63c036cdefa2152761b5c0e8288db199b7c4f72", size = 3955299, upload-time = "2025-12-08T11:44:35.501Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b9/799308cb5f139d4150a200122cb44475f97e81b43a3281372d59216dd9bb/skia_pathops-0.9.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:19202fd9799d5d5428a7d6f3eae0366304cd2cf4e5680541fabb016c54b44109", size = 4316602, upload-time = "2025-12-08T11:44:37.275Z" }, - { url = "https://files.pythonhosted.org/packages/85/3b/45ff339b49dd8eed91ade81842464c6ce955f84dd6805cbe1bafc2b1cd4b/skia_pathops-0.9.1-cp310-abi3-win32.whl", hash = "sha256:56c4b3bc9c281b12693f0f2771367c53a7a20104beac203fb5cb90cb2b9a4649", size = 1450920, upload-time = "2025-12-08T11:44:38.624Z" }, - { url = "https://files.pythonhosted.org/packages/bf/d1/698e85d5f7e2ce3b731232dc9f26e2cecb8afc66194aa494dc78c04194cc/skia_pathops-0.9.1-cp310-abi3-win_amd64.whl", hash = "sha256:e718f2e1284f05ccccde111b1280a79e33093c4af30c118a0b2f5b0753f0727e", size = 1782888, upload-time = "2025-12-08T11:44:39.983Z" }, - { url = "https://files.pythonhosted.org/packages/76/0f/a82fe62ced3d23b2c891780044e562b6dfbe2e27edc2e03f29f64505038a/skia_pathops-0.9.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1f6282a651d774352bf698b7c7b74742b0c4d582b88146375602ec1765422b25", size = 1570681, upload-time = "2025-12-08T11:44:41.271Z" }, - { url = "https://files.pythonhosted.org/packages/da/34/4a0b44bb05661b634ca0ca7dec3203f9a357417252863dce3d397738c0ca/skia_pathops-0.9.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:82fd39c5a899b5e1fbafba22d4f0833ba85d77cb7f9a91ebf92c6958f7b11054", size = 1308211, upload-time = "2025-12-08T11:44:42.707Z" }, - { url = "https://files.pythonhosted.org/packages/72/6a/dd9d68643f526719fa4ff438b9f227265cd6883255d4eef81a663d6a439e/skia_pathops-0.9.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d966363d8e276f37d56a253af3e54f390f8749d311ee5e37522789b8288bca7e", size = 2339790, upload-time = "2025-12-08T11:44:44.504Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f0/d13c18ac93b4259982c196b3101d5e38a4d20f1c59b3561f338545013fa5/skia_pathops-0.9.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72cdd876ac05626708d2367399a8a668510abcdce235de3859c49759a6b92b2b", size = 2027121, upload-time = "2025-12-08T11:44:45.887Z" }, - { url = "https://files.pythonhosted.org/packages/2d/be/7daf7bf5ec6e4f245804842364222b1e857b42b2ca13192791e2b8cafc14/skia_pathops-0.9.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6c9ccc68d316371be3817eb20eaae4a7810d85f329276a7d7ca5a21f47fa6522", size = 1779252, upload-time = "2025-12-08T11:44:47.15Z" }, -] - -[[package]] -name = "snowballstemmer" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, -] - -[[package]] -name = "soupsieve" -version = "2.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" }, -] - -[[package]] -name = "sphinx" -version = "8.2.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils" }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "roman-numerals-py" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, -] - -[[package]] -name = "sphinx-basic-ng" -version = "1.0.0b2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sphinx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" }, -] - -[[package]] -name = "sphinx-copybutton" -version = "0.5.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sphinx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" }, -] - -[[package]] -name = "sphinx-design" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sphinx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2b/69/b34e0cb5336f09c6866d53b4a19d76c227cdec1bbc7ac4de63ca7d58c9c7/sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632", size = 2193689, upload-time = "2024-08-02T13:48:44.277Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/43/65c0acbd8cc6f50195a3a1fc195c404988b15c67090e73c7a41a9f57d6bd/sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c", size = 2215338, upload-time = "2024-08-02T13:48:42.106Z" }, -] - -[[package]] -name = "sphinx-reredirects" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sphinx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1b/8d/0e39fe2740d7d71417edf9a6424aa80ca2c27c17fc21282cdc39f90d5a40/sphinx_reredirects-1.1.0.tar.gz", hash = "sha256:fb9b195335ab14b43f8273287d0c7eeb637ba6c56c66581c11b47202f6718b29", size = 614624, upload-time = "2025-12-22T08:28:02.792Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/81/b5dd07067f3daac6d23687ec737b2d593740671ebcd145830c8f92d381c5/sphinx_reredirects-1.1.0-py3-none-any.whl", hash = "sha256:4b5692273c72cd2d4d917f4c6f87d5919e4d6114a752d4be033f7f5f6310efd9", size = 6351, upload-time = "2025-12-22T08:27:59.724Z" }, -] - -[[package]] -name = "sphinxcontrib-applehelp" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, -] - -[[package]] -name = "sphinxcontrib-devhelp" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, -] - -[[package]] -name = "sphinxcontrib-htmlhelp" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, -] - -[[package]] -name = "sphinxcontrib-jsmath" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, -] - -[[package]] -name = "sphinxcontrib-programoutput" -version = "0.18" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sphinx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3f/c0/834af2290f8477213ec0dd60e90104f5644aa0c37b1a0d6f0a2b5efe03c4/sphinxcontrib_programoutput-0.18.tar.gz", hash = "sha256:09e68b6411d937a80b6085f4fdeaa42e0dc5555480385938465f410589d2eed8", size = 26333, upload-time = "2024-12-06T20:38:36.959Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/2c/7aec6e0580f666d4f61474a50c4995a98abfff27d827f0e7bc8c4fa528f5/sphinxcontrib_programoutput-0.18-py3-none-any.whl", hash = "sha256:8a651bc85de69a808a064ff0e48d06c12b9347da4fe5fdb1e94914b01e1b0c36", size = 20346, upload-time = "2024-12-06T20:38:22.406Z" }, -] - -[[package]] -name = "sphinxcontrib-qthelp" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, -] - -[[package]] -name = "sphinxcontrib-serializinghtml" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, -] - -[[package]] -name = "sphinxext-opengraph" -version = "0.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sphinx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/c0/eb6838e3bae624ce6c8b90b245d17e84252863150e95efdb88f92c8aa3fb/sphinxext_opengraph-0.13.0.tar.gz", hash = "sha256:103335d08567ad8468faf1425f575e3b698e9621f9323949a6c8b96d9793e80b", size = 1026875, upload-time = "2025-08-29T12:20:31.066Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/a4/66c1fd4f8fab88faf71cee04a945f9806ba0fef753f2cfc8be6353f64508/sphinxext_opengraph-0.13.0-py3-none-any.whl", hash = "sha256:936c07828edc9ad9a7b07908b29596dc84ed0b3ceaa77acdf51282d232d4d80e", size = 1004152, upload-time = "2025-08-29T12:20:29.072Z" }, -] - -[[package]] -name = "srt" -version = "3.5.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/b7/4a1bc231e0681ebf339337b0cd05b91dc6a0d701fa852bb812e244b7a030/srt-3.5.3.tar.gz", hash = "sha256:4884315043a4f0740fd1f878ed6caa376ac06d70e135f306a6dc44632eed0cc0", size = 28296, upload-time = "2023-03-28T02:35:44.007Z" } - -[[package]] -name = "stack-data" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "asttokens" }, - { name = "executing" }, - { name = "pure-eval" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, -] - -[[package]] -name = "svgelements" -version = "1.9.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5d/29/1c93c94a2289675ba2ff898612f9c9a03f46d69f253bdf4da0dfc08a599d/svgelements-1.9.6.tar.gz", hash = "sha256:7c02ad6404cd3d1771fd50e40fbfc0550b0893933466f86a6eb815f3ba3f37f7", size = 162145, upload-time = "2023-08-17T02:01:51.822Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/2c/6c9bb53db56c8a12a736d2158a8b842a5993b96daabc29d90a098e840280/svgelements-1.9.6-py2.py3-none-any.whl", hash = "sha256:8a5cf2cc066d98e713d5b875b1d6e5eeb9b92e855e835ebd7caab2713ae1dcad", size = 137856, upload-time = "2023-08-17T02:01:48.76Z" }, -] - -[[package]] -name = "terminado" -version = "0.18.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ptyprocess", marker = "os_name != 'nt'" }, - { name = "pywinpty", marker = "os_name == 'nt'" }, - { name = "tornado" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701, upload-time = "2024-03-12T14:34:39.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, -] - -[[package]] -name = "tinycss2" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, -] - -[[package]] -name = "tomli" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, -] - -[[package]] -name = "tornado" -version = "6.5.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" }, - { url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" }, - { url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" }, - { url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" }, - { url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" }, - { url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" }, - { url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" }, - { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" }, -] - -[[package]] -name = "tqdm" -version = "4.67.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, -] - -[[package]] -name = "traitlets" -version = "5.14.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, -] - -[[package]] -name = "types-decorator" -version = "5.2.0.20251101" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/e4/929a77f6580928a5b4914a62834a0570d2449428ecdbb0a2e916150ed978/types_decorator-5.2.0.20251101.tar.gz", hash = "sha256:120e2bf4792ec8a47653db1cb380c7aacb6862a797c1490a910aacc21548286c", size = 9059, upload-time = "2025-11-01T03:04:02.355Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/cc/aa53df63915e10d429b7aa0491ba520abe4b80aef0304d1b02425cd5bd08/types_decorator-5.2.0.20251101-py3-none-any.whl", hash = "sha256:8176470ec0a2190e9d688577d4987b24039ae4a23913211707eda96bf2755b0c", size = 8074, upload-time = "2025-11-01T03:04:01.353Z" }, -] - -[[package]] -name = "types-docutils" -version = "0.22.3.20251115" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/d7/576ec24bf61a280f571e1f22284793adc321610b9bcfba1bf468cf7b334f/types_docutils-0.22.3.20251115.tar.gz", hash = "sha256:0f79ea6a7bd4d12d56c9f824a0090ffae0ea4204203eb0006392906850913e16", size = 56828, upload-time = "2025-11-15T02:59:57.371Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/01/61ac9eb38f1f978b47443dc6fd2e0a3b0f647c2da741ddad30771f1b2b6f/types_docutils-0.22.3.20251115-py3-none-any.whl", hash = "sha256:c6e53715b65395d00a75a3a8a74e352c669bc63959e65a207dffaa22f4a2ad6e", size = 91951, upload-time = "2025-11-15T02:59:56.413Z" }, -] - -[[package]] -name = "types-pillow" -version = "10.2.0.20240822" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/4a/4495264dddaa600d65d68bcedb64dcccf9d9da61adff51f7d2ffd8e4c9ce/types-Pillow-10.2.0.20240822.tar.gz", hash = "sha256:559fb52a2ef991c326e4a0d20accb3bb63a7ba8d40eb493e0ecb0310ba52f0d3", size = 35389, upload-time = "2024-08-22T02:32:48.15Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/23/e81a5354859831fcf54d488d33b80ba6133ea84f874a9c0ec40a4881e133/types_Pillow-10.2.0.20240822-py3-none-any.whl", hash = "sha256:d9dab025aba07aeb12fd50a6799d4eac52a9603488eca09d7662543983f16c5d", size = 54354, upload-time = "2024-08-22T02:32:46.664Z" }, -] - -[[package]] -name = "types-pygments" -version = "2.19.0.20251121" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "types-docutils" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/90/3b/cd650700ce9e26b56bd1a6aa4af397bbbc1784e22a03971cb633cdb0b601/types_pygments-2.19.0.20251121.tar.gz", hash = "sha256:eef114fde2ef6265365522045eac0f8354978a566852f69e75c531f0553822b1", size = 18590, upload-time = "2025-11-21T03:03:46.623Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/8a/9244b21f1d60dcc62e261435d76b02f1853b4771663d7ec7d287e47a9ba9/types_pygments-2.19.0.20251121-py3-none-any.whl", hash = "sha256:cb3bfde34eb75b984c98fb733ce4f795213bd3378f855c32e75b49318371bb25", size = 25674, upload-time = "2025-11-21T03:03:45.72Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "tzdata" -version = "2025.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, -] - -[[package]] -name = "uri-template" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, -] - -[[package]] -name = "urllib3" -version = "2.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, -] - -[[package]] -name = "virtualenv" -version = "20.36.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "distlib" }, - { name = "filelock" }, - { name = "platformdirs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, -] - -[[package]] -name = "watchdog" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, - { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, - { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, - { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, - { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, - { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, - { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, - { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, -] - -[[package]] -name = "wcwidth" -version = "0.2.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, -] - -[[package]] -name = "webcolors" -version = "25.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, -] - -[[package]] -name = "webencodings" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, -] - -[[package]] -name = "websocket-client" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, -] From 1f3d5114f3c9df3e51ea95efd1584512d49e3088 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Fri, 10 Apr 2026 00:03:58 +0530 Subject: [PATCH 18/33] Added TrueDot support to WebGPU and Cairo Renderers --- manim/mobject/three_d/dot_cloud.py | 188 ++++++++++++++++++ manim/renderer/webgpu/shaders/true_dot.wgsl | 103 ++++++++++ manim/renderer/webgpu/webgpu_renderer.py | 81 ++++++++ .../webgpu/webgpu_vmobject_rendering.py | 111 ++++++++++- 4 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 manim/mobject/three_d/dot_cloud.py create mode 100644 manim/renderer/webgpu/shaders/true_dot.wgsl diff --git a/manim/mobject/three_d/dot_cloud.py b/manim/mobject/three_d/dot_cloud.py new file mode 100644 index 0000000000..5286d93796 --- /dev/null +++ b/manim/mobject/three_d/dot_cloud.py @@ -0,0 +1,188 @@ +"""Point-cloud mobject for WebGPU TrueDot rendering. + +``PointDot`` is a single dot rendered as a 3-D lit sphere. +``DotCloud3D`` is an N-point cloud rendered as N lit spheres. + +These classes are ``Mobject``-based (not ``OpenGLMobject``-based) so they +work transparently with both Cairo scenes (skipped silently) and WebGPU +scenes (routed to the TrueDot pipeline). +""" + +from __future__ import annotations + +__all__ = ["DotCloud3D", "PointDot"] + +from typing import Any + +import numpy as np + +from manim.constants import ORIGIN +from manim.mobject.mobject import Mobject +from manim.typing import Point3DLike +from manim.utils.color import WHITE, ParsableManimColor, color_to_rgba + + +class DotCloud3D(Mobject): + """A cloud of points, each rendered as a lit sphere by the WebGPU renderer. + + In Cairo / OpenGL renderers, ``DotCloud3D`` objects are silently ignored + (they produce no geometry for those pipelines). + + Parameters + ---------- + points + Array of world-space positions, shape (N, 3). + color + Base colour of all dots (can be overridden per-point via ``set_rgbas``). + radius + World-space radius of each sphere in scene units. + gloss + Specular shininess (Cairo-style): 0 = matte, 1 = very shiny. + shadow + Diffuse darkening strength: 0 = no shadow, 1 = full Lambert shading. + """ + + def __init__( + self, + points: np.ndarray | list | None = None, + color: ParsableManimColor = WHITE, + radius: float = 0.05, + gloss: float = 0.3, + shadow: float = 0.3, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + pts = np.zeros((0, 3), dtype=np.float32) if points is None else np.asarray(points, dtype=np.float32) + if pts.ndim == 1: + pts = pts.reshape(1, 3) + self._cloud_points: np.ndarray = pts.astype(np.float32) + self._rgbas: np.ndarray = np.tile( + np.asarray(color_to_rgba(color), dtype=np.float32), (max(len(pts), 1), 1) + ) + self.dot_radius: float = float(radius) + self.gloss: float = float(gloss) + self.shadow: float = float(shadow) + # Set Mobject.points to the cloud positions so bounding-box helpers work. + if len(pts) > 0: + self.set_points(pts) + + # ------------------------------------------------------------------ + # Cloud-specific API + # ------------------------------------------------------------------ + + def get_cloud_points(self) -> np.ndarray: + """Return the (N, 3) float32 array of dot centres.""" + return self._cloud_points + + def set_cloud_points(self, points: np.ndarray) -> "DotCloud3D": + pts = np.asarray(points, dtype=np.float32) + if pts.ndim == 1: + pts = pts.reshape(1, 3) + self._cloud_points = pts + if len(pts) > 0: + self.set_points(pts) + return self + + def get_rgbas(self) -> np.ndarray: + """Return the (N, 4) float32 RGBA array for all dots.""" + return self._rgbas + + def set_rgbas(self, rgbas: np.ndarray) -> "DotCloud3D": + self._rgbas = np.asarray(rgbas, dtype=np.float32) + return self + + def set_color(self, color: ParsableManimColor, family: bool = True) -> "DotCloud3D": # type: ignore[override] + rgba = np.asarray(color_to_rgba(color), dtype=np.float32) + self._rgbas = np.tile(rgba, (max(len(self._cloud_points), 1), 1)) + if family: + for sub in self.submobjects: + if isinstance(sub, DotCloud3D): + sub.set_color(color, family=False) + return self + + def set_opacity(self, opacity: float, family: bool = True) -> "DotCloud3D": # type: ignore[override] + self._rgbas[:, 3] = float(opacity) + if family: + for sub in self.submobjects: + if isinstance(sub, DotCloud3D): + sub.set_opacity(opacity, family=False) + return self + + # ------------------------------------------------------------------ + # Animation support — required Mobject overrides + # ------------------------------------------------------------------ + + def align_points_with_larger(self, larger_mobject: Mobject) -> None: + """Tile _cloud_points and _rgbas to match the size of *larger_mobject*.""" + if not isinstance(larger_mobject, DotCloud3D): + return + n_target = len(larger_mobject._cloud_points) + n_self = len(self._cloud_points) + if n_self == 0 or n_self >= n_target: + return + reps = -(-n_target // n_self) # ceiling division + self._cloud_points = np.tile(self._cloud_points, (reps, 1))[:n_target] + self._rgbas = np.tile(self._rgbas, (reps, 1))[:n_target] + self.set_points(self._cloud_points) + + def interpolate_color( + self, mobject1: Mobject, mobject2: Mobject, alpha: float + ) -> None: + """Linearly interpolate _rgbas between *mobject1* and *mobject2*.""" + if not isinstance(mobject1, DotCloud3D) or not isinstance(mobject2, DotCloud3D): + return + self._rgbas = ( + (1 - alpha) * mobject1._rgbas + alpha * mobject2._rgbas + ).astype(np.float32) + + def interpolate( + self, + mobject1: Mobject, + mobject2: Mobject, + alpha: float, + path_func: Any = None, + ) -> "DotCloud3D": + """Interpolate position and colour; keep _cloud_points in sync with points.""" + from manim.utils.bezier import interpolate as lerp + if path_func is None: + path_func = lerp + super().interpolate(mobject1, mobject2, alpha, path_func) + # Mobject.interpolate writes into self.points; mirror that into _cloud_points. + self._cloud_points = np.asarray(self.points, dtype=np.float32) + return self + + +class PointDot(DotCloud3D): + """A single dot at *center* rendered as a lit sphere by the WebGPU renderer. + + Parameters + ---------- + center + World-space position of the dot. + color + Base colour. + radius + World-space radius of the sphere in scene units. + gloss + Specular shininess: 0 = matte, 1 = very shiny. + shadow + Diffuse darkening: 0 = flat, 1 = full Lambert shading. + """ + + def __init__( + self, + center: Point3DLike = ORIGIN, + color: ParsableManimColor = WHITE, + radius: float = 0.05, + gloss: float = 0.3, + shadow: float = 0.3, + **kwargs: Any, + ) -> None: + super().__init__( + points=np.asarray(center, dtype=np.float32).reshape(1, 3), + color=color, + radius=radius, + gloss=gloss, + shadow=shadow, + **kwargs, + ) diff --git a/manim/renderer/webgpu/shaders/true_dot.wgsl b/manim/renderer/webgpu/shaders/true_dot.wgsl new file mode 100644 index 0000000000..41c17597c4 --- /dev/null +++ b/manim/renderer/webgpu/shaders/true_dot.wgsl @@ -0,0 +1,103 @@ +// WebGPU TrueDot shader — screen-aligned sphere dot rendering. +// +// Each dot is expanded CPU-side into 2 triangles (6 vertices) forming a +// screen-aligned quad. UV coords span (-1,-1) → (1,1) across the quad. +// The fragment shader treats the quad as a sphere projected onto the screen: +// • Pixels outside the unit disc are discarded (anti-aliased edge). +// • The sphere normal is reconstructed from the UV position. +// • Cairo-style lighting (gloss/shadow) is applied to the colour. +// +// The same camera Uniforms struct and bind group layout as the surface +// shaders are reused (binding 0, group 0). +// +// Vertex layout (stride 48 bytes) — must match _TRUE_DOT_DTYPE: +// location 0 — center float32x3 offset 0 (12 B) +// location 1 — color float32x4 offset 12 (16 B) +// location 2 — uv float32x2 offset 28 ( 8 B) +// location 3 — radius float32 offset 36 ( 4 B) +// location 4 — gloss float32 offset 40 ( 4 B) +// location 5 — shadow float32 offset 44 ( 4 B) + +struct Uniforms { + projection : mat4x4, + view : mat4x4, + light_pos : vec3, + light_intensity : f32, + light_color : vec3, + ambient_intensity : f32, + ambient_color : vec3, + _pad : f32, +}; +@group(0) @binding(0) var u : Uniforms; + +struct VertexInput { + @location(0) center : vec3, + @location(1) color : vec4, + @location(2) uv : vec2, + @location(3) radius : f32, + @location(4) gloss : f32, + @location(5) shadow : f32, +}; + +struct VertexOutput { + @builtin(position) clip_position : vec4, + @location(0) v_color : vec4, + @location(1) v_uv : vec2, + @location(2) @interpolate(flat) v_gloss : f32, + @location(3) @interpolate(flat) v_shadow : f32, + @location(4) v_center_view : vec3, +}; + +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + // Project dot center into view space. + let cv = u.view * vec4(in.center, 1.0); + + // Expand quad in view space: move corner by radius × UV along x/y. + // This replicates the OpenGL geometry shader expansion and naturally + // applies the correct perspective foreshortening (larger expansion near + // the camera, smaller far away). + let expanded = cv + vec4(in.uv.x * in.radius, in.uv.y * in.radius, 0.0, 0.0); + + var out: VertexOutput; + out.clip_position = u.projection * expanded; + out.v_color = in.color; + out.v_uv = in.uv; + out.v_gloss = in.gloss; + out.v_shadow = in.shadow; + out.v_center_view = cv.xyz; + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let d = length(in.v_uv); + + // Anti-aliased disc: smoothstep over one pixel width around d == 1. + let fw = fwidth(d); + let alpha_mult = 1.0 - smoothstep(1.0 - fw, 1.0 + fw, d); + if alpha_mult <= 0.001 { discard; } + + // Reconstruct sphere surface normal in view space from UV position. + let z2 = max(0.0, 1.0 - d * d); + let sphere_normal = normalize(vec3(in.v_uv.x, in.v_uv.y, sqrt(z2))); + + // Light and camera directions in view space. + // Camera sits at the origin in view space, so to_camera = -in.v_center_view. + let light_view = (u.view * vec4(u.light_pos, 1.0)).xyz; + let to_light = normalize(light_view - in.v_center_view); + let to_camera = normalize(-in.v_center_view); + + // Cairo-style lighting (finalize_color.glsl → add_light): + // shine = gloss * exp(-3 * (1 - dot(reflect(-L, N), V))^2) + // darkening = mix(1, max(dot(L, N), 0), shadow) + // out_rgb = darkening * mix(color, WHITE, shine) + let light_reflection = reflect(-to_light, sphere_normal); + let dot_rv = clamp(dot(light_reflection, to_camera), 0.0, 1.0); + let shine = in.v_gloss * exp(-3.0 * pow(1.0 - dot_rv, 2.0)); + let dp2 = dot(to_light, sphere_normal); + let darkening = mix(1.0, max(dp2, 0.0), in.v_shadow); + + let lit_rgb = darkening * mix(in.v_color.rgb, vec3(1.0), shine); + return vec4(lit_rgb, in.v_color.a * alpha_mult); +} diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index 791eab097c..94f870cebe 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -49,7 +49,10 @@ from .webgpu_vmobject_rendering import ( FILL_STROKE_VERTEX_LAYOUT, SURFACE_COMBINED_VERTEX_LAYOUT, + TRUE_DOT_VERTEX_LAYOUT, + DotCloud3D, _FrameData, + build_true_dot_vbo, collect_frame_data, draw_frame_data, ) @@ -543,6 +546,10 @@ def __init__( self._oit_compose_bgl: wgpu_t.GPUBindGroupLayout | None = None self._oit_compose_bind_group: wgpu_t.GPUBindGroup | None = None + # TrueDot pipeline (true_dot.wgsl) — renders DotCloud3D/PointDot as + # screen-aligned lit sphere quads (CPU-expanded, 6 verts per dot). + self._true_dot_pipeline: wgpu_t.GPURenderPipeline | None = None + # Image pipeline (image.wgsl) — renders ImageMobject pixel arrays as # textured quads before the VMobject pass (painter's algorithm). self._image_pipeline: wgpu_t.GPURenderPipeline | None = None @@ -677,6 +684,7 @@ def init_scene(self, scene: Scene) -> None: self._create_oit_resources(width, height) self._create_readback_pipeline(width, height) self._image_tex_bgl, self._image_pipeline = self._create_image_pipeline() + self._true_dot_pipeline = self._create_true_dot_pipeline(self._proj_bgl) # Persistent camera uniform buffers — created once, updated each frame via # write_buffer. Using COPY_DST so queue.write_buffer can write into them. @@ -924,6 +932,58 @@ def _create_surface_pipeline( }, ) + def _create_true_dot_pipeline( + self, + proj_bgl: wgpu_t.GPUBindGroupLayout, + ) -> wgpu_t.GPURenderPipeline: + """Create the TrueDot pipeline (true_dot.wgsl). + + Reuses the camera bind group layout (``proj_bgl``) at group 0. + Depth write is enabled so dots occlude each other and other geometry. + Alpha blending is on so the anti-aliased disc edge fades smoothly. + """ + assert self._device is not None + shader_path = Path(__file__).parent / "shaders" / "true_dot.wgsl" + shader = self._device.create_shader_module( + code=shader_path.read_text(encoding="utf-8") + ) + _blend = { + "color": { + "src_factor": "src-alpha", + "dst_factor": "one-minus-src-alpha", + "operation": "add", + }, + "alpha": { + "src_factor": "one", + "dst_factor": "one", + "operation": "add", + }, + } + return self._device.create_render_pipeline( + layout=self._device.create_pipeline_layout(bind_group_layouts=[proj_bgl]), + vertex={ + "module": shader, + "entry_point": "vs_main", + "buffers": [TRUE_DOT_VERTEX_LAYOUT], + }, + fragment={ + "module": shader, + "entry_point": "fs_main", + "targets": [{"format": wgpu.TextureFormat.bgra8unorm, "blend": _blend}], + }, + primitive={"topology": "triangle-list", "cull_mode": "none"}, + depth_stencil={ + "format": wgpu.TextureFormat.depth24plus, + "depth_write_enabled": True, + "depth_compare": "less", + "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_read_mask": 0, + "stencil_write_mask": 0, + }, + multisample={"count": 1, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, + ) + def _create_image_pipeline( self, ) -> tuple[wgpu_t.GPUBindGroupLayout, wgpu_t.GPURenderPipeline]: @@ -1549,6 +1609,10 @@ def _walk(mob: Any) -> None: if isinstance(mob, AbstractImageMobject): _flush_runs() render_queue.append(("image", mob)) + elif isinstance(mob, DotCloud3D): + # WebGPU dot cloud — rendered as screen-aligned sphere quads. + _flush_runs() + render_queue.append(("truedot", mob)) elif isinstance(mob, VMobject): if mob in fixed_in_frame: pass # handled in the overlay pass below @@ -1567,6 +1631,7 @@ def _walk(mob: Any) -> None: # Pre-fetch image GPU resources (texture upload, VBO) before the # command encoder starts. Replace ('image', mob) queue items with # ('image', vbo, tex_bg) so the render loop has no CPU work left. + # Similarly expand TrueDot mobs into vertex arrays and GPU buffers. resolved_queue: list[tuple] = [] for item in render_queue: if item[0] == "image": @@ -1575,6 +1640,16 @@ def _walk(mob: Any) -> None: resources = self._get_image_gpu_resources(mob) if vbo is not None and resources is not None: resolved_queue.append(("image", vbo, resources[1])) + elif item[0] == "truedot": + mob = item[1] + arr = build_true_dot_vbo(mob) + if arr is not None and len(arr) > 0: + buf = self._device.create_buffer_with_data( + data=arr.tobytes(), + usage=wgpu.BufferUsage.VERTEX, + ) + self.frame_vbos.append(buf) + resolved_queue.append(("truedot", buf, len(arr))) else: resolved_queue.append(item) @@ -1678,6 +1753,12 @@ def _f(m: Any) -> None: main_pass.set_bind_group(1, tex_bg, [], 0, 0) main_pass.set_vertex_buffer(0, vbo) main_pass.draw(6) + elif item[0] == "truedot": + _, buf, n_verts = item + main_pass.set_pipeline(self._true_dot_pipeline) + main_pass.set_bind_group(0, self.camera_bind_group, [], 0, 0) + main_pass.set_vertex_buffer(0, buf) + main_pass.draw(n_verts) elif item[0] == "vmobs": _, fd, cam_bg = item draw_frame_data(self, fd, cam_bg) diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py index b3429500cc..d15984d7a9 100644 --- a/manim/renderer/webgpu/webgpu_vmobject_rendering.py +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -41,10 +41,11 @@ import struct import weakref from dataclasses import dataclass, field -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import numpy as np +from manim.mobject.three_d.dot_cloud import DotCloud3D from manim.mobject.three_d.three_dimensions import Surface from manim.mobject.types.vectorized_mobject import VMobject @@ -147,6 +148,114 @@ } +# --------------------------------------------------------------------------- +# TrueDot vertex layout — must match true_dot.wgsl locations. +# +# location 0 — center float32x3 offset 0 (12 B) +# location 1 — color float32x4 offset 12 (16 B) +# location 2 — uv float32x2 offset 28 ( 8 B) +# location 3 — radius float32 offset 36 ( 4 B) +# location 4 — gloss float32 offset 40 ( 4 B) +# location 5 — shadow float32 offset 44 ( 4 B) +# stride: 48 bytes +# --------------------------------------------------------------------------- + +_TRUE_DOT_DTYPE = np.dtype( + [ + ("center", np.float32, (3,)), + ("color", np.float32, (4,)), + ("uv", np.float32, (2,)), + ("radius", np.float32), + ("gloss", np.float32), + ("shadow", np.float32), + ] +) +_TRUE_DOT_STRIDE: int = _TRUE_DOT_DTYPE.itemsize # 48 bytes + +_TRUE_DOT_OFFSETS: dict[str, int] = { + name: _TRUE_DOT_DTYPE.fields[name][1] # type: ignore[index] + for name in _TRUE_DOT_DTYPE.names +} + +TRUE_DOT_VERTEX_LAYOUT: dict = { + "array_stride": _TRUE_DOT_STRIDE, + "step_mode": "vertex", + "attributes": [ + {"format": "float32x3", "offset": _TRUE_DOT_OFFSETS["center"], "shader_location": 0}, + {"format": "float32x4", "offset": _TRUE_DOT_OFFSETS["color"], "shader_location": 1}, + {"format": "float32x2", "offset": _TRUE_DOT_OFFSETS["uv"], "shader_location": 2}, + {"format": "float32", "offset": _TRUE_DOT_OFFSETS["radius"], "shader_location": 3}, + {"format": "float32", "offset": _TRUE_DOT_OFFSETS["gloss"], "shader_location": 4}, + {"format": "float32", "offset": _TRUE_DOT_OFFSETS["shadow"], "shader_location": 5}, + ], +} + +# Corner UV offsets for the two triangles that form a screen-aligned quad: +# triangle 0: (BL, BR, TL) → corners 0,1,2 +# triangle 1: (BR, TR, TL) → corners 1,3,2 +# x_sign: -1 +1 -1 +1 y_sign: -1 -1 +1 +1 +_QUAD_UVS = np.array( + [ + [-1.0, -1.0], # BL (0) + [ 1.0, -1.0], # BR (1) + [-1.0, 1.0], # TL (2) + [ 1.0, -1.0], # BR (1) ← repeated for 2nd triangle + [ 1.0, 1.0], # TR (3) + [-1.0, 1.0], # TL (2) ← repeated + ], + dtype=np.float32, +) # shape (6, 2) + +def build_true_dot_vbo( + mob: DotCloud3D, +) -> np.ndarray | None: + """Expand a ``DotCloud3D`` into a flat vertex array for TrueDot rendering. + + Each point becomes 6 vertices (2 triangles) forming a screen-aligned quad. + UV coords span (−1,−1) → (1,1); the radius is in world-space scene units. + + Returns ``None`` if the mob has no renderable points. + """ + pts = mob.get_cloud_points() + rgbas = mob.get_rgbas() + radius = mob.dot_radius + gloss = mob.gloss + shadow = mob.shadow + + pts = np.asarray(pts, dtype=np.float32) # (N, 3) + N = len(pts) + if N == 0: + return None + + # Broadcast rgbas to (N, 4). + if rgbas is None or len(rgbas) == 0: + rgba = np.ones((N, 4), dtype=np.float32) + else: + rgbas = np.asarray(rgbas, dtype=np.float32) + if len(rgbas) == 1: + rgba = np.repeat(rgbas[:1], N, axis=0) + elif len(rgbas) < N: + # Resize with interpolation (matches OpenGL behaviour). + indices = np.round(np.linspace(0, len(rgbas) - 1, N)).astype(int) + rgba = rgbas[indices] + else: + rgba = rgbas[:N] + + # Expand N points → N×6 vertices. + pts_rep = np.repeat(pts, 6, axis=0) # (N*6, 3) + rgba_rep = np.repeat(rgba, 6, axis=0) # (N*6, 4) + uvs = np.tile(_QUAD_UVS, (N, 1)) # (N*6, 2) + + arr = np.zeros(N * 6, dtype=_TRUE_DOT_DTYPE) + arr["center"] = pts_rep + arr["color"] = rgba_rep + arr["uv"] = uvs + arr["radius"] = radius + arr["gloss"] = gloss + arr["shadow"] = shadow + return arr + + # --------------------------------------------------------------------------- # Per-frame data container # --------------------------------------------------------------------------- From 9d77452d47bd71a399dabe1787dbcc85e355958c Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Fri, 10 Apr 2026 00:13:50 +0530 Subject: [PATCH 19/33] Added support for per surface diffuse and specular strength --- manim/mobject/three_d/three_dimensions.py | 12 +++++ .../webgpu/shaders/surface_combined.wgsl | 38 +++++++++------ .../renderer/webgpu/shaders/surface_oit.wgsl | 38 +++++++++------ .../webgpu/webgpu_vmobject_rendering.py | 47 +++++++++++-------- 4 files changed, 86 insertions(+), 49 deletions(-) diff --git a/manim/mobject/three_d/three_dimensions.py b/manim/mobject/three_d/three_dimensions.py index ac2fa8b0ee..884595664a 100644 --- a/manim/mobject/three_d/three_dimensions.py +++ b/manim/mobject/three_d/three_dimensions.py @@ -88,6 +88,14 @@ class Surface(VGroup, metaclass=ConvertToOpenGL): should_make_jagged Changes the anchor mode of the Bézier curves from smooth to jagged. Defaults to ``False``. + diffuse_strength + Strength of the diffuse (Lambertian) lighting component, in [0, 1]. + Defaults to 0.8. + **WebGPU renderer only** — ignored by the Cairo and OpenGL renderers. + specular_strength + Strength of the specular (Phong) highlight, in [0, ∞]. + Defaults to 0.9. + **WebGPU renderer only** — ignored by the Cairo and OpenGL renderers. Examples -------- @@ -127,10 +135,14 @@ def __init__( stroke_width: float = 0.5, should_make_jagged: bool = False, pre_function_handle_to_anchor_scale_factor: float = 0.00001, + diffuse_strength: float = 0.8, + specular_strength: float = 0.9, **kwargs: Any, ) -> None: self.u_range = u_range self.v_range = v_range + self.diffuse_strength = diffuse_strength + self.specular_strength = specular_strength super().__init__( fill_color=fill_color, fill_opacity=fill_opacity, diff --git a/manim/renderer/webgpu/shaders/surface_combined.wgsl b/manim/renderer/webgpu/shaders/surface_combined.wgsl index bad4aa51ac..b7a3a8e6bf 100644 --- a/manim/renderer/webgpu/shaders/surface_combined.wgsl +++ b/manim/renderer/webgpu/shaders/surface_combined.wgsl @@ -22,13 +22,15 @@ // // Compositing: wireframe stroke "over" Phong fill (Porter-Duff). // -// Vertex layout (must match _SURFACE_COMBINED_DTYPE, stride 72 bytes): -// location 0 — in_vert float32x3 offset 0 -// location 1 — in_normal float32x3 offset 12 -// location 2 — in_fill_color float32x4 offset 24 -// location 3 — in_stroke_color float32x4 offset 40 -// location 4 — in_bary float32x3 offset 56 -// location 5 — stroke_half_px float32 offset 68 +// Vertex layout (must match _SURFACE_COMBINED_DTYPE, stride 80 bytes): +// location 0 — in_vert float32x3 offset 0 +// location 1 — in_normal float32x3 offset 12 +// location 2 — in_fill_color float32x4 offset 24 +// location 3 — in_stroke_color float32x4 offset 40 +// location 4 — in_bary float32x3 offset 56 +// location 5 — stroke_half_px float32 offset 68 +// location 6 — diffuse_strength float32 offset 72 +// location 7 — specular_strength float32 offset 76 struct Uniforms { projection : mat4x4, @@ -43,12 +45,14 @@ struct Uniforms { @group(0) @binding(0) var u : Uniforms; struct VertexInput { - @location(0) in_vert : vec3, - @location(1) in_normal : vec3, - @location(2) in_fill_color : vec4, - @location(3) in_stroke_color : vec4, - @location(4) in_bary : vec3, - @location(5) stroke_half_px : f32, + @location(0) in_vert : vec3, + @location(1) in_normal : vec3, + @location(2) in_fill_color : vec4, + @location(3) in_stroke_color : vec4, + @location(4) in_bary : vec3, + @location(5) stroke_half_px : f32, + @location(6) diffuse_strength : f32, + @location(7) specular_strength : f32, }; struct VertexOutput { @@ -60,6 +64,8 @@ struct VertexOutput { @location(4) v_view_light : vec3, @location(5) v_bary : vec3, @location(6) @interpolate(flat) v_stroke_half : f32, + @location(7) @interpolate(flat) v_diffuse : f32, + @location(8) @interpolate(flat) v_specular : f32, }; @vertex @@ -75,13 +81,15 @@ fn vs_main(in: VertexInput) -> VertexOutput { out.v_stroke_color = in.in_stroke_color; out.v_bary = in.in_bary; out.v_stroke_half = in.stroke_half_px; + out.v_diffuse = in.diffuse_strength; + out.v_specular = in.specular_strength; return out; } @fragment fn fs_main(in: VertexOutput, @builtin(front_facing) front_facing: bool) -> @location(0) vec4 { - let diffuse_strength = 0.8; - let specular_strength = 0.9; + let diffuse_strength = in.v_diffuse; + let specular_strength = in.v_specular; let specular_exp = 16.0; let raw_normal = select(-in.v_view_normal, in.v_view_normal, front_facing); diff --git a/manim/renderer/webgpu/shaders/surface_oit.wgsl b/manim/renderer/webgpu/shaders/surface_oit.wgsl index 3a94585566..fce1f1f4ca 100644 --- a/manim/renderer/webgpu/shaders/surface_oit.wgsl +++ b/manim/renderer/webgpu/shaders/surface_oit.wgsl @@ -13,13 +13,15 @@ // A subsequent full-screen composition pass reads both textures and composites // the result onto the opaque framebuffer. // -// Vertex layout matches surface_combined.wgsl (stride 72 bytes): -// location 0 — in_vert float32x3 offset 0 -// location 1 — in_normal float32x3 offset 12 -// location 2 — in_fill_color float32x4 offset 24 -// location 3 — in_stroke_color float32x4 offset 40 -// location 4 — in_bary float32x3 offset 56 -// location 5 — stroke_half_px float32 offset 68 +// Vertex layout matches surface_combined.wgsl (stride 80 bytes): +// location 0 — in_vert float32x3 offset 0 +// location 1 — in_normal float32x3 offset 12 +// location 2 — in_fill_color float32x4 offset 24 +// location 3 — in_stroke_color float32x4 offset 40 +// location 4 — in_bary float32x3 offset 56 +// location 5 — stroke_half_px float32 offset 68 +// location 6 — diffuse_strength float32 offset 72 +// location 7 — specular_strength float32 offset 76 struct Uniforms { projection : mat4x4, @@ -34,12 +36,14 @@ struct Uniforms { @group(0) @binding(0) var u : Uniforms; struct VertexInput { - @location(0) in_vert : vec3, - @location(1) in_normal : vec3, - @location(2) in_fill_color : vec4, - @location(3) in_stroke_color : vec4, - @location(4) in_bary : vec3, - @location(5) stroke_half_px : f32, + @location(0) in_vert : vec3, + @location(1) in_normal : vec3, + @location(2) in_fill_color : vec4, + @location(3) in_stroke_color : vec4, + @location(4) in_bary : vec3, + @location(5) stroke_half_px : f32, + @location(6) diffuse_strength : f32, + @location(7) specular_strength : f32, }; struct VertexOutput { @@ -51,6 +55,8 @@ struct VertexOutput { @location(4) v_view_light : vec3, @location(5) v_bary : vec3, @location(6) @interpolate(flat) v_stroke_half : f32, + @location(7) @interpolate(flat) v_diffuse : f32, + @location(8) @interpolate(flat) v_specular : f32, }; @vertex @@ -66,6 +72,8 @@ fn vs_main(in: VertexInput) -> VertexOutput { out.v_stroke_color = in.in_stroke_color; out.v_bary = in.in_bary; out.v_stroke_half = in.stroke_half_px; + out.v_diffuse = in.diffuse_strength; + out.v_specular = in.specular_strength; return out; } @@ -76,8 +84,8 @@ struct FragOutput { @fragment fn fs_main(in: VertexOutput, @builtin(front_facing) front_facing: bool) -> FragOutput { - let diffuse_strength = 0.9; - let specular_strength = 0.8; + let diffuse_strength = in.v_diffuse; + let specular_strength = in.v_specular; let specular_exp = 16.0; // Two-sided lighting: flip normal for back-facing fragments. diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py index d15984d7a9..baf1a8a4d0 100644 --- a/manim/renderer/webgpu/webgpu_vmobject_rendering.py +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -70,15 +70,17 @@ _SURFACE_COMBINED_DTYPE = np.dtype( [ - ("in_vert", np.float32, (3,)), - ("in_normal", np.float32, (3,)), - ("in_fill_color", np.float32, (4,)), - ("in_stroke_color", np.float32, (4,)), - ("in_bary", np.float32, (3,)), - ("stroke_half_px", np.float32), + ("in_vert", np.float32, (3,)), + ("in_normal", np.float32, (3,)), + ("in_fill_color", np.float32, (4,)), + ("in_stroke_color", np.float32, (4,)), + ("in_bary", np.float32, (3,)), + ("stroke_half_px", np.float32), + ("diffuse_strength", np.float32), + ("specular_strength", np.float32), ] ) -_SURFACE_COMBINED_STRIDE: int = _SURFACE_COMBINED_DTYPE.itemsize # 72 bytes +_SURFACE_COMBINED_STRIDE: int = _SURFACE_COMBINED_DTYPE.itemsize # 80 bytes _SURFACE_COMBINED_OFFSETS: dict[str, int] = { name: _SURFACE_COMBINED_DTYPE.fields[name][1] # type: ignore[index] @@ -89,12 +91,14 @@ "array_stride": _SURFACE_COMBINED_STRIDE, "step_mode": "vertex", "attributes": [ - {"format": "float32x3", "offset": _SURFACE_COMBINED_OFFSETS["in_vert"], "shader_location": 0}, - {"format": "float32x3", "offset": _SURFACE_COMBINED_OFFSETS["in_normal"], "shader_location": 1}, - {"format": "float32x4", "offset": _SURFACE_COMBINED_OFFSETS["in_fill_color"], "shader_location": 2}, - {"format": "float32x4", "offset": _SURFACE_COMBINED_OFFSETS["in_stroke_color"], "shader_location": 3}, - {"format": "float32x3", "offset": _SURFACE_COMBINED_OFFSETS["in_bary"], "shader_location": 4}, - {"format": "float32", "offset": _SURFACE_COMBINED_OFFSETS["stroke_half_px"], "shader_location": 5}, + {"format": "float32x3", "offset": _SURFACE_COMBINED_OFFSETS["in_vert"], "shader_location": 0}, + {"format": "float32x3", "offset": _SURFACE_COMBINED_OFFSETS["in_normal"], "shader_location": 1}, + {"format": "float32x4", "offset": _SURFACE_COMBINED_OFFSETS["in_fill_color"], "shader_location": 2}, + {"format": "float32x4", "offset": _SURFACE_COMBINED_OFFSETS["in_stroke_color"], "shader_location": 3}, + {"format": "float32x3", "offset": _SURFACE_COMBINED_OFFSETS["in_bary"], "shader_location": 4}, + {"format": "float32", "offset": _SURFACE_COMBINED_OFFSETS["stroke_half_px"], "shader_location": 5}, + {"format": "float32", "offset": _SURFACE_COMBINED_OFFSETS["diffuse_strength"], "shader_location": 6}, + {"format": "float32", "offset": _SURFACE_COMBINED_OFFSETS["specular_strength"], "shader_location": 7}, ], } @@ -1119,13 +1123,18 @@ def _collect_surface_geometry( stroke_half_ndc = float(0.004 * stroke_width * abs(pm[0, 0]) / abs(avg_clip_w)) stroke_half_px = stroke_half_ndc * config.pixel_width * 0.5 + diffuse_strength = float(getattr(vmobject, "diffuse_strength", 0.8)) + specular_strength = float(getattr(vmobject, "specular_strength", 0.9)) + attrs = np.empty(n_total, dtype=_SURFACE_COMBINED_DTYPE) - attrs["in_vert"] = verts - attrs["in_normal"] = normals - attrs["in_fill_color"] = fill_color - attrs["in_stroke_color"] = stroke_color - attrs["in_bary"] = bary - attrs["stroke_half_px"] = stroke_half_px + attrs["in_vert"] = verts + attrs["in_normal"] = normals + attrs["in_fill_color"] = fill_color + attrs["in_stroke_color"] = stroke_color + attrs["in_bary"] = bary + attrs["stroke_half_px"] = stroke_half_px + attrs["diffuse_strength"] = diffuse_strength + attrs["specular_strength"] = specular_strength return attrs From 7c76c352138ce1b19fcfae7c977278e6ce4706f8 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Fri, 10 Apr 2026 08:36:32 +0530 Subject: [PATCH 20/33] Added different kind of light source for WebGPU renderer Now, four different kind of light source exist: ambient, point, directional and spot. A scene can have only one ambient light but it can have multiple light sources of other kind. Right now, support for different light sources is wired for WebGPU renderer only. --- manim/mobject/three_d/light_source.py | 297 ++++++++++++++++++ manim/renderer/webgpu/shaders/image.wgsl | 6 +- .../webgpu/shaders/surface_combined.wgsl | 157 +++++++-- .../renderer/webgpu/shaders/surface_oit.wgsl | 151 +++++++-- manim/renderer/webgpu/shaders/true_dot.wgsl | 129 ++++++-- .../webgpu/shaders/vmobject_fill_stroke.wgsl | 24 +- manim/renderer/webgpu/webgpu_renderer.py | 94 +++--- .../webgpu/webgpu_vmobject_rendering.py | 4 +- manim/scene/three_d_scene.py | 22 ++ 9 files changed, 718 insertions(+), 166 deletions(-) create mode 100644 manim/mobject/three_d/light_source.py diff --git a/manim/mobject/three_d/light_source.py b/manim/mobject/three_d/light_source.py new file mode 100644 index 0000000000..881e9ecadc --- /dev/null +++ b/manim/mobject/three_d/light_source.py @@ -0,0 +1,297 @@ +"""Light source mobjects for WebGPU 3-D rendering. + +.. warning:: + + **WebGPU renderer only.** All classes in this module are silently ignored + by the Cairo and OpenGL renderers. They have no visual effect outside of + scenes rendered with ``--renderer=webgpu``. + +Classes +------- +LightSource + Abstract base for all light types. Extends :class:`~.Mobject` so it + participates in scene management (``add``, ``remove``, ``play``). + +AmbientLight + Uniform omnidirectional light that brightens every surface equally. + Only **one** ambient light may exist per scene; ``ThreeDScene`` adds a + default one automatically. + +DirectionalLight + Parallel light from a fixed direction (like sunlight). Intensity is + constant regardless of position. + +PointLight + Omnidirectional light that radiates from a point in world space. Falls + off with the inverse-square of distance. + +SpotLight + Cone-shaped light from a point in a direction. Same attenuation as + ``PointLight`` but only illuminates within *cone_angle* of the direction. + Soft penumbra can be controlled via the *penumbra* parameter. +""" + +from __future__ import annotations + +__all__ = ["AmbientLight", "DirectionalLight", "LightSource", "PointLight", "SpotLight"] + +from typing import Any + +import numpy as np + +from manim.constants import OUT +from manim.mobject.mobject import Mobject +from manim.typing import Point3DLike, Vector3D +from manim.utils.color import WHITE, ParsableManimColor, color_to_rgb + +# ── Light kind constants (must match WGSL shader) ───────────────────────────── +_KIND_AMBIENT = 0 +_KIND_DIRECTIONAL = 1 +_KIND_POINT = 2 +_KIND_SPOT = 3 + + +class LightSource(Mobject): + """Base class for all WebGPU light sources. + + Parameters + ---------- + color + Light colour. + intensity + Brightness scalar. Typical range is [0, 1] for ambient/directional; + higher values (e.g. 300) are suitable for point/spot lights with + distance attenuation. + **kwargs + Forwarded to :class:`~.Mobject`. + + .. note:: + + **WebGPU renderer only** — ignored by Cairo and OpenGL renderers. + """ + + # Subclasses must set this before calling super().__init__. + _kind: int = -1 + + def __init__( + self, + color: ParsableManimColor = WHITE, + intensity: float = 1.0, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self.light_color: np.ndarray = np.asarray(color_to_rgb(color), dtype=np.float32) + self.intensity: float = float(intensity) + + # ------------------------------------------------------------------ + # Packing helpers (used by the renderer) + # ------------------------------------------------------------------ + + def pack(self) -> bytes: + """Return the 64-byte binary representation of this light. + + Matches the WGSL ``Light`` struct layout: + + .. code-block:: text + + offset 0 position vec3 12 B + offset 12 kind u32 4 B + offset 16 direction vec3 12 B + offset 28 intensity f32 4 B + offset 32 color vec3 12 B + offset 44 cone_angle f32 4 B + offset 48 penumbra f32 4 B + offset 52 _pad0-2 f32×3 12 B (alignment padding) + """ + buf = np.zeros(16, dtype=np.float32) # 16 × 4 B = 64 B + buf[0:3] = self._get_position() + buf[3] = np.float32(self._kind).view(np.float32) + buf[4:7] = self._get_direction() + buf[7] = self.intensity + buf[8:11] = self.light_color + buf[11] = self._get_cone_angle() + buf[12] = self._get_penumbra() + # buf[13], buf[14], buf[15] remain zero (padding) + + # Reinterpret index 3 as u32 so we get exact integer bit pattern. + raw = buf.tobytes() + kind_bytes = np.uint32(self._kind).tobytes() + return raw[:12] + kind_bytes + raw[16:] + + # Subclass hooks — override as needed. + def _get_position(self) -> np.ndarray: + return np.zeros(3, dtype=np.float32) + + def _get_direction(self) -> np.ndarray: + return np.zeros(3, dtype=np.float32) + + def _get_cone_angle(self) -> float: + return 0.0 + + def _get_penumbra(self) -> float: + return 0.0 + + +class AmbientLight(LightSource): + """Uniform ambient light — illuminates every surface equally from all sides. + + Only **one** ambient light is allowed per scene. ``ThreeDScene`` adds one + by default (white, intensity 0.5). Replacing it or adjusting its intensity + gives global brightness control. + + Parameters + ---------- + color + Light colour. Default: white. + intensity + Ambient brightness. Default: ``0.5``. + + .. note:: + + **WebGPU renderer only** — ignored by Cairo and OpenGL renderers. + """ + + _kind = _KIND_AMBIENT + + def __init__( + self, + color: ParsableManimColor = WHITE, + intensity: float = 0.5, + **kwargs: Any, + ) -> None: + super().__init__(color=color, intensity=intensity, **kwargs) + + +class DirectionalLight(LightSource): + """Parallel directional light (like sunlight) — constant intensity everywhere. + + Parameters + ---------- + direction + World-space vector the light travels *toward* (points from light toward + the scene). Does not need to be normalised. Default: ``[0, -1, -1]`` + (down-forward). + color + Light colour. Default: white. + intensity + Brightness scalar. Default: ``0.8``. + + .. note:: + + **WebGPU renderer only** — ignored by Cairo and OpenGL renderers. + """ + + _kind = _KIND_DIRECTIONAL + + def __init__( + self, + direction: Vector3D = np.array([0.0, -1.0, -1.0]), + color: ParsableManimColor = WHITE, + intensity: float = 0.8, + **kwargs: Any, + ) -> None: + super().__init__(color=color, intensity=intensity, **kwargs) + d = np.asarray(direction, dtype=np.float32) + norm = np.linalg.norm(d) + self._direction: np.ndarray = (d / norm) if norm > 1e-8 else np.array([0.0, 0.0, -1.0], dtype=np.float32) + + def _get_direction(self) -> np.ndarray: + return self._direction + + +class PointLight(LightSource): + """Omnidirectional point light — radiates from a fixed world-space position. + + Intensity falls off with the inverse-square of the distance to the surface + (``attenuation = intensity / dot(light_dir, light_dir)``). + + Parameters + ---------- + position + World-space centre of the light. Default: ``[10, 10, -10]``. + color + Light colour. Default: white. + intensity + Source brightness (before distance attenuation). Default: ``300``. + + .. note:: + + **WebGPU renderer only** — ignored by Cairo and OpenGL renderers. + """ + + _kind = _KIND_POINT + + def __init__( + self, + position: Point3DLike = np.array([10.0, 10.0, -10.0]), + color: ParsableManimColor = WHITE, + intensity: float = 300.0, + **kwargs: Any, + ) -> None: + super().__init__(color=color, intensity=intensity, **kwargs) + self._position: np.ndarray = np.asarray(position, dtype=np.float32) + + def _get_position(self) -> np.ndarray: + return self._position + + +class SpotLight(LightSource): + """Cone-shaped point light. + + Like ``PointLight`` but only illuminates within *cone_angle* degrees of the + *direction* vector. A soft penumbra region of width *penumbra* degrees + linearly fades the outer rim. + + Parameters + ---------- + position + World-space origin of the spot. Default: ``[10, 10, -10]``. + direction + World-space vector the cone points toward. Does not need to be + normalised. Default: ``[0, -1, -1]``. + cone_angle + Half-angle of the inner (full-brightness) cone, in **degrees**. + Default: ``30``. + penumbra + Width of the soft penumbra region in **degrees**. Default: ``5``. + color + Light colour. Default: white. + intensity + Source brightness (before distance attenuation). Default: ``300``. + + .. note:: + + **WebGPU renderer only** — ignored by Cairo and OpenGL renderers. + """ + + _kind = _KIND_SPOT + + def __init__( + self, + position: Point3DLike = np.array([10.0, 10.0, -10.0]), + direction: Vector3D = np.array([0.0, -1.0, -1.0]), + cone_angle: float = 30.0, + penumbra: float = 5.0, + color: ParsableManimColor = WHITE, + intensity: float = 300.0, + **kwargs: Any, + ) -> None: + super().__init__(color=color, intensity=intensity, **kwargs) + self._position: np.ndarray = np.asarray(position, dtype=np.float32) + d = np.asarray(direction, dtype=np.float32) + norm = np.linalg.norm(d) + self._direction: np.ndarray = (d / norm) if norm > 1e-8 else np.array([0.0, 0.0, -1.0], dtype=np.float32) + self._cone_angle: float = float(cone_angle) + self._penumbra: float = float(penumbra) + + def _get_position(self) -> np.ndarray: + return self._position + + def _get_direction(self) -> np.ndarray: + return self._direction + + def _get_cone_angle(self) -> float: + return self._cone_angle + + def _get_penumbra(self) -> float: + return self._penumbra diff --git a/manim/renderer/webgpu/shaders/image.wgsl b/manim/renderer/webgpu/shaders/image.wgsl index 09e2d5719a..84e87c7945 100644 --- a/manim/renderer/webgpu/shaders/image.wgsl +++ b/manim/renderer/webgpu/shaders/image.wgsl @@ -3,11 +3,11 @@ // Renders a textured quad from four world-space corner vertices. // Used by WebGPURenderer to draw ImageMobject instances. // -// Uniform layout (group 0, binding 0) — same 176-byte block as the VMobject -// shader; only projection and view are used here: +// Uniform layout (group 0, binding 0) — same 656-byte block as the surface +// shaders; only projection and view are used here: // offset 0 — projection mat4x4 64 B // offset 64 — view mat4x4 64 B -// (remaining 48 bytes are lighting fields, unused by this shader) +// (remaining bytes are lighting fields, unused by this shader) // // Texture / sampler (group 1): // binding 0 — texture_2d (rgba8unorm uploaded as f32 [0,1] per channel) diff --git a/manim/renderer/webgpu/shaders/surface_combined.wgsl b/manim/renderer/webgpu/shaders/surface_combined.wgsl index b7a3a8e6bf..9bcffa1b20 100644 --- a/manim/renderer/webgpu/shaders/surface_combined.wgsl +++ b/manim/renderer/webgpu/shaders/surface_combined.wgsl @@ -32,15 +32,47 @@ // location 6 — diffuse_strength float32 offset 72 // location 7 — specular_strength float32 offset 76 +// ── Lighting uniform layout (656 bytes total) ───────────────────────────── +// +// offset 0 — projection mat4x4 64 B +// offset 64 — view mat4x4 64 B +// offset 128 — num_lights u32 4 B +// offset 132 — _pad u32 × 3 12 B (align array to 16 B) +// offset 144 — lights Light × 8 512 B +// +// Light struct (64 bytes): +// offset 0 position vec3 12 B — point / spot world position +// offset 12 kind u32 4 B — 0=ambient,1=directional,2=point,3=spot +// offset 16 direction vec3 12 B — directional / spot direction +// offset 28 intensity f32 4 B +// offset 32 color vec3 12 B +// offset 44 cone_angle f32 4 B — spot inner half-angle (degrees) +// offset 48 penumbra f32 4 B — spot penumbra width (degrees) +// offset 52 _pad0-2 f32 × 3 12 B + +const MAX_LIGHTS : u32 = 8u; + +struct Light { + position : vec3, + kind : u32, + direction : vec3, + intensity : f32, + color : vec3, + cone_angle : f32, + penumbra : f32, + _pad0 : f32, + _pad1 : f32, + _pad2 : f32, +}; + struct Uniforms { - projection : mat4x4, - view : mat4x4, - light_pos : vec3, - light_intensity : f32, - light_color : vec3, - ambient_intensity : f32, - ambient_color : vec3, - _pad : f32, + projection : mat4x4, + view : mat4x4, + num_lights : u32, + _pad0 : u32, + _pad1 : u32, + _pad2 : u32, + lights : array, }; @group(0) @binding(0) var u : Uniforms; @@ -61,11 +93,10 @@ struct VertexOutput { @location(1) v_stroke_color : vec4, @location(2) v_view_normal : vec3, @location(3) v_view_pos : vec3, - @location(4) v_view_light : vec3, - @location(5) v_bary : vec3, - @location(6) @interpolate(flat) v_stroke_half : f32, - @location(7) @interpolate(flat) v_diffuse : f32, - @location(8) @interpolate(flat) v_specular : f32, + @location(4) v_bary : vec3, + @location(5) @interpolate(flat) v_stroke_half : f32, + @location(6) @interpolate(flat) v_diffuse : f32, + @location(7) @interpolate(flat) v_specular : f32, }; @vertex @@ -76,7 +107,6 @@ fn vs_main(in: VertexInput) -> VertexOutput { out.v_view_pos = view_pos.xyz; let view3 = mat3x3(u.view[0].xyz, u.view[1].xyz, u.view[2].xyz); out.v_view_normal = view3 * in.in_normal; - out.v_view_light = (u.view * vec4(u.light_pos, 1.0)).xyz; out.v_fill_color = in.in_fill_color; out.v_stroke_color = in.in_stroke_color; out.v_bary = in.in_bary; @@ -86,32 +116,95 @@ fn vs_main(in: VertexInput) -> VertexOutput { return out; } +// ── Lighting helpers ────────────────────────────────────────────────────────── + +fn compute_lighting( + view_pos : vec3, + view_normal : vec3, + base_rgb : vec3, + diff_str : f32, + spec_str : f32, +) -> vec3 { + let specular_exp = 16.0; + let view_dir = normalize(-view_pos); + let view3 = mat3x3(u.view[0].xyz, u.view[1].xyz, u.view[2].xyz); + + var acc_rgb = vec3(0.0); + + for (var i = 0u; i < u.num_lights; i++) { + let L = u.lights[i]; + + switch L.kind { + // ── Ambient ─────────────────────────────────────────────────── + case 0u: { + acc_rgb += base_rgb * L.color * L.intensity; + } + // ── Directional ─────────────────────────────────────────────── + case 1u: { + // direction is the direction the light *travels toward* (world space). + // Transform to view space and negate to get the "to-light" direction. + let light_dir = normalize(-(view3 * L.direction)); + let half_vec = normalize(light_dir + view_dir); + let diff = clamp(dot(view_normal, light_dir), 0.0, 1.0); + let spec = pow(max(dot(view_normal, half_vec), 0.0), specular_exp); + acc_rgb += base_rgb * L.color * (diff_str * diff * L.intensity); + acc_rgb += L.color * (spec_str * spec * L.intensity); + } + // ── Point ───────────────────────────────────────────────────── + case 2u: { + let light_view_pos = (u.view * vec4(L.position, 1.0)).xyz; + let light_dir_v = light_view_pos - view_pos; + let light_dir = normalize(light_dir_v); + let half_vec = normalize(light_dir + view_dir); + let diff = clamp(dot(view_normal, light_dir), 0.0, 1.0); + let spec = pow(max(dot(view_normal, half_vec), 0.0), specular_exp); + let attenuation = L.intensity / dot(light_dir_v, light_dir_v); + acc_rgb += base_rgb * L.color * (diff_str * diff * attenuation); + acc_rgb += L.color * (spec_str * spec * attenuation); + } + // ── Spot ────────────────────────────────────────────────────── + case 3u: { + let light_view_pos = (u.view * vec4(L.position, 1.0)).xyz; + let light_dir_v = light_view_pos - view_pos; + let light_dir = normalize(light_dir_v); + let half_vec = normalize(light_dir + view_dir); + let diff = clamp(dot(view_normal, light_dir), 0.0, 1.0); + let spec = pow(max(dot(view_normal, half_vec), 0.0), specular_exp); + let attenuation = L.intensity / dot(light_dir_v, light_dir_v); + + // Cone falloff: compare angle between -light_dir and spot direction. + let spot_dir = normalize(view3 * L.direction); + let cos_theta = dot(-light_dir, spot_dir); + let cos_inner = cos(radians(L.cone_angle)); + let cos_outer = cos(radians(L.cone_angle + L.penumbra)); + let spot_factor = clamp((cos_theta - cos_outer) / (cos_inner - cos_outer + 1e-6), 0.0, 1.0); + + acc_rgb += base_rgb * L.color * (diff_str * diff * attenuation * spot_factor); + acc_rgb += L.color * (spec_str * spec * attenuation * spot_factor); + } + default: {} + } + } + + return clamp(acc_rgb, vec3(0.0), vec3(1.0)); +} + @fragment fn fs_main(in: VertexOutput, @builtin(front_facing) front_facing: bool) -> @location(0) vec4 { let diffuse_strength = in.v_diffuse; let specular_strength = in.v_specular; - let specular_exp = 16.0; - - let raw_normal = select(-in.v_view_normal, in.v_view_normal, front_facing); - let norm = normalize(raw_normal); - let light_dir_v = in.v_view_light - in.v_view_pos; - let light_dir = normalize(light_dir_v); - let view_dir = normalize(-in.v_view_pos); - let half_vec = normalize(light_dir + view_dir); - let diff = clamp(dot(norm, light_dir), 0.0, 1.0); - let spec = pow(max(dot(norm, half_vec), 0.0), specular_exp); - let attenuation = u.light_intensity / dot(light_dir_v, light_dir_v); + let raw_normal = select(-in.v_view_normal, in.v_view_normal, front_facing); + let norm = normalize(raw_normal); - let ambient_rgb = in.v_fill_color.rgb * u.ambient_color * u.ambient_intensity; - let diffuse_rgb = in.v_fill_color.rgb * u.light_color * (diffuse_strength * diff * attenuation); - let specular_rgb = u.light_color * (specular_strength * spec * attenuation); - let lit_rgb = clamp(ambient_rgb + diffuse_rgb + specular_rgb, vec3(0.0), vec3(1.0)); - let fill_a = in.v_fill_color.a; + let lit_rgb = compute_lighting( + in.v_view_pos, norm, + in.v_fill_color.rgb, + diffuse_strength, specular_strength, + ); + let fill_a = in.v_fill_color.a; // ── Barycentric wireframe ────────────────────────────────────────────── - // bary.x is the barycentric weight of the centroid vertex, which equals 0 - // on the outer (mesh-grid) edge. fwidth converts bary.x to pixel units. let edge_dist_px = in.v_bary.x / max(fwidth(in.v_bary.x), 1e-6); let stroke_cov = clamp(in.v_stroke_half + 0.5 - edge_dist_px, 0.0, 1.0); let stroke_a = in.v_stroke_color.a * stroke_cov; diff --git a/manim/renderer/webgpu/shaders/surface_oit.wgsl b/manim/renderer/webgpu/shaders/surface_oit.wgsl index fce1f1f4ca..4d52014f89 100644 --- a/manim/renderer/webgpu/shaders/surface_oit.wgsl +++ b/manim/renderer/webgpu/shaders/surface_oit.wgsl @@ -23,15 +23,47 @@ // location 6 — diffuse_strength float32 offset 72 // location 7 — specular_strength float32 offset 76 +// ── Lighting uniform layout (656 bytes total) ───────────────────────────── +// +// offset 0 — projection mat4x4 64 B +// offset 64 — view mat4x4 64 B +// offset 128 — num_lights u32 4 B +// offset 132 — _pad u32 × 3 12 B +// offset 144 — lights Light × 8 512 B +// +// Light struct (64 bytes): +// offset 0 position vec3 12 B +// offset 12 kind u32 4 B — 0=ambient,1=directional,2=point,3=spot +// offset 16 direction vec3 12 B +// offset 28 intensity f32 4 B +// offset 32 color vec3 12 B +// offset 44 cone_angle f32 4 B +// offset 48 penumbra f32 4 B +// offset 52 _pad0-2 f32 × 3 12 B + +const MAX_LIGHTS : u32 = 8u; + +struct Light { + position : vec3, + kind : u32, + direction : vec3, + intensity : f32, + color : vec3, + cone_angle : f32, + penumbra : f32, + _pad0 : f32, + _pad1 : f32, + _pad2 : f32, +}; + struct Uniforms { - projection : mat4x4, - view : mat4x4, - light_pos : vec3, - light_intensity : f32, - light_color : vec3, - ambient_intensity : f32, - ambient_color : vec3, - _pad : f32, + projection : mat4x4, + view : mat4x4, + num_lights : u32, + _pad0 : u32, + _pad1 : u32, + _pad2 : u32, + lights : array, }; @group(0) @binding(0) var u : Uniforms; @@ -52,11 +84,10 @@ struct VertexOutput { @location(1) v_stroke_color : vec4, @location(2) v_view_normal : vec3, @location(3) v_view_pos : vec3, - @location(4) v_view_light : vec3, - @location(5) v_bary : vec3, - @location(6) @interpolate(flat) v_stroke_half : f32, - @location(7) @interpolate(flat) v_diffuse : f32, - @location(8) @interpolate(flat) v_specular : f32, + @location(4) v_bary : vec3, + @location(5) @interpolate(flat) v_stroke_half : f32, + @location(6) @interpolate(flat) v_diffuse : f32, + @location(7) @interpolate(flat) v_specular : f32, }; @vertex @@ -67,7 +98,6 @@ fn vs_main(in: VertexInput) -> VertexOutput { out.v_view_pos = view_pos.xyz; let view3 = mat3x3(u.view[0].xyz, u.view[1].xyz, u.view[2].xyz); out.v_view_normal = view3 * in.in_normal; - out.v_view_light = (u.view * vec4(u.light_pos, 1.0)).xyz; out.v_fill_color = in.in_fill_color; out.v_stroke_color = in.in_stroke_color; out.v_bary = in.in_bary; @@ -77,6 +107,70 @@ fn vs_main(in: VertexInput) -> VertexOutput { return out; } +// ── Lighting helpers ────────────────────────────────────────────────────────── + +fn compute_lighting( + view_pos : vec3, + view_normal : vec3, + base_rgb : vec3, + diff_str : f32, + spec_str : f32, +) -> vec3 { + let specular_exp = 16.0; + let view_dir = normalize(-view_pos); + let view3 = mat3x3(u.view[0].xyz, u.view[1].xyz, u.view[2].xyz); + + var acc_rgb = vec3(0.0); + + for (var i = 0u; i < u.num_lights; i++) { + let L = u.lights[i]; + + switch L.kind { + case 0u: { + acc_rgb += base_rgb * L.color * L.intensity; + } + case 1u: { + let light_dir = normalize(-(view3 * L.direction)); + let half_vec = normalize(light_dir + view_dir); + let diff = clamp(dot(view_normal, light_dir), 0.0, 1.0); + let spec = pow(max(dot(view_normal, half_vec), 0.0), specular_exp); + acc_rgb += base_rgb * L.color * (diff_str * diff * L.intensity); + acc_rgb += L.color * (spec_str * spec * L.intensity); + } + case 2u: { + let light_view_pos = (u.view * vec4(L.position, 1.0)).xyz; + let light_dir_v = light_view_pos - view_pos; + let light_dir = normalize(light_dir_v); + let half_vec = normalize(light_dir + view_dir); + let diff = clamp(dot(view_normal, light_dir), 0.0, 1.0); + let spec = pow(max(dot(view_normal, half_vec), 0.0), specular_exp); + let attenuation = L.intensity / dot(light_dir_v, light_dir_v); + acc_rgb += base_rgb * L.color * (diff_str * diff * attenuation); + acc_rgb += L.color * (spec_str * spec * attenuation); + } + case 3u: { + let light_view_pos = (u.view * vec4(L.position, 1.0)).xyz; + let light_dir_v = light_view_pos - view_pos; + let light_dir = normalize(light_dir_v); + let half_vec = normalize(light_dir + view_dir); + let diff = clamp(dot(view_normal, light_dir), 0.0, 1.0); + let spec = pow(max(dot(view_normal, half_vec), 0.0), specular_exp); + let attenuation = L.intensity / dot(light_dir_v, light_dir_v); + let spot_dir = normalize(view3 * L.direction); + let cos_theta = dot(-light_dir, spot_dir); + let cos_inner = cos(radians(L.cone_angle)); + let cos_outer = cos(radians(L.cone_angle + L.penumbra)); + let spot_factor = clamp((cos_theta - cos_outer) / (cos_inner - cos_outer + 1e-6), 0.0, 1.0); + acc_rgb += base_rgb * L.color * (diff_str * diff * attenuation * spot_factor); + acc_rgb += L.color * (spec_str * spec * attenuation * spot_factor); + } + default: {} + } + } + + return clamp(acc_rgb, vec3(0.0), vec3(1.0)); +} + struct FragOutput { @location(0) accum : vec4, // weighted colour sum → rgba16float @location(1) reveal : vec4, // transmittance product → rgba16float @@ -86,25 +180,16 @@ struct FragOutput { fn fs_main(in: VertexOutput, @builtin(front_facing) front_facing: bool) -> FragOutput { let diffuse_strength = in.v_diffuse; let specular_strength = in.v_specular; - let specular_exp = 16.0; - - // Two-sided lighting: flip normal for back-facing fragments. - let raw_normal = select(-in.v_view_normal, in.v_view_normal, front_facing); - let norm = normalize(raw_normal); - let light_dir_v = in.v_view_light - in.v_view_pos; - let light_dir = normalize(light_dir_v); - let view_dir = normalize(-in.v_view_pos); - let half_vec = normalize(light_dir + view_dir); - - let diff = clamp(dot(norm, light_dir), 0.0, 1.0); - let spec = pow(max(dot(norm, half_vec), 0.0), specular_exp); - let attenuation = u.light_intensity / dot(light_dir_v, light_dir_v); - - let ambient_rgb = in.v_fill_color.rgb * u.ambient_color * u.ambient_intensity; - let diffuse_rgb = in.v_fill_color.rgb * u.light_color * (diffuse_strength * diff * attenuation); - let specular_rgb = u.light_color * (specular_strength * spec * attenuation); - let lit_rgb = clamp(ambient_rgb + diffuse_rgb + specular_rgb, vec3(0.0), vec3(1.0)); - let fill_a = in.v_fill_color.a; + + let raw_normal = select(-in.v_view_normal, in.v_view_normal, front_facing); + let norm = normalize(raw_normal); + + let lit_rgb = compute_lighting( + in.v_view_pos, norm, + in.v_fill_color.rgb, + diffuse_strength, specular_strength, + ); + let fill_a = in.v_fill_color.a; // ── Barycentric wireframe ────────────────────────────────────────────── let edge_dist_px = in.v_bary.x / max(fwidth(in.v_bary.x), 1e-6); diff --git a/manim/renderer/webgpu/shaders/true_dot.wgsl b/manim/renderer/webgpu/shaders/true_dot.wgsl index 41c17597c4..edf609f6bc 100644 --- a/manim/renderer/webgpu/shaders/true_dot.wgsl +++ b/manim/renderer/webgpu/shaders/true_dot.wgsl @@ -5,7 +5,8 @@ // The fragment shader treats the quad as a sphere projected onto the screen: // • Pixels outside the unit disc are discarded (anti-aliased edge). // • The sphere normal is reconstructed from the UV position. -// • Cairo-style lighting (gloss/shadow) is applied to the colour. +// • Multi-light Phong shading is applied (same light array as surface shaders). +// gloss / shadow parameters blend the result toward the Cairo-style look. // // The same camera Uniforms struct and bind group layout as the surface // shaders are reused (binding 0, group 0). @@ -18,15 +19,31 @@ // location 4 — gloss float32 offset 40 ( 4 B) // location 5 — shadow float32 offset 44 ( 4 B) +// ── Shared uniform (same layout as surface shaders, 656 bytes) ──────────────── + +const MAX_LIGHTS : u32 = 8u; + +struct Light { + position : vec3, + kind : u32, + direction : vec3, + intensity : f32, + color : vec3, + cone_angle : f32, + penumbra : f32, + _pad0 : f32, + _pad1 : f32, + _pad2 : f32, +}; + struct Uniforms { - projection : mat4x4, - view : mat4x4, - light_pos : vec3, - light_intensity : f32, - light_color : vec3, - ambient_intensity : f32, - ambient_color : vec3, - _pad : f32, + projection : mat4x4, + view : mat4x4, + num_lights : u32, + _pad0 : u32, + _pad1 : u32, + _pad2 : u32, + lights : array, }; @group(0) @binding(0) var u : Uniforms; @@ -54,9 +71,6 @@ fn vs_main(in: VertexInput) -> VertexOutput { let cv = u.view * vec4(in.center, 1.0); // Expand quad in view space: move corner by radius × UV along x/y. - // This replicates the OpenGL geometry shader expansion and naturally - // applies the correct perspective foreshortening (larger expansion near - // the camera, smaller far away). let expanded = cv + vec4(in.uv.x * in.radius, in.uv.y * in.radius, 0.0, 0.0); var out: VertexOutput; @@ -73,31 +87,78 @@ fn vs_main(in: VertexInput) -> VertexOutput { fn fs_main(in: VertexOutput) -> @location(0) vec4 { let d = length(in.v_uv); - // Anti-aliased disc: smoothstep over one pixel width around d == 1. - let fw = fwidth(d); + // Anti-aliased disc edge. + let fw = fwidth(d); let alpha_mult = 1.0 - smoothstep(1.0 - fw, 1.0 + fw, d); if alpha_mult <= 0.001 { discard; } - // Reconstruct sphere surface normal in view space from UV position. - let z2 = max(0.0, 1.0 - d * d); + // Reconstruct sphere surface normal in view space. + let z2 = max(0.0, 1.0 - d * d); let sphere_normal = normalize(vec3(in.v_uv.x, in.v_uv.y, sqrt(z2))); + let view_dir = normalize(-in.v_center_view); + let view3 = mat3x3(u.view[0].xyz, u.view[1].xyz, u.view[2].xyz); + + // ── Multi-light accumulation ────────────────────────────────────────── + var acc_rgb = vec3(0.0); + + for (var i = 0u; i < u.num_lights; i++) { + let L = u.lights[i]; + + switch L.kind { + // Ambient + case 0u: { + acc_rgb += in.v_color.rgb * L.color * L.intensity; + } + // Directional + case 1u: { + let to_light = normalize(-(view3 * L.direction)); + let dot_ln = clamp(dot(sphere_normal, to_light), 0.0, 1.0); + // Cairo-style shadow: darken by Lambertian term + let darkening = mix(1.0, dot_ln, in.v_shadow); + // Cairo-style specular gloss + let reflect_l = reflect(-to_light, sphere_normal); + let dot_rv = clamp(dot(reflect_l, view_dir), 0.0, 1.0); + let shine = in.v_gloss * exp(-3.0 * pow(1.0 - dot_rv, 2.0)); + let lit = darkening * mix(in.v_color.rgb, vec3(1.0), shine); + acc_rgb += lit * L.color * L.intensity; + } + // Point + case 2u: { + let light_vpos = (u.view * vec4(L.position, 1.0)).xyz; + let light_dir_v = light_vpos - in.v_center_view; + let to_light = normalize(light_dir_v); + let attenuation = L.intensity / dot(light_dir_v, light_dir_v); + let dot_ln = clamp(dot(sphere_normal, to_light), 0.0, 1.0); + let darkening = mix(1.0, dot_ln, in.v_shadow); + let reflect_l = reflect(-to_light, sphere_normal); + let dot_rv = clamp(dot(reflect_l, view_dir), 0.0, 1.0); + let shine = in.v_gloss * exp(-3.0 * pow(1.0 - dot_rv, 2.0)); + let lit = darkening * mix(in.v_color.rgb, vec3(1.0), shine); + acc_rgb += lit * L.color * attenuation; + } + // Spot + case 3u: { + let light_vpos = (u.view * vec4(L.position, 1.0)).xyz; + let light_dir_v = light_vpos - in.v_center_view; + let to_light = normalize(light_dir_v); + let attenuation = L.intensity / dot(light_dir_v, light_dir_v); + let spot_dir = normalize(view3 * L.direction); + let cos_theta = dot(-to_light, spot_dir); + let cos_inner = cos(radians(L.cone_angle)); + let cos_outer = cos(radians(L.cone_angle + L.penumbra)); + let spot_factor = clamp((cos_theta - cos_outer) / (cos_inner - cos_outer + 1e-6), 0.0, 1.0); + let dot_ln = clamp(dot(sphere_normal, to_light), 0.0, 1.0); + let darkening = mix(1.0, dot_ln, in.v_shadow); + let reflect_l = reflect(-to_light, sphere_normal); + let dot_rv = clamp(dot(reflect_l, view_dir), 0.0, 1.0); + let shine = in.v_gloss * exp(-3.0 * pow(1.0 - dot_rv, 2.0)); + let lit = darkening * mix(in.v_color.rgb, vec3(1.0), shine); + acc_rgb += lit * L.color * attenuation * spot_factor; + } + default: {} + } + } - // Light and camera directions in view space. - // Camera sits at the origin in view space, so to_camera = -in.v_center_view. - let light_view = (u.view * vec4(u.light_pos, 1.0)).xyz; - let to_light = normalize(light_view - in.v_center_view); - let to_camera = normalize(-in.v_center_view); - - // Cairo-style lighting (finalize_color.glsl → add_light): - // shine = gloss * exp(-3 * (1 - dot(reflect(-L, N), V))^2) - // darkening = mix(1, max(dot(L, N), 0), shadow) - // out_rgb = darkening * mix(color, WHITE, shine) - let light_reflection = reflect(-to_light, sphere_normal); - let dot_rv = clamp(dot(light_reflection, to_camera), 0.0, 1.0); - let shine = in.v_gloss * exp(-3.0 * pow(1.0 - dot_rv, 2.0)); - let dp2 = dot(to_light, sphere_normal); - let darkening = mix(1.0, max(dp2, 0.0), in.v_shadow); - - let lit_rgb = darkening * mix(in.v_color.rgb, vec3(1.0), shine); - return vec4(lit_rgb, in.v_color.a * alpha_mult); + let out_rgb = clamp(acc_rgb, vec3(0.0), vec3(1.0)); + return vec4(out_rgb, in.v_color.a * alpha_mult); } diff --git a/manim/renderer/webgpu/shaders/vmobject_fill_stroke.wgsl b/manim/renderer/webgpu/shaders/vmobject_fill_stroke.wgsl index ccd60d004c..b3b2a08bbe 100644 --- a/manim/renderer/webgpu/shaders/vmobject_fill_stroke.wgsl +++ b/manim/renderer/webgpu/shaders/vmobject_fill_stroke.wgsl @@ -15,15 +15,11 @@ // Objects with no fill: pass fill_color.a = 0 or n_fill_curves = 0. // Objects with no stroke: pass stroke_half_ndc = 0 or n_stroke_curves = 0. // -// Uniform layout (group 0, binding 0) — 176-byte block shared with surface.wgsl: -// offset 0 — projection mat4x4 (64 B) -// offset 64 — view mat4x4 (64 B) -// offset 128 — light_pos vec3 (12 B) ← unused here -// offset 140 — light_intensity f32 ( 4 B) ← unused here -// offset 144 — light_color vec3 (12 B) ← unused here -// offset 156 — ambient_intensity f32 ( 4 B) ← unused here -// offset 160 — ambient_color vec3 (12 B) ← unused here -// offset 172 — _pad f32 ( 4 B) +// Uniform layout (group 0, binding 0) — 656-byte block shared with surface shaders +// (only the first two fields are used here): +// offset 0 — projection mat4x4 (64 B) +// offset 64 — view mat4x4 (64 B) +// offset 128 — ... (lighting data, unused by this shader) // // Storage buffer (group 0, binding 1) — array, 9 floats per quadratic: // [p0.x p0.y p0.z pmid.x pmid.y pmid.z p2.x p2.y p2.z] @@ -39,14 +35,8 @@ // location 7 — n_stroke_curves uint32 offset 60 struct Uniforms { - projection : mat4x4, - view : mat4x4, - light_pos : vec3, - light_intensity : f32, - light_color : vec3, - ambient_intensity : f32, - ambient_color : vec3, - _pad : f32, + projection : mat4x4, + view : mat4x4, }; @group(0) @binding(0) var u : Uniforms; @group(0) @binding(1) var quads : array; diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index 94f870cebe..74462e6907 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -32,6 +32,7 @@ from manim import config, logger from manim.constants import IN, OUT, PI, RIGHT, DOWN, LEFT from manim.mobject.mobject import Mobject +from manim.mobject.three_d.light_source import LightSource from manim.mobject.types.image_mobject import AbstractImageMobject from manim.mobject.types.vectorized_mobject import VMobject from manim.scene.scene_file_writer import SceneFileWriter @@ -506,14 +507,6 @@ def __init__( self.background_color = config["background_color"] - # Scene-wide lighting — read by _build_camera_uniform_buf() each frame. - # light_color / ambient_color are RGB floats in [0, 1]. - self.light_source_position: np.ndarray = np.array([10.0, 10.0, -10.0]) - self.light_color: np.ndarray = np.array([1.0, 1.0, 1.0]) - self.light_intensity: float = 300.0 - self.ambient_color: np.ndarray = np.array([1.0, 1.0, 1.0]) - self.ambient_intensity: float = 0.5 - # Filled by init_scene(): self._device: wgpu_t.GPUDevice | None = None self._render_texture: wgpu_t.GPUTexture | None = None @@ -691,16 +684,18 @@ def init_scene(self, scene: Scene) -> None: # These stable GPU objects let cached _FrameData bind groups remain valid # across frames: the bind group references the same buffer; write_buffer # updates its contents so the shader always sees the current camera. + # Uniform buffer size: proj(64) + view(64) + num_lights+pad(16) + Light×8(512) = 656 B + _UBO_SIZE = 656 self._camera_uniform_buf = self._device.create_buffer( - size=176, + size=_UBO_SIZE, usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST, ) self._fixed_orient_uniform_buf = self._device.create_buffer( - size=176, + size=_UBO_SIZE, usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST, ) self._fixed_frame_uniform_buf = self._device.create_buffer( - size=176, + size=_UBO_SIZE, usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST, ) @@ -709,7 +704,7 @@ def init_scene(self, scene: Scene) -> None: def _make_persistent_bg(buf: wgpu_t.GPUBuffer) -> wgpu_t.GPUBindGroup: return self._device.create_bind_group( layout=self._proj_bgl, - entries=[{"binding": 0, "resource": {"buffer": buf, "offset": 0, "size": 176}}], + entries=[{"binding": 0, "resource": {"buffer": buf, "offset": 0, "size": _UBO_SIZE}}], ) self.camera_bind_group = _make_persistent_bg(self._camera_uniform_buf) @@ -727,15 +722,12 @@ def _make_persistent_bg(buf: wgpu_t.GPUBuffer) -> wgpu_t.GPUBindGroup: def _create_camera_bgl(self) -> wgpu_t.GPUBindGroupLayout: """Create the bind group layout shared by stroke, surface, and Slug pipelines. - Layout: binding 0 — one uniform buffer (176 bytes total): - offset 0 — projection mat4x4 64 B - offset 64 — view mat4x4 64 B - offset 128 — light_pos vec3 12 B - offset 140 — light_intensity f32 4 B - offset 144 — light_color vec3 12 B - offset 156 — ambient_intensity f32 4 B - offset 160 — ambient_color vec3 12 B - offset 172 — _pad f32 4 B + Layout: binding 0 — one uniform buffer (656 bytes total): + offset 0 — projection mat4x4 64 B + offset 64 — view mat4x4 64 B + offset 128 — num_lights u32 4 B + offset 132 — _pad u32 × 3 12 B + offset 144 — lights Light × 8 512 B (each Light = 64 B) """ assert self._device is not None return self._device.create_bind_group_layout( @@ -756,7 +748,7 @@ def _create_fill_stroke_pipeline( """Create the combined fill+stroke pipeline (vmobject_fill_stroke.wgsl). The bind group layout mirrors the slug fill layout: - binding 0 — camera uniform (176 bytes) + binding 0 — camera uniform (656 bytes) binding 1 — quads storage buffer (read-only, output of compute shader) depth_test=False — 2-D objects: depth-read-only (painter's algorithm). @@ -1347,46 +1339,58 @@ def _create_oit_resources(self, width: int, height: int) -> None: # Camera bind group (rebuilt each frame when projection changes) # ------------------------------------------------------------------ + def _collect_lights(self) -> list: + """Return all LightSource instances in the current scene (depth-first).""" + lights = [] + if self.scene is None: + return lights + def _walk(mob): + if isinstance(mob, LightSource): + lights.append(mob) + for child in mob.submobjects: + _walk(child) + for mob in self.scene.mobjects: + _walk(mob) + return lights + + _MAX_LIGHTS = 8 + def _pack_camera_uniforms_bytes( self, proj: np.ndarray, view: np.ndarray, ) -> bytes: - """Return a 176-byte camera+lighting uniform payload from explicit proj/view. + """Return a 656-byte camera+lighting uniform payload from explicit proj/view. Layout (matches Uniforms struct in surface_combined.wgsl / surface_oit.wgsl): - offset 0 — projection mat4x4 64 B - offset 64 — view mat4x4 64 B - offset 128 — light_pos vec3 12 B - offset 140 — light_intensity f32 4 B - offset 144 — light_color vec3 12 B - offset 156 — ambient_intensity f32 4 B - offset 160 — ambient_color vec3 12 B - offset 172 — _pad f32 4 B + offset 0 — projection mat4x4 64 B + offset 64 — view mat4x4 64 B + offset 128 — num_lights u32 4 B + offset 132 — _pad u32 × 3 12 B + offset 144 — lights Light × 8 512 B (each Light = 64 B) """ proj_bytes = proj.T.flatten().astype(np.float32).tobytes() view_bytes = view.T.flatten().astype(np.float32).tobytes() - # block A: light_pos (xyz) + light_intensity (w) - block_a = np.zeros(4, dtype=np.float32) - block_a[:3] = np.asarray(self.light_source_position, dtype=np.float32) - block_a[3] = np.float32(self.light_intensity) + lights = self._collect_lights() + n = min(len(lights), self._MAX_LIGHTS) - # block B: light_color (xyz) + ambient_intensity (w) - block_b = np.zeros(4, dtype=np.float32) - block_b[:3] = np.asarray(self.light_color, dtype=np.float32) - block_b[3] = np.float32(self.ambient_intensity) + # num_lights (u32) + 3× padding u32 + header = np.array([n, 0, 0, 0], dtype=np.uint32).tobytes() - # block C: ambient_color (xyz) + _pad (w) - block_c = np.zeros(4, dtype=np.float32) - block_c[:3] = np.asarray(self.ambient_color, dtype=np.float32) + # Pack up to MAX_LIGHTS light structs; pad the rest with zeros. + light_data = b"" + for i in range(self._MAX_LIGHTS): + if i < n: + light_data += lights[i].pack() + else: + light_data += b"\x00" * 64 - return (proj_bytes + view_bytes - + block_a.tobytes() + block_b.tobytes() + block_c.tobytes()) + return proj_bytes + view_bytes + header + light_data # Keep the old name as a shim so any external callers don't break. def _pack_camera_uniforms(self, proj: np.ndarray, view: np.ndarray) -> wgpu_t.GPUBuffer: - """Create a throw-away 176-byte uniform buffer (legacy path, rarely used).""" + """Create a throw-away 656-byte uniform buffer (legacy path, rarely used).""" assert self._device is not None buf = self._device.create_buffer_with_data( data=self._pack_camera_uniforms_bytes(proj, view), diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py index baf1a8a4d0..4c0a29fc9e 100644 --- a/manim/renderer/webgpu/webgpu_vmobject_rendering.py +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -375,7 +375,7 @@ def collect_frame_data( groups. The caller must run the compute pass (via ``_FrameData.compute_bg``) before the render pass. - *camera_uniform_buf* is the 176-byte uniform buffer for this camera group. + *camera_uniform_buf* is the 656-byte uniform buffer for this camera group. It is stored in the render bind group so the fragment shader can project world-space curve data into the correct NDC space. @@ -632,7 +632,7 @@ def collect_frame_data( render_bg = device.create_bind_group( layout=renderer._fill_stroke_bgl, entries=[ - {"binding": 0, "resource": {"buffer": camera_uniform_buf, "offset": 0, "size": 176}}, + {"binding": 0, "resource": {"buffer": camera_uniform_buf, "offset": 0, "size": camera_uniform_buf.size}}, {"binding": 1, "resource": {"buffer": quads_out_buf, "offset": 0, "size": quads_out_buf.size}}, ], ) diff --git a/manim/scene/three_d_scene.py b/manim/scene/three_d_scene.py index 062fafbb50..79bffb7bce 100644 --- a/manim/scene/three_d_scene.py +++ b/manim/scene/three_d_scene.py @@ -8,6 +8,8 @@ import warnings from collections.abc import Iterable, Sequence +from manim.mobject.three_d.light_source import AmbientLight, LightSource + import numpy as np from manim.mobject.geometry.line import Line @@ -53,6 +55,26 @@ def __init__( ) super().__init__(camera_class=camera_class, **kwargs) + # Default ambient light — exactly one is kept at all times. + # WebGPU renderer reads self.mobjects to find LightSource instances. + self._ambient_light = AmbientLight(intensity=0.5) + self.add(self._ambient_light) + + def add(self, *mobjects): + """Override to enforce the single-ambient-light rule. + + If the caller adds a new :class:`~.AmbientLight`, the existing one is + removed first so only one ambient light is ever in the scene. + """ + for mob in mobjects: + if isinstance(mob, AmbientLight): + # Remove any existing AmbientLight before adding the new one. + existing = [m for m in self.mobjects if isinstance(m, AmbientLight)] + for old in existing: + super().remove(old) + self._ambient_light = mob + return super().add(*mobjects) + def set_camera_orientation( self, phi: float | None = None, From 03e1889f6e398879443e34b6744ba3ed75348ea8 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Fri, 10 Apr 2026 09:17:59 +0530 Subject: [PATCH 21/33] Added support for ImageMobjectFromCamera and ZoomedScene in WebGPU renderer --- manim/renderer/webgpu/webgpu_renderer.py | 287 +++++++++++++++++- .../webgpu/webgpu_vmobject_rendering.py | 122 ++++++++ 2 files changed, 403 insertions(+), 6 deletions(-) diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index 74462e6907..02bd7e3459 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -33,7 +33,7 @@ from manim.constants import IN, OUT, PI, RIGHT, DOWN, LEFT from manim.mobject.mobject import Mobject from manim.mobject.three_d.light_source import LightSource -from manim.mobject.types.image_mobject import AbstractImageMobject +from manim.mobject.types.image_mobject import AbstractImageMobject, ImageMobjectFromCamera from manim.mobject.types.vectorized_mobject import VMobject from manim.scene.scene_file_writer import SceneFileWriter from manim.utils.color import color_to_rgba @@ -56,6 +56,7 @@ build_true_dot_vbo, collect_frame_data, draw_frame_data, + draw_frame_data_subcam, ) if TYPE_CHECKING: @@ -169,6 +170,11 @@ def __init__( # so we provide equivalent stubs here. self._frame_center: Mobject = Mobject() + # ImageMobjectFromCamera registration — for ZoomedScene support. + # Each frame the renderer renders the sub-camera view into a dedicated + # GPU texture which is then composited as a regular image quad. + self.image_mobjects_from_cameras: list = [] + def get_value_trackers(self) -> list: """Required by ThreeDScene.get_moving_mobjects. @@ -180,6 +186,21 @@ def get_value_trackers(self) -> list: """ return [self] + def get_mobjects_indicating_movement(self) -> list: + """Return mobjects whose movement implies the whole scene is moving. + + Called by :class:`~.MovingCameraScene` to detect whether the camera + frame (or any registered sub-camera frame) is animated, which forces + all scene mobjects to be treated as moving so the static-frame + optimisation is skipped. + + Mirrors ``MultiCamera.get_mobjects_indicating_movement`` so that + :class:`~.ZoomedScene` works with the WebGPU renderer. + """ + return [ + imfc.camera.frame for imfc in self.image_mobjects_from_cameras + ] + # ------------------------------------------------------------------ # Frame geometry helpers (mirrors OpenGLCamera) # ------------------------------------------------------------------ @@ -456,6 +477,24 @@ def remove_fixed_orientation_mobjects(self, *mobjects: Mobject) -> None: """Unregister mobjects previously added with add_fixed_orientation_mobjects.""" self.fixed_orientation_mobjects.difference_update(mobjects) + def add_image_mobject_from_camera(self, image_mob_from_camera: Any) -> None: + """Register an ImageMobjectFromCamera for sub-camera rendering. + + Called by :class:`~.ZoomedScene` when zooming is activated. Each + registered mob is rendered from its associated ``MovingCamera``'s + perspective into a dedicated GPU texture every frame. + + **WebGPU renderer only** — this method is a no-op for Cairo / OpenGL. + """ + if image_mob_from_camera not in self.image_mobjects_from_cameras: + self.image_mobjects_from_cameras.append(image_mob_from_camera) + + def remove_image_mobject_from_camera(self, image_mob_from_camera: Any) -> None: + """Unregister an ImageMobjectFromCamera previously added via + ``add_image_mobject_from_camera``.""" + if image_mob_from_camera in self.image_mobjects_from_cameras: + self.image_mobjects_from_cameras.remove(image_mob_from_camera) + # --------------------------------------------------------------------------- # Main renderer class @@ -543,6 +582,17 @@ def __init__( # screen-aligned lit sphere quads (CPU-expanded, 6 verts per dot). self._true_dot_pipeline: wgpu_t.GPURenderPipeline | None = None + # Sub-camera pipelines — identical to the main pipelines but target + # rgba8unorm instead of bgra8unorm so the rendered texture can be + # sampled by the image pipeline without B↔R channel confusion. + # Used for ImageMobjectFromCamera (ZoomedScene support). + self._sub_cam_fill_stroke_pipeline: wgpu_t.GPURenderPipeline | None = None + self._sub_cam_fill_stroke_3d_pipeline: wgpu_t.GPURenderPipeline | None = None + self._sub_cam_surface_pipeline: wgpu_t.GPURenderPipeline | None = None + # Per-mob GPU resource cache: mob id → dict with render texture, + # depth texture, uniform buffer, camera bind group, tex bind group. + self._sub_cam_resources: dict[int, dict] = {} + # Image pipeline (image.wgsl) — renders ImageMobject pixel arrays as # textured quads before the VMobject pass (painter's algorithm). self._image_pipeline: wgpu_t.GPURenderPipeline | None = None @@ -679,6 +729,17 @@ def init_scene(self, scene: Scene) -> None: self._image_tex_bgl, self._image_pipeline = self._create_image_pipeline() self._true_dot_pipeline = self._create_true_dot_pipeline(self._proj_bgl) + # Sub-camera pipelines (rgba8unorm target) for ZoomedScene support. + _, self._sub_cam_fill_stroke_pipeline = self._create_fill_stroke_pipeline( + depth_test=False, target_format="rgba8unorm" + ) + _, self._sub_cam_fill_stroke_3d_pipeline = self._create_fill_stroke_pipeline( + depth_test=True, target_format="rgba8unorm" + ) + self._sub_cam_surface_pipeline = self._create_surface_pipeline( + self._proj_bgl, cull_mode="none", depth_write=True, target_format="rgba8unorm" + ) + # Persistent camera uniform buffers — created once, updated each frame via # write_buffer. Using COPY_DST so queue.write_buffer can write into them. # These stable GPU objects let cached _FrameData bind groups remain valid @@ -744,6 +805,7 @@ def _create_camera_bgl(self) -> wgpu_t.GPUBindGroupLayout: def _create_fill_stroke_pipeline( self, depth_test: bool = False, + target_format: str = "bgra8unorm", ) -> tuple[wgpu_t.GPUBindGroupLayout, wgpu_t.GPURenderPipeline]: """Create the combined fill+stroke pipeline (vmobject_fill_stroke.wgsl). @@ -798,7 +860,7 @@ def _create_fill_stroke_pipeline( fragment={ "module": shader_module, "entry_point": "fs_main", - "targets": [{"format": wgpu.TextureFormat.bgra8unorm, "blend": _blend}], + "targets": [{"format": getattr(wgpu.TextureFormat, target_format), "blend": _blend}], }, primitive={"topology": "triangle-list", "cull_mode": "none"}, depth_stencil={ @@ -863,6 +925,7 @@ def _create_surface_pipeline( proj_bgl: wgpu_t.GPUBindGroupLayout, cull_mode: str = "none", depth_write: bool = True, + target_format: str = "bgra8unorm", ) -> wgpu_t.GPURenderPipeline: """Create a surface (mesh) pipeline. @@ -905,7 +968,7 @@ def _create_surface_pipeline( fragment={ "module": shader_module, "entry_point": "fs_main", - "targets": [{"format": wgpu.TextureFormat.bgra8unorm, "blend": _blend}], + "targets": [{"format": getattr(wgpu.TextureFormat, target_format), "blend": _blend}], }, primitive={"topology": "triangle-list", "cull_mode": cull_mode}, depth_stencil={ @@ -1388,6 +1451,197 @@ def _pack_camera_uniforms_bytes( return proj_bytes + view_bytes + header + light_data + # ------------------------------------------------------------------ + # Sub-camera rendering (ZoomedScene / ImageMobjectFromCamera) + # ------------------------------------------------------------------ + + def _sub_camera_proj_view(self, sub_cam_frame: Any) -> tuple[np.ndarray, np.ndarray]: + """Return (proj, view) matrices for a MovingCamera's frame viewport. + + The sub-camera is always orthographic. Its viewport is defined by the + ``frame`` mobject's current center and size. + """ + fw = float(sub_cam_frame.get_width()) + fh = float(sub_cam_frame.get_height()) + cen = sub_cam_frame.get_center() + cx, cy = float(cen[0]), float(cen[1]) + near, far = -100.0, 100.0 + + # Orthographic projection using the sub-camera's frame dimensions + # (centered at origin — the view matrix handles the translation). + proj = np.array( + [ + [2.0 / fw, 0.0, 0.0, 0.0], + [0.0, 2.0 / fh, 0.0, 0.0], + [0.0, 0.0, -1.0 / (far - near), far / (far - near)], + [0.0, 0.0, 0.0, 1.0], + ], + dtype=np.float32, + ) + + # View: translate the world so frame center lands at the origin. + view = np.eye(4, dtype=np.float32) + view[0, 3] = -cx + view[1, 3] = -cy + view[2, 3] = -float(self.camera.focal_distance) + return proj, view + + def _get_sub_cam_resources(self, mob: Any) -> dict: + """Return (and lazily create) per-mob GPU resources for sub-camera rendering. + + Returns a dict with keys: + render_tex, render_view — rgba8unorm render target (RENDER_ATTACHMENT | TEXTURE_BINDING) + depth_tex, depth_view — depth24plus depth buffer + uniform_buf — 656-byte camera uniform buffer (COPY_DST | UNIFORM) + cam_bg — camera-only bind group (proj_bgl, binding 0 = uniform_buf) + tex_bg — image display bind group (image_tex_bgl, binding 0 = render_view) + """ + assert self._device is not None + assert self._proj_bgl is not None + assert self._image_tex_bgl is not None + + mob_id = id(mob) + if mob_id in self._sub_cam_resources: + return self._sub_cam_resources[mob_id] + + w, h = config.pixel_width, config.pixel_height + _UBO_SIZE = 656 + + render_tex = self._device.create_texture( + size=(w, h, 1), + format=wgpu.TextureFormat.rgba8unorm, + usage=wgpu.TextureUsage.RENDER_ATTACHMENT | wgpu.TextureUsage.TEXTURE_BINDING, + ) + render_view = render_tex.create_view() + + depth_tex = self._device.create_texture( + size=(w, h, 1), + format=wgpu.TextureFormat.depth24plus, + usage=wgpu.TextureUsage.RENDER_ATTACHMENT, + ) + depth_view = depth_tex.create_view() + + uniform_buf = self._device.create_buffer( + size=_UBO_SIZE, + usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST, + ) + cam_bg = self._device.create_bind_group( + layout=self._proj_bgl, + entries=[{"binding": 0, "resource": {"buffer": uniform_buf, "offset": 0, "size": _UBO_SIZE}}], + ) + + sampler = self._device.create_sampler( + min_filter="linear", + mag_filter="linear", + address_mode_u="clamp-to-edge", + address_mode_v="clamp-to-edge", + ) + tex_bg = self._device.create_bind_group( + layout=self._image_tex_bgl, + entries=[ + {"binding": 0, "resource": render_view}, + {"binding": 1, "resource": sampler}, + ], + ) + + resources = { + "render_tex": render_tex, + "render_view": render_view, + "depth_tex": depth_tex, + "depth_view": depth_view, + "uniform_buf": uniform_buf, + "cam_bg": cam_bg, + "tex_bg": tex_bg, + } + self._sub_cam_resources[mob_id] = resources + return resources + + def _render_sub_camera_pass( + self, + mob: Any, + encoder: Any, + normal_fds: list, + ) -> None: + """Render a sub-camera view for *mob* (an ImageMobjectFromCamera). + + The sub-camera's view of the main scene is drawn into the mob's + persistent rgba8unorm render texture using sub-camera pipelines. + The result is available as ``resources["tex_bg"]`` in the same frame. + + Parameters + ---------- + mob + An ``ImageMobjectFromCamera`` instance with a ``camera`` attribute + that is a ``MovingCamera``. + encoder + Active ``GPUCommandEncoder`` (compute pass must already be ended). + normal_fds + List of ``_FrameData`` objects from the main frame's normal-camera + render queue. The same GPU geometry buffers are reused here with + a different camera uniform. + """ + assert self._device is not None + assert self._fill_stroke_bgl is not None + assert self._sub_cam_fill_stroke_pipeline is not None + assert self._sub_cam_fill_stroke_3d_pipeline is not None + assert self._sub_cam_surface_pipeline is not None + + sub_cam_frame = mob.camera.frame + proj, view = self._sub_camera_proj_view(sub_cam_frame) + ubo_bytes = self._pack_camera_uniforms_bytes(proj, view) + + res = self._get_sub_cam_resources(mob) + self._device.queue.write_buffer(res["uniform_buf"], 0, ubo_bytes) + + bg = self._background_color + + sub_pass = encoder.begin_render_pass( + color_attachments=[ + { + "view": res["render_view"], + "load_op": "clear", + "store_op": "store", + "clear_value": tuple(float(c) for c in bg), + } + ], + depth_stencil_attachment={ + "view": res["depth_view"], + "depth_clear_value": 1.0, + "depth_load_op": "clear", + "depth_store_op": "store", + }, + ) + + for fd in normal_fds: + if fd is None: + continue + # Build a sub-camera bind group that combines the sub-camera + # uniform buffer (binding 0) with the same quads output buffer + # (binding 1) used in the main render. This reuses the already- + # computed quadratic Bezier data without re-running the compute shader. + if fd.quads_out_buf is not None: + sub_fill_render_bg = self._device.create_bind_group( + layout=self._fill_stroke_bgl, + entries=[ + {"binding": 0, "resource": {"buffer": res["uniform_buf"], "offset": 0, "size": res["uniform_buf"].size}}, + {"binding": 1, "resource": {"buffer": fd.quads_out_buf, "offset": 0, "size": fd.quads_out_buf.size}}, + ], + ) + else: + sub_fill_render_bg = None + + draw_frame_data_subcam( + sub_pass, + fd, + sub_fill_render_bg=sub_fill_render_bg, + sub_cam_bg=res["cam_bg"], + fill_2d_pipeline=self._sub_cam_fill_stroke_pipeline, + fill_3d_pipeline=self._sub_cam_fill_stroke_3d_pipeline, + surf_pipeline=self._sub_cam_surface_pipeline, + ) + + sub_pass.end() + # Keep the old name as a shim so any external callers don't break. def _pack_camera_uniforms(self, proj: np.ndarray, view: np.ndarray) -> wgpu_t.GPUBuffer: """Create a throw-away 656-byte uniform buffer (legacy path, rarely used).""" @@ -1636,14 +1890,24 @@ def _walk(mob: Any) -> None: # command encoder starts. Replace ('image', mob) queue items with # ('image', vbo, tex_bg) so the render loop has no CPU work left. # Similarly expand TrueDot mobs into vertex arrays and GPU buffers. + # + # ImageMobjectFromCamera mobs are handled specially: instead of reading + # their pixel_array (which is never populated by the WebGPU renderer), + # we use the pre-rendered sub-camera texture produced in Pass 0.5. resolved_queue: list[tuple] = [] for item in render_queue: if item[0] == "image": mob = item[1] vbo = self._build_image_vbo(mob) - resources = self._get_image_gpu_resources(mob) - if vbo is not None and resources is not None: - resolved_queue.append(("image", vbo, resources[1])) + if isinstance(mob, ImageMobjectFromCamera): + res = self._sub_cam_resources.get(id(mob)) + tex_bg = res["tex_bg"] if res is not None else None + if vbo is not None and tex_bg is not None: + resolved_queue.append(("image", vbo, tex_bg)) + else: + resources = self._get_image_gpu_resources(mob) + if vbo is not None and resources is not None: + resolved_queue.append(("image", vbo, resources[1])) elif item[0] == "truedot": mob = item[1] arr = build_true_dot_vbo(mob) @@ -1726,6 +1990,17 @@ def _f(m: Any) -> None: cp.dispatch_workgroups((fd.n_cubics_total + 63) // 64, 1, 1) cp.end() + # ── Pass 0.5: sub-camera render passes ─────────────────────────── + # Render scene geometry into each ImageMobjectFromCamera's private + # rgba8unorm texture so it is ready to be sampled as an image in + # Pass 1. These passes share the already-computed quads buffers + # from the compute pass above; no re-dispatch is needed. + if self.camera.image_mobjects_from_cameras: + normal_fds = [item[1] for item in resolved_queue if item[0] == "vmobs"] + for sub_mob in self.camera.image_mobjects_from_cameras: + self._get_sub_cam_resources(sub_mob) # ensure resources created + self._render_sub_camera_pass(sub_mob, encoder, normal_fds) + # ── Pass 1: main render ─────────────────────────────────────────── # Draw the z-ordered render queue (VMobject batches and images # interleaved in scene.mobjects order) so painter's-algorithm depth diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py index 4c0a29fc9e..e4fec665ac 100644 --- a/manim/renderer/webgpu/webgpu_vmobject_rendering.py +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -815,6 +815,128 @@ def draw_frame_data( rp.draw(run_vertex_count, 1, run_first_vertex, 0) +def draw_frame_data_subcam( + rp: Any, + fd: _FrameData, + sub_fill_render_bg: Any, + sub_cam_bg: Any, + fill_2d_pipeline: Any, + fill_3d_pipeline: Any, + surf_pipeline: Any, +) -> None: + """Draw *fd* into render pass *rp* using sub-camera pipelines and bind groups. + + Mirrors ``draw_frame_data`` but accepts explicit pipeline objects and bind + groups instead of reading them from the renderer. Used by + ``_render_sub_camera_pass`` to reuse cached geometry (quads buffer, vertex + buffer, surface buffer) with a different camera uniform. + + Parameters + ---------- + rp + Active render pass encoder targeting the sub-camera texture. + fd + Cached geometry from the main frame's ``collect_frame_data`` call. + sub_fill_render_bg + Bind group with sub-camera uniform (binding 0) + quads storage (binding 1). + Replaces ``fd.render_bg`` for fill-stroke draw calls. + sub_cam_bg + Camera-only bind group with sub-camera uniform (binding 0). + Used for surface draw calls. + fill_2d_pipeline / fill_3d_pipeline / surf_pipeline + Sub-camera render pipelines targeting the sub-camera texture format. + """ + # ── 2-D fill+stroke ─────────────────────────────────────────────────── + if fd.fs_buf is not None and sub_fill_render_bg is not None: + rp.set_pipeline(fill_2d_pipeline) + rp.set_bind_group(0, sub_fill_render_bg, [], 0, 0) + rp.set_vertex_buffer(0, fd.fs_buf) + + run_first_vertex: int = -1 + run_vertex_count: int = 0 + for cmd, idx in fd.draw_plan: + if cmd != "fill_stroke_2d": + if run_vertex_count > 0: + rp.draw(run_vertex_count, 1, run_first_vertex, 0) + run_vertex_count = 0 + run_first_vertex = -1 + continue + arr = fd.fs_parts[idx] + byte_offset = fd.fs_byte_offsets[idx] + first_vert = byte_offset // _FILL_STROKE_STRIDE + if run_first_vertex < 0: + run_first_vertex = first_vert + run_vertex_count = len(arr) + elif first_vert == run_first_vertex + run_vertex_count: + run_vertex_count += len(arr) + else: + rp.draw(run_vertex_count, 1, run_first_vertex, 0) + run_first_vertex = first_vert + run_vertex_count = len(arr) + if run_vertex_count > 0: + rp.draw(run_vertex_count, 1, run_first_vertex, 0) + + # ── 3-D fill+stroke ─────────────────────────────────────────────────── + if fd.fs_buf is not None and sub_fill_render_bg is not None: + rp.set_pipeline(fill_3d_pipeline) + rp.set_bind_group(0, sub_fill_render_bg, [], 0, 0) + rp.set_vertex_buffer(0, fd.fs_buf) + + run_first_vertex = -1 + run_vertex_count = 0 + for cmd, idx in fd.draw_plan: + if cmd != "fill_stroke_3d": + if run_vertex_count > 0: + rp.draw(run_vertex_count, 1, run_first_vertex, 0) + run_vertex_count = 0 + run_first_vertex = -1 + continue + arr = fd.fs_parts[idx] + byte_offset = fd.fs_byte_offsets[idx] + first_vert = byte_offset // _FILL_STROKE_STRIDE + if run_first_vertex < 0: + run_first_vertex = first_vert + run_vertex_count = len(arr) + elif first_vert == run_first_vertex + run_vertex_count: + run_vertex_count += len(arr) + else: + rp.draw(run_vertex_count, 1, run_first_vertex, 0) + run_first_vertex = first_vert + run_vertex_count = len(arr) + if run_vertex_count > 0: + rp.draw(run_vertex_count, 1, run_first_vertex, 0) + + # ── Opaque surfaces ─────────────────────────────────────────────────── + if fd.surface_buf is not None and surf_pipeline is not None: + rp.set_pipeline(surf_pipeline) + rp.set_bind_group(0, sub_cam_bg, [], 0, 0) + rp.set_vertex_buffer(0, fd.surface_buf) + + run_first_vertex = -1 + run_vertex_count = 0 + for cmd, idx in fd.draw_plan: + if cmd != "surface_opaque": + if run_vertex_count > 0: + rp.draw(run_vertex_count, 1, run_first_vertex, 0) + run_vertex_count = 0 + run_first_vertex = -1 + continue + arr = fd.surface_parts[idx] + byte_offset = fd.surface_byte_offsets[idx] + first_vert = byte_offset // _SURFACE_COMBINED_STRIDE + if run_first_vertex < 0: + run_first_vertex = first_vert + run_vertex_count = len(arr) + elif first_vert == run_first_vertex + run_vertex_count: + run_vertex_count += len(arr) + else: + rp.draw(run_vertex_count, 1, run_first_vertex, 0) + run_first_vertex = first_vert + run_vertex_count = len(arr) + if run_vertex_count > 0: + rp.draw(run_vertex_count, 1, run_first_vertex, 0) + + # --------------------------------------------------------------------------- # GPU upload helpers # --------------------------------------------------------------------------- From 0ddf1d3f769dbd7a028d8c55382efde1843da53e Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Fri, 10 Apr 2026 09:45:40 +0530 Subject: [PATCH 22/33] Enabled per surface reflection parameters in WebGPU render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial step towards material modeling of surface. Right three reflection parameters are set: diffuse_strength Strength of the diffuse (Lambertian) lighting component, in [0, 1]. Defaults to 0.8. specular_strength Strength of the specular (Phong) highlight, in [0, ∞]. Defaults to 0.9. specular_exponent Phong shininess exponent — higher values produce a tighter, sharper specular highlight; lower values produce a broad, soft one. Defaults to 16.0. We can implement MeshPhongMaterial (from three.js) like class in future if needed. --- manim/mobject/three_d/three_dimensions.py | 173 +++++++++++++++++- .../webgpu/shaders/surface_combined.wgsl | 43 +++-- .../renderer/webgpu/shaders/surface_oit.wgsl | 43 +++-- .../webgpu/webgpu_vmobject_rendering.py | 93 ++++++---- 4 files changed, 268 insertions(+), 84 deletions(-) diff --git a/manim/mobject/three_d/three_dimensions.py b/manim/mobject/three_d/three_dimensions.py index 884595664a..ef9f114f25 100644 --- a/manim/mobject/three_d/three_dimensions.py +++ b/manim/mobject/three_d/three_dimensions.py @@ -91,11 +91,24 @@ class Surface(VGroup, metaclass=ConvertToOpenGL): diffuse_strength Strength of the diffuse (Lambertian) lighting component, in [0, 1]. Defaults to 0.8. - **WebGPU renderer only** — ignored by the Cairo and OpenGL renderers. specular_strength Strength of the specular (Phong) highlight, in [0, ∞]. Defaults to 0.9. - **WebGPU renderer only** — ignored by the Cairo and OpenGL renderers. + specular_exponent + Phong shininess exponent — higher values produce a tighter, sharper + specular highlight; lower values produce a broad, soft one. + Defaults to 16.0. + + .. warning:: + + The ``diffuse_strength``, ``specular_strength``, and + ``specular_exponent`` parameters — and all material setter methods + (:meth:`set_diffuse_strength`, :meth:`set_specular_strength`, + :meth:`set_specular_exponent`, :meth:`set_material`, + :meth:`set_diffuse_by_func`, :meth:`set_specular_by_func`, + :meth:`set_specular_exponent_by_func`, :meth:`set_material_by_func`) + — are **WebGPU renderer only**. They are silently ignored by the + Cairo and OpenGL renderers. Examples -------- @@ -137,12 +150,14 @@ def __init__( pre_function_handle_to_anchor_scale_factor: float = 0.00001, diffuse_strength: float = 0.8, specular_strength: float = 0.9, + specular_exponent: float = 16.0, **kwargs: Any, ) -> None: self.u_range = u_range self.v_range = v_range - self.diffuse_strength = diffuse_strength - self.specular_strength = specular_strength + self.diffuse_strength = float(diffuse_strength) + self.specular_strength = float(specular_strength) + self.specular_exponent = float(specular_exponent) super().__init__( fill_color=fill_color, fill_opacity=fill_opacity, @@ -171,6 +186,142 @@ def __init__( def func(self, u: float, v: float) -> np.ndarray: return self._func(u, v) + # ------------------------------------------------------------------ + # Material setters (WebGPU renderer only) + # ------------------------------------------------------------------ + + def set_diffuse_strength(self, value: float) -> "Surface": + """Set the Lambertian diffuse strength in [0, 1]. + + .. warning:: **WebGPU renderer only** — ignored by Cairo and OpenGL. + """ + self.diffuse_strength = float(value) + return self + + def set_specular_strength(self, value: float) -> "Surface": + """Set the Phong specular highlight strength. + + .. warning:: **WebGPU renderer only** — ignored by Cairo and OpenGL. + """ + self.specular_strength = float(value) + return self + + def set_specular_exponent(self, value: float) -> "Surface": + """Set the Phong shininess exponent. + + Higher values give a tighter highlight; lower values give a broad, + soft one. Typical range: 4 (very soft) to 128 (mirror-like). + + .. warning:: **WebGPU renderer only** — ignored by Cairo and OpenGL. + """ + self.specular_exponent = float(value) + return self + + def set_material( + self, + diffuse_strength: float | None = None, + specular_strength: float | None = None, + specular_exponent: float | None = None, + ) -> "Surface": + """Set material parameters uniformly across the whole surface. + + Any parameter left as ``None`` is unchanged. Per-patch overrides + set via :meth:`set_diffuse_by_func` etc. take precedence at render + time. + + .. warning:: **WebGPU renderer only** — ignored by Cairo and OpenGL. + """ + if diffuse_strength is not None: + self.diffuse_strength = float(diffuse_strength) + if specular_strength is not None: + self.specular_strength = float(specular_strength) + if specular_exponent is not None: + self.specular_exponent = float(specular_exponent) + return self + + # ------------------------------------------------------------------ + # Per-patch material — function-based assignment + # ------------------------------------------------------------------ + + def set_diffuse_by_func( + self, func: "Callable[[float, float], float]" + ) -> "Surface": + """Assign a per-patch diffuse strength using a ``(u, v)`` function. + + *func* is called with the centre ``(u, v)`` coordinates of each + patch and must return a float in [0, 1]. + + Example — gradient from matte at the bottom to reflective at top:: + + surface.set_diffuse_by_func(lambda u, v: v / v_max) + + .. warning:: **WebGPU renderer only** — ignored by Cairo and OpenGL. + """ + for face in self.list_of_faces: + face.diffuse_strength = float(func(face.u_center, face.v_center)) + return self + + def set_specular_by_func( + self, func: "Callable[[float, float], float]" + ) -> "Surface": + """Assign a per-patch specular strength using a ``(u, v)`` function. + + *func* is called with the centre ``(u, v)`` coordinates of each + patch and must return a float. + + .. warning:: **WebGPU renderer only** — ignored by Cairo and OpenGL. + """ + for face in self.list_of_faces: + face.specular_strength = float(func(face.u_center, face.v_center)) + return self + + def set_specular_exponent_by_func( + self, func: "Callable[[float, float], float]" + ) -> "Surface": + """Assign a per-patch specular exponent (shininess) using a ``(u, v)`` + function. + + *func* is called with the centre ``(u, v)`` coordinates of each + patch and must return a float (typical range: 4 to 128). + + .. warning:: **WebGPU renderer only** — ignored by Cairo and OpenGL. + """ + for face in self.list_of_faces: + face.specular_exponent = float(func(face.u_center, face.v_center)) + return self + + def set_material_by_func( + self, func: "Callable[[float, float], dict]" + ) -> "Surface": + """Assign per-patch material parameters using a ``(u, v)`` function. + + *func* is called with the centre ``(u, v)`` of each patch and must + return a :class:`dict` with any subset of the keys + ``"diffuse_strength"``, ``"specular_strength"``, + ``"specular_exponent"``. Missing keys leave the corresponding + attribute unchanged on that patch. + + Example — shinier at the equator, matte at the poles:: + + def mat(u, v): + t = abs(np.sin(u)) # 0 at poles, 1 at equator + return {"specular_exponent": 8 + 120 * t, + "specular_strength": 0.2 + 0.8 * t} + + sphere.set_material_by_func(mat) + + .. warning:: **WebGPU renderer only** — ignored by Cairo and OpenGL. + """ + for face in self.list_of_faces: + params = func(face.u_center, face.v_center) + if "diffuse_strength" in params: + face.diffuse_strength = float(params["diffuse_strength"]) + if "specular_strength" in params: + face.specular_strength = float(params["specular_strength"]) + if "specular_exponent" in params: + face.specular_exponent = float(params["specular_exponent"]) + return self + def _get_u_values_and_v_values(self) -> tuple[np.ndarray, np.ndarray]: if isinstance(self.resolution, int): u_res = v_res = self.resolution @@ -201,12 +352,14 @@ def _setup_in_uv_space(self) -> None: ], ) faces.add(face) - face.u_index = i - face.v_index = j - face.u1 = u1 - face.u2 = u2 - face.v1 = v1 - face.v2 = v2 + face.u_index = i + face.v_index = j + face.u1 = u1 + face.u2 = u2 + face.v1 = v1 + face.v2 = v2 + face.u_center = float(u1 + u2) * 0.5 + face.v_center = float(v1 + v2) * 0.5 self.list_of_faces.append(face) faces.set_fill(color=self.fill_color, opacity=self.fill_opacity) faces.set_stroke( diff --git a/manim/renderer/webgpu/shaders/surface_combined.wgsl b/manim/renderer/webgpu/shaders/surface_combined.wgsl index 9bcffa1b20..d90776e26a 100644 --- a/manim/renderer/webgpu/shaders/surface_combined.wgsl +++ b/manim/renderer/webgpu/shaders/surface_combined.wgsl @@ -22,15 +22,16 @@ // // Compositing: wireframe stroke "over" Phong fill (Porter-Duff). // -// Vertex layout (must match _SURFACE_COMBINED_DTYPE, stride 80 bytes): -// location 0 — in_vert float32x3 offset 0 -// location 1 — in_normal float32x3 offset 12 -// location 2 — in_fill_color float32x4 offset 24 -// location 3 — in_stroke_color float32x4 offset 40 -// location 4 — in_bary float32x3 offset 56 -// location 5 — stroke_half_px float32 offset 68 -// location 6 — diffuse_strength float32 offset 72 -// location 7 — specular_strength float32 offset 76 +// Vertex layout (must match _SURFACE_COMBINED_DTYPE, stride 84 bytes): +// location 0 — in_vert float32x3 offset 0 +// location 1 — in_normal float32x3 offset 12 +// location 2 — in_fill_color float32x4 offset 24 +// location 3 — in_stroke_color float32x4 offset 40 +// location 4 — in_bary float32x3 offset 56 +// location 5 — stroke_half_px float32 offset 68 +// location 6 — diffuse_strength float32 offset 72 +// location 7 — specular_strength float32 offset 76 +// location 8 — specular_exponent float32 offset 80 // ── Lighting uniform layout (656 bytes total) ───────────────────────────── // @@ -77,14 +78,15 @@ struct Uniforms { @group(0) @binding(0) var u : Uniforms; struct VertexInput { - @location(0) in_vert : vec3, - @location(1) in_normal : vec3, - @location(2) in_fill_color : vec4, - @location(3) in_stroke_color : vec4, - @location(4) in_bary : vec3, - @location(5) stroke_half_px : f32, - @location(6) diffuse_strength : f32, - @location(7) specular_strength : f32, + @location(0) in_vert : vec3, + @location(1) in_normal : vec3, + @location(2) in_fill_color : vec4, + @location(3) in_stroke_color : vec4, + @location(4) in_bary : vec3, + @location(5) stroke_half_px : f32, + @location(6) diffuse_strength : f32, + @location(7) specular_strength : f32, + @location(8) specular_exponent : f32, }; struct VertexOutput { @@ -97,6 +99,7 @@ struct VertexOutput { @location(5) @interpolate(flat) v_stroke_half : f32, @location(6) @interpolate(flat) v_diffuse : f32, @location(7) @interpolate(flat) v_specular : f32, + @location(8) @interpolate(flat) v_spec_exp : f32, }; @vertex @@ -113,6 +116,7 @@ fn vs_main(in: VertexInput) -> VertexOutput { out.v_stroke_half = in.stroke_half_px; out.v_diffuse = in.diffuse_strength; out.v_specular = in.specular_strength; + out.v_spec_exp = in.specular_exponent; return out; } @@ -124,8 +128,9 @@ fn compute_lighting( base_rgb : vec3, diff_str : f32, spec_str : f32, + spec_exp : f32, ) -> vec3 { - let specular_exp = 16.0; + let specular_exp = spec_exp; let view_dir = normalize(-view_pos); let view3 = mat3x3(u.view[0].xyz, u.view[1].xyz, u.view[2].xyz); @@ -200,7 +205,7 @@ fn fs_main(in: VertexOutput, @builtin(front_facing) front_facing: bool) -> @loca let lit_rgb = compute_lighting( in.v_view_pos, norm, in.v_fill_color.rgb, - diffuse_strength, specular_strength, + diffuse_strength, specular_strength, in.v_spec_exp, ); let fill_a = in.v_fill_color.a; diff --git a/manim/renderer/webgpu/shaders/surface_oit.wgsl b/manim/renderer/webgpu/shaders/surface_oit.wgsl index 4d52014f89..b2c8470725 100644 --- a/manim/renderer/webgpu/shaders/surface_oit.wgsl +++ b/manim/renderer/webgpu/shaders/surface_oit.wgsl @@ -13,15 +13,16 @@ // A subsequent full-screen composition pass reads both textures and composites // the result onto the opaque framebuffer. // -// Vertex layout matches surface_combined.wgsl (stride 80 bytes): -// location 0 — in_vert float32x3 offset 0 -// location 1 — in_normal float32x3 offset 12 -// location 2 — in_fill_color float32x4 offset 24 -// location 3 — in_stroke_color float32x4 offset 40 -// location 4 — in_bary float32x3 offset 56 -// location 5 — stroke_half_px float32 offset 68 -// location 6 — diffuse_strength float32 offset 72 -// location 7 — specular_strength float32 offset 76 +// Vertex layout matches surface_combined.wgsl (stride 84 bytes): +// location 0 — in_vert float32x3 offset 0 +// location 1 — in_normal float32x3 offset 12 +// location 2 — in_fill_color float32x4 offset 24 +// location 3 — in_stroke_color float32x4 offset 40 +// location 4 — in_bary float32x3 offset 56 +// location 5 — stroke_half_px float32 offset 68 +// location 6 — diffuse_strength float32 offset 72 +// location 7 — specular_strength float32 offset 76 +// location 8 — specular_exponent float32 offset 80 // ── Lighting uniform layout (656 bytes total) ───────────────────────────── // @@ -68,14 +69,15 @@ struct Uniforms { @group(0) @binding(0) var u : Uniforms; struct VertexInput { - @location(0) in_vert : vec3, - @location(1) in_normal : vec3, - @location(2) in_fill_color : vec4, - @location(3) in_stroke_color : vec4, - @location(4) in_bary : vec3, - @location(5) stroke_half_px : f32, - @location(6) diffuse_strength : f32, - @location(7) specular_strength : f32, + @location(0) in_vert : vec3, + @location(1) in_normal : vec3, + @location(2) in_fill_color : vec4, + @location(3) in_stroke_color : vec4, + @location(4) in_bary : vec3, + @location(5) stroke_half_px : f32, + @location(6) diffuse_strength : f32, + @location(7) specular_strength : f32, + @location(8) specular_exponent : f32, }; struct VertexOutput { @@ -88,6 +90,7 @@ struct VertexOutput { @location(5) @interpolate(flat) v_stroke_half : f32, @location(6) @interpolate(flat) v_diffuse : f32, @location(7) @interpolate(flat) v_specular : f32, + @location(8) @interpolate(flat) v_spec_exp : f32, }; @vertex @@ -104,6 +107,7 @@ fn vs_main(in: VertexInput) -> VertexOutput { out.v_stroke_half = in.stroke_half_px; out.v_diffuse = in.diffuse_strength; out.v_specular = in.specular_strength; + out.v_spec_exp = in.specular_exponent; return out; } @@ -115,8 +119,9 @@ fn compute_lighting( base_rgb : vec3, diff_str : f32, spec_str : f32, + spec_exp : f32, ) -> vec3 { - let specular_exp = 16.0; + let specular_exp = spec_exp; let view_dir = normalize(-view_pos); let view3 = mat3x3(u.view[0].xyz, u.view[1].xyz, u.view[2].xyz); @@ -187,7 +192,7 @@ fn fs_main(in: VertexOutput, @builtin(front_facing) front_facing: bool) -> FragO let lit_rgb = compute_lighting( in.v_view_pos, norm, in.v_fill_color.rgb, - diffuse_strength, specular_strength, + diffuse_strength, specular_strength, in.v_spec_exp, ); let fill_a = in.v_fill_color.a; diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py index e4fec665ac..acad6100cf 100644 --- a/manim/renderer/webgpu/webgpu_vmobject_rendering.py +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -59,28 +59,32 @@ # Combined surface vertex layout — must match surface_combined.wgsl / # surface_oit.wgsl locations. # -# location 0 — in_vert float32x3 offset 0 (12 B) -# location 1 — in_normal float32x3 offset 12 (12 B) -# location 2 — in_fill_color float32x4 offset 24 (16 B) -# location 3 — in_stroke_color float32x4 offset 40 (16 B) -# location 4 — in_bary float32x3 offset 56 (12 B) -# location 5 — stroke_half_px float32 offset 68 ( 4 B) -# stride: 72 bytes +# location 0 — in_vert float32x3 offset 0 (12 B) +# location 1 — in_normal float32x3 offset 12 (12 B) +# location 2 — in_fill_color float32x4 offset 24 (16 B) +# location 3 — in_stroke_color float32x4 offset 40 (16 B) +# location 4 — in_bary float32x3 offset 56 (12 B) +# location 5 — stroke_half_px float32 offset 68 ( 4 B) +# location 6 — diffuse_strength float32 offset 72 ( 4 B) +# location 7 — specular_strength float32 offset 76 ( 4 B) +# location 8 — specular_exponent float32 offset 80 ( 4 B) +# stride: 84 bytes # --------------------------------------------------------------------------- _SURFACE_COMBINED_DTYPE = np.dtype( [ - ("in_vert", np.float32, (3,)), - ("in_normal", np.float32, (3,)), - ("in_fill_color", np.float32, (4,)), - ("in_stroke_color", np.float32, (4,)), - ("in_bary", np.float32, (3,)), - ("stroke_half_px", np.float32), - ("diffuse_strength", np.float32), - ("specular_strength", np.float32), + ("in_vert", np.float32, (3,)), + ("in_normal", np.float32, (3,)), + ("in_fill_color", np.float32, (4,)), + ("in_stroke_color", np.float32, (4,)), + ("in_bary", np.float32, (3,)), + ("stroke_half_px", np.float32), + ("diffuse_strength", np.float32), + ("specular_strength", np.float32), + ("specular_exponent", np.float32), ] ) -_SURFACE_COMBINED_STRIDE: int = _SURFACE_COMBINED_DTYPE.itemsize # 80 bytes +_SURFACE_COMBINED_STRIDE: int = _SURFACE_COMBINED_DTYPE.itemsize # 84 bytes _SURFACE_COMBINED_OFFSETS: dict[str, int] = { name: _SURFACE_COMBINED_DTYPE.fields[name][1] # type: ignore[index] @@ -91,14 +95,15 @@ "array_stride": _SURFACE_COMBINED_STRIDE, "step_mode": "vertex", "attributes": [ - {"format": "float32x3", "offset": _SURFACE_COMBINED_OFFSETS["in_vert"], "shader_location": 0}, - {"format": "float32x3", "offset": _SURFACE_COMBINED_OFFSETS["in_normal"], "shader_location": 1}, - {"format": "float32x4", "offset": _SURFACE_COMBINED_OFFSETS["in_fill_color"], "shader_location": 2}, - {"format": "float32x4", "offset": _SURFACE_COMBINED_OFFSETS["in_stroke_color"], "shader_location": 3}, - {"format": "float32x3", "offset": _SURFACE_COMBINED_OFFSETS["in_bary"], "shader_location": 4}, - {"format": "float32", "offset": _SURFACE_COMBINED_OFFSETS["stroke_half_px"], "shader_location": 5}, - {"format": "float32", "offset": _SURFACE_COMBINED_OFFSETS["diffuse_strength"], "shader_location": 6}, - {"format": "float32", "offset": _SURFACE_COMBINED_OFFSETS["specular_strength"], "shader_location": 7}, + {"format": "float32x3", "offset": _SURFACE_COMBINED_OFFSETS["in_vert"], "shader_location": 0}, + {"format": "float32x3", "offset": _SURFACE_COMBINED_OFFSETS["in_normal"], "shader_location": 1}, + {"format": "float32x4", "offset": _SURFACE_COMBINED_OFFSETS["in_fill_color"], "shader_location": 2}, + {"format": "float32x4", "offset": _SURFACE_COMBINED_OFFSETS["in_stroke_color"], "shader_location": 3}, + {"format": "float32x3", "offset": _SURFACE_COMBINED_OFFSETS["in_bary"], "shader_location": 4}, + {"format": "float32", "offset": _SURFACE_COMBINED_OFFSETS["stroke_half_px"], "shader_location": 5}, + {"format": "float32", "offset": _SURFACE_COMBINED_OFFSETS["diffuse_strength"], "shader_location": 6}, + {"format": "float32", "offset": _SURFACE_COMBINED_OFFSETS["specular_strength"], "shader_location": 7}, + {"format": "float32", "offset": _SURFACE_COMBINED_OFFSETS["specular_exponent"], "shader_location": 8}, ], } @@ -455,12 +460,22 @@ def collect_frame_data( surface_submobs = mob.family_members_with_points() if use_z_index: surface_submobs = sorted(surface_submobs, key=lambda m: m.z_index) + # Read material params from the parent Surface as defaults; each + # submobject patch may override them individually by carrying its + # own diffuse_strength / specular_strength / specular_exponent + # instance attribute (set via set_*_by_func or direct assignment). + surf_diffuse = float(getattr(mob, "diffuse_strength", 0.8)) + surf_specular = float(getattr(mob, "specular_strength", 0.9)) + surf_spec_exp = float(getattr(mob, "specular_exponent", 16.0)) for submob in surface_submobs: if id(submob) in _seen_submobs: continue _seen_submobs.add(id(submob)) data = _collect_surface_geometry( - submob, view_matrix, proj_matrix + submob, view_matrix, proj_matrix, + diffuse_strength = float(getattr(submob, "diffuse_strength", surf_diffuse)), + specular_strength = float(getattr(submob, "specular_strength", surf_specular)), + specular_exponent = float(getattr(submob, "specular_exponent", surf_spec_exp)), ) if data is not None: cls = _surface_opacity_class(data) @@ -1151,9 +1166,17 @@ def _collect_surface_geometry( vmobject: VMobject, view_matrix: np.ndarray, proj_matrix: np.ndarray, + diffuse_strength: float = 0.8, + specular_strength: float = 0.9, + specular_exponent: float = 16.0, ) -> np.ndarray | None: """Return a ``_SURFACE_COMBINED_DTYPE`` array for a shade_in_3d VMobject. + Material parameters are passed from the parent :class:`~.Surface` so + that ``diffuse_strength``, ``specular_strength``, and + ``specular_exponent`` set on the parent are applied to every submobject + patch. + Barycentric coordinates are assigned per triangle in the centroid fan: centroid → bary = (1, 0, 0) (bary.x = 0 on outer edge) anchor_i → bary = (0, 1, 0) @@ -1245,18 +1268,16 @@ def _collect_surface_geometry( stroke_half_ndc = float(0.004 * stroke_width * abs(pm[0, 0]) / abs(avg_clip_w)) stroke_half_px = stroke_half_ndc * config.pixel_width * 0.5 - diffuse_strength = float(getattr(vmobject, "diffuse_strength", 0.8)) - specular_strength = float(getattr(vmobject, "specular_strength", 0.9)) - attrs = np.empty(n_total, dtype=_SURFACE_COMBINED_DTYPE) - attrs["in_vert"] = verts - attrs["in_normal"] = normals - attrs["in_fill_color"] = fill_color - attrs["in_stroke_color"] = stroke_color - attrs["in_bary"] = bary - attrs["stroke_half_px"] = stroke_half_px - attrs["diffuse_strength"] = diffuse_strength - attrs["specular_strength"] = specular_strength + attrs["in_vert"] = verts + attrs["in_normal"] = normals + attrs["in_fill_color"] = fill_color + attrs["in_stroke_color"] = stroke_color + attrs["in_bary"] = bary + attrs["stroke_half_px"] = stroke_half_px + attrs["diffuse_strength"] = float(diffuse_strength) + attrs["specular_strength"] = float(specular_strength) + attrs["specular_exponent"] = float(specular_exponent) return attrs From d77196daa2967e69c212338e4730aac36a9f9abd Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Fri, 10 Apr 2026 21:46:54 +0530 Subject: [PATCH 23/33] Added support for complex SVG in WebGPU renderer --- manim/mobject/svg/svg_mobject.py | 3 + manim/renderer/webgpu/shaders/image.wgsl | 20 +- .../webgpu/shaders/vmobject_fill_stroke.wgsl | 29 ++- manim/renderer/webgpu/webgpu_renderer.py | 224 ++++++++++++++---- .../webgpu/webgpu_vmobject_rendering.py | 67 +++++- 5 files changed, 288 insertions(+), 55 deletions(-) diff --git a/manim/mobject/svg/svg_mobject.py b/manim/mobject/svg/svg_mobject.py index c296130a27..71dac9f830 100644 --- a/manim/mobject/svg/svg_mobject.py +++ b/manim/mobject/svg/svg_mobject.py @@ -380,6 +380,9 @@ def apply_style_to_mobject(mob: VMobject, shape: se.GraphicObject) -> VMobject: fill_color=shape.fill.hexrgb, fill_opacity=shape.fill.opacity, ) + # Extract fill-rule for WebGPU renderer (0=nonzero, 1=evenodd). + fill_rule_str = shape.values.get("fill-rule", "nonzero") + mob.fill_rule = 1 if fill_rule_str == "evenodd" else 0 return mob def path_to_mobject(self, path: se.Path) -> VMobjectFromSVGPath: diff --git a/manim/renderer/webgpu/shaders/image.wgsl b/manim/renderer/webgpu/shaders/image.wgsl index 84e87c7945..e6c859041d 100644 --- a/manim/renderer/webgpu/shaders/image.wgsl +++ b/manim/renderer/webgpu/shaders/image.wgsl @@ -13,6 +13,16 @@ // binding 0 — texture_2d (rgba8unorm uploaded as f32 [0,1] per channel) // binding 1 — sampler (linear, clamp-to-edge) // +// UV origin is top-left (matches WebGPU texture layout and Manim pixel_array +// row-major order: row 0 = top). No y-flip is needed. +// +// Tint uniform (group 2, binding 0) — 16-byte block: +// offset 0 — rgb vec3 12 B per-channel colour multiplier +// offset 12 — _pad f32 4 B alignment padding (unused) +// +// Default white (1, 1, 1) is identity — texture is returned unchanged. +// Set via mob.color; populated by the renderer from mob.color.to_rgb(). +// // Vertex attributes (stride 20 bytes): // location 0 — in_pos float32x3 offset 0 // location 1 — in_uv float32x2 offset 12 @@ -26,6 +36,12 @@ struct Uniforms { @group(1) @binding(0) var img_texture : texture_2d; @group(1) @binding(1) var img_sampler : sampler; +struct TintUniforms { + rgb : vec3, + _pad : f32, +}; +@group(2) @binding(0) var tint : TintUniforms; + struct VertexInput { @location(0) in_pos : vec3, @location(1) in_uv : vec2, @@ -46,5 +62,7 @@ fn vs_main(in: VertexInput) -> VertexOutput { @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { - return textureSample(img_texture, img_sampler, in.uv); + let sample = textureSample(img_texture, img_sampler, in.uv); + // Multiply RGB by the tint; alpha is taken from the texture as-is. + return vec4(sample.rgb * tint.rgb, sample.a); } diff --git a/manim/renderer/webgpu/shaders/vmobject_fill_stroke.wgsl b/manim/renderer/webgpu/shaders/vmobject_fill_stroke.wgsl index b3b2a08bbe..10bd9f5e7b 100644 --- a/manim/renderer/webgpu/shaders/vmobject_fill_stroke.wgsl +++ b/manim/renderer/webgpu/shaders/vmobject_fill_stroke.wgsl @@ -24,7 +24,7 @@ // Storage buffer (group 0, binding 1) — array, 9 floats per quadratic: // [p0.x p0.y p0.z pmid.x pmid.y pmid.z p2.x p2.y p2.z] // -// Vertex attributes (must match _FILL_STROKE_DTYPE, stride 64 bytes): +// Vertex attributes (must match _FILL_STROKE_DTYPE, stride 68 bytes): // location 0 — in_pos float32x3 offset 0 // location 1 — in_fill_color float32x4 offset 12 // location 2 — in_stroke_color float32x4 offset 28 @@ -33,6 +33,7 @@ // location 5 — n_fill_curves uint32 offset 52 // location 6 — stroke_curve_start uint32 offset 56 // location 7 — n_stroke_curves uint32 offset 60 +// location 8 — fill_rule uint32 offset 64 (0=nonzero, 1=evenodd) struct Uniforms { projection : mat4x4, @@ -50,6 +51,7 @@ struct VertexInput { @location(5) n_fill_curves : u32, @location(6) stroke_curve_start : u32, @location(7) n_stroke_curves : u32, + @location(8) fill_rule : u32, }; struct VertexOutput { @@ -62,6 +64,7 @@ struct VertexOutput { @location(5) @interpolate(flat) n_fill_curves : u32, @location(6) @interpolate(flat) stroke_curve_start: u32, @location(7) @interpolate(flat) n_stroke_curves : u32, + @location(8) @interpolate(flat) fill_rule : u32, }; @vertex @@ -80,6 +83,7 @@ fn vs_main(in: VertexInput) -> VertexOutput { out.n_fill_curves = in.n_fill_curves; out.stroke_curve_start = in.stroke_curve_start; out.n_stroke_curves = in.n_stroke_curves; + out.fill_rule = in.fill_rule; return out; } @@ -134,6 +138,20 @@ fn calc_coverage(xcov: f32, ycov: f32, xwgt: f32, ywgt: f32) -> f32 { return clamp(max(blended, min(abs(xcov), abs(ycov))), 0.0, 1.0); } +// Even-odd fill coverage: triangle wave — inside when winding count is odd. +// The raw winding accumulator (xcov or ycov) is a signed integer at stable +// interiors. A triangle wave with period 2 maps even integers → 0 (outside) +// and odd integers → 1 (inside), with half-pixel AA transitions. +fn calc_coverage_evenodd(xcov: f32, ycov: f32, xwgt: f32, ywgt: f32) -> f32 { + let w = (xcov*xwgt + ycov*ywgt) / max(xwgt + ywgt, 1.0/65536.0); + // Triangle wave: period 2, peak at odd integers. + let tri = 1.0 - abs(2.0 * fract(abs(w) * 0.5) - 1.0); + // Fallback: take the max of both axis coverages independently. + let tx = 1.0 - abs(2.0 * fract(abs(xcov) * 0.5) - 1.0); + let ty = 1.0 - abs(2.0 * fract(abs(ycov) * 0.5) - 1.0); + return clamp(max(tri, min(tx, ty)), 0.0, 1.0); +} + // --------------------------------------------------------------------------- // Stroke SDF helper — min distance from origin to a 2-D quadratic Bezier. // @@ -234,7 +252,14 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { } } - let fill_cov = select(0.0, calc_coverage(xcov, ycov, xwgt, ywgt), in.n_fill_curves > 0u); + var fill_cov = 0.0; + if in.n_fill_curves > 0u { + if in.fill_rule == 1u { + fill_cov = calc_coverage_evenodd(xcov, ycov, xwgt, ywgt); + } else { + fill_cov = calc_coverage(xcov, ycov, xwgt, ywgt); + } + } // ── Stroke: SDF minimum distance in physical pixel space ────────────── // stroke_half_ndc is in NDC units; pixels_per_ndc.x converts to pixels. diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index 02bd7e3459..142331fce4 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -597,9 +597,16 @@ def __init__( # textured quads before the VMobject pass (painter's algorithm). self._image_pipeline: wgpu_t.GPURenderPipeline | None = None self._image_tex_bgl: wgpu_t.GPUBindGroupLayout | None = None + self._image_tint_bgl: wgpu_t.GPUBindGroupLayout | None = None # Cache: ImageMobject → (fingerprint, GPUTexture, GPUBindGroup). # Keyed weakly so destroyed mobs release their GPU textures. self._image_cache: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() + # Cache: ImageMobject → (points_fingerprint, GPUBuffer). + # VBO is reused across frames as long as mob.points[:4] hasn't changed. + self._image_vbo_cache: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() + # Cache: ImageMobject → (tint_fingerprint, GPUBuffer, GPUBindGroup). + # Tint bind group is rebuilt only when mob.color changes. + self._image_tint_cache: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() # Compact readback compute pipeline (GPU row-depadding + B↔R fix). self._readback_compute_pipeline: wgpu_t.GPUComputePipeline | None = None @@ -726,7 +733,7 @@ def init_scene(self, scene: Scene) -> None: self._create_oit_resources(width, height) self._create_readback_pipeline(width, height) - self._image_tex_bgl, self._image_pipeline = self._create_image_pipeline() + self._image_tex_bgl, self._image_tint_bgl, self._image_pipeline = self._create_image_pipeline() self._true_dot_pipeline = self._create_true_dot_pipeline(self._proj_bgl) # Sub-camera pipelines (rgba8unorm target) for ZoomedScene support. @@ -1041,13 +1048,14 @@ def _create_true_dot_pipeline( def _create_image_pipeline( self, - ) -> tuple[wgpu_t.GPUBindGroupLayout, wgpu_t.GPURenderPipeline]: + ) -> tuple[wgpu_t.GPUBindGroupLayout, wgpu_t.GPUBindGroupLayout, wgpu_t.GPURenderPipeline]: """Create the render pipeline for ImageMobject textured quads. Layout ------ group 0 — camera uniform (reuses ``_proj_bgl``, same as VMobject shaders) group 1 — texture_2d at binding 0, sampler at binding 1 + group 2 — tint uniform: vec3 rgb colour multiplier (16-byte block) Vertex buffer (stride 20 B): location 0 — in_pos float32x3 (12 B) @@ -1081,8 +1089,19 @@ def _create_image_pipeline( ] ) + # Group 2: tint colour uniform (16 bytes: rgb vec3 + 4-byte pad) + tint_bgl = self._device.create_bind_group_layout( + entries=[ + { + "binding": 0, + "visibility": wgpu.ShaderStage.FRAGMENT, + "buffer": {"type": "uniform", "min_binding_size": 16}, + }, + ] + ) + layout = self._device.create_pipeline_layout( - bind_group_layouts=[self._proj_bgl, tex_bgl] + bind_group_layouts=[self._proj_bgl, tex_bgl, tint_bgl] ) blend = { @@ -1139,7 +1158,7 @@ def _create_image_pipeline( multisample={"count": 1, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, ) - return tex_bgl, pipeline + return tex_bgl, tint_bgl, pipeline def _image_fingerprint(self, pixel_array: np.ndarray) -> int: """Cheap dirty-check fingerprint for a pixel array. @@ -1223,8 +1242,11 @@ def _get_image_gpu_resources( self._image_cache[mob] = (fp, tex, bg) return tex, bg - def _build_image_vbo(self, mob: Any) -> wgpu_t.GPUBuffer | None: - """Build a 6-vertex (20 B/vertex) VBO for *mob*'s bounding quad. + def _get_image_vbo(self, mob: Any) -> wgpu_t.GPUBuffer | None: + """Return a cached 6-vertex (20 B/vertex) VBO for *mob*'s bounding quad. + + The VBO is rebuilt only when mob.points[:4] changes; otherwise the + same GPUBuffer is reused across frames without any CPU/GPU allocation. Corner layout from AbstractImageMobject.reset_points(): points[0] = UP + LEFT → UV (0, 0) @@ -1233,6 +1255,12 @@ def _build_image_vbo(self, mob: Any) -> wgpu_t.GPUBuffer | None: points[3] = DOWN + RIGHT→ UV (1, 1) Two CCW triangles: [0,1,2] and [1,3,2]. + + ``scale_to_resolution`` semantics are enforced at the Mobject level: + ``AbstractImageMobject.reset_points()`` converts pixel dimensions to + world-space units using ``scale_to_resolution`` during ``__init__``. + The renderer reads the resulting world-space corner positions directly + from ``mob.points`` — no additional scaling is applied here. """ assert self._device is not None @@ -1240,11 +1268,17 @@ def _build_image_vbo(self, mob: Any) -> wgpu_t.GPUBuffer | None: if pts is None or len(pts) < 4: return None - corners = pts[:4].astype(np.float32) # (4, 3) + # Fingerprint the 4 corner points exactly (48 bytes — cheap). + corners = pts[:4].astype(np.float32) + fp = hash(corners.tobytes()) + + cached = self._image_vbo_cache.get(mob) + if cached is not None and cached[0] == fp: + return cached[1] + uvs = np.array( [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]], dtype=np.float32 ) - # Index order: 0,1,2, 1,3,2 idx = [0, 1, 2, 1, 3, 2] data = np.empty((6, 5), dtype=np.float32) data[:, :3] = corners[idx] @@ -1254,33 +1288,43 @@ def _build_image_vbo(self, mob: Any) -> wgpu_t.GPUBuffer | None: data=data.tobytes(), usage=wgpu.BufferUsage.VERTEX, ) - self.frame_vbos.append(buf) + # Store persistently — NOT in frame_vbos; WeakKeyDictionary releases + # the buffer when the mob is garbage-collected. + self._image_vbo_cache[mob] = (fp, buf) return buf - def _draw_images_in_pass( - self, - render_pass: Any, - image_mobs: list, - camera_bind_group: Any, - ) -> None: - """Draw all *image_mobs* into *render_pass* using the image pipeline.""" - if not image_mobs or self._image_pipeline is None: - return + def _get_image_tint_bind_group(self, mob: Any) -> wgpu_t.GPUBindGroup | None: + """Return a cached GPUBindGroup for *mob*'s tint colour uniform. - render_pass.set_pipeline(self._image_pipeline) - render_pass.set_bind_group(0, camera_bind_group, [], 0, 0) + The bind group is rebuilt only when ``mob.color`` changes. The + default WHITE tint ``(1, 1, 1)`` is identity — texture is unchanged. + """ + assert self._device is not None + assert self._image_tint_bgl is not None - for mob in image_mobs: - resources = self._get_image_gpu_resources(mob) - if resources is None: - continue - _, tex_bg = resources - vbo = self._build_image_vbo(mob) - if vbo is None: - continue - render_pass.set_bind_group(1, tex_bg, [], 0, 0) - render_pass.set_vertex_buffer(0, vbo) - render_pass.draw(6) + # Read mob.color → (r, g, b) in [0, 1]. Fall back to white. + try: + rgb = np.asarray(mob.color.to_rgb(), dtype=np.float32) + except Exception: + rgb = np.ones(3, dtype=np.float32) + + fp = hash(rgb.tobytes()) + cached = self._image_tint_cache.get(mob) + if cached is not None and cached[0] == fp: + return cached[2] + + # 16-byte block: rgb (12 B) + 4-byte pad. + data = np.array([rgb[0], rgb[1], rgb[2], 0.0], dtype=np.float32) + buf = self._device.create_buffer_with_data( + data=data.tobytes(), + usage=wgpu.BufferUsage.UNIFORM, + ) + bg = self._device.create_bind_group( + layout=self._image_tint_bgl, + entries=[{"binding": 0, "resource": {"buffer": buf, "offset": 0, "size": 16}}], + ) + self._image_tint_cache[mob] = (fp, buf, bg) + return bg def _create_oit_resources(self, width: int, height: int) -> None: """Create OIT accumulation textures, pipelines, and bind groups.""" @@ -1490,11 +1534,14 @@ def _get_sub_cam_resources(self, mob: Any) -> dict: """Return (and lazily create) per-mob GPU resources for sub-camera rendering. Returns a dict with keys: - render_tex, render_view — rgba8unorm render target (RENDER_ATTACHMENT | TEXTURE_BINDING) + render_tex, render_view — rgba8unorm render target + (RENDER_ATTACHMENT | TEXTURE_BINDING | COPY_SRC) depth_tex, depth_view — depth24plus depth buffer uniform_buf — 656-byte camera uniform buffer (COPY_DST | UNIFORM) cam_bg — camera-only bind group (proj_bgl, binding 0 = uniform_buf) tex_bg — image display bind group (image_tex_bgl, binding 0 = render_view) + staging_buf — row-aligned COPY_DST | MAP_READ buffer for CPU readback + staging_aligned_bpr — aligned bytes-per-row used by the staging buffer """ assert self._device is not None assert self._proj_bgl is not None @@ -1507,10 +1554,17 @@ def _get_sub_cam_resources(self, mob: Any) -> dict: w, h = config.pixel_width, config.pixel_height _UBO_SIZE = 656 + # bytes_per_row must be a multiple of 256 for copy_texture_to_buffer. + aligned_bpr = ((w * 4) + 255) & ~255 + render_tex = self._device.create_texture( size=(w, h, 1), format=wgpu.TextureFormat.rgba8unorm, - usage=wgpu.TextureUsage.RENDER_ATTACHMENT | wgpu.TextureUsage.TEXTURE_BINDING, + usage=( + wgpu.TextureUsage.RENDER_ATTACHMENT + | wgpu.TextureUsage.TEXTURE_BINDING + | wgpu.TextureUsage.COPY_SRC + ), ) render_view = render_tex.create_view() @@ -1544,14 +1598,23 @@ def _get_sub_cam_resources(self, mob: Any) -> dict: ], ) + # Staging buffer for CPU readback of the rendered sub-camera texture. + # rgba8unorm — no B↔R swap needed (unlike the main bgra8unorm target). + staging_buf = self._device.create_buffer( + size=aligned_bpr * h, + usage=wgpu.BufferUsage.COPY_DST | wgpu.BufferUsage.MAP_READ, + ) + resources = { - "render_tex": render_tex, - "render_view": render_view, - "depth_tex": depth_tex, - "depth_view": depth_view, - "uniform_buf": uniform_buf, - "cam_bg": cam_bg, - "tex_bg": tex_bg, + "render_tex": render_tex, + "render_view": render_view, + "depth_tex": depth_tex, + "depth_view": depth_view, + "uniform_buf": uniform_buf, + "cam_bg": cam_bg, + "tex_bg": tex_bg, + "staging_buf": staging_buf, + "staging_aligned_bpr": aligned_bpr, } self._sub_cam_resources[mob_id] = resources return resources @@ -1898,16 +1961,17 @@ def _walk(mob: Any) -> None: for item in render_queue: if item[0] == "image": mob = item[1] - vbo = self._build_image_vbo(mob) + vbo = self._get_image_vbo(mob) + tint_bg = self._get_image_tint_bind_group(mob) if isinstance(mob, ImageMobjectFromCamera): res = self._sub_cam_resources.get(id(mob)) tex_bg = res["tex_bg"] if res is not None else None - if vbo is not None and tex_bg is not None: - resolved_queue.append(("image", vbo, tex_bg)) + if vbo is not None and tex_bg is not None and tint_bg is not None: + resolved_queue.append(("image", vbo, tex_bg, tint_bg)) else: resources = self._get_image_gpu_resources(mob) - if vbo is not None and resources is not None: - resolved_queue.append(("image", vbo, resources[1])) + if vbo is not None and resources is not None and tint_bg is not None: + resolved_queue.append(("image", vbo, resources[1], tint_bg)) elif item[0] == "truedot": mob = item[1] arr = build_true_dot_vbo(mob) @@ -2001,6 +2065,27 @@ def _f(m: Any) -> None: self._get_sub_cam_resources(sub_mob) # ensure resources created self._render_sub_camera_pass(sub_mob, encoder, normal_fds) + # Encode texture → staging-buffer copies so that after submit the + # rendered sub-camera pixels are available for CPU readback. + # This allows ImageMobjectFromCamera.get_pixel_array() to return + # current frame data instead of the stale initial pixel_array. + w, h = config.pixel_width, config.pixel_height + for sub_mob in self.camera.image_mobjects_from_cameras: + res = self._sub_cam_resources.get(id(sub_mob)) + if res is None: + continue + aligned_bpr = res["staging_aligned_bpr"] + encoder.copy_texture_to_buffer( + {"texture": res["render_tex"], "mip_level": 0, "origin": (0, 0, 0)}, + { + "buffer": res["staging_buf"], + "offset": 0, + "bytes_per_row": aligned_bpr, + "rows_per_image": h, + }, + (w, h, 1), + ) + # ── Pass 1: main render ─────────────────────────────────────────── # Draw the z-ordered render queue (VMobject batches and images # interleaved in scene.mobjects order) so painter's-algorithm depth @@ -2024,22 +2109,29 @@ def _f(m: Any) -> None: ) self.current_render_pass = main_pass + current_pipeline = None for item in resolved_queue: if item[0] == "image": - _, vbo, tex_bg = item - main_pass.set_pipeline(self._image_pipeline) - main_pass.set_bind_group(0, self.camera_bind_group, [], 0, 0) + _, vbo, tex_bg, tint_bg = item + if current_pipeline != "image": + main_pass.set_pipeline(self._image_pipeline) + main_pass.set_bind_group(0, self.camera_bind_group, [], 0, 0) + current_pipeline = "image" main_pass.set_bind_group(1, tex_bg, [], 0, 0) + main_pass.set_bind_group(2, tint_bg, [], 0, 0) main_pass.set_vertex_buffer(0, vbo) main_pass.draw(6) elif item[0] == "truedot": _, buf, n_verts = item - main_pass.set_pipeline(self._true_dot_pipeline) - main_pass.set_bind_group(0, self.camera_bind_group, [], 0, 0) + if current_pipeline != "truedot": + main_pass.set_pipeline(self._true_dot_pipeline) + main_pass.set_bind_group(0, self.camera_bind_group, [], 0, 0) + current_pipeline = "truedot" main_pass.set_vertex_buffer(0, buf) main_pass.draw(n_verts) elif item[0] == "vmobs": _, fd, cam_bg = item + current_pipeline = "vmobs" # draw_frame_data sets its own pipelines draw_frame_data(self, fd, cam_bg) main_pass.end() @@ -2119,6 +2211,38 @@ def _f(m: Any) -> None: self._device.queue.submit([encoder.finish()]) + # ── Sub-camera CPU readback ─────────────────────────────────────── + # Populate mob.camera.pixel_array from the staged sub-camera texture + # data so that ImageMobjectFromCamera.get_pixel_array() returns the + # current frame rather than the stale initial array. + # rgba8unorm needs no B↔R channel swap (unlike the main bgra8unorm target). + if self.camera.image_mobjects_from_cameras: + w, h = config.pixel_width, config.pixel_height + for sub_mob in self.camera.image_mobjects_from_cameras: + res = self._sub_cam_resources.get(id(sub_mob)) + if res is None: + continue + staging_buf = res["staging_buf"] + aligned_bpr = res["staging_aligned_bpr"] + staging_buf.map_sync(wgpu.MapMode.READ) + raw = bytes(staging_buf.read_mapped()) + staging_buf.unmap() + if aligned_bpr == w * 4: + arr = np.frombuffer(raw, dtype=np.uint8).reshape(h, w, 4).copy() + else: + # Strip row padding before reshaping. + rows = [ + raw[r * aligned_bpr : r * aligned_bpr + w * 4] + for r in range(h) + ] + arr = np.frombuffer(b"".join(rows), dtype=np.uint8).reshape(h, w, 4).copy() + # Write into the Cairo sub-camera's pixel_array so that + # ImageMobjectFromCamera.get_pixel_array() returns current data. + try: + sub_mob.camera.pixel_array = arr + except Exception: + pass + self.current_render_pass = None # camera_bind_group is now persistent (created once in init_scene) — # do NOT null it here. diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py index acad6100cf..13e4b194ff 100644 --- a/manim/renderer/webgpu/webgpu_vmobject_rendering.py +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -119,7 +119,8 @@ # location 5 — n_fill_curves uint32 offset 52 ( 4 B) # location 6 — stroke_curve_start uint32 offset 56 ( 4 B) # location 7 — n_stroke_curves uint32 offset 60 ( 4 B) -# stride: 64 bytes +# location 8 — fill_rule uint32 offset 64 ( 4 B) 0=nonzero, 1=evenodd +# stride: 68 bytes # --------------------------------------------------------------------------- _FILL_STROKE_DTYPE = np.dtype( @@ -132,6 +133,7 @@ ("n_fill_curves", np.uint32), ("stroke_curve_start", np.uint32), ("n_stroke_curves", np.uint32), + ("fill_rule", np.uint32), ] ) _FILL_STROKE_STRIDE: int = _FILL_STROKE_DTYPE.itemsize # 64 bytes @@ -153,6 +155,7 @@ {"format": "uint32", "offset": _FILL_STROKE_OFFSETS["n_fill_curves"], "shader_location": 5}, {"format": "uint32", "offset": _FILL_STROKE_OFFSETS["stroke_curve_start"], "shader_location": 6}, {"format": "uint32", "offset": _FILL_STROKE_OFFSETS["n_stroke_curves"], "shader_location": 7}, + {"format": "uint32", "offset": _FILL_STROKE_OFFSETS["fill_rule"], "shader_location": 8}, ], } @@ -548,6 +551,19 @@ def collect_frame_data( if fill_color[3] < 0.001 and (stroke_color[3] < 0.001 or stroke_width < 0.001): continue + # 0 = nonzero (default), 1 = evenodd (set by SVG parser) + fill_rule = int(getattr(submob, "fill_rule", 0)) + + # Gradient fill: pass all colour stops and gradient axis endpoints. + gradient_start = gradient_end = None + if fill_rgba.shape[0] > 1: + try: + gradient_start, gradient_end = ( + submob.get_gradient_start_and_end_points() + ) + except Exception: + pass # fall back to solid fill_color[0] + # Build bounding quad with placeholder curve indices. quad_verts = _build_fill_stroke_quad( fill_cubics=fill_cubics, @@ -559,6 +575,10 @@ def collect_frame_data( stroke_curve_start=0, # assigned below view_matrix=view_matrix, proj_matrix=proj_matrix, + fill_rule=fill_rule, + fill_rgbas=fill_rgba if fill_rgba.shape[0] > 1 else None, + gradient_start=gradient_start, + gradient_end=gradient_end, ) if len(quad_verts) == 0: continue @@ -1062,6 +1082,10 @@ def _build_fill_stroke_quad( stroke_curve_start: int, view_matrix: np.ndarray, proj_matrix: np.ndarray, + fill_rule: int = 0, + fill_rgbas: np.ndarray | None = None, + gradient_start: np.ndarray | None = None, + gradient_end: np.ndarray | None = None, ) -> np.ndarray: """Build a ``_FILL_STROKE_DTYPE`` bounding quad (6 vertices) for one object. @@ -1073,6 +1097,9 @@ def _build_fill_stroke_quad( *stroke_half_ndc* is the stroke half-width in NDC units, computed from the current projection matrix and average clip-w so that stroke width is consistent across perspective depths. + + *fill_rgbas* — if provided and has more than one row, enables gradient fill. + *gradient_start* / *gradient_end* — world-space endpoints of the gradient axis. """ # Gather all anchor points (b0 and b3 of every cubic). anchor_lists: list[np.ndarray] = [] @@ -1137,18 +1164,54 @@ def _build_fill_stroke_quad( corners_w = (R_inv @ corners_v.T).T + t_inv # (4, 3) world space quad_pos = corners_w[[0, 1, 2, 1, 3, 2]] # (6, 3) two CCW triangles + # ── Per-vertex fill colours (gradient support) ──────────────────────────── + # If fill_rgbas has >1 colour row, interpolate along the gradient axis. + # gradient_start / gradient_end are world-space endpoints of the axis. + if ( + fill_rgbas is not None + and fill_rgbas.shape[0] > 1 + and gradient_start is not None + and gradient_end is not None + ): + gs = np.asarray(gradient_start, dtype=np.float32) + ge = np.asarray(gradient_end, dtype=np.float32) + axis = ge - gs + axis_len2 = float(np.dot(axis, axis)) + if axis_len2 > 1e-12: + # Project each of the 4 corners onto the gradient axis → t ∈ [0, 1]. + t_corners = np.clip( + np.dot(corners_w - gs, axis) / axis_len2, 0.0, 1.0 + ) # (4,) + n_stops = fill_rgbas.shape[0] + # Interpolate: t_corners maps to colour stop indices. + idx_f = t_corners * (n_stops - 1) # float indices + idx_lo = np.floor(idx_f).astype(int).clip(0, n_stops - 2) + idx_hi = idx_lo + 1 + frac = (idx_f - idx_lo)[:, None] # (4, 1) + corner_colors = ( + fill_rgbas[idx_lo].astype(np.float32) * (1.0 - frac) + + fill_rgbas[idx_hi].astype(np.float32) * frac + ) # (4, 4) + # Map corners [0,1,2,3] → quad vertices [0,1,2,1,3,2]. + per_vertex_fill = corner_colors[[0, 1, 2, 1, 3, 2]] # (6, 4) + else: + per_vertex_fill = np.broadcast_to(fill_color, (6, 4)).copy() + else: + per_vertex_fill = np.broadcast_to(fill_color, (6, 4)).copy() + n_fill_quads = len(fill_cubics) * 4 # 4 quadratics per cubic n_stroke_quads = len(stroke_cubics) * 4 verts = np.empty(6, dtype=_FILL_STROKE_DTYPE) verts["in_pos"] = quad_pos - verts["in_fill_color"] = fill_color + verts["in_fill_color"] = per_vertex_fill verts["in_stroke_color"] = stroke_color verts["stroke_half_ndc"] = stroke_half_ndc verts["fill_curve_start"] = fill_curve_start verts["n_fill_curves"] = n_fill_quads verts["stroke_curve_start"] = stroke_curve_start verts["n_stroke_curves"] = n_stroke_quads + verts["fill_rule"] = fill_rule return verts From 4545e0a3731bc8dd3d56e923dbada1bb99d69c65 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Sat, 11 Apr 2026 11:11:26 +0530 Subject: [PATCH 24/33] Better support for gradient in webGPU VMobject Renderer --- .../webgpu/webgpu_vmobject_rendering.py | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py index 13e4b194ff..ec4e0b52cc 100644 --- a/manim/renderer/webgpu/webgpu_vmobject_rendering.py +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -564,6 +564,16 @@ def collect_frame_data( except Exception: pass # fall back to solid fill_color[0] + # Gradient stroke: same pattern as fill gradient. + stroke_gradient_start = stroke_gradient_end = None + if stroke_rgba.shape[0] > 1: + try: + stroke_gradient_start, stroke_gradient_end = ( + submob.get_gradient_start_and_end_points() + ) + except Exception: + pass # fall back to solid stroke_color[0] + # Build bounding quad with placeholder curve indices. quad_verts = _build_fill_stroke_quad( fill_cubics=fill_cubics, @@ -579,6 +589,9 @@ def collect_frame_data( fill_rgbas=fill_rgba if fill_rgba.shape[0] > 1 else None, gradient_start=gradient_start, gradient_end=gradient_end, + stroke_rgbas=stroke_rgba if stroke_rgba.shape[0] > 1 else None, + stroke_gradient_start=stroke_gradient_start, + stroke_gradient_end=stroke_gradient_end, ) if len(quad_verts) == 0: continue @@ -1086,6 +1099,9 @@ def _build_fill_stroke_quad( fill_rgbas: np.ndarray | None = None, gradient_start: np.ndarray | None = None, gradient_end: np.ndarray | None = None, + stroke_rgbas: np.ndarray | None = None, + stroke_gradient_start: np.ndarray | None = None, + stroke_gradient_end: np.ndarray | None = None, ) -> np.ndarray: """Build a ``_FILL_STROKE_DTYPE`` bounding quad (6 vertices) for one object. @@ -1099,7 +1115,9 @@ def _build_fill_stroke_quad( consistent across perspective depths. *fill_rgbas* — if provided and has more than one row, enables gradient fill. - *gradient_start* / *gradient_end* — world-space endpoints of the gradient axis. + *gradient_start* / *gradient_end* — world-space endpoints of the fill gradient axis. + *stroke_rgbas* — if provided and has more than one row, enables gradient stroke. + *stroke_gradient_start* / *stroke_gradient_end* — world-space endpoints of the stroke gradient axis. """ # Gather all anchor points (b0 and b3 of every cubic). anchor_lists: list[np.ndarray] = [] @@ -1199,13 +1217,43 @@ def _build_fill_stroke_quad( else: per_vertex_fill = np.broadcast_to(fill_color, (6, 4)).copy() + # ── Per-vertex stroke colours (gradient support) ───────────────────────── + if ( + stroke_rgbas is not None + and stroke_rgbas.shape[0] > 1 + and stroke_gradient_start is not None + and stroke_gradient_end is not None + ): + sgs = np.asarray(stroke_gradient_start, dtype=np.float32) + sge = np.asarray(stroke_gradient_end, dtype=np.float32) + s_axis = sge - sgs + s_axis_len2 = float(np.dot(s_axis, s_axis)) + if s_axis_len2 > 1e-12: + t_corners = np.clip( + np.dot(corners_w - sgs, s_axis) / s_axis_len2, 0.0, 1.0 + ) # (4,) + n_stops = stroke_rgbas.shape[0] + idx_f = t_corners * (n_stops - 1) + idx_lo = np.floor(idx_f).astype(int).clip(0, n_stops - 2) + idx_hi = idx_lo + 1 + frac = (idx_f - idx_lo)[:, None] + corner_colors = ( + stroke_rgbas[idx_lo].astype(np.float32) * (1.0 - frac) + + stroke_rgbas[idx_hi].astype(np.float32) * frac + ) # (4, 4) + per_vertex_stroke = corner_colors[[0, 1, 2, 1, 3, 2]] # (6, 4) + else: + per_vertex_stroke = np.broadcast_to(stroke_color, (6, 4)).copy() + else: + per_vertex_stroke = np.broadcast_to(stroke_color, (6, 4)).copy() + n_fill_quads = len(fill_cubics) * 4 # 4 quadratics per cubic n_stroke_quads = len(stroke_cubics) * 4 verts = np.empty(6, dtype=_FILL_STROKE_DTYPE) verts["in_pos"] = quad_pos verts["in_fill_color"] = per_vertex_fill - verts["in_stroke_color"] = stroke_color + verts["in_stroke_color"] = per_vertex_stroke verts["stroke_half_ndc"] = stroke_half_ndc verts["fill_curve_start"] = fill_curve_start verts["n_fill_curves"] = n_fill_quads From f80c4b83c74f716719a81b8a66079d848de71c22 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Sat, 11 Apr 2026 12:14:43 +0530 Subject: [PATCH 25/33] Export Light Classes from Manim --- manim/__init__.py | 1 + manim/mobject/three_d/__init__.py | 1 + 2 files changed, 2 insertions(+) diff --git a/manim/__init__.py b/manim/__init__.py index 0605d4a3ae..002bf63a62 100644 --- a/manim/__init__.py +++ b/manim/__init__.py @@ -73,6 +73,7 @@ from .mobject.text.numbers import * from .mobject.text.tex_mobject import * from .mobject.text.text_mobject import * +from .mobject.three_d.light_source import * from .mobject.three_d.polyhedra import * from .mobject.three_d.three_d_utils import * from .mobject.three_d.three_dimensions import * diff --git a/manim/mobject/three_d/__init__.py b/manim/mobject/three_d/__init__.py index 98d295a24e..c3236a3a0e 100644 --- a/manim/mobject/three_d/__init__.py +++ b/manim/mobject/three_d/__init__.py @@ -6,6 +6,7 @@ .. autosummary:: :toctree: ../reference + ~light_source ~polyhedra ~three_d_utils ~three_dimensions From 2b5714c919f2f0cdc0459a24e84bde561346af62 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Sat, 11 Apr 2026 12:21:14 +0530 Subject: [PATCH 26/33] Color in Surface.__init__() is not ignored now --- manim/mobject/three_d/three_dimensions.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/manim/mobject/three_d/three_dimensions.py b/manim/mobject/three_d/three_dimensions.py index f2698de7e8..694bd432f7 100644 --- a/manim/mobject/three_d/three_dimensions.py +++ b/manim/mobject/three_d/three_dimensions.py @@ -129,6 +129,11 @@ def __init__( pre_function_handle_to_anchor_scale_factor: float = 0.00001, **kwargs: Any, ) -> None: + # If `color` is explicitly passed, use it as fill_color and disable + # checkerboard so the explicit color isn't silently overridden. + if "color" in kwargs: + fill_color = kwargs.pop("color") + checkerboard_colors = False self.u_range = u_range self.v_range = v_range super().__init__( From a6c72815cadd7b81372694fd838ed16e09b788cf Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Sat, 11 Apr 2026 12:57:44 +0530 Subject: [PATCH 27/33] Fixed test_plot_suface with Surface Color Support --- .../coordinate_system/plot_surface.npz | Bin 205863 -> 189542 bytes .../test_coordinate_systems.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_graphical_units/control_data/coordinate_system/plot_surface.npz b/tests/test_graphical_units/control_data/coordinate_system/plot_surface.npz index 83697c1d58682382dcdf8491f4a2f43a97ff5b62..7a2ac508ac389ce5c1e9f515551cff4e8c3d74bf 100644 GIT binary patch literal 189542 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

Hxz$DCjuDmot8YI{T*{Rv9r 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 P@GT4cy}f)D`2GI?AJ7H@ literal 205863 zcmYhiby$=C8#b(86hulvN-3peC`d^OC`wAhHeds!h0)!p^eBm;A|=veBPOFkq+4=y zcX!8ot z=0;}5F2;Oz4xZPq-MRMRCL{39Yu8ZhME}07jazCk=_T@?B|ms@Iz1j48F_v*F%%l< zv6;-$CuE!5`D@c^vvQtU@xvWzS;PP4Zptd&;;1^jSCjYQk2l9OW@|!{)N^y+U!BX0 z2MrS9Rdsc?e`DJq(?8nxDeqI;i(0Rg-J~?u1mDZPyvg&v8h?qc*!rgNgG$4bQ~z2Q zhfM^3_1Pr9xZPxlB_Y@rKk6=SlW}oQ{{X)+p?Gm_mrWu0JofUEssMPGea-Gl`{jAp zbvAi2!^37*1j~1yvEc9Apfly2| zA}=b3#yv@4vE8#Fe@%Q?wE{Okk!{;*T(2SR$li5*$x^e^-T1}Q^=L+gGP(P_c>9|@ z-G!rmxVo>fi?n!VQ!}^Fc8oi*^otlZOQ;!fO-^d#qN|XLG&;FS#yV@sM_)>5F^-JJ zMOs=$ZSkkGsf%>}{YDZLyURoYL)Nb0@tx$_FBB$5v6P!E+SAEDl}@UBIW$vCDY^e1 zkN)uRop`~Zuoy{HbW&9#6WoQ^=yAc{;JEK&e{g6iL%iVl%(K+xWvaXK$-ZZ085L{7 zo{4%A<)&xW-{VS!V(Ho>@T`XZ-KdU%RhwI z%J%gh!lhzY+TN#<#i6O-^oKNkwtd?1;L{ti1&j@Mv75|)V*Uo@co#f!n97xY70@k4 z^TI4?(M2}cDC6A@OD`R^WUZl1n@YS2 z&|!&L+`4uBoND>#P%6zCe|WuXZ!9}b;Mwh3%imVisoB4Cxj!75PTdG2uumPm{ylr$ zA!2UHcWO&&XR{^Mus_wk)!+S8TrFnxL}@3x8?*S=g?w7WnBHxb&jhOWz|ErMw_G22 zNWa%mcE52z9x;N3@133sZGt@1L`f&;!uQNnm(-Wp(x|@rcHTb0V0IO=U6SA%X=+5s zSsnV4Sv@qB8d1AO(cJF+XYY7-L&Nvt7w?-?nGN#iEv#-gdh{G$Fcp}I%<}7DTnq>C z4h!9L;=AUmzlbkK8z(s~5vzKddVWuIFH0^m>|&Xkr`~jJUXG-&iJfqpXnWj#&M`Md z(s{Eg(fMvcp~-i6C=||aZVA&(ykN0rT3NK=!v?wPChOLJ6HzLu(nIId!`-DF&H5tDVA!pC>WP0dox`k%x$#!s{t|72K+FKzCRDr*4ITGQBGLjpc zdP1!zNMdItg~{31wuL2NL-vC`OPTX9aV&R3}qZeI?|3p zBG5zl5ju+%kTlG(T)71z2`>Kyd~3$(|NP7Z-Z*6a)2OP^KyLn;XH^6`!nW}R)k3+C zt042Jsq=I~QuO(X7SkgHd+E+|oA*f#rJcLj5!kTtU8n#;u=bC2c)9YZDCZJL0H1Ck z@}g-K#DfQYEvs7v72!e5sYRh^;q{-tex`?{u*ETOWP4LrT#()~sv)M;LvM+Y zKR2o$E45+2Hs0pcxZG_0IjeY|>6Pr}wV4s`yQ3p=8x-rIR&2Sp3O1^eGm0kszu3#F zzGaYFBJVpssZ5MgaAS7M(&-^Z+XTKHCCB4cOcA>rl)obCFD3*;-(60^-bJ8AQrh4poS~!OrsMbi zVoCqS6>@@@qm;^zq|tPgMS4j1yqaH=50=^#U0=TFX}~!MvAn%vrTxtr#Q+=L7%Eyq z$RP5I76{?d2T89m@7#i>JCsNuTO4Dt>2Wo6R-;cUmZYH8 z>w8tT8|tz6aJ%cZNis=;I8~wXj`^Ge1v?^Fo&!f8o#H$TgyyMxgYh);#^xT zcBG7k+J0Hc4Y$>1yt>rm9tGRL1h9g5+If&E@WP1_Iy-u@Y?NtH^0ef41MtHzSCnIC z+XR14gG)joE!b$*nf41NBDH%mnzN2iC!9SuNbJp^#w%Kit=7CxFn!KJ?i6hsbeuS< z*udNd{VPSF5u2X=fQi-xr3;&nHoWTsTqUpyJi+Lv*U7zOC{|YVd06l_#N%&?uLozj zKiLVa<^_47v|x;t%DI^SbAk$-lK{z#D_vr_#s_UJ0@Eoi97(-V>!?K9q8(xz!nhS# zYe-c$In7*D*s4u$SgqG<97eF?{HgWk++Rw%(>S`b)Ez!7+?006Dx|~bM(Tcns~3pp zmMpg&k?G!bPr>qN?sKg8D*3wA!HsfP^R`7$t=DaXE+kqnI>IVg1PKpUD(c~;If(Yj z+umCiuS6$WHU(QGZyGp|zWrIfJ5l#I{{`mLTMvn`8C)SRB!HQ8cqpqah)fSHiQt|7 zY7{+>@;%Dgv5|579t+u>ZMLjUY^!b`(D#O{5+=kSKX1ePBMjbRaiZgQ>VR{b~f+x zj(jybzCT}f$?xe7<)nxH-Q1f@QHw?&pq@lk zeDFX^Unq*TxYzABh4~njqOUz31G(|Ou-x$snyV*{ZWnu+o$Mvb$e%bAN9$n;ORX-2FkBG8XfPn0QO(qWMyH z&oxzKP@ICt1D*$ zm*G>!O<&K0z45jlX-3GGK5T91K|;Ke-z_H&ER2y6vuJAJOe$AqO-Y9!Bi7rAsBn<_ zU$6Eb@q_oEl-m zO96L%^12+&0BhWj_+7bxK`wiM7>OMW>PL;rrl)*Whfm4|%cJ=ix2cs|H?%(cpP%b{*Q2n8{ z<1q4MSOec#>q#?Aqus();I20Lc;_@*L&%lPQT>By^v9nZ3M|({=$%46R@hpv$DYgM zM-JajfZ=!vm~J%1a;Fzeb5*VB-$kV@p+e?z;tpR55Wsl!n9Jgpc|B*qDu=7D+2I=F z;j(7x=rcTUiO`?ip6n_KI#7MVFhDgSQ@ekZqu=>_9SDuI;vBkbEm)&)2_gM&2S(0L1niC7dfT7>AFo8w?1^c`6;!kgDd(_$E~2fpaLveXO^a%P z;?jNUH=0dHk?SBXSb(<5$+`@e9#kkIpg}Ihliq>j=Pi<0r|E>x0a8Wfh0z`L?$^h% zOh;E3^tc_$m90A4vT|-to@89!7D2x+vjppofnmMKrPPl}iL{f>5{B^RRS;MI8W~qd z2iYmUeJg)8z}{0xZ7wN5!GfnZZ}g6YMEHldrPGe9jBdORV3p0s>!3`0+bS#lJjfZ} zyRRDRMr(@Zpza*go>QB6GqJ__3tkcYr$^}?h5ApM8DinMKYlaekl$b}8`Q+}mStcn zOyIP?r>~~N;xyxMJk7wI6o!g6nk~6Ez?xI-PUZQ9kKNmYT>PB9g;Qp+>SKH8at z6M{>LGy3W0vbNcxTnwdDqC0H?#slt}m2=&V@#!y2j^E2BNdHKD5v`Evr)821o5nq& z;(}QnxHJD0x~ctSH8bAtWgBEIaX`I7-pBVvT}D>JL6l=BvYQMo^+_C|z)yoXOT#om zz%2MX1w*M(!IAD9HHV>p(BAbbHYEaVQGH-7Ylz8#==Qf2|2P{?yJ;^z6L<-qJo_kk zvpmQo2%ACxt6U_GXLUaix7Gck9hn>^E`~~pmP&M5>p(Xq?1bwY3L(1@jg{8wR^cCW z=lb)RFs>9mCs$ z-|A2%5YPXeOg)(((Yn3VZS>Rtck;vJn%dprTIEh_Bd3%Ja6<6_mujXSR0*TUJQ&|| zHMn2UUo}=mxnO9LDV0IGu0*;c@cyHkAFSJv)uGla@@5FuKawPOWs_*6J#Sj_jejCw zvWNV?k3wRIXuPgC@$^*udnoC!GC|a0DvKkw1O0MvUg0sDT^pxe+j=TT)PvV`Q(|r0 z7LFGq6!Fr2pZ0=8eCCtALmA^X9&1A*Nh_4|F}b5gw!%o)Qy(^$(kvqrCD4eKbKm2O-0KjTOyXz7UHce?Uqez@&*5& z zFx~>2@GuO1(e1T}cnF5Tx_dx0qPo3LG-0qrB0PpDDRsPoPH$5=kk&jj!zR+2KA26U zE%CD~7+5FL+6I?wMLeGM?O5wp8uophN_Y6XyzC|k4m6dR5#X;TelSOPhcG9B2&=&I zE%qOhf@NlLp|ObrQA;Y}m2J|cl&q)L@^%v@B+z3YPBr4+`y#M`C8GA`L*(>n2v3n;zm`(Q5SFM zMorjOiubLhDl-4rzV$OK=;6flu6C2Gpi(i9q?E^pGgzsn%elmDJ|p>we)933*$)_B z@2-JIR2u7Pa=umsl0gf8?pzSv(?A^D6#e%0xL!e#q4WcqxT8`z0@KS+$=IP5kKlEy z2H10sbk#lft6hGpNN4A-?|W~)SA8N;3B!nnLEIc-qCsgT0%_6bP>$?|tkj4fX=3$r z^umg0X8}nH%ukbir`(Vwe#N%2lz^0?ANSyr=%ZSKjHUV8gVssL(sKV89m;(zQ&y8n z!?-hS`f>C5p9$c4IE<_iQ_1;_O&YW|Cc?Ujkf>Vo62;;H?gqppYdEznqbc;<)c;vc zi`87R$onqvL7h^uK}j3?je?btQJ|`o=nrOz5@&ez@xZBxVPu92(TLKykyp>Gy z5*?J?uXE$N>b}pt8NBIXPWa89O}Q~5vDW|G^nqtozluL-HEvY}POD)v0gnIXLysk$ z?oN5QGa=P5SrvBA$ySYxu+WfeW!Bv>lPPm%+(YpBN0Yz%Uo85|YnXqj)6-J6L5+z- z*ow0D;i*^thenF)B~D9Kw+S6&C*{-t7PUd(|NDeaZ4nu&@ltf^L`r@6Sdog8;WsZ< zLcN8}ufHxTBy0PCkfl6`L7e%`V3W&5hTsE{tgBq+1NmcNqY+XjVTl8SpUsH60fGG% zIau!q> zK8*Z~zu03GcHelo*<`!4fbHR_@**;KCj9tbLB8I=IS1Gv*a`X0J&}9ShVsWpOxBk6 z)b!(N+Ty+sGKCH`g?*`RI(Pplzu{C%{HRGaPP9d;z#t;~YkbW9tKK8$Mr_-CrbSoe zJ(zhRJhK4;$HpRfS1iBx`p4<6gz3RTr1u0C$fOF71tBxv=*5j@f&`$pXKxz?D&fSaa{yO z9*uAMmHuBc6dj%Q9|>QFZUdxbEb5q580Bo3TFyuZ z44Cz}8m32PPsi9_DQ?(VwP7%S2=AoGDrcA6`4TI zsUpZEw7QD9t5eE=@&p=EJk{ z2@QBFYCjKkfyIbWl?+a`GZA!4~hA%q(FdC{sdSH0hzQy zY~k$l^q||rE9}v4lHT9vSVcA`;Z2IuFCW@NVmU;pdJybC3eOw9m=>+b8LjG>MvBy` zQU+yDz1z>1v!Qjoc;q(bCrh}JN9P}Q@q!y9b1Btb15`^e4O6GC_`l?2zh&*%H4$esgHvi3p=gTq(s20wNFv$KX z|692=L>BOOz$1Oxir+`guU=roki!GMkV6e40H;|HyZXuJE4ZaD6aV%Nl+5 zqh0LP3s>1dOhZSJ*vtLv)KJY+E1s)sv2dWj-DzlPGw)XeyPPmTB+|mqtdeA472trb zZN14wfRqC-K9X8=uZk?@QT4kXwX(PU7H`6vEK-ejnRK?kx1m(}WS~Qwz)kzkG+k|{ z>Q;czN6!WOP)|Nq>(#M?j)k|5g@u zWr(JtSv6M1TnQQQu5(po1f+KE`AlUTs4kH<6%)~|@S`j97nx#Rjo4G)toh|6L9vhw z(;y=W=@%&`MTw|nqfdZ`TP?4n8s&NZR1-Hson$=&;uLyvhFn#Jb7opJ=CiuLzaMjU z6y`_qv=MMzHBVgyxmg`6l#!U*AzU3@RnHbd6iO#kwvE_c3N@q?QXFQ!QwC_#p(_j1 znlf$W!Z#;7TT@UvjO+`R{j|&1w_5A*0vshGY&8QM%w7VT*kbCu|1X#eAN#C%M$BJ1mn@9KNDK1Qx^>dWJM`lZ&-cw0bXlgeV zYz$IK<9x$_v&5o==RKeo^7K6E2mfMYwQw^lBxCUtA0k#oRR(*sY~?V^BBt&h2IgIW zk&g^%?4-TtL@P2WE9IeG1ejA z_H$%$31Q)JD)L6!E8}uF(HSKUvm$BYEC*aI7ot8fB{%@cO<=7>-6X#wChqn{Z- zg|gn5AsnInK>umjmX?Aa@a+;wJ$7KrzOY0iI zCJ4y)^nqBoxsUxQOM=A|Fhvr2li8vufF0*nSXFHg!oq6?aZUFTe&024gV6(>dJT{Z zaJ87zCXWG5aE(NG9C4-NhL9^HFZ74=Hrp*nm6yl%ZRaZ5MdQ?!I#)1%?AACB%?_BH z#YK3rLfix|g=yQL;*1@`Pg7~#>NrndP-e^-tGjUFZ*tey$uOpR=5`F`*K8>_EVS{3 zS6X}b`cx~cbpqr9f03&@?q?8-3AE7l0M3QA%#;N9>907UbU3FO?}~q4;gYPGYg|U9WpOOx9rNX|QRw2L6b4df(|G+0RQe&Mi=u&biNqNNRh#_=br14qK*k;?UV#Vhz&25Q#VEB;a994 zprGz=mH<LEx!4LE#+W8tud#A+rmahT^423jFiCL#LG=JpXL`tSLM8@OIO#2L< z8dTF$sz5uH;3TdaInwGrZakQi0{zLUDe^y3qA;nErc>#fUUfr}zT!h~N`3~QZ$~w)i zZ|;OJUi!?BD341bX)vWvEYNEgN<>i4w?l|Vk>*D$bwVDH$fZ-7e;7|Yx#n6^4e4rK zoR*#F?$-sM`lG3Gx0&96)%>PJK3fw9;Yt!|yj3h<*&r-MJF_fM7^N zJGygQ&tWh8kAA_kDxAAtaGFp#eXA?V)+6sR)N$CF6VX(m1Fg=g43!cX+rRpwQ_8_y zSioazsbHajwl0&rh>5LTT77Y4la;n#36Mt_eoP803-?B??YG%_`3#yov&4=kx2l%Y}TU6K0z;4Vf^FQ{I+#XePm+0Nkcl+|hxfR1WcQqOTV>EGYB;8uHc*uf}8G10@bCHENZ}JUxL; zOMobhM!-3~-~U&&dMbVu<)Kem*~-n|6r;;* zO1k-ddI+9Z3eo=iXCx&Prq<*x%$!o)cr(Cq$agacgCF<#GkOJb1+0HdoQb1a>B#}M zCG@1B%b=7@r8&BU=E{0{TQxWr@t8-1clMaJrq|)^f z=JWi3YvG-xwKTjquMnHToC=NvOfU;u+5+@kXQ5(4STC0$R4Li`Rp*H1!dISkk_h{i zDOZ5+nD49gV6{xpdCT8-aVz(L*%v|&frEa2>8t<@0pW3WK>HLAEO&W_u2p;tOmNIF zV3Hr4;`mj%nYKUpRENr72F|Zk0%mfgb)|ev=!QgL1Y4Yi7lE|6ZH2iR;18+UV|a-g z2aCcXak@N&yF;N#V9lU5-elqT{X?R}QAA^hp-iGSRfypV`O&BstmyQ5?qOcM-){gn zeNqjSmPyWzUW)nkitMC}@(zMm|MUX;wF&b5ryxhAvQhmbyYo7Bq_ijkqfWjSKx6#n zb3lQxNIHC;p+m-WJPDJtTRT@_?P)<<4b^P{LJWOB3C2;94Up47)Wr8&cGI=;ibbf| zl=JYKrhnUL^e9$juP7KzR|IxiymKJsBTvY8d$3*tX!&2?XEsP&SgL~9#UqJNlb;H> z`wr`gJQs;L2LUQ618xZA!RW0kcm0bh87JG59`&iR)N?D$7K%S1I;Acy;>KK)ETJ9J z(TKJOk;s*|36v7E@>eYYEP=T%xz8VuaS0BB$$bZ$&I2NONa+%x*~9>_+HJc7CxGY) zEbU9OOnvnYRl+Y5C~de4u<^IckwpV~GdW)m+7+!BUwJJ)bKCYpC0+)wQNy6`+vX*g{Um|csG}UT8D1aX% zcW`f(q(~lRu0~Bje9k|_V~$R)=OTn%pN`O`nma_&;zCfsGTNVZ@JXl#%m#chg%V^g zZ!+%ES36w|kFLpFI9{g^JgzW`SuDL!d16zqRRrQ;wSTYLO_9Y?i+cmC%z1{dzmGjr z7+@H6p1C9h?;zxRfL6=MG-&|<97BmPyFbHp05>?HE0)%4Q(ZY=$)A zsFuW+wXS5b>tehK6cu4KpKQR_2pLJa>rpyB2m^i2uLG|OHE6zWSz~^EQSx2v?L{TK zd=#}4u-T8`jd-vLK_rm$ie(5&gpk8CT?&AZ-KYsncK)GwLHEg8NpA-{h_`%0o8X-# z^J>MA_h8L+rJ^QpkuSkzGSik5&-%HT7Alig1YoB5h6*}0I%sx12{LkJ(Kq2o?j)HF z0tdp&RQ(RbGQQny;5PWWK$L@2M4mb3FftRN*Um1u1zzy1Qb{2(f0`$gB*m_YMB`&m z!x(@C)%H+&6v!)1ua^d8^#b_8n_6M`N;?drcN5N#eyIQC;Qs#v5>Dn>LP|S=6edS- z`Ln+5{Qt{uh7pY$B;+7Oe|Lqy4@LAy@`TY6#i~#LHak2Q4dT}o3Wg;xyVm|Tf>w;?F zHv(HQE*dWorvIdKCVAfS^tcOW9#ocL=%GbreUlA|k{X#LgCDgDuwsjnhuMkn zP1S#%Pm@p)kXK;ziZ;z3P`joZG>Vd>oR@E=x|8%Rn%rDW_yb0T8{B0aVo9KEz9Uj| z*coqt8)42|a6u(8(j9d6iRu641MVu8b<4&$@-g%q4#jjOn^&pRy!RCGK*pLY*<^JU zh#b#R;4C-pb8jqbzb`NdaWjiZaaCIv@1szi*>W!KY7W13?ltp~R$gROR@7sw1cb+;D8S=6?bdps;=~0o0Yff>%PtzgwT`5jOpx zSOafj4T-?a?GaVbKm`c;oaY}9)qenE!!r10NwZ=YI)Q~W!4?Es{i9`GX}ui779bRr zd#0*Q)!YCVONwl~-~H~)LF@+qrm*Xvy29&iyA3%dtjT0}4&y2uOFc^EQJmic0F`Y? zVqjh*ZgSPhpybr-@Bh0WyadV5rw6JuFi&B4U27Ka-ZA9W(=T_2o`rolA-y50!d7hc zpRq{&EexZq;yzpJHItPPN4xiQ+XOqdwHQC($uM2 zMFSJ7(S-v51el|>!B0;ZwbqWGTz15SA#ek9Y2_Fqb?6v z9RL}3mA`4|9vwSQ*0Pw#ysb|JEn4I%POV>*GjM1_hrqlZUiE}TID({2ZN`U%eSBWX zZRTMf<`QGI1nXx`7sLbOOBu_Q?PSvI{g~Apuhi_jgQ*+9MjZhqoVni=wFkMO@TUF0 z!A>^d7mnXIP(QoJID?fdy=X_mc$SEpH3Wqt^DlU}@+-~o&*Qt?Q2)Vzc3U1MwzA-_ zFH!}gnTyflr|k7^$*2GZ)oE1T zw@)1kf3cp1dpl-i(1(!m&7@(!MOhlsL|F{X9~hY4qHs22C#Kn1tN+rv4z|7Pac4;KPPUgzt9m&@D7{-OnzOZ3$sc4jQ;$I z!4kj)moEk|$af+0&o-V^4(qg08~!!?%bQBTe0tzs&VO>!b(J}<@AhzCzEmE)^Jw+- z&cKVnGW^{J*NBJtrc8bYeOpWApT@@tg+I=3`}&&}ZrV3T}A^w?+poav{cXiYV;F5+(~FYx7%lL zqET>y1h+@BJ4g20j55IDn2g)+f;Q7UF~p7BkFh8`jOYgYbWdleyRG#?Ojz}VY{}j> ze(R83D_Cw!7-W31dB+X))$e!Oj>S349pFGpAMgJH(qv^(37`)O)IY1-RCn@SX`rr& zR~q4w{D=7I)4gNpejGGL@3z~#v^<5uKD-K8ujz2tSN|Fgy;2H< zv7yu1_#seH_8iDlbHAWO#6qHr8Ps8ZNYPM&gfbUk!4`%hDf=Ttm5A zpiardv~=3*bKO%wpSI#}*zXa(qrtr?MO#)k0+p%h_^mZW@H?nf=qBi7+t!n{hG(_0 zxsiY3P;y3?m{47MpwJ_vnCs)iL6^!B+1RVCq$_K(nu>h-V|j=flXci#)@0dpnwwKl zB=zutxAu;sgldSYW$XP(QOZ^IxFgOP%gq};E3XE}NKD?OTA9zAPlTB z>v;il?8u#Xwox$9l^{Prh8_DASPYu_I^B(Gu>A876qmAk_}YzmB3=V`@*GXs-sNz3 z9U$+ZNi#7jsb`LW6k}fh`a@<_z_0dg$$+L4=5*sr=KtH^X9WLqdc2!a<5h7pGh9M_ zj?OQgkl(M5(ofY|?L`xxGpzYG0FnA2c|Bj=--c&vxSwBH`13A)!@0lVdU^aAzSmFi zs~J5wN%Of@t8-wydo0cL81d24nE6F$dz4wi%uD6#a2gi}?%@Y-H%hk49grRMd0YSa zxN?n}1vhqussyA^zXkS)w~X^aWqcy4{FoL|^>aJw#+Oixo^L*)u>!|a75&PtvyDeA z3$WRz2ND06P|1Qwtwm>{KO2aB3N5D^S>qze2XRi z=V@eE$fqVO0aRvQwd0Y6{t(dDL-~6~xuK|G=DTIcA~3^tob?({aj>0ipA>~EfdQ~e zKYK5OW>Teo8inVNx;v&D9#uUS0L%dtIZ#JUU*Q87_}P$FC4Zl**zC8cVuOK`D**(S zzpMfdd{AtIluuwhZ^IakcIYbe8ygt?Orqo+gVErYT}-G1LD}!iYN6CTQ$i5!UlD(< zlws7T#r^x;QmM7lHAhup=YHA6Qu3X83Ns_S4N1f89oa(CAqOafSB2{Q0uG%FF)(5LU|Dyv;`EeVFBRAgU05qhCRCC#%?^2B6-_L0up_OXx_TK=)NppT9y1~8FklQp4OT| zcL6h>xf<=aQPpaS=KX}%?|CM-VZ>R5dsGI{V^9-wg!r1z?3?5VjrrZfh#mghlJB7X z{~cD5IkZ0xhB@)M$sr2a9}!)QP_I3<3+yXr$q{(aqZUD@zIe1P zr&KzVh}u41QyrHp3(Wdf2DGk_iMYD6q&Xm%>^8P?W3G`&wiEPEE}!&UiV2LI9u`Y5 zjHsVm^wrQNb{`inqZ9qT50rgHqD=KxlP18@jQ}PAsWx0@YC1{2*v=PQW&sxS4srv! zzw_N4D+WOASA2Z;6m6pLF``aV;}9+@-z+9@Z@X_x7`cFD-*45f<}Ob!KfK2MAmb`y z2F$|uK!O=R0*WkaORWb89I991-PBdDq|$@z?mF~yzr@GD;8*cf>W^Z|c#4W>@`CMd zKn)wDZP_6WI$H;t&YhP%m7r`b2Wi$*=f?~8fd+8vkyNb|s6IoXyDZqu2Y=6dLv$2u zzd;ckSs~k7UL8R+G4T8y>fi-jr`#b*r9Q?(WRS*xm}BmpoIa0qmvw^?=OD14o*O5t z=bHhFxn#j3^bu6kHxytob)vC{`+{Hg8!$~9<~zM^{yx6K)LpMYHk7JO@;hDy(d~Z{ zBbLIvY8B{ZK?zN}PaCBu+y!6{S zN`+43a)HUHb=1&GaTvxgC$*_=Pq0qh2cT*;Fik0DldedUg6`o}M?CwGbBIkG*`B4~ z@KN?VqkOtkYFrVwQb=;^@lcB1ZF&QdwQ$&0U->H^Cp#UNPHW35$feH{ap&{hpFO&I zy$}DXQAzgRo2&sziKy}A_l7`8ds_6^wve` zm5Wh{9EWv8_u!Sm@SQ`K`Ucg2pYOj`2mkrT+w$o7?Yo6>8p2epbs=!}j0zwrW^4e5 z9Kkv3AWc{Hn9EEDP7p9(&}KiqfYMhNelly(i-Sgz^Eu_CAC4cytQlIR`a+qi!-%xI z-w6>*Wbaxpc`o$pX*Qi|A1~zH9&QuLRyI2H-7Zhu`lhwkog5)UG%Al3g$tdQdF?U) z_qNfnpmz4Ytc}YnU@)q7S!84PYmV5DM`S$T7?TzK$x7oG3d;V?r5`zv_n!|ox6#Pc zX8(H85Wis$5?<0JspEtRZB6BY-fVG45qZ`)8O-0g(+$)Xo4UUxcGkpK85)vWuOa*X za6Y*S`wu*HFYVLQa#4S85DLBsRJ!@gL&(a3T`s$NbXCYoF=H+oLR8a!+mC0i$**u7fpQy6HuIq`%Vb!htCnb@4ifaVCD7N{@61+LYLYI#3CYS`o$gjI)Pe4 z{M$fyNn|rv*&3pwPI0=(fwyS1tdHIqEfNt7-|cvZJ;fBEBmC(q(*>6q%{rKhU8PDL z(=H{t44D)>Oj-X!k2qwNeKh=e5_@#VGPMoU*jh!*)F;xL;sg;j^@flM+D(c$1_43# zL`6UYqa!dGz@sU`#jS+)N`KZM0+!u=fqI8)p)-D5=2O*2fqUdC=`M4EP6~F9{I(vd zxlf3OdcD(0$Q)|**|}Gvx^rdK$Zu8w$nmctj(P=m4Q>V&AnK13_}Q_qj;m1s@`xul z>?qgsPVRVIgPLfe`-1qO`iH3XBOivhMa~lT7vzSy_!(Ce7HN7=ZY9HZs={+%sor${ z0amZ2y6~fYoy4bGiu`TyDkmU*Z{r`%EJ*oO$plWkF5wgl+wg@w_#TO3q;v^3q%jrh^vo^QmH$Yqmemz`=`Bb+hihiWsJ?+mN zO8y$a+`~Aey!LjVULr;HcN0$OSGC`cQeD{E?V*SlU^Dd5!gau{2Z~#(4{DZHCH^-W zLfOc&6&3?rVkvD2VI?8I8e*ug4JF+tN<`~nc9ecE(gx5K=xJ7y6vTR$F~Ev|e$(Wr z(KyW~^NH=Dy9x4#B{q=*@0_*x)U1*3yyb(st7D(&OtzarWS?r8%7`7VpMW!+@T*wSq^YUyab|8PkG~)&ZP+R*wNaf;F)ufSGE= z!k!gxVD6{N$}c_>tPK9#+d~Dml+p4gjv12-JxaNrLmUz#_AFwrh%~3E>8_CQC*4?= zndJUu#)b&>G@A|3?U2Q^_3y8w^)3-!T%RYzgU2qc=JsO!HyA$REtys}G|l!S9%cJ8 zReU5hqf%va_aP1va(^3o6-qD6XeVvN^cWGSU?t?k?=q#KNA?0VGNqb)2^+W`O=ck0Blh9v z{`un-W(5ydev_3Jkt$DyR@H#eyyaJ7$M!i5ahF_J?3c6Qw->(jv56;lCWk_FcPy1p zpT&;ko@|S~c+F1lYjBlPJ~4#%?i5yS)=WT}M0hW%AsltSJNo71m-UXUVPZ>0d_#X&iRF=Ni_=hRym z=zpGmI~LeYwWm|UVZDhc8nmH#ZsNfG&BsKS`sqNG=0DR^`^*gBg{`O?V8=Ul)*VH0 zWMzGqW=Bc1hlqEFi1VW)2qEI5AxLdEZr>&<=U@=cC0V^98{LnEhe!N#YqDKW8lq_@ zgOZ*rpETaB&K`;};eA^Miu77j?LCAtR)YFk04l=S45U*xq^L@~iMu_j$p<~ErbSCQA5_q2op z054UBeSR+w40#vMyUUQayg(|Jx=vLGt;mvdr;TH!x;b0Y;dQcF{W=22$HR)hzFoLY z84w9&sFAp*eD=qv^vBGd&urdq4fIyvqxb$_^P4Y9rC#Zx^my9Ao9NZTeqIP34L1=< zm!_H5uzVt={N}>Eul0XeAMC9AchXWIUXFaua#4+)rh7VxG&;%yL{Zn zQo0XdyTfjM!lOzMJ2%5U*JQx+$9W7>fB#5(ei(+?c)CY)d$T0b2HdXjZj5L~GG>`FiVYSdC(Zu+58KJQDSfuqL&ByjAI2H8-GPo|OG6cV=+` z^CQ-8KO@}$hp+?a72=V38$dDN26%)KXkP&jMWAU=0g7IGEx!R3HgO4wgghc0m#aP-jRbC((BxGb3(3n?jChT zSn7z^=b?U*=yb{eTt*~~;>Cj|;9(EosZMjC)nNlm?gF}4u>jtBF49lXh>D$D=O;TL zDh3h|!hd;GpI4cTelXGj3#4QJpgo}ONUTz+pWMY6GL-gif+&tt;nT8|n$N`%#P>QZ zePGBzf*LLLH8J6b$n&7g7Po=!T+XA_u9tez=QO2kK3L4GE9$T5@$*ck&59t$E*oJ> zw2aMe$XorQB||b(n6E0lTA(7-(A{Ae=#GEJ;1w2spIcfllg4HYg zvoYM%;R%HD#j|46uWG>9N3E2~B!P`*K)?-j7C}cxHJQSc!iO1sfIi)2i)|gq2$fS2 zt^?D=B>_xKxb$!}NxsZ1!59{C|9i{%_=St`^P@Mj*|R-+$9(;!8C`)|642Ui{%9Xf!+sxi(CM?B|dSAI|Om zLs9GV(}N#kW%J;^Lvz3hnCL5sIn~`Mn8_PR$->4uaUr)QK1hU^ztz8sm$&>aB>q4x z4{oCaeW&CnG{KMWIJ@ob#JadD7e7jNG#v9dpKmpG4&>Zz?0pvQ1vCdxKQGCy+9;{$ zPw0{NPyu{wA61!9!2>I^b=I<}s|k4s%Pfsvy1JY|$|p{wjWnQDpdNZ=p5xsF zJV|?1ep4zLC`x1u6p46<$5R#USBuHK2tOj;!F5^BCRKO4c70>GdLsE3dIzyw03?nJ z))2#Ekv;DKPZ^EPAo^5{<35oZ>D9g9c(BL$7`PY5?-?dq?)uvmq@nM1^mtE$%q`V5 zE!R>Mwd%tdx}QI}`Ig3MJ4^m-(unbksS``WQnMe2;$NV$pwIkMpo~h{aX^&h)3&E( z_S6g5L)dU8?W$$|)c1XG z38xzR(pOU-=LF9ITCB>-oglz$lSC4$T0^u>t|u3d#CV<)|9?DvcRbbqAMdXeQ7A%W zL?OpY$fjYGii~rPaqLz02-#(XLuN=BQPw#edpl%r634OkUMKU|?)!A_{r+?O(W7+U zpZ9A$$Lq5W;V$b~6MG9v%QAUfHRS7^cl99BGd399fJ2wL8i2wS?Pj~^cm!G2&>p>E${k8ureaq(~mQ8sAWzAJ(owqhxI3ct5F8Yx+jqxoSnWK@|UA58s;; zQ%<O9j z4+mt3YlM>JVzw^e&7d2<0sF5M6*v$XmSWJu^pX&$OKKYv(0i%hnub3zHx1Hq7S_`8 z**$s6ToX>#uTEWKnzVHKoQE{V{)+dtt+HK#FUi|Oc_c~! zgi5`u6^G}Hx(p{-Vod)ICxZV2L&|haewMe|&<)`Ff_5@gA@9eozd4m!_8VW0EX^`4 z*mxl@7-MtXF+fLVmB&dGpWQBKvYZz=z!Y&3d89Duk9XgNNCiaqLVH4`$QRGIEmQ0W zO7nIA3bs^&&#n*u_8$gexqfOU01$lDV?*>wCDRMb9hOm2`(`cpV%&XPH0)*1Y^h?m zwb#3^!y{qW^+kwuE8G2eXQ5LFrieRoIEf^O@ta@{vEVf=lzMuRcV(C02Oz@pOL~qB@NU&E*2~s3N5d1XNZU9C*&jpD0gna~>e=*#Rmv)q!+DDNDx?LP zX9m^M_dEo3^YK8CP?Pu$SEbkaEKBx=h(_K8ghhg-r(p>Y+LcoO#uNpTngqUVyaHne zXd&jI+A13tiMs^a6?x+k+BU#h{`*wJp@#@cwZ0EAq9nJ&d<6)y2*xEcM9y!h1A4Wv zLH{^uM`G_nAmHP zML4zFBpKNiw9OBLj1tTFH74M?{5RJ*6}Rl(8@!_M0HxbLY~y;7V%r8h3nUN^<;4Lx zxc#ycx!hrVwywsor3qYQ@Zi+(E~r)v>kvMh53bS6{>7J?Cwjm1icY#xe>fSwzzVnM zvqFpQ<3{<9q2 zWC5Foo*7OueK2*J6G}UhzNV4l#l=OS1Z$Q=lJ+%BcOngx>($BO!EO{K)|R;Db}#ds5iHx&w@Hu*7?O*`FyXN$=5A0glUf9dBl+E(i(_b4*o za^B$2!wm{r9T!6o0r^z_`Y8>$p|z9?fCS0F(2fn znv$`ZHw|lVOT(Vx-CDUDimA`J7YLYbs^~dLS>9Rz9bhgSbDlmT(ca&zee>#}#dQ6& z`x*I+8c${&eJ(VY>DBf6{1*nh6DK+{TY-(R2CuvYeB#G4=~u5;AQ_?p2(8OIL^EK9 z{bI9K7V=i3l*t8j6FrL*Pd1_UqH^xP0a@&-k;v#2x0y7`K^r{fpet-??eqEN;Em;s zA^Q9@EBcWG#m0@d0=3)7RvXN5*?o+zhRbvif}~g$$#_M8WQe03YM_n;ENS6~m7a$n zK;7ep{!!G+^szvIScL~`LDZ^ML-UZzi%U{7;2h*?vs*;ktv0Ag%)H?pP6>NX+jrNe z*T(POmiE0g>K*kAv*0QkU;!-iLdfrt_Aw4Najd=XzwOvY#{I{Y!tCGXKNkGn3o ztQ8h-r@M$fV!0Hv_1|6j!NL9>y_YqmGp}#e%3SxK+?MRz)2;wA|0}9dKElqo$gh93 z;-5p|pV4GoUorrKKu1?c8vhO1tMd>}{dMjG-{y}03ISBcU>Bn&di=BxltH5SO0KPyC644$_!6CgWRp-1tt1S zG9~bK-t3N^MsD7|H7unGTCMsKlw_b7KzQZ-QE9zaGX=qXjr)Ry4UPZs2u(taK9ZxQ1(05h#ODD#mS((|I-xO<1E8aM7$Bc(j14{5czCSWkFhwpj3@C z^NS<7oFGdxHKe~c#(KE}dh^dWYSIMxyE>QjZ2H8}2gHuBuNz^yiNJzUob*pr3I5!% z6dst~i@LG`{Xkhn!rxdZWDVuu_ZjUFme`JSkYi4U=+2EDX0du`=2!xD1V zJW%ER<4!fWX0h+lUE^U%*|o1kt%CjzubkAlRd6${;#r&z&y_1%%>Mqd2x!QG+=xj} zWWwow>wga@ZgdZ9z0!*KihEMU@%wE)&Taw=+fbtB5%p~DDfn*N3@si>NV2-8r3&b& z3Xb1WT+o92Pn|GR#IAe!z1%;z?f*-?Bg&cqn1|!tsNaX-@4l9;ZQZeiiP}1&3UBLD z@S+m_zP)Z+bNlah;ia{R`vjl>02XI-G@Wf!NLGfUz4$CkKWoz zl*N=LMWuZvt*9_vmjA_M3EDE#YQR!x67`Y=t;L*3vmb9~`>Nc22u7C~WGbutZ~s1v zk4RzCeV5cee*`QG$Ny%@c%N5vXeFX+pYvr9U$k$K?R<*gIjPlvH}c(QE1NKpWc?P= zd%tJ4cSZS48JAIOVixVIE^HgO^6gHjc5RPMbNs&M2pAkd&Z>|wq$qFw4&+6(vG7fY zvpt8f+Dwwhh*5&yz^%b@jW06q1yK*|cL3683e^_|8E-!}%i}J?K|W7hr5Iz078(Zk zY?sCfSGC460*&9eh)9m&ZzmZBfI-~&nHp9AzL}>nfqyXd)S1Rj#VYD3lSMz~?T{j( z1Q=>3KfBGcF^ludykt0$AvqxRgJ+#kMcqv#=Ob(0>kJYwRS3d@Jebrq^-VuW=>{w}4(BAhurlxG>*el=LKF z4Z`jj{NkM(1Do>|mY*KO1)m9JTA@2#+B9O~Fa@va%Mum^EA1JE zPX*|S-%*nibvnAl^ht;9)}Dd$8jGVY!`78$ES-&1B%=?auXQ!S4UbYW9tl#x{9E4)QN1b7D$by?aDw#R6>FApU^6T{J1q zj3It?=z-mAo6H!z(Ix!^{L5*dWh)du0Df~Zs71-B_uu%CI{z&x6rDm3mlMtY*G7G> zX`Wl7FBBEHtM3HHIG%+YNgqrxmY|RXxdaw~1v4w%Sk3}`O8>vvA}*(yk%SS~ZCnZT z(aX6DY}mNN-`9H#pVhXW4NpInq{#Zk&BgGoUC!Ig`vG8q3zXr(>d2}BO?V{XWB|~w z9&^cf;)6mw5{NWuQwcvwNEKKF^9CDG0xpq-eLi%0z~Wz6%d~P??L0Waej-6pe1bnN zy-8BkeyJd$EkXBMj%%*qvjaBT*iVfBQoyLy7yJu)*geIc zbE_+7eHhga_#CA5YTO!Oea_+Z7^7fMY(>)dz3nA_EYQyV)Y)L^%3LeWC)I18mEU0_>7_n_eQ?rR4|IY)S#ZG0;<;aD}qe3b4R5qKf~9KYuCHsbZN zP_S{tbs`k_=CWJ4nxSPTd_4J)pIdumTWI%!8&7SyXm~B_MRiqD<;ukGF!ZD`>&&Kp z5LUNnE5coSSqTuXt^!Hz@c=oCALOLR@MTc+yE}08Fc?*#NIxw;O(@-n1dmHet}IZ- zJxW$QpsDuJ9oL=5xIY06oBTsE+lb-tkTZG%9PcL~kg@>%&WP^18vghHiTA#zP~~C*$iDuWl$P3#v51B!N&z1B%Z`Rg*#c4THQcsUY=HbVdXB3tRYX^ zKX`g+V(g~vp6?E>{;IeB828YbpgF&_T>Vb7BQCdThwSjWHqs3Mn|pUMg$_MQN-*7H z@2&1g#&1F89}((7vYfl3iV(12>79@IrHM%nWut%u`u@B-#0^|Zn$98v`DpC5IX!l2 z6=!uvO7)=UNsQH^$muWeNN(=r{r6dxr9pt}wA)?TF!toQcH~;1-EFO_$2_h=v>C`n zw0SEO%?Zq|b{9qF)u;^(#e*1^RX7JFu;AtmqHPr21e0op^XU(N^r z5&WKlAFwtG?uGdKS2XZviDnv}^Hn70l&Z|$|E?*= zr`N*Q8iSu=oiC#LobK%H++H>n)*L;&YB(OurYp!}_;)=8ChH|!~Xh%Sg>aPJ%|E)vj~$ zBb!%~q4;GyF@KG!$qQ4z@9cLqo|Uqt==P%>DeQm%n@}nv!%I&x&Rbfbl><^k80EHf00j0>Q1_}U+Z5yR<@M04N1%yE*z)~Sq+hZ zM<4CE2S3?tnYemFX!&E7_O865H5aJp=U-d_g2*J_U++<#YkpMC;`|A}e*c&7)$5E* zMc{edJ=eAZqd4~mmjP3F2YeQ_rG{xc%z-z_W~LKAD-BNZ z70^Cy!MDzCqRbEx` zBN-toM-+5mtL}hK=o!J2k9zZ5Z1dr0#n7L3L#0BbROXUIAKvr7bN`b61+s=aR~n43 zlE+#_k-40wB!Bi((PACrmO(wSyW#1+?vlT5c2qfupL-ba$X%U?mupDbI^Mj_E$CjA zm34G9VN^#rs%f1#^SbVRS{JpSc$^d$nWsuP`=3hw={s1Ds=eIz*>)|=3^4G^Gcx&Y z%e+=NnJgOa_`6(3XSi=Qq;N=qIP1$7Io>w4&>d{yIUY zytylF8N$&&i{dmfTME4!k$x?m+PSv#K-*}`kc&s{WSt?m)oYuARvms+ok2$IWfj|h zi8oY9wE2jK=Jt7oRP3`qK%CwP&$UU6c!M)V3$UNTa$FLdCgd^=?>Qaqn)Z^}x~X%; zA5YIhCQbb(Q>3pFF={WeP61?{HF%rR7MCoXLhwHXr;0<-_?$fcJY_oIfU@Mp8v zd_YD&(|$4bqnZf=$ zm6TSxT20s7&n`4tse7oZpROHG*EMbpD>QCyIv9Ixx#)2CP##r-C%hC~KyO*-yTgqz zI;L7kJvo=cfE>qX)kmAp9bvf-=-cjKABBD^yV?8@{YHwMV#-Ahc;Ntdx-iR`?*-c}bpU+jTl}C0XS8;8+>m^2w zGjzL^NJxHT4g{{z?E20UjFm|^|0jCpmHFMCDy!>}zkL@)#SG4_O4J=+9IHD~xpKDQ{`YhXUT`u)m_9x- zGMys$U2KIb1kJY^HEhnq3y!B$mX79JbQBJ*VAbW7@jqxyJdUp&vik(CDjZWa2F8vl z98m0>920+^Elj>TlKK8rS!hmR8b$wu@RB%RP=q_6t&Qn|KDM>+JZp`qfB?%$GgMo% zLX}2;S^oHZbKsj*-$V7+oL+o8n%vjq zN>kg4Ho5c8U|k0AVI#_r8*Eyi&`Vy7d;))2Ofc(%{2}XWD*p9I?IMb~2ky5|!zr1q z^y8f3JfaSc-y|#B_g?LH!dKdxnJMx2JN;mAgd(c-2UAW*Oa$F=wP|(m4V#!eWtXcv6n{~-&wWMVHzT-?E8o&RQ7{_nj;;k1(_mlB_2ve)c> zSIBf7s0rTx>mD#G!JeGk|?zbE258)!sd#Q8Tec=qfyLN%=@f;4(b9ubPuk&S6 z<{odJ&Oqqgv)iGg#P?bNFCUbc!H;bxC51AT>phnbPVnB|7;#YS%lYA{QPtIdgiS$XMNlf0A98 zhw#}pw?cDmxr0eg!d9qe?-MF`VF8p~S3xaO{#Q_;Og3x9$C&!n-_!61* zxv&mbI%SEq*D#(bj?j3Ez~!;>A&kke2wA_A8EUu^l7>rrtuB1u>%!#C3q6W+tHUGW zhctVm_Aa#vr=p|T`A*-6xu8XrQv$C>7y>iuZo}Tx=J!eGw$; zN-meNxB`ac?KE=u#T5p;55~RKU0~!nwoEa=z@mHXG}Z~jaM8R@aAS*W*oTZicp|z> zR6ki_6XxOdv;vZa6Z(esZ&r3P2q+@me4o_pA^8W5R`6#%kp8)uPr3&)P?53`r;c!c zTHo6%%uBNnKN26&3;TqhOx$as*>R#bML$e=U9lyv>PLJ1^v6B@i`&UEyR>H?iu$q; z8G(5{3W@Dd0bA|_bl^}&D*AH~v{yxe0n4b(+!LIg{EmsKCCkxziBateqP0G4v@P{r zh@6DqduXrh%N(lR2O0{`Im@=jvpx{5KWrB*dDs0K64jA2F3SwYi^i3*-^*xbe0vgA zb*fB!ncWHvH}pJJ{iAQ`y-+h_Y0KXqQ)(34q~!^OaF)II@K#N|P_nMYGgFP*_q|jS zd%UvN?=!OhxyL4f`RK#w%w=NB?R9vr^B!TX#>PhG z#f%8gIMf}D|CfCi_OB@UzB}E#zpEefRXFNvaR==+xW$U+lLqMJaoQ+nj8;tQ0Ka)} z27B-(@9xtan@7&rYwe#b=iEJ`o_<+Q*6CkKaSDJC+_K{5fQuXWxetz|J&d&V zOI>@IZ;RRvM0cbB??BlMcp3-%4ba<0E^(hdAIg_eb&)RoBK-kE{BO_Vjdw@I%!fO? z3azbm$KoITHiLCF8=%W`BiiA$VJ%@qC(pSDsuT`0ZA)xxbz4H|%ZN*Li3_KC%dOCh zo~H+crY`53CeB@#6o#Y_7Qu>y=+gU+Jozz6Q0p*ge17&23#%P85?$2p!8eam^hsS3 zsr}}DPMGn5X}=RSU0anp_H)eFswmakR<2$$Oj*?Ku@TSFGgQzgw41*0G{c9KLSxPQ z#r*BY78Cf?+xTeDGb+-TQe~^@?za6wx`&_9$HPcxa1s~yoGi*{X7f*1@)u-NF7xLW zEEs;HchTNv?tQ)VIZik#2CV1XB-0MaE4t#-g#b>u{OV_8US+Wf^FLRlwunp|gU0a6 zc(g&5sK%e3^4Zr}ms>~dhV<;{lFXXHW3``NgmBw3;N`eL#1!IAH$E^UGVWN#O)SU7 zUA}6H7A%#MoN0xg+A@48rbANW1d@n#=4+G1iFVd7_hIqq_N zBL#($+{%81YD8o+2R%H__dxh{4N-*Eog*#j-aua=-*p3@@V0gi)Ae=XO~z}Jio96j2%@8fyK=s(=RA&f+Gmbm9=NUS@`34)Zs6B%c z2yn)z-qTTxd{l{4zfAkU1fIyK@^<_we(zRsdpNFD-#l5tM?Jxs%}knros<5PSSNrN zEbRH^jg4{LI`Q@S3eSDi_v@1`;mX_qndLjn-g?U%iY|%0+1}H&-v7>8UvmrkZCa7& z|EmJ_tj9WKky9e{gF5Om*>#!7aN#q028Bbio)_|uN*C{|R#P~Ak+lhZNs}u*91Iz~F{tJ%{=`9U z79wT4D-IvdtW>4azqncod5fS+O73CuBS%yGTR|Eh0d0G;obL; zPZXeKnn;&5QW~5!ZoOv8j|xFrZnzlo=$TbV+=yj*7ARLFCKrOTVX}C-qVeU0RjX){ zyZPjWTBBcXI$`Fuq7^q}`h;bd|L4T7m*YBGBM?vQ>&Vip@WMlQ)3rUVSadek3k(J`>9gq#k&VsiC&}|oH z-$6j-4qvwv>JF{QnsA9=Vj5t;_S4=4!i2jCs$_H`v!ZY;wIZbOka;@8Y%b-l{jM?L zA_eSf%Vy!HI*8q@j{9O(%rK}M`$MRL<_Lz;GF?RThz)eALh@wzREi%)*ggN_(=6&? znQ6Cf2D3ns)q_iK;FA`QdR;2hOQ(^bI#;V!K#GbyS{AWWH41qWZ0-e-xjCC}?Ho29 zj|8c0jn`SKO2_iI`z9{e{1BY&!hagfFE-4-a?$nlE&1CH9IeMHB4dzjK~l&CF`rs& z3a&o4QwO5e*(8WzkR#Y_fg_yZ9WZ9u0|=~a z#Io97BJwqE8bUYAKaaZb!{AS0V1>0&kmaD&VR2j?W7UQU?R1*Xj{vV=*w5nczg;b8 zDv;y)@>-+aHTn4pX^pk3sV^q`1$N(i?1drxpttnf!8J!YeB0I$$}MO(oc)~=ATGN1 zoZA*^%WS%3L64l2s3e$bSyca&{m5FD?jpbC75umX>JaTRp@(Cn!=%@0zBSDFgPqKR z8xRZYJ;J{E=nl_6-eR8B^4s4y*W}4tn0;nvfxt=htU9CzqNzZQWRXID-gv~Lu=)_A zr-chbsu;a=i32@`aZg8T-j2Kcmjy_n=R*;~?ns!r;|geOPU@I?bV-ST_snpX)J?ih zpQPS8v6?|Jgy-p0KoXBnI;JTk6+9_$WOygrC16WEydAEv|390RTFV=L5!YrQ@@1%^ zh?g!5{Q4K2aUEKJh9NrZ*3o9Bhc_PYzIU#{U)Ebk7{aK_(t90w)N7HY?2e+kb+A<9 zZ3-qm_FY`l(IK5s1#W`7GPY*{5DBZeZi1@MD@Gx9^}ljpA&Snv`KQ?zSJ^-z~&?}Jxw zsFRWSzI|9Q7iOfh5vn6=Os_L=J>2=sjprFC}GW*MiWq z#fNfPOR|_YRl%4XmA}lli768KXbzbg_f4wd;;e0GNfyF*keMGoUoHho7@z*dSx62$ zBR?e^s<3}9UH;(g-M^Cu$@BypnN_J8lIM>jgQ?5bC;A=c#SfG&#dSZmHY;vs3tUfQ)>#l&S^lJPgqfM9(ec!aNCP#CA7m1!PxN zhGYLae&Mt03-L$Ei|>Hha(IdHmjS%whWgCJ zD@TUBPQy_@BL)nWIlnZ8dHo~1kP4&^2CIkQ{>g;=T2Q4JcB#Sl?WxFkapz05>rpo# zo8GkX*EV2{E^_%$IgIK$TlD(nUkSx}h9)iTIlYpgGe?6ST}STLJf!hNJxcU1z`s

clZI%rh*0KM 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 P;Eyo)dj$O6ojd;zH1#b7 diff --git a/tests/test_graphical_units/test_coordinate_systems.py b/tests/test_graphical_units/test_coordinate_systems.py index 299c7bcae6..fe2dcb1263 100644 --- a/tests/test_graphical_units/test_coordinate_systems.py +++ b/tests/test_graphical_units/test_coordinate_systems.py @@ -51,7 +51,7 @@ def param_trig(u, v): param_trig, u_range=(-5, 5), v_range=(-5, 5), - color=BLUE, + color=GREEN, ) scene.add(axes, trig_plane) From 1c6e2ad992d22ff671800184da350e513fc78d70 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Sat, 11 Apr 2026 13:52:04 +0530 Subject: [PATCH 28/33] Added configurable MSAA in WebGPU renderer --- manim/renderer/webgpu/webgpu_renderer.py | 228 ++++++++++++++++++++--- 1 file changed, 202 insertions(+), 26 deletions(-) diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index 142331fce4..1e4c83cf22 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -508,7 +508,32 @@ def __init__( self, file_writer_class: type[SceneFileWriter] = SceneFileWriter, skip_animations: bool = False, + msaa_samples: int = 1, ) -> None: + """Create a WebGPU renderer. + + Parameters + ---------- + file_writer_class: + Class used to write frames to disk. + skip_animations: + When True the renderer skips all animations (used for caching). + msaa_samples: + Multisample Anti-Aliasing sample count. Must be 1 (disabled) or 4. + MSAA smooths geometric edges of surfaces, images, and dot-clouds. + VMobjects already use SDF/coverage-based AA, so the visual gain for + pure-2D scenes is modest; the benefit is most visible for 3-D + scenes with surface meshes and ``DotCloud3D``. + + When MSAA is enabled the static-frame optimisation is bypassed + (every frame is fully re-rendered), which increases per-frame GPU + work. This is acceptable because MSAA already implies a quality- + over-speed trade-off. + """ + if msaa_samples not in (1, 4): + msg = f"msaa_samples must be 1 or 4, got {msaa_samples}" + raise ValueError(msg) + self._msaa_samples = msaa_samples self._file_writer_class = file_writer_class self._original_skipping_status = skip_animations self.skip_animations = skip_animations @@ -554,12 +579,30 @@ def __init__( self._depth_texture_view: wgpu_t.GPUTextureView | None = None self._proj_bgl: wgpu_t.GPUBindGroupLayout | None = None + # MSAA textures — created only when msaa_samples > 1. + # _msaa_texture: bgra8unorm, sample_count=N, RENDER_ATTACHMENT only. + # Used as the draw target in Pass 1; resolves to + # _render_texture at the end of each pass. + # _msaa_depth_texture: depth24plus, sample_count=N, RENDER_ATTACHMENT only. + # Must match the sample count of all MSAA pipelines. + self._msaa_texture: wgpu_t.GPUTexture | None = None + self._msaa_texture_view: wgpu_t.GPUTextureView | None = None + self._msaa_depth_texture: wgpu_t.GPUTexture | None = None + self._msaa_depth_texture_view: wgpu_t.GPUTextureView | None = None + # Combined fill+stroke pipelines (vmobject_fill_stroke.wgsl). # _fill_stroke_bgl is reused for both compute output and render input # (camera uniform + read-only quads storage). self._fill_stroke_bgl: wgpu_t.GPUBindGroupLayout | None = None + # Main pipelines — multisample count matches self._msaa_samples. + # Used in Pass 1 (the MSAA main-render pass). self._fill_stroke_pipeline: wgpu_t.GPURenderPipeline | None = None # 2-D, no depth write self._fill_stroke_3d_pipeline: wgpu_t.GPURenderPipeline | None = None # 3-D, depth write + # Overlay pipelines — always count=1. + # Used in Pass 4 (fixed-in-frame overlay, renders to _render_texture_view + # at sample_count=1 after the MSAA resolve has already completed). + self._fill_stroke_pipeline_1x: wgpu_t.GPURenderPipeline | None = None + self._fill_stroke_3d_pipeline_1x: wgpu_t.GPURenderPipeline | None = None # Compute pipeline: cubic_to_quads.wgsl. self._compute_bgl: wgpu_t.GPUBindGroupLayout | None = None @@ -710,6 +753,23 @@ def init_scene(self, scene: Scene) -> None: ) self._depth_texture_view = self._depth_texture.create_view() + # MSAA textures — only when msaa_samples > 1. + if self._msaa_samples > 1: + self._msaa_texture = self._device.create_texture( + size=(width, height, 1), + format=wgpu.TextureFormat.bgra8unorm, + usage=wgpu.TextureUsage.RENDER_ATTACHMENT, + sample_count=self._msaa_samples, + ) + self._msaa_texture_view = self._msaa_texture.create_view() + self._msaa_depth_texture = self._device.create_texture( + size=(width, height, 1), + format=wgpu.TextureFormat.depth24plus, + usage=wgpu.TextureUsage.RENDER_ATTACHMENT, + sample_count=self._msaa_samples, + ) + self._msaa_depth_texture_view = self._msaa_depth_texture.create_view() + self._proj_bgl = self._create_camera_bgl() # Surface mesh lines sit exactly on the surface triangles. A negative @@ -719,13 +779,28 @@ def init_scene(self, scene: Scene) -> None: # (depth24plus unit ≈ 6e-8), which is large enough to reliably beat # floating-point depth jitter on flat/low-slope surface regions where # depth_bias_slope_scale alone contributes nearly zero. - self._surface_pipeline = self._create_surface_pipeline(self._proj_bgl, cull_mode="none", depth_write=True) + self._surface_pipeline = self._create_surface_pipeline( + self._proj_bgl, cull_mode="none", depth_write=True, + msaa_samples=self._msaa_samples, + ) # Combined fill+stroke pipeline (replaces separate slug + stroke pipelines). self._fill_stroke_bgl, self._fill_stroke_pipeline = \ - self._create_fill_stroke_pipeline(depth_test=False) + self._create_fill_stroke_pipeline(depth_test=False, msaa_samples=self._msaa_samples) _, self._fill_stroke_3d_pipeline = \ - self._create_fill_stroke_pipeline(depth_test=True) + self._create_fill_stroke_pipeline(depth_test=True, msaa_samples=self._msaa_samples) + + # Overlay pipelines — always count=1. Used in Pass 4 (fixed-in-frame) + # which renders directly into _render_texture_view after the MSAA resolve. + if self._msaa_samples > 1: + _, self._fill_stroke_pipeline_1x = \ + self._create_fill_stroke_pipeline(depth_test=False, msaa_samples=1) + _, self._fill_stroke_3d_pipeline_1x = \ + self._create_fill_stroke_pipeline(depth_test=True, msaa_samples=1) + else: + # When MSAA is off the overlay pipelines are the same objects. + self._fill_stroke_pipeline_1x = self._fill_stroke_pipeline + self._fill_stroke_3d_pipeline_1x = self._fill_stroke_3d_pipeline # GPU compute: cubic → quadratic conversion. self._compute_bgl, self._cubic_to_quads_pipeline = \ @@ -733,18 +808,24 @@ def init_scene(self, scene: Scene) -> None: self._create_oit_resources(width, height) self._create_readback_pipeline(width, height) - self._image_tex_bgl, self._image_tint_bgl, self._image_pipeline = self._create_image_pipeline() - self._true_dot_pipeline = self._create_true_dot_pipeline(self._proj_bgl) + self._image_tex_bgl, self._image_tint_bgl, self._image_pipeline = \ + self._create_image_pipeline(msaa_samples=self._msaa_samples) + self._true_dot_pipeline = self._create_true_dot_pipeline( + self._proj_bgl, msaa_samples=self._msaa_samples, + ) # Sub-camera pipelines (rgba8unorm target) for ZoomedScene support. + # Sub-camera targets are always count=1 (they render into their own + # rgba8unorm textures, not into the MSAA main buffer). _, self._sub_cam_fill_stroke_pipeline = self._create_fill_stroke_pipeline( - depth_test=False, target_format="rgba8unorm" + depth_test=False, target_format="rgba8unorm", msaa_samples=1 ) _, self._sub_cam_fill_stroke_3d_pipeline = self._create_fill_stroke_pipeline( - depth_test=True, target_format="rgba8unorm" + depth_test=True, target_format="rgba8unorm", msaa_samples=1 ) self._sub_cam_surface_pipeline = self._create_surface_pipeline( - self._proj_bgl, cull_mode="none", depth_write=True, target_format="rgba8unorm" + self._proj_bgl, cull_mode="none", depth_write=True, + target_format="rgba8unorm", msaa_samples=1, ) # Persistent camera uniform buffers — created once, updated each frame via @@ -813,6 +894,7 @@ def _create_fill_stroke_pipeline( self, depth_test: bool = False, target_format: str = "bgra8unorm", + msaa_samples: int = 1, ) -> tuple[wgpu_t.GPUBindGroupLayout, wgpu_t.GPURenderPipeline]: """Create the combined fill+stroke pipeline (vmobject_fill_stroke.wgsl). @@ -879,7 +961,7 @@ def _create_fill_stroke_pipeline( "stencil_read_mask": 0, "stencil_write_mask": 0, }, - multisample={"count": 1, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, + multisample={"count": msaa_samples, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, ) return bgl, pipeline @@ -933,6 +1015,7 @@ def _create_surface_pipeline( cull_mode: str = "none", depth_write: bool = True, target_format: str = "bgra8unorm", + msaa_samples: int = 1, ) -> wgpu_t.GPURenderPipeline: """Create a surface (mesh) pipeline. @@ -988,7 +1071,7 @@ def _create_surface_pipeline( "stencil_write_mask": 0, }, multisample={ - "count": 1, + "count": msaa_samples, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False, }, @@ -997,6 +1080,7 @@ def _create_surface_pipeline( def _create_true_dot_pipeline( self, proj_bgl: wgpu_t.GPUBindGroupLayout, + msaa_samples: int = 1, ) -> wgpu_t.GPURenderPipeline: """Create the TrueDot pipeline (true_dot.wgsl). @@ -1043,11 +1127,12 @@ def _create_true_dot_pipeline( "stencil_read_mask": 0, "stencil_write_mask": 0, }, - multisample={"count": 1, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, + multisample={"count": msaa_samples, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, ) def _create_image_pipeline( self, + msaa_samples: int = 1, ) -> tuple[wgpu_t.GPUBindGroupLayout, wgpu_t.GPUBindGroupLayout, wgpu_t.GPURenderPipeline]: """Create the render pipeline for ImageMobject textured quads. @@ -1155,7 +1240,7 @@ def _create_image_pipeline( "stencil_read_mask": 0, "stencil_write_mask": 0, }, - multisample={"count": 1, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, + multisample={"count": msaa_samples, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, ) return tex_bgl, tint_bgl, pipeline @@ -1781,6 +1866,26 @@ def fill_stroke_3d_pipeline(self) -> wgpu_t.GPURenderPipeline: assert self._fill_stroke_3d_pipeline is not None, "init_scene() has not been called" return self._fill_stroke_3d_pipeline + @property + def fill_stroke_pipeline_1x(self) -> wgpu_t.GPURenderPipeline: + """Combined fill+stroke pipeline — 2-D, always count=1. + + Used for the fixed-in-frame overlay pass (Pass 4) which renders directly + into ``_render_texture_view`` after the MSAA resolve has completed. + """ + assert self._fill_stroke_pipeline_1x is not None, "init_scene() has not been called" + return self._fill_stroke_pipeline_1x + + @property + def fill_stroke_3d_pipeline_1x(self) -> wgpu_t.GPURenderPipeline: + """Combined fill+stroke pipeline — 3-D, always count=1. + + Used for the fixed-in-frame overlay pass (Pass 4) which renders directly + into ``_render_texture_view`` after the MSAA resolve has completed. + """ + assert self._fill_stroke_3d_pipeline_1x is not None, "init_scene() has not been called" + return self._fill_stroke_3d_pipeline_1x + @property def surface_pipeline(self) -> wgpu_t.GPURenderPipeline: """Opaque surface pipeline (depth_write=True).""" @@ -2033,7 +2138,19 @@ def _f(m: Any) -> None: # copy the static texture into the render texture before any render # passes. The subsequent main pass uses load_op="load" so the static # pixels are preserved under the newly drawn moving mobs. - if blit_static and self._has_static_frame and self._static_texture is not None: + # + # With MSAA the static optimisation is bypassed: the MSAA texture is an + # intermediate buffer that always resolves into _render_texture at the + # end of Pass 1, overwriting whatever was there. Every frame is fully + # re-rendered instead. (MSAA already implies a quality-over-speed + # trade-off, so the extra work per frame is acceptable.) + _do_static_blit = ( + blit_static + and self._has_static_frame + and self._static_texture is not None + and self._msaa_samples == 1 + ) + if _do_static_blit: encoder.copy_texture_to_texture( {"texture": self._static_texture, "mip_level": 0, "origin": (0, 0, 0)}, {"texture": self._render_texture, "mip_level": 0, "origin": (0, 0, 0)}, @@ -2090,22 +2207,52 @@ def _f(m: Any) -> None: # Draw the z-ordered render queue (VMobject batches and images # interleaved in scene.mobjects order) so painter's-algorithm depth # is respected for any combination of images and geometry. - color_load_op = "load" if blit_static else "clear" - main_pass = encoder.begin_render_pass( - color_attachments=[ - { - "view": self._render_texture_view, - "load_op": color_load_op, - "store_op": "store", - "clear_value": tuple(float(c) for c in bg), - } - ], - depth_stencil_attachment={ + # + # MSAA path (msaa_samples > 1): + # color view — _msaa_texture_view (intermediate MSAA buffer) + # resolve_target — _render_texture_view (receives the resolved pixels) + # store_op — "discard" for the MSAA buffer (transient, never read back) + # depth view — _msaa_depth_texture_view (sample_count must match pipeline) + # depth store — "discard" (MSAA depth is not sampled later; OIT uses + # _depth_texture_view at count=1 separately) + # + # Non-MSAA path (msaa_samples == 1): + # color view — _render_texture_view directly + # depth view — _depth_texture_view (reused by OIT pass below) + if self._msaa_samples > 1: + assert self._msaa_texture_view is not None + assert self._msaa_depth_texture_view is not None + _color_attachment = { + "view": self._msaa_texture_view, + "resolve_target": self._render_texture_view, + "load_op": "clear", + "store_op": "discard", + "clear_value": tuple(float(c) for c in bg), + } + _depth_attachment = { + "view": self._msaa_depth_texture_view, + "depth_clear_value": 1.0, + "depth_load_op": "clear", + "depth_store_op": "discard", + } + else: + color_load_op = "load" if _do_static_blit else "clear" + _color_attachment = { + "view": self._render_texture_view, + "load_op": color_load_op, + "store_op": "store", + "clear_value": tuple(float(c) for c in bg), + } + _depth_attachment = { "view": self._depth_texture_view, "depth_clear_value": 1.0, "depth_load_op": "clear", "depth_store_op": "store", - }, + } + + main_pass = encoder.begin_render_pass( + color_attachments=[_color_attachment], + depth_stencil_attachment=_depth_attachment, ) self.current_render_pass = main_pass @@ -2146,6 +2293,14 @@ def _f(m: Any) -> None: else: oit_fd = None if oit_fds: + # When MSAA is enabled the opaque depth lives in _msaa_depth_texture + # (sample_count=N). _depth_texture (count=1) was not written in + # Pass 1, so OIT transparent surfaces cannot depth-test against + # opaque geometry — use "clear" to avoid reading stale data. + # In practice this means transparent surfaces are not clipped by + # opaque geometry when MSAA is on, which is acceptable for the + # typical use case (transparent surfaces in 3-D scenes). + oit_depth_load_op = "clear" if self._msaa_samples > 1 else "load" oit_pass = encoder.begin_render_pass( color_attachments=[ { @@ -2163,7 +2318,7 @@ def _f(m: Any) -> None: ], depth_stencil_attachment={ "view": self._depth_texture_view, - "depth_load_op": "load", + "depth_load_op": oit_depth_load_op, "depth_store_op": "discard", }, ) @@ -2193,6 +2348,17 @@ def _f(m: Any) -> None: # ── Pass 4: fixed-in-frame overlay ─────────────────────────────── # Rendered after OIT so overlays always appear on top of the 3-D scene. # Fresh depth buffer: overlays only depth-test against each other. + # + # This pass always renders directly into _render_texture_view at + # sample_count=1, regardless of the MSAA setting. The MSAA resolve + # (end of Pass 1) has already completed, so _render_texture contains + # the full scene. Fixed-in-frame mobjects are 2-D overlays whose SDF + # anti-aliasing is already excellent; MSAA adds nothing here. + # + # When MSAA is enabled the fill+stroke pipelines have multisample + # count=N and cannot be used in a count=1 pass. We temporarily swap + # in the _1x pipeline variants so that draw_frame_data issues + # compatible GPU commands. if fixed_frame_fd is not None: fixed_pass = encoder.begin_render_pass( color_attachments=[ @@ -2206,7 +2372,17 @@ def _f(m: Any) -> None: }, ) self.current_render_pass = fixed_pass + if self._msaa_samples > 1: + # Temporarily expose count=1 pipelines so draw_frame_data + # records compatible draw calls for this count=1 pass. + _saved_2d = self._fill_stroke_pipeline + _saved_3d = self._fill_stroke_3d_pipeline + self._fill_stroke_pipeline = self._fill_stroke_pipeline_1x + self._fill_stroke_3d_pipeline = self._fill_stroke_3d_pipeline_1x draw_frame_data(self, fixed_frame_fd, self.fixed_frame_bind_group) + if self._msaa_samples > 1: + self._fill_stroke_pipeline = _saved_2d + self._fill_stroke_3d_pipeline = _saved_3d fixed_pass.end() self._device.queue.submit([encoder.finish()]) From 2476ec08beb7d2b6cfd41dfe1f8789cd5c983db4 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Sat, 11 Apr 2026 19:07:00 +0530 Subject: [PATCH 29/33] WebGPU Renderer Window is now interactive --- manim/renderer/webgpu/webgpu_interactive.py | 190 ++++++++ manim/renderer/webgpu/webgpu_renderer.py | 102 ++++- .../renderer/webgpu/webgpu_renderer_window.py | 427 ++++++++++++++++-- manim/scene/scene.py | 54 ++- pyproject.toml | 1 + 5 files changed, 720 insertions(+), 54 deletions(-) create mode 100644 manim/renderer/webgpu/webgpu_interactive.py diff --git a/manim/renderer/webgpu/webgpu_interactive.py b/manim/renderer/webgpu/webgpu_interactive.py new file mode 100644 index 0000000000..b868e83a51 --- /dev/null +++ b/manim/renderer/webgpu/webgpu_interactive.py @@ -0,0 +1,190 @@ +"""Interactive IPython embed loop for the WebGPU renderer. + +Called by :meth:`~manim.scene.scene.Scene.interactive_embed` when the WebGPU +renderer is active. Mirrors the OpenGL :meth:`~manim.scene.scene.Scene.interact` +loop but drives the ``rendercanvas`` event loop instead of moderngl-window. + +Thread model +------------ +* **Main thread** — window event loop + scene method execution. Drains + ``scene.queue`` and calls scene methods (``play``, ``add``, …) so that all + GPU work stays on the thread that owns the WebGPU device. +* **IPython thread** (daemon) — blocking readline / prompt_toolkit. Scene + method calls are not executed here; they are posted to ``scene.queue`` and + picked up by the main thread. + +After every IPython cell ``post_run_cell`` triggers a re-render so the window +immediately reflects any changes (``add``, ``remove``, property mutations …) +that did not go through ``play`` / ``wait``. +""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from manim.scene.scene import Scene + + from .webgpu_renderer import WebGPURenderer + +# Frames per second at which the main loop polls OS events when idle (no +# scene method is running). Higher values make mouse / keyboard more +# responsive; lower values save CPU. +_POLL_HZ: int = 60 + + +def interactive_embed( + scene: Scene, + renderer: WebGPURenderer, + local_namespace: dict[str, Any], +) -> None: + """Run an interactive IPython session alongside the WebGPU preview window. + + Parameters + ---------- + scene: + The running scene instance. + renderer: + The active :class:`~.WebGPURenderer`. + local_namespace: + The caller's (``construct()``'s) local variables — captured in + :meth:`~manim.scene.scene.Scene.interactive_embed` before this + function is called. Scene shortcuts (``play``, ``wait``, ``add``, + ``remove``) and the full ``manim`` namespace are injected here so + the user can type commands without a ``self.`` prefix. + """ + import threading + + import manim + from manim import logger + from manim.data_structures import MethodWithArgs + from manim.scene.scene import SceneInteractContinue + + window = renderer.window + + # ── IPython imports ────────────────────────────────────────────────── + try: + from sqlite3 import connect + + from IPython.core.getipython import get_ipython + from IPython.terminal.embed import InteractiveShellEmbed + from traitlets.config import Config as IPConfig + except ImportError: + logger.error( + "IPython is required for the interactive WebGPU embed.\n" + "Install it with: pip install ipython", + ) + return + + # ── Build shell ────────────────────────────────────────────────────── + ipcfg = IPConfig() + ipcfg.TerminalInteractiveShell.confirm_exit = False + + existing = get_ipython() + if existing is None: + shell = InteractiveShellEmbed.instance(config=ipcfg) + else: + shell = InteractiveShellEmbed(config=ipcfg) + + # Make the SQLite history database thread-safe so IPython history works + # correctly from the daemon keyboard thread. + hist = get_ipython().history_manager + hist.db = connect(hist.hist_file, check_same_thread=False) + + # ── Populate namespace ─────────────────────────────────────────────── + # Pre-import the full manim namespace so users don't need import + # statements inside the session. + for name in dir(manim): + local_namespace[name] = getattr(manim, name) + + # Proxy scene methods: posting to scene.queue keeps all GPU work on the + # main thread (matching the OpenGL embedded_method pattern). + def _make_proxy(method_name: str): + method = getattr(scene, method_name) + + def _proxy(*args: Any, **kwargs: Any) -> None: + scene.queue.put(MethodWithArgs(method, args, kwargs)) + + _proxy.__name__ = method_name + return _proxy + + for _name in ("play", "wait", "add", "remove"): + local_namespace[_name] = _make_proxy(_name) + + # ── After every cell: schedule a re-render on the main thread ──────── + # _post_cell runs in the IPython thread — GPU calls must not happen here. + # Posting a sentinel to scene.queue ensures update_frame() is called on + # the main thread, which owns the WebGPU device. + _RENDER = object() # sentinel: "please re-render" + + def _post_cell(*_a: Any, **_kw: Any) -> None: + scene.queue.put(_RENDER) + + shell.events.register("post_run_cell", _post_cell) + + # ── IPython thread ─────────────────────────────────────────────────── + def _keyboard_thread() -> None: + shell(local_ns=local_namespace) + # Signal the main loop that the user closed the shell. + scene.queue.put(SceneInteractContinue("keyboard")) + + keyboard_thread = threading.Thread(target=_keyboard_thread) + # Run as a daemon so the thread is killed if the main thread exits + # (e.g. the window is closed before the shell prompt is answered). + if not shell.pt_app: + keyboard_thread.daemon = True + keyboard_thread.start() + + # ── Main thread: event loop ────────────────────────────────────────── + scene.quit_interaction = False + keyboard_thread_needs_join = shell.pt_app is not None + sleep_s = 1.0 / _POLL_HZ + + while not (window.is_closing or scene.quit_interaction): + if not scene.queue.empty(): + action = scene.queue.get_nowait() + + if isinstance(action, SceneInteractContinue): + # IPython shell exited normally (user typed exit / Ctrl-D). + keyboard_thread.join() + # Drain any stale items left in the queue. + while not scene.queue.empty(): + scene.queue.get() + keyboard_thread_needs_join = False + break + + elif isinstance(action, MethodWithArgs): + # Execute the proxied scene method on the main thread. + action.method(*action.args, **action.kwargs) + # Re-render so the result is visible immediately. + # (play/wait already render internally; add/remove do not.) + if renderer._device is not None: + renderer.update_frame(scene) + window._canvas.force_draw() + + elif action is _RENDER: + # Triggered by _post_cell — re-render after any IPython cell + # that mutated the scene without going through a proxy method. + if renderer._device is not None: + renderer.update_frame(scene) + window._canvas.force_draw() + else: + # Idle — process OS events so mouse/keyboard controls stay live. + window._canvas._process_events() + time.sleep(sleep_s) + + # ── Teardown ───────────────────────────────────────────────────────── + if keyboard_thread_needs_join and shell.pt_app: + # Window closed while IPython was still running — force the prompt + # to exit so the keyboard thread can be joined cleanly. + try: + shell.pt_app.app.exit(exception=EOFError) + except Exception: + pass + keyboard_thread.join() + while not scene.queue.empty(): + scene.queue.get() + + if window.is_closing: + window.destroy() diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index 1e4c83cf22..5afafff5cd 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -93,9 +93,26 @@ class WebGPUCamera(Mobject): ---------- * 2-D scenes: orthographic, z mapped to the WebGPU [0, 1] NDC range. - * 3-D scenes (Phase 3): perspective projection driven by ``focal_distance`` + * 3-D scenes: perspective projection driven by ``focal_distance`` and the Euler-angle view matrix. + Interactive controls (preview window only) + ------------------------------------------ + When a preview window is open, :class:`WebGPUWindow` handles mouse + events and mutates the camera in real time: + + * **Left-drag** — orbit: horizontal motion changes ``theta``; vertical + motion changes ``phi`` (clamped to ``[minimum_polar_angle, + maximum_polar_angle]``). + * **Right-drag / middle-drag** — pan: translates the view laterally in + camera space. The pan offset is owned by the window and injected into + ``view_matrix`` via ``_cam_pan_x`` / ``_cam_pan_y`` just before each + render; it is not part of the scripted camera model. + * **Scroll wheel** — zoom: adjusts ``focal_distance`` for perspective + cameras or scales ``frame_shape`` for orthographic cameras. + * **Key** ``r`` — reset: restores ``euler_angles`` and ``focal_distance`` + to their defaults and clears the window pan offset. + Parameters ---------- frame_shape @@ -359,16 +376,22 @@ def view_matrix(self) -> np.ndarray: Uses T(-c) @ R_inv, which rotates the world around the origin (matches OpenGLCamera behavior where the camera orbits the focal point). + + ``_cam_pan_x`` / ``_cam_pan_y`` are injected by :class:`WebGPUWindow` + just before each render call. They are not initialised in ``__init__`` + so that the camera model stays free of window/interaction state. + ``getattr`` defaults to 0 when no window is attached. """ R = np.asarray(self.inverse_rotation_matrix, dtype=np.float32) # 3×3 c = self.frame_center.astype(np.float32) view = np.eye(4, dtype=np.float32) view[:3, :3] = R - # Translation in camera space: T(-c) followed by rotation R is equivalent - # to rotating the origin then translating, or translating the origin then rotating. - # To stay centered on origin: rotate first, then translate by -distance. - # V = translation(0, 0, -11) @ R_inv - view[:3, 3] = [0.0, 0.0, -c[2]] + # Camera-space translation: orbit distance along -Z plus lateral pan. + # Positive pan_x shifts the scene right (camera moves left), matching + # the "drag scene to the right" expectation for right-drag pan. + pan_x = float(getattr(self, "_cam_pan_x", 0.0)) + pan_y = float(getattr(self, "_cam_pan_y", 0.0)) + view[:3, 3] = [-pan_x, -pan_y, -c[2]] return view @property @@ -502,13 +525,67 @@ def remove_image_mobject_from_camera(self, image_mob_from_camera: Any) -> None: class WebGPURenderer: - """Headless WebGPU renderer (Phase 1: fill rendering to PNG / video).""" + """WebGPU renderer for Manim — headless and interactive preview. + + Supports PNG / video output in headless mode and a live preview window + (enabled by ``config.preview = True`` or the ``-p`` CLI flag). + + Preview window controls + ----------------------- + When the preview window is open, the camera can be navigated interactively + with the mouse. All interactions take effect immediately without restarting + the scene. + + ======================== ================================================ + Input Action + ======================== ================================================ + Left-drag **Orbit** — horizontal drag rotates theta (yaw); + vertical drag tilts phi (pitch), clamped to ±90°. + Right-drag / Middle-drag **Pan** — translates the view laterally in camera + space, proportional to the current frame size. + Scroll wheel **Zoom** — perspective: adjusts ``focal_distance`` + exponentially (~12 % per notch); orthographic: + scales ``frame_shape`` by the same factor. + Key ``r`` **Reset** — restores the camera's default orbit, + zoom, and clears any accumulated pan offset. + Key ``q`` **Quit** — closes the preview window. + ======================== ================================================ + + Customising window interaction + ------------------------------ + Subclass :class:`~.WebGPUWindow` and pass it via ``window_class`` to + override any interaction hook without modifying the renderer itself: + + ======================== ================================================ + Hook to override Triggered by + ======================== ================================================ + ``on_mouse_drag`` Pointer move while a button is held. + ``on_scroll`` Wheel / scroll event. + ``on_key_press`` Key pressed down. + ``on_key_release`` Key released. + ``on_mouse_left_click`` Left button clicked (no drag). + ``on_mouse_right_click`` Right button clicked (no drag). + ======================== ================================================ + + Building-block helpers ``orbit(dx, dy)``, ``pan(dx, dy)``, and + ``zoom(scroll_dy)`` are also overridable for finer control. See + :class:`~.WebGPUWindow` for a full example. + + MSAA + ---- + Pass ``msaa_samples=4`` to enable 4× multisample anti-aliasing. This + smooths geometric edges on surfaces, images, and dot-clouds (VMobjects + already use SDF/coverage-based AA so the gain for pure-2D scenes is + modest). MSAA bypasses the static-frame optimisation, re-rendering every + frame in full. + """ def __init__( self, file_writer_class: type[SceneFileWriter] = SceneFileWriter, skip_animations: bool = False, msaa_samples: int = 1, + window_class: type | None = None, ) -> None: """Create a WebGPU renderer. @@ -529,11 +606,19 @@ def __init__( (every frame is fully re-rendered), which increases per-frame GPU work. This is acceptable because MSAA already implies a quality- over-speed trade-off. + window_class: + Class used to create the preview window. Must be + :class:`~.WebGPUWindow` or a subclass of it. Defaults to + :class:`~.WebGPUWindow`. Pass a subclass to customise mouse/ + keyboard interaction by overriding :meth:`~.WebGPUWindow.on_mouse_drag`, + :meth:`~.WebGPUWindow.on_scroll`, :meth:`~.WebGPUWindow.on_key_press`, + or :meth:`~.WebGPUWindow.on_key_release`. """ if msaa_samples not in (1, 4): msg = f"msaa_samples must be 1 or 4, got {msaa_samples}" raise ValueError(msg) self._msaa_samples = msaa_samples + self._window_class = window_class self._file_writer_class = file_writer_class self._original_skipping_status = skip_animations self.skip_animations = skip_animations @@ -862,7 +947,8 @@ def _make_persistent_bg(buf: wgpu_t.GPUBuffer) -> wgpu_t.GPUBindGroup: if self.should_create_window(): from .webgpu_renderer_window import WebGPUWindow - self.window = WebGPUWindow(self) + wclass = self._window_class or WebGPUWindow + self.window = wclass(self) # ------------------------------------------------------------------ # Pipeline creation diff --git a/manim/renderer/webgpu/webgpu_renderer_window.py b/manim/renderer/webgpu/webgpu_renderer_window.py index c6fde3d447..3053e88bba 100644 --- a/manim/renderer/webgpu/webgpu_renderer_window.py +++ b/manim/renderer/webgpu/webgpu_renderer_window.py @@ -1,20 +1,41 @@ """Preview window for the WebGPU renderer. -Uses rendercanvas (bundled with wgpu-py) to open a native OS window. -The offscreen render texture (``bgra8unorm``) is copied directly to the -window surface via ``copy_texture_to_texture`` — no blit shader needed -because both textures share the same format. +Uses ``rendercanvas`` to open a native OS window. The offscreen render +texture (``bgra8unorm``) is copied directly to the window surface via +``copy_texture_to_texture`` — no format conversion needed. + +Interactive camera controls +--------------------------- + +======================== ================================================ +Input Action +======================== ================================================ +Left-drag **Orbit** — horizontal drag rotates theta (yaw); + vertical drag tilts phi (pitch), clamped to ±90°. +Right-drag / Middle-drag **Pan** — translates the view laterally in camera + space; one screen-width drag = one frame width. +Scroll wheel **Zoom** — perspective: ``focal_distance`` scales + exponentially (~12 % per notch); orthographic: + ``frame_shape`` scales by the same factor. +Key ``r`` **Reset** — restores default orbit, zoom, and + clears the accumulated pan offset. +Key ``q`` **Quit** — closes the preview window. +======================== ================================================ + +Pan state (``_pan_x``, ``_pan_y``) is stored on :class:`WebGPUWindow` and +injected into the camera just before each render; it is not part of the +scripted camera model. Event mapping ------------- -rendercanvas uses the Web standard key-name strings (``"ArrowLeft"``, -``"q"``, ...). Manim scene callbacks expect pyglet-compatible integer -key codes. A small mapping table is included below. Single printable -characters are mapped with ``ord()``. +rendercanvas delivers Web-standard key strings (``"ArrowLeft"``, ``"q"`` +…). A mapping table converts these to pyglet-compatible integer codes so +that ``scene.on_key_press`` callbacks work unchanged. """ from __future__ import annotations +import math from typing import TYPE_CHECKING import numpy as np @@ -109,6 +130,25 @@ def _compute_window_size() -> tuple[int, int]: return config.pixel_width, config.pixel_height +# --------------------------------------------------------------------------- +# Interactive camera sensitivity constants +# --------------------------------------------------------------------------- + +# Wheel: zoom sensitivity. +# rendercanvas delivers wheel deltas in CSS pixels (≈100–120 per notch on +# most platforms). A factor of 0.001 gives ≈10 % zoom per notch +# (exp(120 * 0.001) ≈ 1.13). +_ZOOM_SCROLL_FACTOR: float = 0.001 + +# Minimum/maximum focal distance (perspective only). +_ZOOM_MIN_FD: float = 0.5 +_ZOOM_MAX_FD: float = 100.0 + +# Maximum pointer movement (in window pixels) between press and release that +# is still classified as a click rather than a drag. +_CLICK_THRESHOLD_PX: float = 4.0 + + # --------------------------------------------------------------------------- # Window class # --------------------------------------------------------------------------- @@ -121,8 +161,69 @@ class WebGPUWindow: Interface expected by ``scene.py`` and ``WebGPURenderer``: - * ``is_closing`` — True once the user closes the window - * ``destroy()`` — tear down the underlying canvas + * ``is_closing`` — True once the OS window has been closed. + * ``destroy()`` — tear down the underlying canvas. + + Interactive camera controls + --------------------------- + Mouse events are translated into camera mutations and an immediate + re-render so the view updates in real time. Pan state (``_pan_x``, + ``_pan_y``) is owned by this class and pushed to the camera just before + each render via :meth:`_sync_pan_to_camera`; orbit and zoom are applied + directly to :class:`~.WebGPUCamera` fields. + + ======================== ================================================ + Input Action + ======================== ================================================ + Left-drag **Orbit** — horizontal drag: ``increment_theta``; + vertical drag: ``increment_phi`` (clamped). + Right-drag / Middle-drag **Pan** — accumulates ``_pan_x`` / ``_pan_y`` + proportional to ``frame_shape / pixel_size``. + Scroll wheel **Zoom** — perspective: ``focal_distance *= exp(dy + * 0.001)``; orthographic: ``frame_shape *= same``. + Key ``r`` **Reset** — clears ``_pan_x``, ``_pan_y`` and + calls ``camera.to_default_state()``. + Key ``q`` **Quit** — closes the preview window. + ======================== ================================================ + + Customising interaction + ----------------------- + Subclass :class:`WebGPUWindow` and override any of the four interaction + hooks to change behaviour without touching internal event dispatch: + + ======================== ================================================ + Method When called + ======================== ================================================ + :meth:`on_mouse_drag` Pointer moves while a button is held. + :meth:`on_scroll` Wheel (scroll) event. + :meth:`on_key_press` Key pressed down. + :meth:`on_key_release` Key released. + :meth:`on_mouse_left_click` Left button pressed and released in place. + :meth:`on_mouse_right_click` Right button pressed and released in place. + ======================== ================================================ + + Each hook can call the building-block helpers :meth:`orbit`, :meth:`pan`, + and :meth:`zoom` and finish with :meth:`_render_from_window` to trigger a + re-render. Pass the subclass to the renderer via + ``WebGPURenderer(window_class=MyWindow)``. + + Example — swap orbit and pan, double zoom speed:: + + class MyWindow(WebGPUWindow): + def on_mouse_drag(self, x, y, dx, dy, button): + if button == 3: # right-drag → orbit + self.orbit(dx, dy) + elif button == 1: # left-drag → pan + self.pan(dx, dy) + else: + return + self._render_from_window() + + def on_scroll(self, x, y, dy): + self.zoom(dy * 2) # 2× sensitivity + self._render_from_window() + + renderer = WebGPURenderer(window_class=MyWindow) """ def __init__(self, renderer: WebGPURenderer) -> None: @@ -152,11 +253,31 @@ def __init__(self, renderer: WebGPURenderer) -> None: # on every force_draw() call). self._canvas.request_draw(self._draw_frame) + # ── Drag / click state ──────────────────────────────────────────── + # _drag_button: 1 = left (orbit), 2 = middle (pan), 3 = right (pan) + # None when no button is held. + self._drag_button: int | None = None + self._last_px: float = 0.0 + self._last_py: float = 0.0 + # _press_x/y: pointer position at the moment the button was pressed, + # used to distinguish a click (≤ _CLICK_THRESHOLD_PX movement) from a drag. + self._press_x: float = 0.0 + self._press_y: float = 0.0 + + # ── Pan state ───────────────────────────────────────────────────── + # Camera-space lateral offset in scene units, accumulated from + # right/middle-drag events. Stored here (not on the camera) because + # pan is interactive view navigation, not part of the scripted camera + # model. Synced to camera._cam_pan_x/y just before every render. + self._pan_x: float = 0.0 + self._pan_y: float = 0.0 + # Register event handlers. self._canvas.add_event_handler(self._on_key_down, "key_down") self._canvas.add_event_handler(self._on_key_up, "key_up") self._canvas.add_event_handler(self._on_pointer_move, "pointer_move") self._canvas.add_event_handler(self._on_pointer_down, "pointer_down") + self._canvas.add_event_handler(self._on_pointer_up, "pointer_up") self._canvas.add_event_handler(self._on_wheel, "wheel") # ------------------------------------------------------------------ @@ -206,38 +327,276 @@ def _draw_frame(self) -> None: renderer._device.queue.submit([encoder.finish()]) # ------------------------------------------------------------------ - # Event handlers + # Interactive camera helpers + # ------------------------------------------------------------------ + + def _sync_pan_to_camera(self) -> None: + """Write the window's pan offset into the camera before rendering. + + ``WebGPUCamera.view_matrix`` reads ``_cam_pan_x`` / ``_cam_pan_y`` + via ``getattr`` so they don't need to be initialised in ``__init__``. + This call makes the camera pick up the current window pan without + the camera model needing to know about interactive navigation. + """ + cam = self._renderer.camera + cam._cam_pan_x = self._pan_x + cam._cam_pan_y = self._pan_y + + def _render_from_window(self) -> None: + """Sync pan, re-render the current scene, and present to the window. + + Called after every camera mutation so the preview updates immediately — + even when no animation is running and the main loop is not calling + ``update_frame`` in a tight loop. + """ + renderer = self._renderer + if renderer._device is None: + return + scene = getattr(renderer, "scene", None) + if scene is None: + return + self._sync_pan_to_camera() + renderer.update_frame(scene) + self._canvas.force_draw() + + # ------------------------------------------------------------------ + # Overridable interaction building blocks + # ------------------------------------------------------------------ + + def orbit(self, dx: float, dy: float) -> None: + """Rotate the camera by *dx* horizontal and *dy* vertical pixel deltas. + + Override to change orbit behaviour or sensitivity. + + Horizontal drag rotates around the vertical axis (theta). + Vertical drag tilts up/down (phi, clamped to ±90°). + + A full horizontal swipe (pixel_width pixels) = one full revolution. + A full vertical swipe (pixel_height pixels) = 180° tilt. + + Sign convention (rendercanvas y-axis points downward): + * Drag right (dx > 0) → scene rotates to the right → theta increases. + * Drag down (dy > 0) → scene tilts down → phi decreases. + """ + cam = self._renderer.camera + pw = max(config.pixel_width, 1) + ph = max(config.pixel_height, 1) + dtheta = dx * (2.0 * math.pi / pw) + dphi = -dy * (math.pi / ph) + cam.increment_theta(dtheta) + cam.increment_phi(dphi) + + def pan(self, dx: float, dy: float) -> None: + """Translate the camera laterally by *dx* / *dy* pixel deltas. + + Override to change pan behaviour or sensitivity. + + Updates the window-owned pan state. The new values are pushed to the + camera by ``_render_from_window`` → ``_sync_pan_to_camera``. + + One full horizontal swipe (pixel_width pixels) shifts the scene by + exactly one ``frame_width`` scene unit. + + Signs (rendercanvas y-axis downward; Manim y-axis upward): + * Drag right (dx > 0) → scene moves right → _pan_x increases. + * Drag down (dy > 0) → scene moves down → _pan_y decreases. + """ + fw, fh = self._renderer.camera.frame_shape + pw = max(config.pixel_width, 1) + ph = max(config.pixel_height, 1) + self._pan_x += dx * (fw / pw) + self._pan_y -= dy * (fh / ph) + + def zoom(self, scroll_dy: float) -> None: + """Zoom in/out by *scroll_dy* CSS-pixel scroll units. + + Override to change zoom behaviour, sensitivity, or limits. + + Positive *scroll_dy* (scroll down) zooms out; negative zooms in. + + Perspective camera: adjusts ``focal_distance``. + Orthographic camera: scales ``frame_shape`` proportionally. + + The zoom is exponential so that successive zoom steps are perceptually + uniform: each notch (≈ 120 CSS pixels) changes the scale by ~12 %. + """ + cam = self._renderer.camera + factor = math.exp(scroll_dy * _ZOOM_SCROLL_FACTOR) + + if cam.orthographic: + fw, fh = cam.frame_shape + new_fw = max(fw * factor, 0.01) + new_fh = max(fh * factor, 0.01) + cam.frame_shape = (new_fw, new_fh) + else: + new_fd = float(np.clip( + cam.focal_distance * factor, + _ZOOM_MIN_FD, + _ZOOM_MAX_FD, + )) + cam.set_focal_distance(new_fd) + + # ------------------------------------------------------------------ + # Overridable interaction hooks + # ------------------------------------------------------------------ + + def on_mouse_drag(self, x: float, y: float, dx: float, dy: float, button: int) -> None: + """Called on every pointer-move event while a mouse button is held. + + Override to customise drag behaviour. The default implementation + maps button 1 (left) to :meth:`orbit` and buttons 2/3 + (middle/right) to :meth:`pan`. + + Parameters + ---------- + x, y: + Current pointer position in window pixels (y-axis downward). + dx, dy: + Delta from the previous pointer position in window pixels. + button: + Web-standard button code: 1 = left, 2 = middle, 3 = right. + """ + if button == 1: + self.orbit(dx, dy) + elif button in (2, 3): + self.pan(dx, dy) + else: + return + self._render_from_window() + + def on_scroll(self, x: float, y: float, dy: float) -> None: + """Called on every wheel (scroll) event. + + Override to customise scroll behaviour. The default implementation + calls :meth:`zoom`. + + Parameters + ---------- + x, y: + Pointer position at the time of the scroll, in window pixels. + dy: + Vertical scroll delta in CSS pixels (positive = scroll down = + zoom out). + """ + self.zoom(dy) + self._render_from_window() + + def on_key_press(self, key: str, modifiers: int) -> None: + """Called when a key is pressed. + + Override to add or replace key bindings. Call ``super().on_key_press(key, + modifiers)`` to keep the default ``r`` → reset and ``q`` → quit bindings. + + Parameters + ---------- + key: + Web-standard key string (e.g. ``"r"``, ``"ArrowLeft"``, + ``"Escape"``). + modifiers: + Pyglet-compatible modifier bitmask (Shift=1, Control=4, Alt=8). + """ + if key == "r": + self._pan_x = 0.0 + self._pan_y = 0.0 + self._renderer.camera.to_default_state() + self._render_from_window() + elif key == "q": + self._canvas.close() + + def on_key_release(self, key: str, modifiers: int) -> None: + """Called when a key is released. + + Override to react to key-release events. The default implementation + does nothing (key tracking is handled internally). + + Parameters + ---------- + key: + Web-standard key string. + modifiers: + Pyglet-compatible modifier bitmask. + """ + + def on_mouse_left_click(self, x: float, y: float) -> None: + """Called when the left mouse button is clicked (pressed and released + without dragging more than ``_CLICK_THRESHOLD_PX`` pixels). + + Override to add left-click behaviour. The default implementation + does nothing. + + Parameters + ---------- + x, y: + Pointer position at release, in window pixels (y-axis downward). + """ + + def on_mouse_right_click(self, x: float, y: float) -> None: + """Called when the right mouse button is clicked (pressed and released + without dragging more than ``_CLICK_THRESHOLD_PX`` pixels). + + Override to add right-click behaviour. The default implementation + does nothing. + + Parameters + ---------- + x, y: + Pointer position at release, in window pixels (y-axis downward). + """ + + # ------------------------------------------------------------------ + # Raw event handlers — dispatch to the overridable hooks above. + # Subclasses should override the hooks, not these methods. # ------------------------------------------------------------------ def _on_key_down(self, event: dict) -> None: key = event.get("key", "") - symbol = _key_to_int(key) - self._renderer.pressed_keys.add(symbol) - # scene.on_key_press asserts OpenGLCamera/Renderer — skip for WebGPU. + modifiers = _modifiers_to_int(event.get("modifiers", [])) + self._renderer.pressed_keys.add(_key_to_int(key)) + self.on_key_press(key, modifiers) def _on_key_up(self, event: dict) -> None: key = event.get("key", "") - symbol = _key_to_int(key) - self._renderer.pressed_keys.discard(symbol) - - def _on_pointer_move(self, event: dict) -> None: - point = self._renderer.pixel_coords_to_space_coords( - event["x"], event["y"], top_left=True - ) - d_point = self._renderer.pixel_coords_to_space_coords( - event.get("dx", 0), event.get("dy", 0), relative=True - ) - # scene.on_mouse_motion asserts OpenGLCamera — skip for WebGPU. - _ = point, d_point # suppress unused-var warnings + modifiers = _modifiers_to_int(event.get("modifiers", [])) + self._renderer.pressed_keys.discard(_key_to_int(key)) + self.on_key_release(key, modifiers) def _on_pointer_down(self, event: dict) -> None: - point = self._renderer.pixel_coords_to_space_coords( - event["x"], event["y"], top_left=True - ) - _ = point + # button: 1 = left, 2 = middle, 3 = right (Web standard) + self._drag_button = event.get("button", 1) + x = float(event.get("x", 0)) + y = float(event.get("y", 0)) + self._last_px = x + self._last_py = y + self._press_x = x + self._press_y = y + + def _on_pointer_up(self, event: dict) -> None: + button = self._drag_button + x = float(event.get("x", 0)) + y = float(event.get("y", 0)) + self._drag_button = None + # Fire a click hook only when the pointer barely moved (not a drag). + if math.hypot(x - self._press_x, y - self._press_y) < _CLICK_THRESHOLD_PX: + if button == 1: + self.on_mouse_left_click(x, y) + elif button == 3: + self.on_mouse_right_click(x, y) + + def _on_pointer_move(self, event: dict) -> None: + if self._drag_button is None: + return + px = float(event.get("x", 0)) + py = float(event.get("y", 0)) + dx = px - self._last_px + dy = py - self._last_py + self._last_px = px + self._last_py = py + if dx == 0.0 and dy == 0.0: + return + self.on_mouse_drag(px, py, dx, dy, self._drag_button) def _on_wheel(self, event: dict) -> None: - point = self._renderer.pixel_coords_to_space_coords( - event["x"], event["y"], top_left=True - ) - _ = point + dy = float(event.get("dy", 0)) + if dy == 0.0: + return + self.on_scroll(float(event.get("x", 0)), float(event.get("y", 0)), dy) diff --git a/manim/scene/scene.py b/manim/scene/scene.py index 0839c73ef7..52aaa4127d 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -210,15 +210,16 @@ def __init__( if renderer is None: renderer = WebGPURenderer() - if renderer is None: - self.renderer: CairoRenderer | OpenGLRenderer = CairoRenderer( + elif config.renderer == RendererType.CAIRO: + if renderer is None: + renderer = CairoRenderer( # TODO: Is it a suitable approach to make an instance of # the self.camera_class here? camera_class=self.camera_class, skip_animations=self.skip_animations, ) - else: - self.renderer = renderer + + self.renderer: CairoRenderer | OpenGLRenderer | WebGPURenderer = renderer self.renderer.init_scene(self) self.mobjects: list[Mobject] = [] @@ -474,8 +475,7 @@ def get_mobject_family_members(self) -> list[Mobject]: for mob in self.mobjects: family_members.extend(mob.get_family()) return family_members - else: - assert config.renderer in {RendererType.CAIRO, RendererType.WEBGPU} + elif config.renderer in {RendererType.CAIRO, RendererType.WEBGPU}: return extract_mobject_family_members( self.mobjects, use_z_index=self.renderer.camera.use_z_index, @@ -509,8 +509,7 @@ def add(self, *mobjects: Mobject | OpenGLMobject) -> Self: self.mobjects += new_mobjects # type: ignore[arg-type] self.remove(*new_meshes) # type: ignore[arg-type] self.meshes += new_meshes - else: - assert config.renderer in {RendererType.CAIRO, RendererType.WEBGPU} + elif config.renderer in {RendererType.CAIRO, RendererType.WEBGPU}: new_and_foreground_mobjects: list[Mobject] = [ *mobjects, # type: ignore[list-item] *self.foreground_mobjects, @@ -569,8 +568,7 @@ def lambda_function(mesh: Object3D) -> bool: filter(lambda_function, self.meshes), ) return self - else: - assert config.renderer in {RendererType.CAIRO, RendererType.WEBGPU} + elif config.renderer in {RendererType.CAIRO, RendererType.WEBGPU}: for list_name in "mobjects", "foreground_mobjects": self.restructure_mobjects(mobjects, list_name, False) return self @@ -1389,7 +1387,7 @@ def play_internal(self, skip_rendering: bool = False) -> None: self.time_progression.close() def check_interactive_embed_is_valid(self) -> bool: - assert isinstance(self.renderer, OpenGLRenderer) + assert isinstance(self.renderer, OpenGLRenderer) or isinstance(self.renderer, WebGPURenderer) if config["force_window"]: return True if self.skip_animation_preview: @@ -1415,7 +1413,39 @@ def check_interactive_embed_is_valid(self) -> bool: return True def interactive_embed(self) -> None: - """Like embed(), but allows for screen interaction.""" + """Like embed(), but allows for screen interaction. + + Drops into an IPython shell while the preview window stays alive and + responds to mouse / keyboard. Scene methods (``play``, ``wait``, + ``add``, ``remove``) are available without a ``self.`` prefix inside + the shell. + + Supported renderers: OpenGL, WebGPU (when ``-p`` / ``--preview`` is + active). Call this from inside :meth:`construct` after the animations + you want to have already played. + + Example + ------- + .. code-block:: python + + class MyScene(ThreeDScene): + def construct(self): + ax = ThreeDAxes() + self.add(ax) + self.interactive_embed() + """ + if config.renderer == RendererType.WEBGPU: + if not self.check_interactive_embed_is_valid(): + return + self.interactive_mode = True + from manim.renderer.webgpu.webgpu_interactive import ( + interactive_embed as _webgpu_embed, + ) + currentframe: FrameType = inspect.currentframe() # type: ignore[assignment] + local_namespace = currentframe.f_back.f_locals # type: ignore[union-attr] + _webgpu_embed(self, self.renderer, local_namespace) + return + assert isinstance(self.camera, OpenGLCamera) assert isinstance(self.renderer, OpenGLRenderer) if not self.check_interactive_embed_is_valid(): diff --git a/pyproject.toml b/pyproject.toml index 915ba00894..3c171ad147 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,7 @@ homepage = "https://www.manim.community/" [project.optional-dependencies] gui = [ "dearpygui>=1.0.0", + "rendercanvas>=2.6.3", ] jupyterlab = [ "jupyterlab>=4.3.4", From 69cba9e632bb8cd56108ed4030ed216999f819d2 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Sat, 11 Apr 2026 19:23:07 +0530 Subject: [PATCH 30/33] File watcher / rerun() is enabled in WebGPU Renderer Interactive Mode --- manim/cli/render/commands.py | 22 ++++ manim/renderer/webgpu/webgpu_interactive.py | 113 ++++++++++++++++---- manim/renderer/webgpu/webgpu_renderer.py | 11 ++ manim/scene/scene.py | 4 +- 4 files changed, 129 insertions(+), 21 deletions(-) diff --git a/manim/cli/render/commands.py b/manim/cli/render/commands.py index fde82f4970..e6240b3805 100644 --- a/manim/cli/render/commands.py +++ b/manim/cli/render/commands.py @@ -117,6 +117,28 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]: except Exception: error_console.print_exception() sys.exit(1) + elif config.renderer == RendererType.WEBGPU: + from manim.renderer.webgpu.webgpu_renderer import WebGPURenderer + + try: + renderer = WebGPURenderer() + keep_running = True + while keep_running: + for SceneClass in scene_classes_from_file(file): + with tempconfig({}): + scene = SceneClass(renderer) + rerun = scene.render() + if rerun or config["write_all"]: + renderer.num_plays = 0 + continue + else: + keep_running = False + break + if config["write_all"]: + keep_running = False + except Exception: + error_console.print_exception() + sys.exit(1) else: for SceneClass in scene_classes_from_file(file): try: diff --git a/manim/renderer/webgpu/webgpu_interactive.py b/manim/renderer/webgpu/webgpu_interactive.py index b868e83a51..51df2b8972 100644 --- a/manim/renderer/webgpu/webgpu_interactive.py +++ b/manim/renderer/webgpu/webgpu_interactive.py @@ -13,9 +13,19 @@ method calls are not executed here; they are posted to ``scene.queue`` and picked up by the main thread. -After every IPython cell ``post_run_cell`` triggers a re-render so the window -immediately reflects any changes (``add``, ``remove``, property mutations …) -that did not go through ``play`` / ``wait``. +After every IPython cell ``post_run_cell`` schedules a re-render sentinel on +``scene.queue`` so the window immediately reflects any changes (``add``, +``remove``, property mutations …) that did not go through ``play`` / ``wait``. + +File watching +------------- +A ``watchdog.Observer`` watches the scene's source file. When the file is +saved on disk the observer posts ``SceneInteractRerun("file")`` to +``scene.queue``. The main loop then tears down the IPython session and raises +:class:`~manim.utils.exceptions.RerunSceneException`, which propagates through +``construct()`` back to the render command's rerun loop. + +``rerun()`` in the IPython shell triggers the same mechanism manually. """ from __future__ import annotations @@ -38,7 +48,7 @@ def interactive_embed( scene: Scene, renderer: WebGPURenderer, local_namespace: dict[str, Any], -) -> None: +) -> bool: """Run an interactive IPython session alongside the WebGPU preview window. Parameters @@ -53,13 +63,22 @@ def interactive_embed( function is called. Scene shortcuts (``play``, ``wait``, ``add``, ``remove``) and the full ``manim`` namespace are injected here so the user can type commands without a ``self.`` prefix. + + Returns + ------- + bool + ``True`` if the session ended because ``rerun()`` was called or the + source file changed on disk — the caller should raise + :class:`~manim.utils.exceptions.RerunSceneException` in that case. + ``False`` for a normal exit (shell closed / window closed). """ import threading import manim - from manim import logger + from manim import config, logger from manim.data_structures import MethodWithArgs - from manim.scene.scene import SceneInteractContinue + from manim.scene.scene import SceneInteractContinue, SceneInteractRerun + from manim.utils.exceptions import RerunSceneException window = renderer.window @@ -75,7 +94,25 @@ def interactive_embed( "IPython is required for the interactive WebGPU embed.\n" "Install it with: pip install ipython", ) - return + return False + + # ── File watcher ───────────────────────────────────────────────────── + try: + from watchdog.events import FileSystemEventHandler + from watchdog.observers import Observer + + class _FileHandler(FileSystemEventHandler): + def on_modified(self, event: Any) -> None: + scene.queue.put(SceneInteractRerun("file")) + + file_observer = Observer() + file_observer.schedule( + _FileHandler(), config["input_file"], recursive=True + ) + file_observer.start() + except Exception: + logger.debug("watchdog not available — file-watching disabled.") + file_observer = None # ── Build shell ────────────────────────────────────────────────────── ipcfg = IPConfig() @@ -112,6 +149,14 @@ def _proxy(*args: Any, **kwargs: Any) -> None: for _name in ("play", "wait", "add", "remove"): local_namespace[_name] = _make_proxy(_name) + # rerun(): tear down this session and re-run the scene from scratch, + # picking up any edits saved to the source file. + def _rerun(*args: Any, **kwargs: Any) -> None: + scene.queue.put(SceneInteractRerun("keyboard")) + shell.exiter() + + local_namespace["rerun"] = _rerun + # ── After every cell: schedule a re-render on the main thread ──────── # _post_cell runs in the IPython thread — GPU calls must not happen here. # Posting a sentinel to scene.queue ensures update_frame() is called on @@ -126,7 +171,7 @@ def _post_cell(*_a: Any, **_kw: Any) -> None: # ── IPython thread ─────────────────────────────────────────────────── def _keyboard_thread() -> None: shell(local_ns=local_namespace) - # Signal the main loop that the user closed the shell. + # Signal the main loop that the user closed the shell (not a rerun). scene.queue.put(SceneInteractContinue("keyboard")) keyboard_thread = threading.Thread(target=_keyboard_thread) @@ -136,19 +181,51 @@ def _keyboard_thread() -> None: keyboard_thread.daemon = True keyboard_thread.start() + # ── Helpers ────────────────────────────────────────────────────────── + def _stop_file_observer() -> None: + if file_observer is not None: + file_observer.unschedule_all() + file_observer.stop() + file_observer.join() + + def _exit_keyboard_thread() -> None: + if shell.pt_app: + try: + shell.pt_app.app.exit(exception=EOFError) + except Exception: + pass + keyboard_thread.join() + while not scene.queue.empty(): + scene.queue.get() + # ── Main thread: event loop ────────────────────────────────────────── scene.quit_interaction = False keyboard_thread_needs_join = shell.pt_app is not None sleep_s = 1.0 / _POLL_HZ + rerun_requested = False while not (window.is_closing or scene.quit_interaction): if not scene.queue.empty(): action = scene.queue.get_nowait() - if isinstance(action, SceneInteractContinue): + if isinstance(action, SceneInteractRerun): + rerun_requested = True + _stop_file_observer() + if action.sender == "keyboard": + # rerun() was called from the shell — thread already + # exiting via shell.exiter(); just join it. + keyboard_thread.join() + else: + # File changed — kill the prompt and join. + _exit_keyboard_thread() + # Drain stale queue items and signal rerun to the caller. + while not scene.queue.empty(): + scene.queue.get() + break + + elif isinstance(action, SceneInteractContinue): # IPython shell exited normally (user typed exit / Ctrl-D). keyboard_thread.join() - # Drain any stale items left in the queue. while not scene.queue.empty(): scene.queue.get() keyboard_thread_needs_join = False @@ -175,16 +252,12 @@ def _keyboard_thread() -> None: time.sleep(sleep_s) # ── Teardown ───────────────────────────────────────────────────────── - if keyboard_thread_needs_join and shell.pt_app: - # Window closed while IPython was still running — force the prompt - # to exit so the keyboard thread can be joined cleanly. - try: - shell.pt_app.app.exit(exception=EOFError) - except Exception: - pass - keyboard_thread.join() - while not scene.queue.empty(): - scene.queue.get() + _stop_file_observer() + + if keyboard_thread_needs_join: + _exit_keyboard_thread() if window.is_closing: window.destroy() + + return rerun_requested diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index 5afafff5cd..e6790973d3 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -785,6 +785,17 @@ def static_image(self, value: Any) -> None: def init_scene(self, scene: Scene) -> None: """Create the wgpu device, offscreen texture, and file writer.""" self.scene = scene + + if self._device is not None: + # Rerun path — reuse the existing GPU device, textures, and window. + # Only reset the per-scene state so the renderer is ready for a + # fresh construct() call without tearing down and recreating GPU + # resources (which would lose the live preview window). + self.partial_movie_files = [] + self.file_writer = self._file_writer_class(self, scene.__class__.__name__) + self.background_color = config["background_color"] + return + self.partial_movie_files: list[str | None] = [] self.file_writer: SceneFileWriter = self._file_writer_class( self, diff --git a/manim/scene/scene.py b/manim/scene/scene.py index 52aaa4127d..0e5a6503e3 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -1443,7 +1443,9 @@ def construct(self): ) currentframe: FrameType = inspect.currentframe() # type: ignore[assignment] local_namespace = currentframe.f_back.f_locals # type: ignore[union-attr] - _webgpu_embed(self, self.renderer, local_namespace) + rerun = _webgpu_embed(self, self.renderer, local_namespace) + if rerun: + raise RerunSceneException return assert isinstance(self.camera, OpenGLCamera) From 4e1e19078b18f364acfa9d6e12dad578de1a6d64 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Sun, 12 Apr 2026 13:06:26 +0530 Subject: [PATCH 31/33] WebGPU Renderer window now respect window configuration --- .../renderer/webgpu/webgpu_renderer_window.py | 323 +++++++++++++++++- pyproject.toml | 3 +- 2 files changed, 307 insertions(+), 19 deletions(-) diff --git a/manim/renderer/webgpu/webgpu_renderer_window.py b/manim/renderer/webgpu/webgpu_renderer_window.py index 3053e88bba..33a60cb2a4 100644 --- a/manim/renderer/webgpu/webgpu_renderer_window.py +++ b/manim/renderer/webgpu/webgpu_renderer_window.py @@ -36,11 +36,12 @@ from __future__ import annotations import math +import re from typing import TYPE_CHECKING import numpy as np -from manim import __version__, config +from manim import __version__, config, logger if TYPE_CHECKING: from .webgpu_renderer import WebGPURenderer @@ -117,19 +118,178 @@ def _modifiers_to_int(modifiers: tuple | list) -> int: # --------------------------------------------------------------------------- -# Window size helper +# Window configuration helpers # --------------------------------------------------------------------------- def _compute_window_size() -> tuple[int, int]: - """Return ``(width, height)`` for the preview window in logical pixels. - - Defaults to ``(pixel_width, pixel_height)`` so that the surface texture - produced by the canvas always matches the offscreen render texture, - making ``copy_texture_to_texture`` safe without any size bookkeeping. + """Return the initial canvas size in logical pixels. + + If ``config.window_size`` is ``"default"`` the canvas is made exactly + ``(pixel_width, pixel_height)`` so the offscreen render texture and the + window surface are always the same size — keeping + ``copy_texture_to_texture`` safe with no extra bookkeeping. + + If the user specified an explicit size (e.g. ``--window_size 960,540``) + that size is used for the *display* window instead. The offscreen render + texture still has its full ``pixel_width × pixel_height`` resolution; the + copy in :meth:`WebGPUWindow._draw_frame` uses the minimum of the two + dimensions so it never overflows either texture. """ + win_size = config.window_size + if win_size != "default": + return int(win_size[0]), int(win_size[1]) return config.pixel_width, config.pixel_height +def _resolve_window_position( + pos: str, + monitor: object, + win_w: int, + win_h: int, +) -> tuple[int, int]: + """Convert a ``config.window_position`` string to absolute ``(x, y)``. + + Accepts direction strings (``"UL"``, ``"UR"``, ``"DL"``, ``"DR"``, + ``"ORIGIN"``, ``"LEFT"``, ``"RIGHT"``, ``"UP"``, ``"DOWN"``) or a pixel + coordinate pair in ``"x,y"`` / ``"x;y"`` format. + + Parameters + ---------- + pos: + The raw ``config.window_position`` string. + monitor: + A ``screeninfo.Monitor`` (or any object with ``.x``, ``.y``, + ``.width``, ``.height`` attributes). + win_w, win_h: + Current canvas logical width / height in pixels. + """ + mx: int = monitor.x # type: ignore[attr-defined] + my: int = monitor.y # type: ignore[attr-defined] + mw: int = monitor.width # type: ignore[attr-defined] + mh: int = monitor.height # type: ignore[attr-defined] + + # Numeric "x,y" or "x;y" coordinate pair + m = re.match(r"^(\d+)\s*[,;]\s*(\d+)$", pos.strip()) + if m: + return int(m.group(1)), int(m.group(2)) + + pos_u = pos.strip().upper() + right = mx + mw - win_w + bottom = my + mh - win_h + h_center = mx + (mw - win_w) // 2 + v_center = my + (mh - win_h) // 2 + + return { + "UL": (mx, my), + "UR": (right, my), + "DL": (mx, bottom), + "DR": (right, bottom), + "ORIGIN": (h_center, v_center), + "LEFT": (mx, v_center), + "RIGHT": (right, v_center), + "UP": (h_center, my), + "DOWN": (h_center, bottom), + }.get(pos_u, (h_center, v_center)) + + +def _apply_window_config(canvas) -> None: + """Apply all window-related manim config options to a live canvas. + + Handles ``window_size``, ``window_position``, ``window_monitor``, and + ``fullscreen``. Errors are caught and logged as debug messages so that a + missing ``screeninfo`` installation or an unsupported backend never + prevents the window from opening. + """ + # ── Window display size ────────────────────────────────────────────── + win_size = config.window_size + if win_size != "default": + try: + canvas.set_logical_size(float(win_size[0]), float(win_size[1])) + except Exception as exc: + logger.debug("WebGPU: could not set window size: %s", exc) + + # ── Monitor list ───────────────────────────────────────────────────── + try: + import screeninfo + monitors = screeninfo.get_monitors() + except Exception: + monitors = [] + + mon_idx = int(config.window_monitor) if config.window_monitor is not None else 0 + monitor = None + if monitors: + monitor = monitors[mon_idx] if mon_idx < len(monitors) else monitors[0] + + # ── Backend-specific placement ─────────────────────────────────────── + # Try GLFW backend first (canvas has a raw ``_window`` handle). + glfw_window = getattr(canvas, "_window", None) + if glfw_window is not None: + _apply_glfw_placement(canvas, glfw_window, monitor, mon_idx) + return + + # Try Qt backend (canvas IS a QWidget — move() / showFullScreen() work). + if hasattr(canvas, "showFullScreen") and hasattr(canvas, "move"): + _apply_qt_placement(canvas, monitor) + + +def _apply_glfw_placement(canvas, glfw_window, monitor, mon_idx: int) -> None: + """Apply position / fullscreen via the raw GLFW window handle.""" + try: + import glfw # pyGLFW — installed as a rendercanvas dependency + except ImportError: + logger.debug("WebGPU: glfw not importable; skipping window placement.") + return + + if config.fullscreen: + glfw_monitors = glfw.get_monitors() + if not glfw_monitors: + return + glfw_mon = ( + glfw_monitors[mon_idx] + if mon_idx < len(glfw_monitors) + else glfw_monitors[0] + ) + mode = glfw.get_video_mode(glfw_mon) + glfw.set_window_monitor( + glfw_window, glfw_mon, + 0, 0, mode.size.width, mode.size.height, mode.refresh_rate, + ) + return + + if monitor is None: + return + + try: + lw, lh = canvas.get_logical_size() + except Exception: + lw, lh = config.pixel_width, config.pixel_height + + x, y = _resolve_window_position( + str(config.window_position), monitor, int(lw), int(lh) + ) + glfw.set_window_pos(glfw_window, x, y) + + +def _apply_qt_placement(canvas, monitor) -> None: + """Apply position / fullscreen on a Qt-backed canvas (QWidget).""" + if config.fullscreen: + canvas.showFullScreen() + return + + if monitor is None: + return + + try: + lw, lh = canvas.get_logical_size() + except Exception: + lw, lh = config.pixel_width, config.pixel_height + + x, y = _resolve_window_position( + str(config.window_position), monitor, int(lw), int(lh) + ) + canvas.move(x, y) + + # --------------------------------------------------------------------------- # Interactive camera sensitivity constants # --------------------------------------------------------------------------- @@ -246,7 +406,7 @@ def __init__(self, renderer: WebGPURenderer) -> None: self._context.configure( device=renderer._device, format=wgpu.TextureFormat.bgra8unorm, - usage=wgpu.TextureUsage.RENDER_ATTACHMENT | wgpu.TextureUsage.COPY_DST, + usage=wgpu.TextureUsage.RENDER_ATTACHMENT, ) # Register the draw callback (executed inside the rendercanvas lifecycle @@ -272,6 +432,12 @@ def __init__(self, renderer: WebGPURenderer) -> None: self._pan_x: float = 0.0 self._pan_y: float = 0.0 + # Blit pipeline — scales the offscreen render texture to whatever size + # the window surface happens to be (supports config.window_size). + self._blit_pipeline, self._blit_bgl, self._blit_sampler = ( + self._create_blit_pipeline() + ) + # Register event handlers. self._canvas.add_event_handler(self._on_key_down, "key_down") self._canvas.add_event_handler(self._on_key_up, "key_up") @@ -280,6 +446,9 @@ def __init__(self, renderer: WebGPURenderer) -> None: self._canvas.add_event_handler(self._on_pointer_up, "pointer_up") self._canvas.add_event_handler(self._on_wheel, "wheel") + # Apply window configuration: size, position, monitor, fullscreen. + _apply_window_config(self._canvas) + # ------------------------------------------------------------------ # Public interface (consumed by WebGPURenderer and scene.py) # ------------------------------------------------------------------ @@ -304,27 +473,145 @@ def present(self) -> None: # Trigger _draw_frame → copy_texture_to_texture → present to screen. self._canvas.force_draw() + # ------------------------------------------------------------------ + # Blit pipeline — scales render texture → window surface + # ------------------------------------------------------------------ + + _BLIT_SHADER = """ + struct VertOut { + @builtin(position) pos : vec4, + @location(0) uv : vec2, + }; + + // Full-screen quad from 4 vertices (triangle-strip). + // NDC y=+1 is the top of the screen; UV y=0 is the top of the texture. + @vertex + fn vs_main(@builtin(vertex_index) vi: u32) -> VertOut { + var pos = array, 4>( + vec2(-1.0, 1.0), // top-left + vec2( 1.0, 1.0), // top-right + vec2(-1.0, -1.0), // bottom-left + vec2( 1.0, -1.0), // bottom-right + ); + var uv = array, 4>( + vec2(0.0, 0.0), // top-left + vec2(1.0, 0.0), // top-right + vec2(0.0, 1.0), // bottom-left + vec2(1.0, 1.0), // bottom-right + ); + var out: VertOut; + out.pos = vec4(pos[vi], 0.0, 1.0); + out.uv = uv[vi]; + return out; + } + + @group(0) @binding(0) var tex : texture_2d; + @group(0) @binding(1) var samp : sampler; + + @fragment + fn fs_main(in: VertOut) -> @location(0) vec4 { + return textureSample(tex, samp, in.uv); + } + """ + + def _create_blit_pipeline(self): + """Build the pipeline used to blit the render texture to the window. + + Returns ``(pipeline, bind_group_layout, sampler)``. All three are + reused every frame; only the per-frame bind group (which wraps the + current render texture view) is created anew in ``_draw_frame``. + """ + device = self._renderer._device + + shader = device.create_shader_module(code=self._BLIT_SHADER) + + bgl = device.create_bind_group_layout( + entries=[ + { + "binding": 0, + "visibility": wgpu.ShaderStage.FRAGMENT, + "texture": { + "sample_type": "float", + "view_dimension": "2d", + "multisampled": False, + }, + }, + { + "binding": 1, + "visibility": wgpu.ShaderStage.FRAGMENT, + "sampler": {"type": "filtering"}, + }, + ] + ) + + pipeline = device.create_render_pipeline( + layout=device.create_pipeline_layout(bind_group_layouts=[bgl]), + vertex={"module": shader, "entry_point": "vs_main"}, + fragment={ + "module": shader, + "entry_point": "fs_main", + "targets": [{"format": wgpu.TextureFormat.bgra8unorm}], + }, + primitive={ + "topology": wgpu.PrimitiveTopology.triangle_strip, + "strip_index_format": wgpu.IndexFormat.uint32, + }, + ) + + # Linear filtering gives a smooth downscale when the window is + # smaller than the render texture; nearest would produce aliasing. + sampler = device.create_sampler( + mag_filter="linear", + min_filter="linear", + ) + + return pipeline, bgl, sampler + # ------------------------------------------------------------------ # Draw callback (runs inside the rendercanvas present lifecycle) # ------------------------------------------------------------------ def _draw_frame(self) -> None: - """Copy the offscreen render texture to the window surface texture.""" + """Blit the offscreen render texture to the window surface. + + A full-screen-quad render pass scales the render texture to whatever + size the window surface currently is, so the image always fills the + window correctly regardless of ``config.window_size``. + """ renderer = self._renderer if renderer._render_texture is None or renderer._device is None: return - surface_tex = self._context.get_current_texture() - w = config.pixel_width - h = config.pixel_height + device = renderer._device + surface_tex = self._context.get_current_texture() + surface_view = surface_tex.create_view() + + render_tex_view = renderer._render_texture.create_view() + bind_group = device.create_bind_group( + layout=self._blit_bgl, + entries=[ + {"binding": 0, "resource": render_tex_view}, + {"binding": 1, "resource": self._blit_sampler}, + ], + ) - encoder = renderer._device.create_command_encoder() - encoder.copy_texture_to_texture( - {"texture": renderer._render_texture, "mip_level": 0, "origin": (0, 0, 0)}, - {"texture": surface_tex, "mip_level": 0, "origin": (0, 0, 0)}, - (w, h, 1), + encoder = device.create_command_encoder() + rp = encoder.begin_render_pass( + color_attachments=[ + { + "view": surface_view, + "load_op": "clear", + "store_op": "store", + "clear_value": (0.0, 0.0, 0.0, 1.0), + } + ] ) - renderer._device.queue.submit([encoder.finish()]) + rp.set_pipeline(self._blit_pipeline) + rp.set_bind_group(0, bind_group) + rp.draw(4) # 4 vertices → one triangle-strip quad + rp.end() + + device.queue.submit([encoder.finish()]) # ------------------------------------------------------------------ # Interactive camera helpers diff --git a/pyproject.toml b/pyproject.toml index 3c171ad147..6b7581109c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,8 @@ dependencies = [ "typing-extensions>=4.12.0", "watchdog>=2.0.0", "wgpu>=0.31.0", + "rendercanvas>=2.6.3", + "glfw>=2.9.0", ] @@ -70,7 +72,6 @@ homepage = "https://www.manim.community/" [project.optional-dependencies] gui = [ "dearpygui>=1.0.0", - "rendercanvas>=2.6.3", ] jupyterlab = [ "jupyterlab>=4.3.4", From 5fb7949e2c7448147aef1e32764a197521bd2495 Mon Sep 17 00:00:00 2001 From: Mayank Suman Date: Tue, 14 Apr 2026 16:53:52 +0530 Subject: [PATCH 32/33] Optimization of WebGPU renderer for interactive mode --- manim/_config/utils.py | 5 +- manim/cli/render/commands.py | 28 +- manim/mobject/three_d/light_source.py | 3 +- manim/renderer/webgpu/webgpu_renderer.py | 282 +++++++++++---- .../renderer/webgpu/webgpu_renderer_window.py | 5 +- .../webgpu/webgpu_vmobject_rendering.py | 333 +++++++++++++++++- manim/scene/three_d_scene.py | 28 +- 7 files changed, 583 insertions(+), 101 deletions(-) diff --git a/manim/_config/utils.py b/manim/_config/utils.py index 3e45846539..05d7443e41 100644 --- a/manim/_config/utils.py +++ b/manim/_config/utils.py @@ -846,7 +846,10 @@ def digest_args(self, args: argparse.Namespace) -> Self: if args.tex_template: self.tex_template = TexTemplate.from_file(args.tex_template) - if self.renderer == RendererType.OPENGL and args.write_to_movie is None: + if ( + self.renderer in (RendererType.OPENGL, RendererType.WEBGPU) + and args.write_to_movie is None + ): # --write_to_movie was not passed on the command line, so don't generate video. self["write_to_movie"] = False diff --git a/manim/cli/render/commands.py b/manim/cli/render/commands.py index e6240b3805..4ed8069477 100644 --- a/manim/cli/render/commands.py +++ b/manim/cli/render/commands.py @@ -120,25 +120,15 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]: elif config.renderer == RendererType.WEBGPU: from manim.renderer.webgpu.webgpu_renderer import WebGPURenderer - try: - renderer = WebGPURenderer() - keep_running = True - while keep_running: - for SceneClass in scene_classes_from_file(file): - with tempconfig({}): - scene = SceneClass(renderer) - rerun = scene.render() - if rerun or config["write_all"]: - renderer.num_plays = 0 - continue - else: - keep_running = False - break - if config["write_all"]: - keep_running = False - except Exception: - error_console.print_exception() - sys.exit(1) + renderer = WebGPURenderer() + for SceneClass in scene_classes_from_file(file): + try: + with tempconfig({}): + scene = SceneClass(renderer) + scene.render() + except Exception: + error_console.print_exception() + sys.exit(1) else: for SceneClass in scene_classes_from_file(file): try: diff --git a/manim/mobject/three_d/light_source.py b/manim/mobject/three_d/light_source.py index 881e9ecadc..09246f8f90 100644 --- a/manim/mobject/three_d/light_source.py +++ b/manim/mobject/three_d/light_source.py @@ -82,6 +82,7 @@ def __init__( super().__init__(**kwargs) self.light_color: np.ndarray = np.asarray(color_to_rgb(color), dtype=np.float32) self.intensity: float = float(intensity) + self.should_render: bool = False # ------------------------------------------------------------------ # Packing helpers (used by the renderer) @@ -187,7 +188,7 @@ def __init__( self, direction: Vector3D = np.array([0.0, -1.0, -1.0]), color: ParsableManimColor = WHITE, - intensity: float = 0.8, + intensity: float = 1.0, **kwargs: Any, ) -> None: super().__init__(color=color, intensity=intensity, **kwargs) diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index e6790973d3..42d2f159ea 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -21,6 +21,7 @@ from __future__ import annotations +import collections import time import weakref from pathlib import Path @@ -33,6 +34,7 @@ from manim.constants import IN, OUT, PI, RIGHT, DOWN, LEFT from manim.mobject.mobject import Mobject from manim.mobject.three_d.light_source import LightSource +from manim.mobject.three_d.three_dimensions import Surface from manim.mobject.types.image_mobject import AbstractImageMobject, ImageMobjectFromCamera from manim.mobject.types.vectorized_mobject import VMobject from manim.scene.scene_file_writer import SceneFileWriter @@ -741,9 +743,29 @@ def __init__( self._readback_compute_bgl: wgpu_t.GPUBindGroupLayout | None = None self._readback_compute_bind_group: wgpu_t.GPUBindGroup | None = None # Storage buffer the compute shader writes into (STORAGE | COPY_SRC). + # One shared buffer — the compute shader always writes here first. self._readback_storage_buf: wgpu_t.GPUBuffer | None = None - # Mappable buffer we copy into before CPU readback (COPY_DST | MAP_READ). - self._readback_map_buf: wgpu_t.GPUBuffer | None = None + + # ── Staging-buffer pool ────────────────────────────────────────── + # Instead of a single MAP_READ buffer we keep a ring of N buffers. + # update_frame() writes the readback into the next free slot and + # starts map_async immediately. _get_mapped_frame_array() dequeues + # the oldest in-flight slot; by the time N frames have been submitted + # the GPU has had N frame-times to finish and sync_wait() returns + # instantly with no pipeline stall. + _READBACK_POOL = 3 # triple-buffering + self._READBACK_POOL: int = _READBACK_POOL + self._readback_pool: list[Any] = [] # GPUBuffer × _READBACK_POOL + # FIFO of slot indices submitted but not yet read. + self._readback_queue: collections.deque = collections.deque() + # Ring write pointer: next pool slot for update_frame to fill. + self._readback_write_slot: int = 0 + + # Cache the last successfully read pixel array. Cleared by + # update_frame() so that get_image() / get_frame() hit the GPU path + # only once per rendered frame; repeated calls within the same frame + # are served from this cache without any GPU interaction. + self._readback_cache: np.ndarray | None = None # Per-frame state (set during update_frame, cleared after submit). self.current_render_pass: wgpu_t.GPURenderPassEncoder | None = None @@ -815,7 +837,7 @@ def init_scene(self, scene: Scene) -> None: height = config.pixel_height # bgra8unorm matches the window surface format on all major platforms # (Metal/Vulkan/DX12), enabling copy_texture_to_texture without a - # blit shader. Readback in _get_raw_frame_data() swaps B↔R to + # blit shader. Readback in _get_mapped_frame_array() swaps B↔R to # produce the RGBA output expected by PIL / numpy callers. self._render_texture = self._device.create_texture( size=(width, height, 1), @@ -1629,18 +1651,16 @@ def _create_oit_resources(self, width: int, height: int) -> None: # ------------------------------------------------------------------ def _collect_lights(self) -> list: - """Return all LightSource instances in the current scene (depth-first).""" - lights = [] + """Return all LightSource instances in the current scene. + + Lights are always added directly to the scene via ``self.add(light)``, + so scanning the top-level ``scene.mobjects`` list is sufficient and + avoids a deep O(N_submobjects) traversal through thousands of Surface + patches every frame. + """ if self.scene is None: - return lights - def _walk(mob): - if isinstance(mob, LightSource): - lights.append(mob) - for child in mob.submobjects: - _walk(child) - for mob in self.scene.mobjects: - _walk(mob) - return lights + return [] + return [m for m in self.scene.mobjects if isinstance(m, LightSource)] _MAX_LIGHTS = 8 @@ -2003,6 +2023,7 @@ def update_frame( scene: Scene, mob_list: list | None = None, blit_static: bool = False, + _readback: bool = True, ) -> None: """Render one frame into the offscreen texture. @@ -2132,13 +2153,37 @@ def _walk(mob: Any) -> None: if isinstance(mob, AbstractImageMobject): _flush_runs() render_queue.append(("image", mob)) + # Recurse into submobjects (e.g. the display_frame SurroundingRectangle + # added by ImageMobjectFromCamera.add_display_frame()) so they are + # rendered as VMobject overlays on top of the image quad. + for sub in mob.submobjects: + _walk(sub) elif isinstance(mob, DotCloud3D): # WebGPU dot cloud — rendered as screen-aligned sphere quads. _flush_runs() render_queue.append(("truedot", mob)) - elif isinstance(mob, VMobject): + elif isinstance(mob, Surface): + # Parametric Surface: add individually so collect_frame_data + # uses the Surface lighting/geometry cache path. if mob in fixed_in_frame: - pass # handled in the overlay pass below + pass # handled in overlay pass below + elif mob in fixed_orient: + _run_orient.append(mob) + else: + _run_normal.append(mob) + elif isinstance(mob, VMobject): + # Container check: if any *direct* child is a Surface, Image, or + # DotCloud, recurse rather than treating this mob as a monolithic + # VMobject. This ensures e.g. VGroup(Sphere(), Sphere()) routes + # each Sphere through the surface rendering path. + if mob.submobjects and any( + isinstance(s, (Surface, AbstractImageMobject, DotCloud3D)) + for s in mob.submobjects + ): + for sub in mob.submobjects: + _walk(sub) + elif mob in fixed_in_frame: + pass # handled in overlay pass below elif mob in fixed_orient: _run_orient.append(mob) else: @@ -2482,7 +2527,58 @@ def _f(m: Any) -> None: self._fill_stroke_3d_pipeline = _saved_3d fixed_pass.end() - self._device.queue.submit([encoder.finish()]) + # ── Invalidate readback cache ──────────────────────────────────── + # A new frame is being rendered; cached pixel data from the previous + # frame is no longer valid. + self._readback_cache = None + + # ── Pool-based pre-staged readback ─────────────────────────────── + # Append the readback compute dispatch + buffer copy into the current + # pool slot, then call map_async immediately after submit. The GPU + # processes the readback asynchronously while the CPU continues. + # When get_image() / get_frame() dequeues the slot, it calls + # sync_wait() — which returns instantly when the pool has been filled + # (the oldest slot has been in-flight for >= _READBACK_POOL frames). + # + # Guard: only pre-stage if the pool has a free slot. A slot is free + # once it has been read (unmap called) by _get_mapped_frame_array. + # If all slots are still in-flight we skip pre-staging for this frame; + # _get_mapped_frame_array falls back to a fresh submit+map_sync. + # + # _readback=False skips readback staging entirely (used by + # save_static_frame_data, which renders for texture capture only and + # must not pollute the pool with non-movie frames). + if ( + _readback + and self._readback_compute_pipeline is not None + and self._readback_compute_bind_group is not None + and self._readback_storage_buf is not None + and self._readback_pool + and len(self._readback_queue) < self._READBACK_POOL + ): + width = config.pixel_width + height = config.pixel_height + packed_size = width * height * 4 + slot = self._readback_write_slot + + cp = encoder.begin_compute_pass() + cp.set_pipeline(self._readback_compute_pipeline) + cp.set_bind_group(0, self._readback_compute_bind_group) + cp.dispatch_workgroups((width + 15) // 16, (height + 15) // 16) + cp.end() + + encoder.copy_buffer_to_buffer( + self._readback_storage_buf, 0, + self._readback_pool[slot], 0, + packed_size, + ) + + self._device.queue.submit([encoder.finish()]) + + self._readback_queue.append(slot) + self._readback_write_slot = (slot + 1) % self._READBACK_POOL + else: + self._device.queue.submit([encoder.finish()]) # ── Sub-camera CPU readback ─────────────────────────────────────── # Populate mob.camera.pixel_array from the staged sub-camera texture @@ -2582,10 +2678,14 @@ def _create_readback_pipeline(self, width: int, height: int) -> None: size=packed_size, usage=wgpu.BufferUsage.STORAGE | wgpu.BufferUsage.COPY_SRC, ) - self._readback_map_buf = self._device.create_buffer( - size=packed_size, - usage=wgpu.BufferUsage.COPY_DST | wgpu.BufferUsage.MAP_READ, - ) + # Allocate all pool slots up front — no per-frame allocation ever. + self._readback_pool = [ + self._device.create_buffer( + size=packed_size, + usage=wgpu.BufferUsage.COPY_DST | wgpu.BufferUsage.MAP_READ, + ) + for _ in range(self._READBACK_POOL) + ] self._readback_compute_bind_group = self._device.create_bind_group( layout=self._readback_compute_bgl, entries=[ @@ -2601,61 +2701,90 @@ def _create_readback_pipeline(self, width: int, height: int) -> None: ], ) - def _get_raw_frame_data(self) -> bytes: - """Readback the current frame as tightly-packed RGBA bytes. + def _get_mapped_frame_array(self) -> np.ndarray: + """Return the current frame as a flat uint8 numpy array (H*W*4 elements). - A compute shader (readback_compact.wgsl) handles both row-depadding and - the bgra→rgba channel fix on the GPU. The CPU no longer needs to loop - over rows or touch a numpy channel-swap. + Cache path (fastest): update_frame() has not been called since the last + readback, so the frame is unchanged — return the cached array directly + with zero GPU interaction. + + Pool path: update_frame() enqueued a slot index into _readback_queue. + We dequeue the oldest slot and call map_sync(). When the pool has + been filled (_READBACK_POOL frames submitted before the first read), + the GPU work is already complete; map_sync() only pays the driver's + buffer-mapping overhead (< 1 ms) rather than a full GPU pipeline + stall. + + Fallback path: nothing is in the queue (get_image called without a + preceding update_frame, or the pool was full at submit time). We + submit fresh readback work to the next pool slot and block with + map_sync — same behaviour as before, but still using pool buffers so + no new GPU allocation occurs. """ assert self._device is not None assert self._readback_compute_pipeline is not None assert self._readback_compute_bind_group is not None assert self._readback_storage_buf is not None - assert self._readback_map_buf is not None + assert self._readback_pool + + # ── Cache hit ──────────────────────────────────────────────────── + if self._readback_cache is not None: + return self._readback_cache width = config.pixel_width height = config.pixel_height packed_size = width * height * 4 - encoder = self._device.create_command_encoder() + if self._readback_queue: + # ── Pool path: dequeue oldest in-flight slot ───────────────── + # The slot was submitted >= _READBACK_POOL frames ago, so the GPU + # has had enough time to finish. map_sync initiates the mapping + # and waits; since the GPU work is already done, only the driver's + # buffer-mapping overhead remains (typically < 1 ms). + slot = self._readback_queue.popleft() + buf = self._readback_pool[slot] + buf.map_sync(wgpu.MapMode.READ) + else: + # ── Fallback path: submit now, block ───────────────────────── + slot = self._readback_write_slot + buf = self._readback_pool[slot] + + encoder = self._device.create_command_encoder() + + cp = encoder.begin_compute_pass() + cp.set_pipeline(self._readback_compute_pipeline) + cp.set_bind_group(0, self._readback_compute_bind_group) + cp.dispatch_workgroups((width + 15) // 16, (height + 15) // 16) + cp.end() + + encoder.copy_buffer_to_buffer( + self._readback_storage_buf, 0, + buf, 0, + packed_size, + ) - # Compact pass: row-depad + bgra→rgba in one GPU dispatch. - compute_pass = encoder.begin_compute_pass() - compute_pass.set_pipeline(self._readback_compute_pipeline) - compute_pass.set_bind_group(0, self._readback_compute_bind_group) - compute_pass.dispatch_workgroups( - (width + 15) // 16, - (height + 15) // 16, - ) - compute_pass.end() + self._device.queue.submit([encoder.finish()]) + buf.map_sync(wgpu.MapMode.READ) + self._readback_write_slot = (slot + 1) % self._READBACK_POOL - # Copy packed storage buffer → mappable buffer. - encoder.copy_buffer_to_buffer( - self._readback_storage_buf, 0, - self._readback_map_buf, 0, - packed_size, - ) + # numpy copy is ~3× faster than bytes() for large buffers (SIMD path). + arr = np.frombuffer(buf.read_mapped(), dtype=np.uint8).copy() + buf.unmap() - self._device.queue.submit([encoder.finish()]) - - self._readback_map_buf.map_sync(wgpu.MapMode.READ) - raw = bytes(self._readback_map_buf.read_mapped()) - self._readback_map_buf.unmap() - return raw + self._readback_cache = arr + return arr def get_image(self) -> Image.Image: """Return the current frame as a PIL Image (RGBA).""" - raw = self._get_raw_frame_data() - return Image.frombytes( - "RGBA", (config.pixel_width, config.pixel_height), raw + arr = self._get_mapped_frame_array() + return Image.fromarray( + arr.reshape(config.pixel_height, config.pixel_width, 4), "RGBA" ) def get_frame(self) -> np.ndarray: """Return the current frame as a (height, width, 4) uint8 NumPy array.""" - raw = self._get_raw_frame_data() - return np.frombuffer(raw, dtype=np.uint8).reshape( - (config.pixel_height, config.pixel_width, 4) + return self._get_mapped_frame_array().reshape( + config.pixel_height, config.pixel_width, 4 ) # ------------------------------------------------------------------ @@ -2742,9 +2871,13 @@ def render(self, scene: Scene, frame_offset: float, moving_mobjects: list) -> No return self.file_writer.write_frame(self) if self.window is not None: + if self.window.is_closing: + self.window = None + return self.window.present() while self.animation_elapsed_time < frame_offset: if self.window.is_closing: + self.window = None break if self._has_static_frame: self.update_frame(scene, mob_list=list(moving_mobjects), blit_static=True) @@ -2796,11 +2929,15 @@ def play(self, scene: Scene, *animations: Any, **kwargs: Any) -> None: self, num_frames=int(config.frame_rate * scene.duration) ) if self.window is not None: - self.window.present() - while time.time() - self.animation_start_time < scene.duration: - if self.window.is_closing: - break + if self.window.is_closing: + self.window = None + else: self.window.present() + while time.time() - self.animation_start_time < scene.duration: + if self.window.is_closing: + self.window = None + break + self.window.present() self.animation_elapsed_time = scene.duration else: scene.play_internal() @@ -2821,6 +2958,18 @@ def scene_finished(self, scene: Scene) -> None: self.update_frame(scene) self.file_writer.save_image(self.get_image()) + # Explicitly close the preview window so that GlfwRenderCanvas._rc_close() + # destroys the GLFW window handle while GLFW is still alive. Without this, + # Python shutdown calls GlfwCanvasGroup.__del__ → glfw.terminate() first, + # then the GlfwRenderCanvas.__del__ fires and tries glfw.destroy_window() + # on an already-terminated GLFW library, producing a GLFWError warning. + if self.window is not None: + try: + self.window.destroy() + except Exception: + pass + self.window = None + def save_static_frame_data(self, scene: Scene, static_mobjects: Any) -> None: """Render *static_mobjects* once and cache the result in ``_static_texture``. @@ -2843,10 +2992,23 @@ def save_static_frame_data(self, scene: Scene, static_mobjects: Any) -> None: self._static_mob_ids = set() return + # If the camera itself is animated (e.g. begin_ambient_camera_rotation), + # any pre-rendered static texture is stale on the very next frame because + # the projection changes. Disable the optimisation so every frame is + # fully re-rendered with the correct camera orientation. + if self.camera.has_time_based_updater(): + self._has_static_frame = False + self._static_mob_ids = set() + return + self._static_mob_ids = set(id(m) for m in static_list) # Render the static mob list into _render_texture (full clear + draw). - self.update_frame(scene, mob_list=static_list, blit_static=False) + # _readback=False: this render is for texture capture only — it must not + # enqueue a readback pool slot, because save_static_frame_data is not + # writing a movie frame and a stale slot in the pool would cause the next + # write_frame() call to read wrong pixel data. + self.update_frame(scene, mob_list=static_list, blit_static=False, _readback=False) # Copy _render_texture → _static_texture for later per-frame blits. encoder = self._device.create_command_encoder() diff --git a/manim/renderer/webgpu/webgpu_renderer_window.py b/manim/renderer/webgpu/webgpu_renderer_window.py index 33a60cb2a4..da953c59f9 100644 --- a/manim/renderer/webgpu/webgpu_renderer_window.py +++ b/manim/renderer/webgpu/webgpu_renderer_window.py @@ -130,10 +130,7 @@ def _compute_window_size() -> tuple[int, int]: ``copy_texture_to_texture`` safe with no extra bookkeeping. If the user specified an explicit size (e.g. ``--window_size 960,540``) - that size is used for the *display* window instead. The offscreen render - texture still has its full ``pixel_width × pixel_height`` resolution; the - copy in :meth:`WebGPUWindow._draw_frame` uses the minimum of the two - dimensions so it never overflows either texture. + that size is used for the *display* window instead. """ win_size = config.window_size if win_size != "default": diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py index ec4e0b52cc..6176bcebc9 100644 --- a/manim/renderer/webgpu/webgpu_vmobject_rendering.py +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -319,12 +319,130 @@ class _FrameData: # Geometry only; colors/widths are fetched fresh every frame. _fill_stroke_cache: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() +# surface_mob_cache: Surface mob → +# (geom_hash, color_hash, big_template, seg_starts, seg_ends, +# sw_arr, sa_arr, has_sw, draw_cmds) +# +# geom_hash : bytes — hash of patch geometry + material ONLY (no colors). +# Unchanged when FadeIn/set_fill changes opacity. +# color_hash : bytes — hash of fill_rgba, stroke_rgba, stroke_width per patch. +# Changes on FadeIn/set_fill without requiring retessellation. +# big_template: single concatenated _SURFACE_COMBINED_DTYPE array with +# already-smoothed normals; colors reflect the last stored frame; +# stroke_half_px == 0.0 (recomputed per-frame on each hit). +# seg_starts/ends: numpy intp arrays of part boundaries in big_template. +# sw_arr/sa_arr/has_sw: stroke metadata, updated in-place on color misses. +# draw_cmds : list[str] — "surface_opaque" or "surface_oit" per part; +# updated in-place on color misses (opacity class can change). +# +# Cache hit states: +# geom HIT + color HIT → just recompute stroke_half_px (camera rotation path) +# geom HIT + color MISS → patch colors in big_template + recompute stroke_half_px +# (FadeIn / set_fill without geometry change; O(N_parts)) +# geom MISS → full retessellation + normal smoothing +_surface_mob_cache: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() + +# surface_color_memo: Surface mob → +# (fill_cols, stroke_cols, sw_arr, sa_arr) +# fill_cols : float32 (N_active, 4) — fill RGBA per active part +# stroke_cols : float32 (N_active, 4) — stroke RGBA per active part +# sw_arr : float32 (N_active,) — stroke width per active part +# sa_arr : float32 (N_active,) — stroke alpha per active part +# +# Populated by _surface_hash_pair whenever the color hash is recomputed +# (color-only miss or full miss path). The color-only update path in +# collect_frame_data reads from here instead of re-iterating all submobjects, +# saving ~2 full O(N_submobs) passes per Surface per color-changing frame. +_surface_color_memo: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() + def _points_hash(vmobject: VMobject) -> int: pts = vmobject.points if pts.size == 0: return 0 - return hash(pts.tobytes()) + # Cast to float32 before hashing so that sub-epsilon float64 noise + # (e.g. from FadeIn / Transform's straight_path interpolation, which + # produces ~1e-17 differences when start == end) does not create + # spurious cache misses. Genuine geometry changes are at least + # float32-epsilon (~1e-7) in magnitude and are still detected. + return hash(pts.astype(np.float32).tobytes()) + + +# _surface_geom_hash_memo: Surface mob → (fast_geom_id, fast_color_id, geom_hash, color_hash) +# fast_geom_id — XOR of id(s.points) for all submobs. +# fast_color_id — XOR of id(fill_rgbas) ^ id(stroke_rgbas) for all submobs. +# Separately tracking the two fast IDs lets us skip recomputing the geometry hash +# on a color-only change without missing a genuine geometry update. +_surface_geom_hash_memo: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() + + +def _surface_hash_pair( + mob: "Surface", + submobs: list | None = None, +) -> "tuple[bytes, bytes]": + """Return ``(geom_hash, color_hash)`` for *mob*. + + ``geom_hash`` covers patch point positions + material params (diffuse, + specular, specular_exp). It does NOT include fill/stroke colors, so a + FadeIn that only changes opacity does not invalidate it. + + ``color_hash`` is ``fast_color_id`` packed as 8 bytes, where + ``fast_color_id`` is the XOR of ``id(fill_rgbas)`` / ``id(stroke_rgbas)`` + for all submobject patches. It changes whenever Manim replaces any color + array (which happens on every FadeIn / set_fill frame). + + Two-level memoisation avoids full per-submob rehashing on every frame: + the fast IDs (XOR of array ``id()``s) detect changes in O(N_submobs) + without touching array data; the slow geometry hash runs only on a + geometry miss (first call or actual point change). + + *submobs* — optional pre-computed ``mob.family_members_with_points()``. + Pass this from ``collect_frame_data`` to avoid a redundant tree walk. + """ + if submobs is None: + submobs = mob.family_members_with_points() + + fast_geom_id = 0 + fast_color_id = 0 + for s in submobs: + fast_geom_id ^= id(s.points) + fast_color_id ^= id(getattr(s, "fill_rgbas", None)) + fast_color_id ^= id(getattr(s, "stroke_rgbas", None)) + + memo = _surface_geom_hash_memo.get(mob) + if memo is not None: + cached_fgi, cached_fci, cached_gh, cached_ch = memo + if cached_fgi == fast_geom_id and cached_fci == fast_color_id: + # Both geometry and colors unchanged. + return cached_gh, cached_ch + if cached_fgi == fast_geom_id: + # Geometry unchanged, colors changed — use fast_color_id as the + # color hash (no submob method calls needed here). The actual + # color arrays are read lazily by collect_frame_data when it + # applies the per-vertex update, so we skip the second O(N_submobs) + # iteration entirely. + new_ch = struct.pack(" 0 + fill_list.append(f_rgba[0].astype(np.float32)) + stroke_list.append( + s_rgba[0].astype(np.float32) if has_stroke + else np.zeros(4, dtype=np.float32) + ) + sw_list.append(float(submob.stroke_width) if has_stroke else 0.0) + sa_list.append(float(s_rgba[0, 3]) if has_stroke else 0.0) + + if len(fill_list) == len(draw_cmds): + fill_cols = np.array(fill_list, dtype=np.float32) + stroke_cols = np.array(stroke_list, dtype=np.float32) + sw_arr = np.array(sw_list, dtype=np.float32) + sa_arr = np.array(sa_list, dtype=np.float32) + has_sw = (sw_arr > 0.0) & (sa_arr > 0.001) + draw_cmds = [ + "surface_opaque" if float(fill_cols[i, 3]) >= 0.99 + else "surface_oit" + for i in range(len(draw_cmds)) + ] + # Vectorized color write: expand per-part colors to + # per-vertex with repeat counts, then assign in one op. + rep_counts = (seg_ends - seg_starts).astype(np.intp) + big_template["in_fill_color"] = np.repeat(fill_cols, rep_counts, axis=0) + big_template["in_stroke_color"] = np.repeat(stroke_cols, rep_counts, axis=0) + _surface_mob_cache[mob] = ( + geom_hash, color_hash, big_template, + seg_starts, seg_ends, + sw_arr, sa_arr, has_sw, draw_cmds, + ) + else: + # Part count changed — treat as full miss. + cached_entry = None + + if cached_entry is not None and cached_entry[0] == geom_hash: + # ── Full HIT: copy template and recompute stroke_half_px ── + big_copy = big_template.copy() + vm = view_matrix.astype(np.float32) + pm = proj_matrix.astype(np.float32) + R, t = vm[:3, :3], vm[:3, 3] + from manim import config as _cfg + px_half = _cfg.pixel_width * 0.5 + pm_00 = abs(float(pm[0, 0])) + pm_32 = float(pm[3, 2]) + pm_33 = float(pm[3, 3]) + + # Vectorized stroke_half_px: one matrix multiply + reduceat + # instead of per-part Python slice+mean inside a loop. + z_vals = ((R @ big_copy["in_vert"].T).T + t)[:, 2] + part_sizes = (seg_ends - seg_starts).astype(np.float32) + + # Per-part average view-space z via reduceat sum / count. + z_sums = np.add.reduceat(z_vals, seg_starts) + avg_z = z_sums / part_sizes # (n_parts,) + clip_w = pm_32 * avg_z + pm_33 # (n_parts,) + clip_w = np.where(np.abs(clip_w) < 1e-8, 1.0, clip_w) + + shp = np.where( + has_sw, + 0.004 * sw_arr * pm_00 / np.abs(clip_w) * px_half, + 0.0, + ).astype(np.float32) # (n_parts,) + + # Write per-part stroke_half_px into the copy using index ranges. + for i in range(len(draw_cmds)): + big_copy["stroke_half_px"][seg_starts[i]:seg_ends[i]] = shp[i] + + # Slice views for draw_plan / surface_parts. + for i, cmd in enumerate(draw_cmds): + draw_plan.append((cmd, len(surface_parts))) + surface_parts.append(big_copy[seg_starts[i]:seg_ends[i]]) + + # Mark submobs as seen so they aren't re-processed as VMobjects. + for submob in surface_submobs: + _seen_submobs.add(id(submob)) + continue + + # ── Full MISS: tessellation + smoothing (original path) ─────── + # Collect (stroke_width, stroke_color_alpha) per part so we can + # recompute stroke_half_px on future cache hits. + new_parts_start = len(surface_parts) + stroke_per_part_new: list[tuple[float, float]] = [] + for submob in surface_submobs: if id(submob) in _seen_submobs: continue _seen_submobs.add(id(submob)) + stroke_rgba_sub = submob.get_stroke_rgbas() + sw_sub = ( + float(submob.get_stroke_width()) + if stroke_rgba_sub.shape[0] > 0 + else 0.0 + ) + s_alpha_sub = ( + float(stroke_rgba_sub[0, 3]) + if stroke_rgba_sub.shape[0] > 0 + else 0.0 + ) data = _collect_surface_geometry( submob, view_matrix, proj_matrix, diffuse_strength = float(getattr(submob, "diffuse_strength", surf_diffuse)), @@ -485,6 +748,13 @@ def collect_frame_data( cmd = "surface_opaque" if cls == "opaque" else "surface_oit" draw_plan.append((cmd, len(surface_parts))) surface_parts.append(data) + stroke_per_part_new.append((sw_sub, s_alpha_sub)) + + # Record this mob so we can cache its smoothed parts later. + _new_surface_mobs.append( + (mob, geom_hash, color_hash, new_parts_start, len(surface_parts), + stroke_per_part_new) + ) continue # ── Regular VMobject (2-D or shade_in_3d) ──────────────────────── @@ -686,9 +956,60 @@ def collect_frame_data( ) # ── Upload surface data ────────────────────────────────────────────── + # Apply normal smoothing only to parts from cache-miss mobs; cached + # parts already carry correctly smoothed normals. surface_buf, surface_byte_offsets = None, [] if surface_parts: - _smooth_surface_normals(surface_parts) + if _new_surface_mobs: + # Smooth normals for newly-tessellated slices. + # _new_surface_mobs entries: + # (mob, geom_hash, color_hash, start, end, stroke_per_part_new) + new_slices: list[np.ndarray] = [] + for mob, geom_hash, color_hash, start, end, _ in _new_surface_mobs: + new_slices.extend(surface_parts[start:end]) + _smooth_surface_normals(new_slices) + + # Cache each newly-tessellated mob's smoothed parts. + for mob, geom_hash, color_hash, start, end, stroke_per_part_new in _new_surface_mobs: + parts_for_mob = surface_parts[start:end] + if not parts_for_mob: + continue + # Collect draw commands for this mob's global part indices. + # Must filter on cmd type to exclude VMobject ("fill_stroke_*") + # entries: draw_plan is shared between VMobject and Surface paths, + # and both index from 0 (fs_parts vs surface_parts respectively), + # so index-range-only filtering incorrectly includes VMobject entries + # whose fs_parts index happens to fall inside [start, end). + draw_cmds_for_mob = [ + cmd for cmd, idx in draw_plan + if start <= idx < end + and cmd in ("surface_opaque", "surface_oit") + ] + # Cache as a SINGLE concatenated array so a hit can copy the + # whole mob's geometry in one numpy operation. stroke_half_px + # is zeroed in the template; it is recomputed on every hit. + # Colors in big_template reflect the current frame's colors so + # that a color-only miss can patch them in O(N_parts). + big_template = np.concatenate(parts_for_mob, axis=0) + big_template["stroke_half_px"] = 0.0 + # Part boundary offsets as numpy arrays (avoid Python list ops on hit). + sizes = np.array([len(p) for p in parts_for_mob], dtype=np.intp) + starts = np.concatenate([[0], np.cumsum(sizes[:-1])]).astype(np.intp) + ends = starts + sizes + # Precompute stroke metadata as numpy arrays for vectorised hit path. + sw_arr_c = np.array([sw for sw, _ in stroke_per_part_new], + dtype=np.float32) + sa_arr_c = np.array([alpha for _, alpha in stroke_per_part_new], + dtype=np.float32) + has_sw_c = (sw_arr_c > 0.0) & (sa_arr_c > 0.001) + _surface_mob_cache[mob] = ( + geom_hash, color_hash, + big_template, + starts, ends, + sw_arr_c, sa_arr_c, has_sw_c, + draw_cmds_for_mob, + ) + surface_buf, surface_byte_offsets = _batch_upload(device, surface_parts) renderer.frame_vbos.append(surface_buf) @@ -714,7 +1035,9 @@ def collect_frame_data( # frame_vbos are NOT added for cached buffers — the cache itself is the owner. # Remove the just-uploaded buffers from frame_vbos so they aren't released at # end-of-frame (the cache needs them to survive across frames). - if cache_slot is not None: + # _all_surface_call paths are excluded: the surface GPU buffer changes every + # frame (stroke_half_px update), so the _FrameData cache cannot help there. + if cache_slot is not None and not _all_surface_call: cached_bufs = { id(result.fs_buf), id(result.cubics_buf), diff --git a/manim/scene/three_d_scene.py b/manim/scene/three_d_scene.py index 79bffb7bce..7c1b21dcf5 100644 --- a/manim/scene/three_d_scene.py +++ b/manim/scene/three_d_scene.py @@ -55,24 +55,30 @@ def __init__( ) super().__init__(camera_class=camera_class, **kwargs) - # Default ambient light — exactly one is kept at all times. - # WebGPU renderer reads self.mobjects to find LightSource instances. - self._ambient_light = AmbientLight(intensity=0.5) - self.add(self._ambient_light) + if config.renderer == RendererType.WEBGPU: + # Default ambient light — exactly one is kept at all times. + # WebGPU renderer reads self.mobjects to find LightSource instances. + self._ambient_light = AmbientLight(intensity=0.5) + self.add(self._ambient_light) def add(self, *mobjects): """Override to enforce the single-ambient-light rule. + Every scene can have only one ambient light setting. If the caller adds a new :class:`~.AmbientLight`, the existing one is removed first so only one ambient light is ever in the scene. """ - for mob in mobjects: - if isinstance(mob, AmbientLight): - # Remove any existing AmbientLight before adding the new one. - existing = [m for m in self.mobjects if isinstance(m, AmbientLight)] - for old in existing: - super().remove(old) - self._ambient_light = mob + if config.renderer == RendererType.WEBGPU: + for mob in mobjects: + if isinstance(mob, AmbientLight): + # Remove any existing AmbientLight before adding the new one. + existing = [m for m in self.mobjects if isinstance(m, AmbientLight)] + for old in existing: + super().remove(old) + self._ambient_light = mob + else: # do not allow LightSource to be added for Cairo or OpenGL renderer + mobjects = [mob for mob in mobjects if not isinstance(mob, LightSource)] + return super().add(*mobjects) def set_camera_orientation( From 2aeeec198aabf86bdad887f2e3d31775d904f78c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:02:27 +0000 Subject: [PATCH 33/33] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- manim/__init__.py | 2 +- manim/mobject/three_d/dot_cloud.py | 23 +- manim/mobject/three_d/light_source.py | 31 +- manim/mobject/three_d/three_dimensions.py | 50 +- manim/renderer/base_renderer.py | 11 +- manim/renderer/webgpu/webgpu_interactive.py | 5 +- manim/renderer/webgpu/webgpu_renderer.py | 508 +++++++++---- .../renderer/webgpu/webgpu_renderer_window.py | 154 ++-- .../webgpu/webgpu_vmobject_rendering.py | 703 +++++++++++------- manim/scene/scene.py | 19 +- manim/scene/scene_file_writer.py | 2 +- manim/scene/three_d_scene.py | 19 +- 12 files changed, 980 insertions(+), 547 deletions(-) diff --git a/manim/__init__.py b/manim/__init__.py index 8f38c59f19..66fc11a30c 100644 --- a/manim/__init__.py +++ b/manim/__init__.py @@ -74,8 +74,8 @@ from .mobject.text.numbers import * from .mobject.text.tex_mobject import * from .mobject.text.text_mobject import * -from .mobject.three_d.light_source import * from .mobject.text.typst_mobject import * +from .mobject.three_d.light_source import * from .mobject.three_d.polyhedra import * from .mobject.three_d.three_d_utils import * from .mobject.three_d.three_dimensions import * diff --git a/manim/mobject/three_d/dot_cloud.py b/manim/mobject/three_d/dot_cloud.py index 5286d93796..093b2afcbe 100644 --- a/manim/mobject/three_d/dot_cloud.py +++ b/manim/mobject/three_d/dot_cloud.py @@ -52,7 +52,11 @@ def __init__( **kwargs: Any, ) -> None: super().__init__(**kwargs) - pts = np.zeros((0, 3), dtype=np.float32) if points is None else np.asarray(points, dtype=np.float32) + pts = ( + np.zeros((0, 3), dtype=np.float32) + if points is None + else np.asarray(points, dtype=np.float32) + ) if pts.ndim == 1: pts = pts.reshape(1, 3) self._cloud_points: np.ndarray = pts.astype(np.float32) @@ -74,7 +78,7 @@ def get_cloud_points(self) -> np.ndarray: """Return the (N, 3) float32 array of dot centres.""" return self._cloud_points - def set_cloud_points(self, points: np.ndarray) -> "DotCloud3D": + def set_cloud_points(self, points: np.ndarray) -> DotCloud3D: pts = np.asarray(points, dtype=np.float32) if pts.ndim == 1: pts = pts.reshape(1, 3) @@ -87,11 +91,11 @@ def get_rgbas(self) -> np.ndarray: """Return the (N, 4) float32 RGBA array for all dots.""" return self._rgbas - def set_rgbas(self, rgbas: np.ndarray) -> "DotCloud3D": + def set_rgbas(self, rgbas: np.ndarray) -> DotCloud3D: self._rgbas = np.asarray(rgbas, dtype=np.float32) return self - def set_color(self, color: ParsableManimColor, family: bool = True) -> "DotCloud3D": # type: ignore[override] + def set_color(self, color: ParsableManimColor, family: bool = True) -> DotCloud3D: # type: ignore[override] rgba = np.asarray(color_to_rgba(color), dtype=np.float32) self._rgbas = np.tile(rgba, (max(len(self._cloud_points), 1), 1)) if family: @@ -100,7 +104,7 @@ def set_color(self, color: ParsableManimColor, family: bool = True) -> "DotCloud sub.set_color(color, family=False) return self - def set_opacity(self, opacity: float, family: bool = True) -> "DotCloud3D": # type: ignore[override] + def set_opacity(self, opacity: float, family: bool = True) -> DotCloud3D: # type: ignore[override] self._rgbas[:, 3] = float(opacity) if family: for sub in self.submobjects: @@ -131,9 +135,9 @@ def interpolate_color( """Linearly interpolate _rgbas between *mobject1* and *mobject2*.""" if not isinstance(mobject1, DotCloud3D) or not isinstance(mobject2, DotCloud3D): return - self._rgbas = ( - (1 - alpha) * mobject1._rgbas + alpha * mobject2._rgbas - ).astype(np.float32) + self._rgbas = ((1 - alpha) * mobject1._rgbas + alpha * mobject2._rgbas).astype( + np.float32 + ) def interpolate( self, @@ -141,9 +145,10 @@ def interpolate( mobject2: Mobject, alpha: float, path_func: Any = None, - ) -> "DotCloud3D": + ) -> DotCloud3D: """Interpolate position and colour; keep _cloud_points in sync with points.""" from manim.utils.bezier import interpolate as lerp + if path_func is None: path_func = lerp super().interpolate(mobject1, mobject2, alpha, path_func) diff --git a/manim/mobject/three_d/light_source.py b/manim/mobject/three_d/light_source.py index 09246f8f90..8e67f15361 100644 --- a/manim/mobject/three_d/light_source.py +++ b/manim/mobject/three_d/light_source.py @@ -39,16 +39,15 @@ import numpy as np -from manim.constants import OUT from manim.mobject.mobject import Mobject from manim.typing import Point3DLike, Vector3D from manim.utils.color import WHITE, ParsableManimColor, color_to_rgb # ── Light kind constants (must match WGSL shader) ───────────────────────────── -_KIND_AMBIENT = 0 +_KIND_AMBIENT = 0 _KIND_DIRECTIONAL = 1 -_KIND_POINT = 2 -_KIND_SPOT = 3 +_KIND_POINT = 2 +_KIND_SPOT = 3 class LightSource(Mobject): @@ -105,13 +104,13 @@ def pack(self) -> bytes: offset 52 _pad0-2 f32×3 12 B (alignment padding) """ buf = np.zeros(16, dtype=np.float32) # 16 × 4 B = 64 B - buf[0:3] = self._get_position() - buf[3] = np.float32(self._kind).view(np.float32) - buf[4:7] = self._get_direction() - buf[7] = self.intensity + buf[0:3] = self._get_position() + buf[3] = np.float32(self._kind).view(np.float32) + buf[4:7] = self._get_direction() + buf[7] = self.intensity buf[8:11] = self.light_color - buf[11] = self._get_cone_angle() - buf[12] = self._get_penumbra() + buf[11] = self._get_cone_angle() + buf[12] = self._get_penumbra() # buf[13], buf[14], buf[15] remain zero (padding) # Reinterpret index 3 as u32 so we get exact integer bit pattern. @@ -194,7 +193,9 @@ def __init__( super().__init__(color=color, intensity=intensity, **kwargs) d = np.asarray(direction, dtype=np.float32) norm = np.linalg.norm(d) - self._direction: np.ndarray = (d / norm) if norm > 1e-8 else np.array([0.0, 0.0, -1.0], dtype=np.float32) + self._direction: np.ndarray = ( + (d / norm) if norm > 1e-8 else np.array([0.0, 0.0, -1.0], dtype=np.float32) + ) def _get_direction(self) -> np.ndarray: return self._direction @@ -278,12 +279,14 @@ def __init__( **kwargs: Any, ) -> None: super().__init__(color=color, intensity=intensity, **kwargs) - self._position: np.ndarray = np.asarray(position, dtype=np.float32) + self._position: np.ndarray = np.asarray(position, dtype=np.float32) d = np.asarray(direction, dtype=np.float32) norm = np.linalg.norm(d) - self._direction: np.ndarray = (d / norm) if norm > 1e-8 else np.array([0.0, 0.0, -1.0], dtype=np.float32) + self._direction: np.ndarray = ( + (d / norm) if norm > 1e-8 else np.array([0.0, 0.0, -1.0], dtype=np.float32) + ) self._cone_angle: float = float(cone_angle) - self._penumbra: float = float(penumbra) + self._penumbra: float = float(penumbra) def _get_position(self) -> np.ndarray: return self._position diff --git a/manim/mobject/three_d/three_dimensions.py b/manim/mobject/three_d/three_dimensions.py index d2daf9aacc..6479a5e4d8 100644 --- a/manim/mobject/three_d/three_dimensions.py +++ b/manim/mobject/three_d/three_dimensions.py @@ -195,7 +195,7 @@ def func(self, u: float, v: float) -> np.ndarray: # Material setters (WebGPU renderer only) # ------------------------------------------------------------------ - def set_diffuse_strength(self, value: float) -> "Surface": + def set_diffuse_strength(self, value: float) -> Surface: """Set the Lambertian diffuse strength in [0, 1]. .. warning:: **WebGPU renderer only** — ignored by Cairo and OpenGL. @@ -203,7 +203,7 @@ def set_diffuse_strength(self, value: float) -> "Surface": self.diffuse_strength = float(value) return self - def set_specular_strength(self, value: float) -> "Surface": + def set_specular_strength(self, value: float) -> Surface: """Set the Phong specular highlight strength. .. warning:: **WebGPU renderer only** — ignored by Cairo and OpenGL. @@ -211,7 +211,7 @@ def set_specular_strength(self, value: float) -> "Surface": self.specular_strength = float(value) return self - def set_specular_exponent(self, value: float) -> "Surface": + def set_specular_exponent(self, value: float) -> Surface: """Set the Phong shininess exponent. Higher values give a tighter highlight; lower values give a broad, @@ -227,7 +227,7 @@ def set_material( diffuse_strength: float | None = None, specular_strength: float | None = None, specular_exponent: float | None = None, - ) -> "Surface": + ) -> Surface: """Set material parameters uniformly across the whole surface. Any parameter left as ``None`` is unchanged. Per-patch overrides @@ -248,9 +248,7 @@ def set_material( # Per-patch material — function-based assignment # ------------------------------------------------------------------ - def set_diffuse_by_func( - self, func: "Callable[[float, float], float]" - ) -> "Surface": + def set_diffuse_by_func(self, func: Callable[[float, float], float]) -> Surface: """Assign a per-patch diffuse strength using a ``(u, v)`` function. *func* is called with the centre ``(u, v)`` coordinates of each @@ -266,9 +264,7 @@ def set_diffuse_by_func( face.diffuse_strength = float(func(face.u_center, face.v_center)) return self - def set_specular_by_func( - self, func: "Callable[[float, float], float]" - ) -> "Surface": + def set_specular_by_func(self, func: Callable[[float, float], float]) -> Surface: """Assign a per-patch specular strength using a ``(u, v)`` function. *func* is called with the centre ``(u, v)`` coordinates of each @@ -281,8 +277,8 @@ def set_specular_by_func( return self def set_specular_exponent_by_func( - self, func: "Callable[[float, float], float]" - ) -> "Surface": + self, func: Callable[[float, float], float] + ) -> Surface: """Assign a per-patch specular exponent (shininess) using a ``(u, v)`` function. @@ -295,9 +291,7 @@ def set_specular_exponent_by_func( face.specular_exponent = float(func(face.u_center, face.v_center)) return self - def set_material_by_func( - self, func: "Callable[[float, float], dict]" - ) -> "Surface": + def set_material_by_func(self, func: Callable[[float, float], dict]) -> Surface: """Assign per-patch material parameters using a ``(u, v)`` function. *func* is called with the centre ``(u, v)`` of each patch and must @@ -309,9 +303,12 @@ def set_material_by_func( Example — shinier at the equator, matte at the poles:: def mat(u, v): - t = abs(np.sin(u)) # 0 at poles, 1 at equator - return {"specular_exponent": 8 + 120 * t, - "specular_strength": 0.2 + 0.8 * t} + t = abs(np.sin(u)) # 0 at poles, 1 at equator + return { + "specular_exponent": 8 + 120 * t, + "specular_strength": 0.2 + 0.8 * t, + } + sphere.set_material_by_func(mat) @@ -357,12 +354,12 @@ def _setup_in_uv_space(self) -> None: ], ) faces.add(face) - face.u_index = i - face.v_index = j - face.u1 = u1 - face.u2 = u2 - face.v1 = v1 - face.v2 = v2 + face.u_index = i + face.v_index = j + face.u1 = u1 + face.u2 = u2 + face.v1 = v1 + face.v2 = v2 face.u_center = float(u1 + u2) * 0.5 face.v_center = float(v1 + v2) * 0.5 self.list_of_faces.append(face) @@ -513,7 +510,10 @@ def param_surface(u, v): if config.renderer == RendererType.OPENGL: assert isinstance(mob, OpenGLMobject) mob.set_color(mob_color, recurse=False) - elif config.renderer in {RendererType.CAIRO, RendererType.WEBGPU}: + elif config.renderer in { + RendererType.CAIRO, + RendererType.WEBGPU, + }: mob.set_color(mob_color, family=False) break diff --git a/manim/renderer/base_renderer.py b/manim/renderer/base_renderer.py index 9e37470e8a..7db1072e0f 100644 --- a/manim/renderer/base_renderer.py +++ b/manim/renderer/base_renderer.py @@ -7,10 +7,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, runtime_checkable +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable import numpy as np -from typing import Protocol if TYPE_CHECKING: from PIL import Image @@ -66,7 +65,9 @@ def clear_updaters(self) -> None: ... def get_value_trackers(self) -> list[ValueTracker]: ... # ── fixed-orientation / fixed-in-frame helpers (Cairo) ────────────────── - def add_fixed_orientation_mobjects(self, *mobjects: Mobject, **kwargs: Any) -> None: ... + def add_fixed_orientation_mobjects( + self, *mobjects: Mobject, **kwargs: Any + ) -> None: ... def remove_fixed_orientation_mobjects(self, *mobjects: Mobject) -> None: ... def add_fixed_in_frame_mobjects(self, *mobjects: Mobject) -> None: ... def remove_fixed_in_frame_mobjects(self, *mobjects: Mobject) -> None: ... @@ -87,12 +88,12 @@ class RendererProtocol(Protocol): """ # ── core attributes ────────────────────────────────────────────────────── - camera: Any # ThreeDCameraProtocol for 3D renderers; Any for 2D + camera: Any # ThreeDCameraProtocol for 3D renderers; Any for 2D skip_animations: bool num_plays: int time: float file_writer: Any - window: Any # WebGPUWindow | pyglet window | None + window: Any # WebGPUWindow | pyglet window | None animation_start_time: float static_image: Any diff --git a/manim/renderer/webgpu/webgpu_interactive.py b/manim/renderer/webgpu/webgpu_interactive.py index 51df2b8972..f5a387fe26 100644 --- a/manim/renderer/webgpu/webgpu_interactive.py +++ b/manim/renderer/webgpu/webgpu_interactive.py @@ -78,7 +78,6 @@ def interactive_embed( from manim import config, logger from manim.data_structures import MethodWithArgs from manim.scene.scene import SceneInteractContinue, SceneInteractRerun - from manim.utils.exceptions import RerunSceneException window = renderer.window @@ -106,9 +105,7 @@ def on_modified(self, event: Any) -> None: scene.queue.put(SceneInteractRerun("file")) file_observer = Observer() - file_observer.schedule( - _FileHandler(), config["input_file"], recursive=True - ) + file_observer.schedule(_FileHandler(), config["input_file"], recursive=True) file_observer.start() except Exception: logger.debug("watchdog not available — file-watching disabled.") diff --git a/manim/renderer/webgpu/webgpu_renderer.py b/manim/renderer/webgpu/webgpu_renderer.py index 42d2f159ea..7141512bdc 100644 --- a/manim/renderer/webgpu/webgpu_renderer.py +++ b/manim/renderer/webgpu/webgpu_renderer.py @@ -31,11 +31,14 @@ from PIL import Image from manim import config, logger -from manim.constants import IN, OUT, PI, RIGHT, DOWN, LEFT +from manim.constants import OUT, PI, RIGHT from manim.mobject.mobject import Mobject from manim.mobject.three_d.light_source import LightSource from manim.mobject.three_d.three_dimensions import Surface -from manim.mobject.types.image_mobject import AbstractImageMobject, ImageMobjectFromCamera +from manim.mobject.types.image_mobject import ( + AbstractImageMobject, + ImageMobjectFromCamera, +) from manim.mobject.types.vectorized_mobject import VMobject from manim.scene.scene_file_writer import SceneFileWriter from manim.utils.color import color_to_rgba @@ -54,7 +57,6 @@ SURFACE_COMBINED_VERTEX_LAYOUT, TRUE_DOT_VERTEX_LAYOUT, DotCloud3D, - _FrameData, build_true_dot_vbo, collect_frame_data, draw_frame_data, @@ -65,6 +67,7 @@ import wgpu as wgpu_t from manim.scene.scene import Scene + from .webgpu_renderer_window import WebGPUWindow try: @@ -216,9 +219,7 @@ def get_mobjects_indicating_movement(self) -> list: Mirrors ``MultiCamera.get_mobjects_indicating_movement`` so that :class:`~.ZoomedScene` works with the WebGPU renderer. """ - return [ - imfc.camera.frame for imfc in self.image_mobjects_from_cameras - ] + return [imfc.camera.frame for imfc in self.image_mobjects_from_cameras] # ------------------------------------------------------------------ # Frame geometry helpers (mirrors OpenGLCamera) @@ -340,7 +341,9 @@ def set_focal_distance(self, focal_distance: float) -> WebGPUCamera: logger.warning( "WebGPUCamera.set_focal_distance: value %.4g clamped to %.4g " "(must be in (0, far=%.4g))", - focal_distance, clamped, self._PERSPECTIVE_FAR, + focal_distance, + clamped, + self._PERSPECTIVE_FAR, ) self.focal_distance = clamped # Keep the virtual camera position (frame_center z) in sync so that @@ -423,14 +426,14 @@ def ortho_projection_matrix(self) -> np.ndarray: near, far = self.near, self.far return np.array( [ - [2.0 / fw, 0.0, 0.0, 0.0], - [0.0, 2.0 / fh, 0.0, 0.0], - [0.0, 0.0, -1.0 / (far - near), far / (far - near)], - [0.0, 0.0, 0.0, 1.0], + [2.0 / fw, 0.0, 0.0, 0.0], + [0.0, 2.0 / fh, 0.0, 0.0], + [0.0, 0.0, -1.0 / (far - near), far / (far - near)], + [0.0, 0.0, 0.0, 1.0], ], dtype=np.float32, ) - + # ------------------------------------------------------------------ # Projection matrix (used by the shader uniform upload) # ------------------------------------------------------------------ @@ -463,15 +466,14 @@ def projection_matrix(self) -> np.ndarray: w, h = fw / 6.0, fh / 6.0 return np.array( [ - [2.0 * n / w, 0.0, 0.0, 0.0], - [0.0, 2.0 * n / h, 0.0, 0.0], - [0.0, 0.0, f / (n - f), n * f / (n - f)], - [0.0, 0.0, -1.0, 0.0], + [2.0 * n / w, 0.0, 0.0, 0.0], + [0.0, 2.0 * n / h, 0.0, 0.0], + [0.0, 0.0, f / (n - f), n * f / (n - f)], + [0.0, 0.0, -1.0, 0.0], ], dtype=np.float32, ) - # ------------------------------------------------------------------ # Fixed-mobject registry (used by ThreeDScene) # ------------------------------------------------------------------ @@ -516,7 +518,8 @@ def add_image_mobject_from_camera(self, image_mob_from_camera: Any) -> None: def remove_image_mobject_from_camera(self, image_mob_from_camera: Any) -> None: """Unregister an ImageMobjectFromCamera previously added via - ``add_image_mobject_from_camera``.""" + ``add_image_mobject_from_camera``. + """ if image_mob_from_camera in self.image_mobjects_from_cameras: self.image_mobjects_from_cameras.remove(image_mob_from_camera) @@ -683,8 +686,12 @@ def __init__( self._fill_stroke_bgl: wgpu_t.GPUBindGroupLayout | None = None # Main pipelines — multisample count matches self._msaa_samples. # Used in Pass 1 (the MSAA main-render pass). - self._fill_stroke_pipeline: wgpu_t.GPURenderPipeline | None = None # 2-D, no depth write - self._fill_stroke_3d_pipeline: wgpu_t.GPURenderPipeline | None = None # 3-D, depth write + self._fill_stroke_pipeline: wgpu_t.GPURenderPipeline | None = ( + None # 2-D, no depth write + ) + self._fill_stroke_3d_pipeline: wgpu_t.GPURenderPipeline | None = ( + None # 3-D, depth write + ) # Overlay pipelines — always count=1. # Used in Pass 4 (fixed-in-frame overlay, renders to _render_texture_view # at sample_count=1 after the MSAA resolve has already completed). @@ -753,9 +760,9 @@ def __init__( # the oldest in-flight slot; by the time N frames have been submitted # the GPU has had N frame-times to finish and sync_wait() returns # instantly with no pipeline stall. - _READBACK_POOL = 3 # triple-buffering + _READBACK_POOL = 3 # triple-buffering self._READBACK_POOL: int = _READBACK_POOL - self._readback_pool: list[Any] = [] # GPUBuffer × _READBACK_POOL + self._readback_pool: list[Any] = [] # GPUBuffer × _READBACK_POOL # FIFO of slot indices submitted but not yet read. self._readback_queue: collections.deque = collections.deque() # Ring write pointer: next pool slot for update_frame to fill. @@ -845,7 +852,7 @@ def init_scene(self, scene: Scene) -> None: usage=( wgpu.TextureUsage.RENDER_ATTACHMENT | wgpu.TextureUsage.COPY_SRC - | wgpu.TextureUsage.COPY_DST # receives blit from _static_texture + | wgpu.TextureUsage.COPY_DST # receives blit from _static_texture | wgpu.TextureUsage.TEXTURE_BINDING # read by compact-readback compute shader ), ) @@ -898,38 +905,49 @@ def init_scene(self, scene: Scene) -> None: # floating-point depth jitter on flat/low-slope surface regions where # depth_bias_slope_scale alone contributes nearly zero. self._surface_pipeline = self._create_surface_pipeline( - self._proj_bgl, cull_mode="none", depth_write=True, + self._proj_bgl, + cull_mode="none", + depth_write=True, msaa_samples=self._msaa_samples, ) # Combined fill+stroke pipeline (replaces separate slug + stroke pipelines). - self._fill_stroke_bgl, self._fill_stroke_pipeline = \ - self._create_fill_stroke_pipeline(depth_test=False, msaa_samples=self._msaa_samples) - _, self._fill_stroke_3d_pipeline = \ - self._create_fill_stroke_pipeline(depth_test=True, msaa_samples=self._msaa_samples) + self._fill_stroke_bgl, self._fill_stroke_pipeline = ( + self._create_fill_stroke_pipeline( + depth_test=False, msaa_samples=self._msaa_samples + ) + ) + _, self._fill_stroke_3d_pipeline = self._create_fill_stroke_pipeline( + depth_test=True, msaa_samples=self._msaa_samples + ) # Overlay pipelines — always count=1. Used in Pass 4 (fixed-in-frame) # which renders directly into _render_texture_view after the MSAA resolve. if self._msaa_samples > 1: - _, self._fill_stroke_pipeline_1x = \ - self._create_fill_stroke_pipeline(depth_test=False, msaa_samples=1) - _, self._fill_stroke_3d_pipeline_1x = \ - self._create_fill_stroke_pipeline(depth_test=True, msaa_samples=1) + _, self._fill_stroke_pipeline_1x = self._create_fill_stroke_pipeline( + depth_test=False, msaa_samples=1 + ) + _, self._fill_stroke_3d_pipeline_1x = self._create_fill_stroke_pipeline( + depth_test=True, msaa_samples=1 + ) else: # When MSAA is off the overlay pipelines are the same objects. self._fill_stroke_pipeline_1x = self._fill_stroke_pipeline self._fill_stroke_3d_pipeline_1x = self._fill_stroke_3d_pipeline # GPU compute: cubic → quadratic conversion. - self._compute_bgl, self._cubic_to_quads_pipeline = \ + self._compute_bgl, self._cubic_to_quads_pipeline = ( self._create_cubic_to_quads_pipeline() + ) self._create_oit_resources(width, height) self._create_readback_pipeline(width, height) - self._image_tex_bgl, self._image_tint_bgl, self._image_pipeline = \ + self._image_tex_bgl, self._image_tint_bgl, self._image_pipeline = ( self._create_image_pipeline(msaa_samples=self._msaa_samples) + ) self._true_dot_pipeline = self._create_true_dot_pipeline( - self._proj_bgl, msaa_samples=self._msaa_samples, + self._proj_bgl, + msaa_samples=self._msaa_samples, ) # Sub-camera pipelines (rgba8unorm target) for ZoomedScene support. @@ -942,8 +960,11 @@ def init_scene(self, scene: Scene) -> None: depth_test=True, target_format="rgba8unorm", msaa_samples=1 ) self._sub_cam_surface_pipeline = self._create_surface_pipeline( - self._proj_bgl, cull_mode="none", depth_write=True, - target_format="rgba8unorm", msaa_samples=1, + self._proj_bgl, + cull_mode="none", + depth_write=True, + target_format="rgba8unorm", + msaa_samples=1, ) # Persistent camera uniform buffers — created once, updated each frame via @@ -971,15 +992,23 @@ def init_scene(self, scene: Scene) -> None: def _make_persistent_bg(buf: wgpu_t.GPUBuffer) -> wgpu_t.GPUBindGroup: return self._device.create_bind_group( layout=self._proj_bgl, - entries=[{"binding": 0, "resource": {"buffer": buf, "offset": 0, "size": _UBO_SIZE}}], + entries=[ + { + "binding": 0, + "resource": {"buffer": buf, "offset": 0, "size": _UBO_SIZE}, + } + ], ) - self.camera_bind_group = _make_persistent_bg(self._camera_uniform_buf) - self.fixed_camera_bind_group = _make_persistent_bg(self._fixed_orient_uniform_buf) - self.fixed_frame_bind_group = _make_persistent_bg(self._fixed_frame_uniform_buf) + self.camera_bind_group = _make_persistent_bg(self._camera_uniform_buf) + self.fixed_camera_bind_group = _make_persistent_bg( + self._fixed_orient_uniform_buf + ) + self.fixed_frame_bind_group = _make_persistent_bg(self._fixed_frame_uniform_buf) if self.should_create_window(): from .webgpu_renderer_window import WebGPUWindow + wclass = self._window_class or WebGPUWindow self.window = wclass(self) @@ -1008,7 +1037,6 @@ def _create_camera_bgl(self) -> wgpu_t.GPUBindGroupLayout: ] ) - def _create_fill_stroke_pipeline( self, depth_test: bool = False, @@ -1025,7 +1053,7 @@ def _create_fill_stroke_pipeline( depth_test=True — 3-D objects: depth-write + depth-test. """ assert self._device is not None - shader_path = Path(__file__).parent / "shaders" / "vmobject_fill_stroke.wgsl" + shader_path = Path(__file__).parent / "shaders" / "vmobject_fill_stroke.wgsl" shader_module = self._device.create_shader_module( code=shader_path.read_text(encoding="utf-8") ) @@ -1040,7 +1068,10 @@ def _create_fill_stroke_pipeline( { "binding": 1, "visibility": wgpu.ShaderStage.FRAGMENT, - "buffer": {"type": "read-only-storage", "has_dynamic_offset": False}, + "buffer": { + "type": "read-only-storage", + "has_dynamic_offset": False, + }, }, ] ) @@ -1068,19 +1099,38 @@ def _create_fill_stroke_pipeline( fragment={ "module": shader_module, "entry_point": "fs_main", - "targets": [{"format": getattr(wgpu.TextureFormat, target_format), "blend": _blend}], + "targets": [ + { + "format": getattr(wgpu.TextureFormat, target_format), + "blend": _blend, + } + ], }, primitive={"topology": "triangle-list", "cull_mode": "none"}, depth_stencil={ "format": wgpu.TextureFormat.depth24plus, "depth_write_enabled": depth_test, "depth_compare": "less", - "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, - "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_front": { + "compare": "always", + "fail_op": "keep", + "depth_fail_op": "keep", + "pass_op": "keep", + }, + "stencil_back": { + "compare": "always", + "fail_op": "keep", + "depth_fail_op": "keep", + "pass_op": "keep", + }, "stencil_read_mask": 0, "stencil_write_mask": 0, }, - multisample={"count": msaa_samples, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, + multisample={ + "count": msaa_samples, + "mask": 0xFFFF_FFFF, + "alpha_to_coverage_enabled": False, + }, ) return bgl, pipeline @@ -1097,7 +1147,7 @@ def _create_cubic_to_quads_pipeline( Dispatch: ceil(n_cubics / 64) × 1 × 1 workgroups. """ assert self._device is not None - shader_path = Path(__file__).parent / "shaders" / "cubic_to_quads.wgsl" + shader_path = Path(__file__).parent / "shaders" / "cubic_to_quads.wgsl" shader_module = self._device.create_shader_module( code=shader_path.read_text(encoding="utf-8") ) @@ -1166,9 +1216,7 @@ def _create_surface_pipeline( }, } return self._device.create_render_pipeline( - layout=self._device.create_pipeline_layout( - bind_group_layouts=[proj_bgl] - ), + layout=self._device.create_pipeline_layout(bind_group_layouts=[proj_bgl]), vertex={ "module": shader_module, "entry_point": "vs_main", @@ -1177,15 +1225,30 @@ def _create_surface_pipeline( fragment={ "module": shader_module, "entry_point": "fs_main", - "targets": [{"format": getattr(wgpu.TextureFormat, target_format), "blend": _blend}], + "targets": [ + { + "format": getattr(wgpu.TextureFormat, target_format), + "blend": _blend, + } + ], }, primitive={"topology": "triangle-list", "cull_mode": cull_mode}, depth_stencil={ "format": wgpu.TextureFormat.depth24plus, "depth_write_enabled": depth_write, "depth_compare": "less", - "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, - "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_front": { + "compare": "always", + "fail_op": "keep", + "depth_fail_op": "keep", + "pass_op": "keep", + }, + "stencil_back": { + "compare": "always", + "fail_op": "keep", + "depth_fail_op": "keep", + "pass_op": "keep", + }, "stencil_read_mask": 0, "stencil_write_mask": 0, }, @@ -1241,18 +1304,34 @@ def _create_true_dot_pipeline( "format": wgpu.TextureFormat.depth24plus, "depth_write_enabled": True, "depth_compare": "less", - "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, - "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_front": { + "compare": "always", + "fail_op": "keep", + "depth_fail_op": "keep", + "pass_op": "keep", + }, + "stencil_back": { + "compare": "always", + "fail_op": "keep", + "depth_fail_op": "keep", + "pass_op": "keep", + }, "stencil_read_mask": 0, "stencil_write_mask": 0, }, - multisample={"count": msaa_samples, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, + multisample={ + "count": msaa_samples, + "mask": 0xFFFF_FFFF, + "alpha_to_coverage_enabled": False, + }, ) def _create_image_pipeline( self, msaa_samples: int = 1, - ) -> tuple[wgpu_t.GPUBindGroupLayout, wgpu_t.GPUBindGroupLayout, wgpu_t.GPURenderPipeline]: + ) -> tuple[ + wgpu_t.GPUBindGroupLayout, wgpu_t.GPUBindGroupLayout, wgpu_t.GPURenderPipeline + ]: """Create the render pipeline for ImageMobject textured quads. Layout @@ -1331,7 +1410,7 @@ def _create_image_pipeline( "array_stride": 20, # 3+2 floats × 4 B "step_mode": "vertex", "attributes": [ - {"format": "float32x3", "offset": 0, "shader_location": 0}, + {"format": "float32x3", "offset": 0, "shader_location": 0}, {"format": "float32x2", "offset": 12, "shader_location": 1}, ], } @@ -1354,12 +1433,26 @@ def _create_image_pipeline( "format": wgpu.TextureFormat.depth24plus, "depth_write_enabled": False, "depth_compare": "always", - "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, - "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_front": { + "compare": "always", + "fail_op": "keep", + "depth_fail_op": "keep", + "pass_op": "keep", + }, + "stencil_back": { + "compare": "always", + "fail_op": "keep", + "depth_fail_op": "keep", + "pass_op": "keep", + }, "stencil_read_mask": 0, "stencil_write_mask": 0, }, - multisample={"count": msaa_samples, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, + multisample={ + "count": msaa_samples, + "mask": 0xFFFF_FFFF, + "alpha_to_coverage_enabled": False, + }, ) return tex_bgl, tint_bgl, pipeline @@ -1411,7 +1504,8 @@ def _get_image_gpu_resources( data = pixel_array.tobytes() else: rows = [ - pixel_array[r].ravel().tobytes() + b"\x00" * (aligned_bpr - bytes_per_row) + pixel_array[r].ravel().tobytes() + + b"\x00" * (aligned_bpr - bytes_per_row) for r in range(h) ] data = b"".join(rows) @@ -1525,7 +1619,9 @@ def _get_image_tint_bind_group(self, mob: Any) -> wgpu_t.GPUBindGroup | None: ) bg = self._device.create_bind_group( layout=self._image_tint_bgl, - entries=[{"binding": 0, "resource": {"buffer": buf, "offset": 0, "size": 16}}], + entries=[ + {"binding": 0, "resource": {"buffer": buf, "offset": 0, "size": 16}} + ], ) self._image_tint_cache[mob] = (fp, buf, bg) return bg @@ -1536,7 +1632,9 @@ def _create_oit_resources(self, width: int, height: int) -> None: assert self._proj_bgl is not None # ── Accumulation textures ────────────────────────────────────────── - oit_usage = wgpu.TextureUsage.RENDER_ATTACHMENT | wgpu.TextureUsage.TEXTURE_BINDING + oit_usage = ( + wgpu.TextureUsage.RENDER_ATTACHMENT | wgpu.TextureUsage.TEXTURE_BINDING + ) self._oit_accum_texture = self._device.create_texture( size=(width, height, 1), format=wgpu.TextureFormat.rgba16float, @@ -1561,8 +1659,12 @@ def _create_oit_resources(self, width: int, height: int) -> None: "alpha": {"src_factor": "one", "dst_factor": "one", "operation": "add"}, } _reveal_blend = { - "color": {"src_factor": "zero", "dst_factor": "one-minus-src-alpha", "operation": "add"}, - "alpha": {"src_factor": "zero", "dst_factor": "one", "operation": "add"}, + "color": { + "src_factor": "zero", + "dst_factor": "one-minus-src-alpha", + "operation": "add", + }, + "alpha": {"src_factor": "zero", "dst_factor": "one", "operation": "add"}, } self._surface_oit_pipeline = self._device.create_render_pipeline( layout=self._device.create_pipeline_layout( @@ -1586,12 +1688,26 @@ def _create_oit_resources(self, width: int, height: int) -> None: "format": wgpu.TextureFormat.depth24plus, "depth_write_enabled": False, "depth_compare": "less", - "stencil_front": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, - "stencil_back": {"compare": "always", "fail_op": "keep", "depth_fail_op": "keep", "pass_op": "keep"}, + "stencil_front": { + "compare": "always", + "fail_op": "keep", + "depth_fail_op": "keep", + "pass_op": "keep", + }, + "stencil_back": { + "compare": "always", + "fail_op": "keep", + "depth_fail_op": "keep", + "pass_op": "keep", + }, "stencil_read_mask": 0, "stencil_write_mask": 0, }, - multisample={"count": 1, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, + multisample={ + "count": 1, + "mask": 0xFFFF_FFFF, + "alpha_to_coverage_enabled": False, + }, ) # ── OIT composition pipeline ─────────────────────────────────────── @@ -1622,8 +1738,12 @@ def _create_oit_resources(self, width: int, height: int) -> None: ] ) _compose_blend = { - "color": {"src_factor": "src-alpha", "dst_factor": "one-minus-src-alpha", "operation": "add"}, - "alpha": {"src_factor": "one", "dst_factor": "one", "operation": "add"}, + "color": { + "src_factor": "src-alpha", + "dst_factor": "one-minus-src-alpha", + "operation": "add", + }, + "alpha": {"src_factor": "one", "dst_factor": "one", "operation": "add"}, } self._oit_compose_pipeline = self._device.create_render_pipeline( layout=self._device.create_pipeline_layout( @@ -1633,10 +1753,16 @@ def _create_oit_resources(self, width: int, height: int) -> None: fragment={ "module": compose_shader, "entry_point": "fs_main", - "targets": [{"format": wgpu.TextureFormat.bgra8unorm, "blend": _compose_blend}], + "targets": [ + {"format": wgpu.TextureFormat.bgra8unorm, "blend": _compose_blend} + ], }, primitive={"topology": "triangle-list", "cull_mode": "none"}, - multisample={"count": 1, "mask": 0xFFFF_FFFF, "alpha_to_coverage_enabled": False}, + multisample={ + "count": 1, + "mask": 0xFFFF_FFFF, + "alpha_to_coverage_enabled": False, + }, ) self._oit_compose_bind_group = self._device.create_bind_group( layout=self._oit_compose_bgl, @@ -1701,15 +1827,17 @@ def _pack_camera_uniforms_bytes( # Sub-camera rendering (ZoomedScene / ImageMobjectFromCamera) # ------------------------------------------------------------------ - def _sub_camera_proj_view(self, sub_cam_frame: Any) -> tuple[np.ndarray, np.ndarray]: + def _sub_camera_proj_view( + self, sub_cam_frame: Any + ) -> tuple[np.ndarray, np.ndarray]: """Return (proj, view) matrices for a MovingCamera's frame viewport. The sub-camera is always orthographic. Its viewport is defined by the ``frame`` mobject's current center and size. """ - fw = float(sub_cam_frame.get_width()) - fh = float(sub_cam_frame.get_height()) - cen = sub_cam_frame.get_center() + fw = float(sub_cam_frame.get_width()) + fh = float(sub_cam_frame.get_height()) + cen = sub_cam_frame.get_center() cx, cy = float(cen[0]), float(cen[1]) near, far = -100.0, 100.0 @@ -1717,10 +1845,10 @@ def _sub_camera_proj_view(self, sub_cam_frame: Any) -> tuple[np.ndarray, np.ndar # (centered at origin — the view matrix handles the translation). proj = np.array( [ - [2.0 / fw, 0.0, 0.0, 0.0], - [0.0, 2.0 / fh, 0.0, 0.0], - [0.0, 0.0, -1.0 / (far - near), far / (far - near)], - [0.0, 0.0, 0.0, 1.0], + [2.0 / fw, 0.0, 0.0, 0.0], + [0.0, 2.0 / fh, 0.0, 0.0], + [0.0, 0.0, -1.0 / (far - near), far / (far - near)], + [0.0, 0.0, 0.0, 1.0], ], dtype=np.float32, ) @@ -1783,7 +1911,12 @@ def _get_sub_cam_resources(self, mob: Any) -> dict: ) cam_bg = self._device.create_bind_group( layout=self._proj_bgl, - entries=[{"binding": 0, "resource": {"buffer": uniform_buf, "offset": 0, "size": _UBO_SIZE}}], + entries=[ + { + "binding": 0, + "resource": {"buffer": uniform_buf, "offset": 0, "size": _UBO_SIZE}, + } + ], ) sampler = self._device.create_sampler( @@ -1808,14 +1941,14 @@ def _get_sub_cam_resources(self, mob: Any) -> dict: ) resources = { - "render_tex": render_tex, - "render_view": render_view, - "depth_tex": depth_tex, - "depth_view": depth_view, - "uniform_buf": uniform_buf, - "cam_bg": cam_bg, - "tex_bg": tex_bg, - "staging_buf": staging_buf, + "render_tex": render_tex, + "render_view": render_view, + "depth_tex": depth_tex, + "depth_view": depth_view, + "uniform_buf": uniform_buf, + "cam_bg": cam_bg, + "tex_bg": tex_bg, + "staging_buf": staging_buf, "staging_aligned_bpr": aligned_bpr, } self._sub_cam_resources[mob_id] = resources @@ -1853,7 +1986,7 @@ def _render_sub_camera_pass( sub_cam_frame = mob.camera.frame proj, view = self._sub_camera_proj_view(sub_cam_frame) - ubo_bytes = self._pack_camera_uniforms_bytes(proj, view) + ubo_bytes = self._pack_camera_uniforms_bytes(proj, view) res = self._get_sub_cam_resources(mob) self._device.queue.write_buffer(res["uniform_buf"], 0, ubo_bytes) @@ -1863,17 +1996,17 @@ def _render_sub_camera_pass( sub_pass = encoder.begin_render_pass( color_attachments=[ { - "view": res["render_view"], - "load_op": "clear", - "store_op": "store", + "view": res["render_view"], + "load_op": "clear", + "store_op": "store", "clear_value": tuple(float(c) for c in bg), } ], depth_stencil_attachment={ - "view": res["depth_view"], + "view": res["depth_view"], "depth_clear_value": 1.0, - "depth_load_op": "clear", - "depth_store_op": "store", + "depth_load_op": "clear", + "depth_store_op": "store", }, ) @@ -1888,8 +2021,22 @@ def _render_sub_camera_pass( sub_fill_render_bg = self._device.create_bind_group( layout=self._fill_stroke_bgl, entries=[ - {"binding": 0, "resource": {"buffer": res["uniform_buf"], "offset": 0, "size": res["uniform_buf"].size}}, - {"binding": 1, "resource": {"buffer": fd.quads_out_buf, "offset": 0, "size": fd.quads_out_buf.size}}, + { + "binding": 0, + "resource": { + "buffer": res["uniform_buf"], + "offset": 0, + "size": res["uniform_buf"].size, + }, + }, + { + "binding": 1, + "resource": { + "buffer": fd.quads_out_buf, + "offset": 0, + "size": fd.quads_out_buf.size, + }, + }, ], ) else: @@ -1908,7 +2055,9 @@ def _render_sub_camera_pass( sub_pass.end() # Keep the old name as a shim so any external callers don't break. - def _pack_camera_uniforms(self, proj: np.ndarray, view: np.ndarray) -> wgpu_t.GPUBuffer: + def _pack_camera_uniforms( + self, proj: np.ndarray, view: np.ndarray + ) -> wgpu_t.GPUBuffer: """Create a throw-away 656-byte uniform buffer (legacy path, rarely used).""" assert self._device is not None buf = self._device.create_buffer_with_data( @@ -1947,16 +2096,23 @@ def _build_camera_bind_group(self) -> wgpu_t.GPUBindGroup: fixed_view = self.camera.fixed_view_matrix self._device.queue.write_buffer( - self._camera_uniform_buf, 0, - self._pack_camera_uniforms_bytes(self.camera.projection_matrix, self.camera.view_matrix), + self._camera_uniform_buf, + 0, + self._pack_camera_uniforms_bytes( + self.camera.projection_matrix, self.camera.view_matrix + ), ) self._device.queue.write_buffer( - self._fixed_orient_uniform_buf, 0, + self._fixed_orient_uniform_buf, + 0, self._pack_camera_uniforms_bytes(self.camera.projection_matrix, fixed_view), ) self._device.queue.write_buffer( - self._fixed_frame_uniform_buf, 0, - self._pack_camera_uniforms_bytes(self.camera.ortho_projection_matrix, fixed_view), + self._fixed_frame_uniform_buf, + 0, + self._pack_camera_uniforms_bytes( + self.camera.ortho_projection_matrix, fixed_view + ), ) # Return the persistent normal bind group (unchanged object). @@ -1974,13 +2130,17 @@ def device(self) -> wgpu_t.GPUDevice: @property def fill_stroke_pipeline(self) -> wgpu_t.GPURenderPipeline: """Combined fill+stroke pipeline — 2-D (no depth write).""" - assert self._fill_stroke_pipeline is not None, "init_scene() has not been called" + assert self._fill_stroke_pipeline is not None, ( + "init_scene() has not been called" + ) return self._fill_stroke_pipeline @property def fill_stroke_3d_pipeline(self) -> wgpu_t.GPURenderPipeline: """Combined fill+stroke pipeline — 3-D (depth write + test).""" - assert self._fill_stroke_3d_pipeline is not None, "init_scene() has not been called" + assert self._fill_stroke_3d_pipeline is not None, ( + "init_scene() has not been called" + ) return self._fill_stroke_3d_pipeline @property @@ -1990,7 +2150,9 @@ def fill_stroke_pipeline_1x(self) -> wgpu_t.GPURenderPipeline: Used for the fixed-in-frame overlay pass (Pass 4) which renders directly into ``_render_texture_view`` after the MSAA resolve has completed. """ - assert self._fill_stroke_pipeline_1x is not None, "init_scene() has not been called" + assert self._fill_stroke_pipeline_1x is not None, ( + "init_scene() has not been called" + ) return self._fill_stroke_pipeline_1x @property @@ -2000,7 +2162,9 @@ def fill_stroke_3d_pipeline_1x(self) -> wgpu_t.GPURenderPipeline: Used for the fixed-in-frame overlay pass (Pass 4) which renders directly into ``_render_texture_view`` after the MSAA resolve has completed. """ - assert self._fill_stroke_3d_pipeline_1x is not None, "init_scene() has not been called" + assert self._fill_stroke_3d_pipeline_1x is not None, ( + "init_scene() has not been called" + ) return self._fill_stroke_3d_pipeline_1x @property @@ -2011,7 +2175,9 @@ def surface_pipeline(self) -> wgpu_t.GPURenderPipeline: @property def surface_oit_pipeline(self) -> wgpu_t.GPURenderPipeline: - assert self._surface_oit_pipeline is not None, "init_scene() has not been called" + assert self._surface_oit_pipeline is not None, ( + "init_scene() has not been called" + ) return self._surface_oit_pipeline # ------------------------------------------------------------------ @@ -2071,10 +2237,10 @@ def update_frame( self.frame_vbos = [] # ── Partition and z-sort mobjects ──────────────────────────────── - cam = self.camera + cam = self.camera fixed_in_frame = cam.fixed_in_frame_mobjects - fixed_orient = cam.fixed_orientation_mobjects - fixed_view = self.camera.fixed_view_matrix + fixed_orient = cam.fixed_orientation_mobjects + fixed_view = self.camera.fixed_view_matrix assert self._camera_uniform_buf is not None assert self._fixed_orient_uniform_buf is not None @@ -2093,7 +2259,9 @@ def update_frame( # key[1] = z_index # This ensures foreground mobs always draw last (on top) even when # they share z_index=0 with regular mobs (Bug 3). - all_mobs = list_update(list(scene.mobjects), list(scene.foreground_mobjects)) + all_mobs = list_update( + list(scene.mobjects), list(scene.foreground_mobjects) + ) if self.camera.use_z_index: foreground_ids = {id(m) for m in scene.foreground_mobjects} source = sorted( @@ -2129,7 +2297,9 @@ def update_frame( def _flush_runs() -> None: if _run_normal: fd = collect_frame_data( - self, list(_run_normal), self._camera_uniform_buf, + self, + list(_run_normal), + self._camera_uniform_buf, cache_slot="normal", ) if fd is not None: @@ -2137,7 +2307,9 @@ def _flush_runs() -> None: _run_normal.clear() if _run_orient: fd = collect_frame_data( - self, list(_run_orient), self._fixed_orient_uniform_buf, + self, + list(_run_orient), + self._fixed_orient_uniform_buf, view_matrix_override=fixed_view, center_view_matrix=self.camera.view_matrix, cache_slot="orient", @@ -2217,7 +2389,11 @@ def _walk(mob: Any) -> None: resolved_queue.append(("image", vbo, tex_bg, tint_bg)) else: resources = self._get_image_gpu_resources(mob) - if vbo is not None and resources is not None and tint_bg is not None: + if ( + vbo is not None + and resources is not None + and tint_bg is not None + ): resolved_queue.append(("image", vbo, resources[1], tint_bg)) elif item[0] == "truedot": mob = item[1] @@ -2234,9 +2410,11 @@ def _walk(mob: Any) -> None: # Fixed-in-frame: always last, separate overlay pass. fixed_frame_mobs = [ - m for m in _seen + m + for m in _seen if False # placeholder — rebuilt below from source flatten ] + # Re-flatten source to get all VMobjects (including those inside containers) # and filter to the fixed_in_frame set. def _flatten_vmobjects(src: list) -> list: @@ -2261,7 +2439,9 @@ def _f(m: Any) -> None: m for m in _flatten_vmobjects(source) if m in fixed_in_frame ] fixed_frame_fd = collect_frame_data( - self, fixed_frame_mobs, self._fixed_frame_uniform_buf, + self, + fixed_frame_mobs, + self._fixed_frame_uniform_buf, view_matrix_override=fixed_view, proj_matrix_override=self.camera.ortho_projection_matrix, cache_slot="frame", @@ -2269,8 +2449,9 @@ def _f(m: Any) -> None: # OIT surfaces come from all normal VMobject batches in the queue. all_normal_fds = [ - item[1] for item in resolved_queue if item[0] == "vmobs" - and item[2] is self.camera_bind_group + item[1] + for item in resolved_queue + if item[0] == "vmobs" and item[2] is self.camera_bind_group ] encoder = self._device.create_command_encoder() @@ -2304,9 +2485,9 @@ def _f(m: Any) -> None: # ready by the time the fragment shader reads it in Pass 1. cp = encoder.begin_compute_pass() cp.set_pipeline(self._cubic_to_quads_pipeline) - all_fds = [ - item[1] for item in resolved_queue if item[0] == "vmobs" - ] + ([fixed_frame_fd] if fixed_frame_fd is not None else []) + all_fds = [item[1] for item in resolved_queue if item[0] == "vmobs"] + ( + [fixed_frame_fd] if fixed_frame_fd is not None else [] + ) for fd in all_fds: if fd.n_cubics_total > 0 and fd.compute_bg is not None: cp.set_bind_group(0, fd.compute_bg, [], 0, 0) @@ -2337,9 +2518,9 @@ def _f(m: Any) -> None: encoder.copy_texture_to_buffer( {"texture": res["render_tex"], "mip_level": 0, "origin": (0, 0, 0)}, { - "buffer": res["staging_buf"], - "offset": 0, - "bytes_per_row": aligned_bpr, + "buffer": res["staging_buf"], + "offset": 0, + "bytes_per_row": aligned_bpr, "rows_per_image": h, }, (w, h, 1), @@ -2470,8 +2651,10 @@ def _f(m: Any) -> None: for idx in oit_fd.oit_indices: arr = oit_fd.surface_parts[idx] oit_pass.set_vertex_buffer( - 0, oit_fd.surface_buf, - oit_fd.surface_byte_offsets[idx], arr.nbytes, + 0, + oit_fd.surface_buf, + oit_fd.surface_byte_offsets[idx], + arr.nbytes, ) oit_pass.draw(len(arr), 1, 0, 0) oit_pass.end() @@ -2479,7 +2662,11 @@ def _f(m: Any) -> None: # ── Pass 3: OIT composition ────────────────────────────────── compose_pass = encoder.begin_render_pass( color_attachments=[ - {"view": self._render_texture_view, "load_op": "load", "store_op": "store"} + { + "view": self._render_texture_view, + "load_op": "load", + "store_op": "store", + } ], ) compose_pass.set_pipeline(self._oit_compose_pipeline) @@ -2504,7 +2691,11 @@ def _f(m: Any) -> None: if fixed_frame_fd is not None: fixed_pass = encoder.begin_render_pass( color_attachments=[ - {"view": self._render_texture_view, "load_op": "load", "store_op": "store"} + { + "view": self._render_texture_view, + "load_op": "load", + "store_op": "store", + } ], depth_stencil_attachment={ "view": self._depth_texture_view, @@ -2519,11 +2710,11 @@ def _f(m: Any) -> None: # records compatible draw calls for this count=1 pass. _saved_2d = self._fill_stroke_pipeline _saved_3d = self._fill_stroke_3d_pipeline - self._fill_stroke_pipeline = self._fill_stroke_pipeline_1x + self._fill_stroke_pipeline = self._fill_stroke_pipeline_1x self._fill_stroke_3d_pipeline = self._fill_stroke_3d_pipeline_1x draw_frame_data(self, fixed_frame_fd, self.fixed_frame_bind_group) if self._msaa_samples > 1: - self._fill_stroke_pipeline = _saved_2d + self._fill_stroke_pipeline = _saved_2d self._fill_stroke_3d_pipeline = _saved_3d fixed_pass.end() @@ -2556,7 +2747,7 @@ def _f(m: Any) -> None: and self._readback_pool and len(self._readback_queue) < self._READBACK_POOL ): - width = config.pixel_width + width = config.pixel_width height = config.pixel_height packed_size = width * height * 4 slot = self._readback_write_slot @@ -2568,8 +2759,10 @@ def _f(m: Any) -> None: cp.end() encoder.copy_buffer_to_buffer( - self._readback_storage_buf, 0, - self._readback_pool[slot], 0, + self._readback_storage_buf, + 0, + self._readback_pool[slot], + 0, packed_size, ) @@ -2601,10 +2794,13 @@ def _f(m: Any) -> None: else: # Strip row padding before reshaping. rows = [ - raw[r * aligned_bpr : r * aligned_bpr + w * 4] - for r in range(h) + raw[r * aligned_bpr : r * aligned_bpr + w * 4] for r in range(h) ] - arr = np.frombuffer(b"".join(rows), dtype=np.uint8).reshape(h, w, 4).copy() + arr = ( + np.frombuffer(b"".join(rows), dtype=np.uint8) + .reshape(h, w, 4) + .copy() + ) # Write into the Cairo sub-camera's pixel_array so that # ImageMobjectFromCamera.get_pixel_array() returns current data. try: @@ -2731,7 +2927,7 @@ def _get_mapped_frame_array(self) -> np.ndarray: if self._readback_cache is not None: return self._readback_cache - width = config.pixel_width + width = config.pixel_width height = config.pixel_height packed_size = width * height * 4 @@ -2742,12 +2938,12 @@ def _get_mapped_frame_array(self) -> np.ndarray: # and waits; since the GPU work is already done, only the driver's # buffer-mapping overhead remains (typically < 1 ms). slot = self._readback_queue.popleft() - buf = self._readback_pool[slot] + buf = self._readback_pool[slot] buf.map_sync(wgpu.MapMode.READ) else: # ── Fallback path: submit now, block ───────────────────────── slot = self._readback_write_slot - buf = self._readback_pool[slot] + buf = self._readback_pool[slot] encoder = self._device.create_command_encoder() @@ -2758,8 +2954,10 @@ def _get_mapped_frame_array(self) -> np.ndarray: cp.end() encoder.copy_buffer_to_buffer( - self._readback_storage_buf, 0, - buf, 0, + self._readback_storage_buf, + 0, + buf, + 0, packed_size, ) @@ -2833,7 +3031,7 @@ def pixel_coords_to_space_coords( When True (the default for ``rendercanvas``), the origin is at the top-left corner; y increases downward. """ - pixel_width = config.pixel_width + pixel_width = config.pixel_width pixel_height = config.pixel_height frame_height = config.frame_height frame_center = self.camera.get_center() @@ -2843,12 +3041,8 @@ def pixel_coords_to_space_coords( scale = frame_height / pixel_height y_direction = -1 if top_left else 1 - return ( - frame_center - + scale - * np.array( - [(px - pixel_width / 2), y_direction * (py - pixel_height / 2), 0.0] - ) + return frame_center + scale * np.array( + [(px - pixel_width / 2), y_direction * (py - pixel_height / 2), 0.0] ) # ------------------------------------------------------------------ @@ -2880,7 +3074,9 @@ def render(self, scene: Scene, frame_offset: float, moving_mobjects: list) -> No self.window = None break if self._has_static_frame: - self.update_frame(scene, mob_list=list(moving_mobjects), blit_static=True) + self.update_frame( + scene, mob_list=list(moving_mobjects), blit_static=True + ) else: self.update_frame(scene) self.window.present() @@ -3008,7 +3204,9 @@ def save_static_frame_data(self, scene: Scene, static_mobjects: Any) -> None: # enqueue a readback pool slot, because save_static_frame_data is not # writing a movie frame and a stale slot in the pool would cause the next # write_frame() call to read wrong pixel data. - self.update_frame(scene, mob_list=static_list, blit_static=False, _readback=False) + self.update_frame( + scene, mob_list=static_list, blit_static=False, _readback=False + ) # Copy _render_texture → _static_texture for later per-frame blits. encoder = self._device.create_command_encoder() diff --git a/manim/renderer/webgpu/webgpu_renderer_window.py b/manim/renderer/webgpu/webgpu_renderer_window.py index da953c59f9..ba6ee7abc7 100644 --- a/manim/renderer/webgpu/webgpu_renderer_window.py +++ b/manim/renderer/webgpu/webgpu_renderer_window.py @@ -63,38 +63,47 @@ # rendercanvas key strings → pyglet-compatible integer codes. # Printable single chars use ord() directly (see _key_to_int below). _SPECIAL_KEY_MAP: dict[str, int] = { - "ArrowLeft": 65361, - "ArrowRight": 65363, - "ArrowUp": 65362, - "ArrowDown": 65364, - "Escape": 65307, - "Enter": 65293, - "Backspace": 65288, - "Tab": 65289, - "Delete": 65535, - "Home": 65360, - "End": 65367, - "PageUp": 65365, - "PageDown": 65366, - "Insert": 65379, - "F1": 65470, "F2": 65471, "F3": 65472, "F4": 65473, - "F5": 65474, "F6": 65475, "F7": 65476, "F8": 65477, - "F9": 65478, "F10": 65479, "F11": 65480, "F12": 65481, - "Shift": 65505, # SHIFT_VALUE in manim/constants.py - "Control": 65507, - "Alt": 65513, - "Meta": 65511, - "CapsLock": 65509, - "NumLock": 65407, + "ArrowLeft": 65361, + "ArrowRight": 65363, + "ArrowUp": 65362, + "ArrowDown": 65364, + "Escape": 65307, + "Enter": 65293, + "Backspace": 65288, + "Tab": 65289, + "Delete": 65535, + "Home": 65360, + "End": 65367, + "PageUp": 65365, + "PageDown": 65366, + "Insert": 65379, + "F1": 65470, + "F2": 65471, + "F3": 65472, + "F4": 65473, + "F5": 65474, + "F6": 65475, + "F7": 65476, + "F8": 65477, + "F9": 65478, + "F10": 65479, + "F11": 65480, + "F12": 65481, + "Shift": 65505, # SHIFT_VALUE in manim/constants.py + "Control": 65507, + "Alt": 65513, + "Meta": 65511, + "CapsLock": 65509, + "NumLock": 65407, "ScrollLock": 65300, } # rendercanvas modifier strings → pyglet modifier bitmask bits _MODIFIER_BITS: dict[str, int] = { - "Shift": 1, + "Shift": 1, "Control": 4, - "Alt": 8, - "Meta": 16, + "Alt": 8, + "Meta": 16, } @@ -121,6 +130,7 @@ def _modifiers_to_int(modifiers: tuple | list) -> int: # Window configuration helpers # --------------------------------------------------------------------------- + def _compute_window_size() -> tuple[int, int]: """Return the initial canvas size in logical pixels. @@ -160,9 +170,9 @@ def _resolve_window_position( win_w, win_h: Current canvas logical width / height in pixels. """ - mx: int = monitor.x # type: ignore[attr-defined] - my: int = monitor.y # type: ignore[attr-defined] - mw: int = monitor.width # type: ignore[attr-defined] + mx: int = monitor.x # type: ignore[attr-defined] + my: int = monitor.y # type: ignore[attr-defined] + mw: int = monitor.width # type: ignore[attr-defined] mh: int = monitor.height # type: ignore[attr-defined] # Numeric "x,y" or "x;y" coordinate pair @@ -171,21 +181,21 @@ def _resolve_window_position( return int(m.group(1)), int(m.group(2)) pos_u = pos.strip().upper() - right = mx + mw - win_w - bottom = my + mh - win_h + right = mx + mw - win_w + bottom = my + mh - win_h h_center = mx + (mw - win_w) // 2 v_center = my + (mh - win_h) // 2 return { - "UL": (mx, my), - "UR": (right, my), - "DL": (mx, bottom), - "DR": (right, bottom), + "UL": (mx, my), + "UR": (right, my), + "DL": (mx, bottom), + "DR": (right, bottom), "ORIGIN": (h_center, v_center), - "LEFT": (mx, v_center), - "RIGHT": (right, v_center), - "UP": (h_center, my), - "DOWN": (h_center, bottom), + "LEFT": (mx, v_center), + "RIGHT": (right, v_center), + "UP": (h_center, my), + "DOWN": (h_center, bottom), }.get(pos_u, (h_center, v_center)) @@ -208,6 +218,7 @@ def _apply_window_config(canvas) -> None: # ── Monitor list ───────────────────────────────────────────────────── try: import screeninfo + monitors = screeninfo.get_monitors() except Exception: monitors = [] @@ -242,14 +253,17 @@ def _apply_glfw_placement(canvas, glfw_window, monitor, mon_idx: int) -> None: if not glfw_monitors: return glfw_mon = ( - glfw_monitors[mon_idx] - if mon_idx < len(glfw_monitors) - else glfw_monitors[0] + glfw_monitors[mon_idx] if mon_idx < len(glfw_monitors) else glfw_monitors[0] ) mode = glfw.get_video_mode(glfw_mon) glfw.set_window_monitor( - glfw_window, glfw_mon, - 0, 0, mode.size.width, mode.size.height, mode.refresh_rate, + glfw_window, + glfw_mon, + 0, + 0, + mode.size.width, + mode.size.height, + mode.refresh_rate, ) return @@ -310,6 +324,7 @@ def _apply_qt_placement(canvas, monitor) -> None: # Window class # --------------------------------------------------------------------------- + class WebGPUWindow: """Preview window wrapping a ``rendercanvas.RenderCanvas``. @@ -368,18 +383,19 @@ class WebGPUWindow: class MyWindow(WebGPUWindow): def on_mouse_drag(self, x, y, dx, dy, button): - if button == 3: # right-drag → orbit + if button == 3: # right-drag → orbit self.orbit(dx, dy) - elif button == 1: # left-drag → pan + elif button == 1: # left-drag → pan self.pan(dx, dy) else: return self._render_from_window() def on_scroll(self, x, y, dy): - self.zoom(dy * 2) # 2× sensitivity + self.zoom(dy * 2) # 2× sensitivity self._render_from_window() + renderer = WebGPURenderer(window_class=MyWindow) """ @@ -436,12 +452,12 @@ def __init__(self, renderer: WebGPURenderer) -> None: ) # Register event handlers. - self._canvas.add_event_handler(self._on_key_down, "key_down") - self._canvas.add_event_handler(self._on_key_up, "key_up") + self._canvas.add_event_handler(self._on_key_down, "key_down") + self._canvas.add_event_handler(self._on_key_up, "key_up") self._canvas.add_event_handler(self._on_pointer_move, "pointer_move") self._canvas.add_event_handler(self._on_pointer_down, "pointer_down") - self._canvas.add_event_handler(self._on_pointer_up, "pointer_up") - self._canvas.add_event_handler(self._on_wheel, "wheel") + self._canvas.add_event_handler(self._on_pointer_up, "pointer_up") + self._canvas.add_event_handler(self._on_wheel, "wheel") # Apply window configuration: size, position, monitor, fullscreen. _apply_window_config(self._canvas) @@ -579,8 +595,8 @@ def _draw_frame(self) -> None: if renderer._render_texture is None or renderer._device is None: return - device = renderer._device - surface_tex = self._context.get_current_texture() + device = renderer._device + surface_tex = self._context.get_current_texture() surface_view = surface_tex.create_view() render_tex_view = renderer._render_texture.create_view() @@ -596,16 +612,16 @@ def _draw_frame(self) -> None: rp = encoder.begin_render_pass( color_attachments=[ { - "view": surface_view, - "load_op": "clear", - "store_op": "store", + "view": surface_view, + "load_op": "clear", + "store_op": "store", "clear_value": (0.0, 0.0, 0.0, 1.0), } ] ) rp.set_pipeline(self._blit_pipeline) rp.set_bind_group(0, bind_group) - rp.draw(4) # 4 vertices → one triangle-strip quad + rp.draw(4) # 4 vertices → one triangle-strip quad rp.end() device.queue.submit([encoder.finish()]) @@ -663,10 +679,10 @@ def orbit(self, dx: float, dy: float) -> None: * Drag down (dy > 0) → scene tilts down → phi decreases. """ cam = self._renderer.camera - pw = max(config.pixel_width, 1) + pw = max(config.pixel_width, 1) ph = max(config.pixel_height, 1) - dtheta = dx * (2.0 * math.pi / pw) - dphi = -dy * (math.pi / ph) + dtheta = dx * (2.0 * math.pi / pw) + dphi = -dy * (math.pi / ph) cam.increment_theta(dtheta) cam.increment_phi(dphi) @@ -686,7 +702,7 @@ def pan(self, dx: float, dy: float) -> None: * Drag down (dy > 0) → scene moves down → _pan_y decreases. """ fw, fh = self._renderer.camera.frame_shape - pw = max(config.pixel_width, 1) + pw = max(config.pixel_width, 1) ph = max(config.pixel_height, 1) self._pan_x += dx * (fw / pw) self._pan_y -= dy * (fh / ph) @@ -713,18 +729,22 @@ def zoom(self, scroll_dy: float) -> None: new_fh = max(fh * factor, 0.01) cam.frame_shape = (new_fw, new_fh) else: - new_fd = float(np.clip( - cam.focal_distance * factor, - _ZOOM_MIN_FD, - _ZOOM_MAX_FD, - )) + new_fd = float( + np.clip( + cam.focal_distance * factor, + _ZOOM_MIN_FD, + _ZOOM_MAX_FD, + ) + ) cam.set_focal_distance(new_fd) # ------------------------------------------------------------------ # Overridable interaction hooks # ------------------------------------------------------------------ - def on_mouse_drag(self, x: float, y: float, dx: float, dy: float, button: int) -> None: + def on_mouse_drag( + self, x: float, y: float, dx: float, dy: float, button: int + ) -> None: """Called on every pointer-move event while a mouse button is held. Override to customise drag behaviour. The default implementation diff --git a/manim/renderer/webgpu/webgpu_vmobject_rendering.py b/manim/renderer/webgpu/webgpu_vmobject_rendering.py index 6176bcebc9..6186279ca3 100644 --- a/manim/renderer/webgpu/webgpu_vmobject_rendering.py +++ b/manim/renderer/webgpu/webgpu_vmobject_rendering.py @@ -40,7 +40,7 @@ import struct import weakref -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import TYPE_CHECKING, Any import numpy as np @@ -73,15 +73,15 @@ _SURFACE_COMBINED_DTYPE = np.dtype( [ - ("in_vert", np.float32, (3,)), - ("in_normal", np.float32, (3,)), - ("in_fill_color", np.float32, (4,)), - ("in_stroke_color", np.float32, (4,)), - ("in_bary", np.float32, (3,)), - ("stroke_half_px", np.float32), - ("diffuse_strength", np.float32), - ("specular_strength", np.float32), - ("specular_exponent", np.float32), + ("in_vert", np.float32, (3,)), + ("in_normal", np.float32, (3,)), + ("in_fill_color", np.float32, (4,)), + ("in_stroke_color", np.float32, (4,)), + ("in_bary", np.float32, (3,)), + ("stroke_half_px", np.float32), + ("diffuse_strength", np.float32), + ("specular_strength", np.float32), + ("specular_exponent", np.float32), ] ) _SURFACE_COMBINED_STRIDE: int = _SURFACE_COMBINED_DTYPE.itemsize # 84 bytes @@ -95,15 +95,51 @@ "array_stride": _SURFACE_COMBINED_STRIDE, "step_mode": "vertex", "attributes": [ - {"format": "float32x3", "offset": _SURFACE_COMBINED_OFFSETS["in_vert"], "shader_location": 0}, - {"format": "float32x3", "offset": _SURFACE_COMBINED_OFFSETS["in_normal"], "shader_location": 1}, - {"format": "float32x4", "offset": _SURFACE_COMBINED_OFFSETS["in_fill_color"], "shader_location": 2}, - {"format": "float32x4", "offset": _SURFACE_COMBINED_OFFSETS["in_stroke_color"], "shader_location": 3}, - {"format": "float32x3", "offset": _SURFACE_COMBINED_OFFSETS["in_bary"], "shader_location": 4}, - {"format": "float32", "offset": _SURFACE_COMBINED_OFFSETS["stroke_half_px"], "shader_location": 5}, - {"format": "float32", "offset": _SURFACE_COMBINED_OFFSETS["diffuse_strength"], "shader_location": 6}, - {"format": "float32", "offset": _SURFACE_COMBINED_OFFSETS["specular_strength"], "shader_location": 7}, - {"format": "float32", "offset": _SURFACE_COMBINED_OFFSETS["specular_exponent"], "shader_location": 8}, + { + "format": "float32x3", + "offset": _SURFACE_COMBINED_OFFSETS["in_vert"], + "shader_location": 0, + }, + { + "format": "float32x3", + "offset": _SURFACE_COMBINED_OFFSETS["in_normal"], + "shader_location": 1, + }, + { + "format": "float32x4", + "offset": _SURFACE_COMBINED_OFFSETS["in_fill_color"], + "shader_location": 2, + }, + { + "format": "float32x4", + "offset": _SURFACE_COMBINED_OFFSETS["in_stroke_color"], + "shader_location": 3, + }, + { + "format": "float32x3", + "offset": _SURFACE_COMBINED_OFFSETS["in_bary"], + "shader_location": 4, + }, + { + "format": "float32", + "offset": _SURFACE_COMBINED_OFFSETS["stroke_half_px"], + "shader_location": 5, + }, + { + "format": "float32", + "offset": _SURFACE_COMBINED_OFFSETS["diffuse_strength"], + "shader_location": 6, + }, + { + "format": "float32", + "offset": _SURFACE_COMBINED_OFFSETS["specular_strength"], + "shader_location": 7, + }, + { + "format": "float32", + "offset": _SURFACE_COMBINED_OFFSETS["specular_exponent"], + "shader_location": 8, + }, ], } @@ -125,15 +161,15 @@ _FILL_STROKE_DTYPE = np.dtype( [ - ("in_pos", np.float32, (3,)), - ("in_fill_color", np.float32, (4,)), - ("in_stroke_color", np.float32, (4,)), - ("stroke_half_ndc", np.float32), - ("fill_curve_start", np.uint32), - ("n_fill_curves", np.uint32), + ("in_pos", np.float32, (3,)), + ("in_fill_color", np.float32, (4,)), + ("in_stroke_color", np.float32, (4,)), + ("stroke_half_ndc", np.float32), + ("fill_curve_start", np.uint32), + ("n_fill_curves", np.uint32), ("stroke_curve_start", np.uint32), - ("n_stroke_curves", np.uint32), - ("fill_rule", np.uint32), + ("n_stroke_curves", np.uint32), + ("fill_rule", np.uint32), ] ) _FILL_STROKE_STRIDE: int = _FILL_STROKE_DTYPE.itemsize # 64 bytes @@ -147,15 +183,51 @@ "array_stride": _FILL_STROKE_STRIDE, "step_mode": "vertex", "attributes": [ - {"format": "float32x3", "offset": _FILL_STROKE_OFFSETS["in_pos"], "shader_location": 0}, - {"format": "float32x4", "offset": _FILL_STROKE_OFFSETS["in_fill_color"], "shader_location": 1}, - {"format": "float32x4", "offset": _FILL_STROKE_OFFSETS["in_stroke_color"], "shader_location": 2}, - {"format": "float32", "offset": _FILL_STROKE_OFFSETS["stroke_half_ndc"], "shader_location": 3}, - {"format": "uint32", "offset": _FILL_STROKE_OFFSETS["fill_curve_start"], "shader_location": 4}, - {"format": "uint32", "offset": _FILL_STROKE_OFFSETS["n_fill_curves"], "shader_location": 5}, - {"format": "uint32", "offset": _FILL_STROKE_OFFSETS["stroke_curve_start"], "shader_location": 6}, - {"format": "uint32", "offset": _FILL_STROKE_OFFSETS["n_stroke_curves"], "shader_location": 7}, - {"format": "uint32", "offset": _FILL_STROKE_OFFSETS["fill_rule"], "shader_location": 8}, + { + "format": "float32x3", + "offset": _FILL_STROKE_OFFSETS["in_pos"], + "shader_location": 0, + }, + { + "format": "float32x4", + "offset": _FILL_STROKE_OFFSETS["in_fill_color"], + "shader_location": 1, + }, + { + "format": "float32x4", + "offset": _FILL_STROKE_OFFSETS["in_stroke_color"], + "shader_location": 2, + }, + { + "format": "float32", + "offset": _FILL_STROKE_OFFSETS["stroke_half_ndc"], + "shader_location": 3, + }, + { + "format": "uint32", + "offset": _FILL_STROKE_OFFSETS["fill_curve_start"], + "shader_location": 4, + }, + { + "format": "uint32", + "offset": _FILL_STROKE_OFFSETS["n_fill_curves"], + "shader_location": 5, + }, + { + "format": "uint32", + "offset": _FILL_STROKE_OFFSETS["stroke_curve_start"], + "shader_location": 6, + }, + { + "format": "uint32", + "offset": _FILL_STROKE_OFFSETS["n_stroke_curves"], + "shader_location": 7, + }, + { + "format": "uint32", + "offset": _FILL_STROKE_OFFSETS["fill_rule"], + "shader_location": 8, + }, ], } @@ -175,10 +247,10 @@ _TRUE_DOT_DTYPE = np.dtype( [ ("center", np.float32, (3,)), - ("color", np.float32, (4,)), - ("uv", np.float32, (2,)), + ("color", np.float32, (4,)), + ("uv", np.float32, (2,)), ("radius", np.float32), - ("gloss", np.float32), + ("gloss", np.float32), ("shadow", np.float32), ] ) @@ -193,12 +265,36 @@ "array_stride": _TRUE_DOT_STRIDE, "step_mode": "vertex", "attributes": [ - {"format": "float32x3", "offset": _TRUE_DOT_OFFSETS["center"], "shader_location": 0}, - {"format": "float32x4", "offset": _TRUE_DOT_OFFSETS["color"], "shader_location": 1}, - {"format": "float32x2", "offset": _TRUE_DOT_OFFSETS["uv"], "shader_location": 2}, - {"format": "float32", "offset": _TRUE_DOT_OFFSETS["radius"], "shader_location": 3}, - {"format": "float32", "offset": _TRUE_DOT_OFFSETS["gloss"], "shader_location": 4}, - {"format": "float32", "offset": _TRUE_DOT_OFFSETS["shadow"], "shader_location": 5}, + { + "format": "float32x3", + "offset": _TRUE_DOT_OFFSETS["center"], + "shader_location": 0, + }, + { + "format": "float32x4", + "offset": _TRUE_DOT_OFFSETS["color"], + "shader_location": 1, + }, + { + "format": "float32x2", + "offset": _TRUE_DOT_OFFSETS["uv"], + "shader_location": 2, + }, + { + "format": "float32", + "offset": _TRUE_DOT_OFFSETS["radius"], + "shader_location": 3, + }, + { + "format": "float32", + "offset": _TRUE_DOT_OFFSETS["gloss"], + "shader_location": 4, + }, + { + "format": "float32", + "offset": _TRUE_DOT_OFFSETS["shadow"], + "shader_location": 5, + }, ], } @@ -209,15 +305,16 @@ _QUAD_UVS = np.array( [ [-1.0, -1.0], # BL (0) - [ 1.0, -1.0], # BR (1) - [-1.0, 1.0], # TL (2) - [ 1.0, -1.0], # BR (1) ← repeated for 2nd triangle - [ 1.0, 1.0], # TR (3) - [-1.0, 1.0], # TL (2) ← repeated + [1.0, -1.0], # BR (1) + [-1.0, 1.0], # TL (2) + [1.0, -1.0], # BR (1) ← repeated for 2nd triangle + [1.0, 1.0], # TR (3) + [-1.0, 1.0], # TL (2) ← repeated ], dtype=np.float32, ) # shape (6, 2) + def build_true_dot_vbo( mob: DotCloud3D, ) -> np.ndarray | None: @@ -228,10 +325,10 @@ def build_true_dot_vbo( Returns ``None`` if the mob has no renderable points. """ - pts = mob.get_cloud_points() + pts = mob.get_cloud_points() rgbas = mob.get_rgbas() radius = mob.dot_radius - gloss = mob.gloss + gloss = mob.gloss shadow = mob.shadow pts = np.asarray(pts, dtype=np.float32) # (N, 3) @@ -254,16 +351,16 @@ def build_true_dot_vbo( rgba = rgbas[:N] # Expand N points → N×6 vertices. - pts_rep = np.repeat(pts, 6, axis=0) # (N*6, 3) - rgba_rep = np.repeat(rgba, 6, axis=0) # (N*6, 4) - uvs = np.tile(_QUAD_UVS, (N, 1)) # (N*6, 2) + pts_rep = np.repeat(pts, 6, axis=0) # (N*6, 3) + rgba_rep = np.repeat(rgba, 6, axis=0) # (N*6, 4) + uvs = np.tile(_QUAD_UVS, (N, 1)) # (N*6, 2) arr = np.zeros(N * 6, dtype=_TRUE_DOT_DTYPE) arr["center"] = pts_rep - arr["color"] = rgba_rep - arr["uv"] = uvs + arr["color"] = rgba_rep + arr["uv"] = uvs arr["radius"] = radius - arr["gloss"] = gloss + arr["gloss"] = gloss arr["shadow"] = shadow return arr @@ -282,16 +379,16 @@ class _FrameData: """ # VMobject fill+stroke via combined pipeline - fs_parts: list[np.ndarray] # _FILL_STROKE_DTYPE arrays, one per draw call + fs_parts: list[np.ndarray] # _FILL_STROKE_DTYPE arrays, one per draw call fs_buf: wgpu_t.GPUBuffer | None # concatenated vertex buffer - fs_byte_offsets: list[int] # byte offset of each part in fs_buf + fs_byte_offsets: list[int] # byte offset of each part in fs_buf # GPU compute: cubic → quadratic conversion - cubics_buf: wgpu_t.GPUBuffer | None # input (12 floats/cubic), all objects - quads_out_buf: wgpu_t.GPUBuffer | None # output (36 floats/cubic = 4 quads × 9) + cubics_buf: wgpu_t.GPUBuffer | None # input (12 floats/cubic), all objects + quads_out_buf: wgpu_t.GPUBuffer | None # output (36 floats/cubic = 4 quads × 9) n_cubics_total: int compute_bg: wgpu_t.GPUBindGroup | None # compute pass bind group - render_bg: wgpu_t.GPUBindGroup | None # fragment bind group (camera + quads) + render_bg: wgpu_t.GPUBindGroup | None # fragment bind group (camera + quads) # Parametric surfaces (combined fill + barycentric wireframe pipeline) surface_parts: list[np.ndarray] @@ -377,9 +474,9 @@ def _points_hash(vmobject: VMobject) -> int: def _surface_hash_pair( - mob: "Surface", + mob: Surface, submobs: list | None = None, -) -> "tuple[bytes, bytes]": +) -> tuple[bytes, bytes]: """Return ``(geom_hash, color_hash)`` for *mob*. ``geom_hash`` covers patch point positions + material params (diffuse, @@ -402,11 +499,11 @@ def _surface_hash_pair( if submobs is None: submobs = mob.family_members_with_points() - fast_geom_id = 0 + fast_geom_id = 0 fast_color_id = 0 for s in submobs: - fast_geom_id ^= id(s.points) - fast_color_id ^= id(getattr(s, "fill_rgbas", None)) + fast_geom_id ^= id(s.points) + fast_color_id ^= id(getattr(s, "fill_rgbas", None)) fast_color_id ^= id(getattr(s, "stroke_rgbas", None)) memo = _surface_geom_hash_memo.get(mob) @@ -422,7 +519,12 @@ def _surface_hash_pair( # applies the per-vertex update, so we skip the second O(N_submobs) # iteration entirely. new_ch = struct.pack(" 0 else 0.0 - parts.append(struct.pack(' 0 fill_list.append(f_rgba[0].astype(np.float32)) stroke_list.append( - s_rgba[0].astype(np.float32) if has_stroke + s_rgba[0].astype(np.float32) + if has_stroke else np.zeros(4, dtype=np.float32) ) - sw_list.append(float(submob.stroke_width) if has_stroke else 0.0) + sw_list.append( + float(submob.stroke_width) if has_stroke else 0.0 + ) sa_list.append(float(s_rgba[0, 3]) if has_stroke else 0.0) if len(fill_list) == len(draw_cmds): - fill_cols = np.array(fill_list, dtype=np.float32) + fill_cols = np.array(fill_list, dtype=np.float32) stroke_cols = np.array(stroke_list, dtype=np.float32) - sw_arr = np.array(sw_list, dtype=np.float32) - sa_arr = np.array(sa_list, dtype=np.float32) - has_sw = (sw_arr > 0.0) & (sa_arr > 0.001) + sw_arr = np.array(sw_list, dtype=np.float32) + sa_arr = np.array(sa_list, dtype=np.float32) + has_sw = (sw_arr > 0.0) & (sa_arr > 0.001) draw_cmds = [ - "surface_opaque" if float(fill_cols[i, 3]) >= 0.99 + "surface_opaque" + if float(fill_cols[i, 3]) >= 0.99 else "surface_oit" for i in range(len(draw_cmds)) ] # Vectorized color write: expand per-part colors to # per-vertex with repeat counts, then assign in one op. rep_counts = (seg_ends - seg_starts).astype(np.intp) - big_template["in_fill_color"] = np.repeat(fill_cols, rep_counts, axis=0) - big_template["in_stroke_color"] = np.repeat(stroke_cols, rep_counts, axis=0) + big_template["in_fill_color"] = np.repeat( + fill_cols, rep_counts, axis=0 + ) + big_template["in_stroke_color"] = np.repeat( + stroke_cols, rep_counts, axis=0 + ) _surface_mob_cache[mob] = ( - geom_hash, color_hash, big_template, - seg_starts, seg_ends, - sw_arr, sa_arr, has_sw, draw_cmds, + geom_hash, + color_hash, + big_template, + seg_starts, + seg_ends, + sw_arr, + sa_arr, + has_sw, + draw_cmds, ) else: # Part count changed — treat as full miss. @@ -675,25 +802,26 @@ def collect_frame_data( if cached_entry is not None and cached_entry[0] == geom_hash: # ── Full HIT: copy template and recompute stroke_half_px ── - big_copy = big_template.copy() - vm = view_matrix.astype(np.float32) - pm = proj_matrix.astype(np.float32) - R, t = vm[:3, :3], vm[:3, 3] + big_copy = big_template.copy() + vm = view_matrix.astype(np.float32) + pm = proj_matrix.astype(np.float32) + R, t = vm[:3, :3], vm[:3, 3] from manim import config as _cfg - px_half = _cfg.pixel_width * 0.5 - pm_00 = abs(float(pm[0, 0])) - pm_32 = float(pm[3, 2]) - pm_33 = float(pm[3, 3]) + + px_half = _cfg.pixel_width * 0.5 + pm_00 = abs(float(pm[0, 0])) + pm_32 = float(pm[3, 2]) + pm_33 = float(pm[3, 3]) # Vectorized stroke_half_px: one matrix multiply + reduceat # instead of per-part Python slice+mean inside a loop. - z_vals = ((R @ big_copy["in_vert"].T).T + t)[:, 2] + z_vals = ((R @ big_copy["in_vert"].T).T + t)[:, 2] part_sizes = (seg_ends - seg_starts).astype(np.float32) # Per-part average view-space z via reduceat sum / count. z_sums = np.add.reduceat(z_vals, seg_starts) - avg_z = z_sums / part_sizes # (n_parts,) - clip_w = pm_32 * avg_z + pm_33 # (n_parts,) + avg_z = z_sums / part_sizes # (n_parts,) + clip_w = pm_32 * avg_z + pm_33 # (n_parts,) clip_w = np.where(np.abs(clip_w) < 1e-8, 1.0, clip_w) shp = np.where( @@ -704,12 +832,12 @@ def collect_frame_data( # Write per-part stroke_half_px into the copy using index ranges. for i in range(len(draw_cmds)): - big_copy["stroke_half_px"][seg_starts[i]:seg_ends[i]] = shp[i] + big_copy["stroke_half_px"][seg_starts[i] : seg_ends[i]] = shp[i] # Slice views for draw_plan / surface_parts. for i, cmd in enumerate(draw_cmds): draw_plan.append((cmd, len(surface_parts))) - surface_parts.append(big_copy[seg_starts[i]:seg_ends[i]]) + surface_parts.append(big_copy[seg_starts[i] : seg_ends[i]]) # Mark submobs as seen so they aren't re-processed as VMobjects. for submob in surface_submobs: @@ -719,7 +847,7 @@ def collect_frame_data( # ── Full MISS: tessellation + smoothing (original path) ─────── # Collect (stroke_width, stroke_color_alpha) per part so we can # recompute stroke_half_px on future cache hits. - new_parts_start = len(surface_parts) + new_parts_start = len(surface_parts) stroke_per_part_new: list[tuple[float, float]] = [] for submob in surface_submobs: @@ -738,10 +866,18 @@ def collect_frame_data( else 0.0 ) data = _collect_surface_geometry( - submob, view_matrix, proj_matrix, - diffuse_strength = float(getattr(submob, "diffuse_strength", surf_diffuse)), - specular_strength = float(getattr(submob, "specular_strength", surf_specular)), - specular_exponent = float(getattr(submob, "specular_exponent", surf_spec_exp)), + submob, + view_matrix, + proj_matrix, + diffuse_strength=float( + getattr(submob, "diffuse_strength", surf_diffuse) + ), + specular_strength=float( + getattr(submob, "specular_strength", surf_specular) + ), + specular_exponent=float( + getattr(submob, "specular_exponent", surf_spec_exp) + ), ) if data is not None: cls = _surface_opacity_class(data) @@ -752,8 +888,14 @@ def collect_frame_data( # Record this mob so we can cache its smoothed parts later. _new_surface_mobs.append( - (mob, geom_hash, color_hash, new_parts_start, len(surface_parts), - stroke_per_part_new) + ( + mob, + geom_hash, + color_hash, + new_parts_start, + len(surface_parts), + stroke_per_part_new, + ) ) continue @@ -765,7 +907,7 @@ def collect_frame_data( if id(submob) in _seen_submobs: continue _seen_submobs.add(id(submob)) - phash = _points_hash(submob) + phash = _points_hash(submob) cached = _fill_stroke_cache.get(submob) if cached is None or cached[0] != phash: result = _collect_cubics(submob) @@ -800,25 +942,31 @@ def collect_frame_data( if center_view_matrix is not None: R_full = center_view_matrix[:3, :3].astype(np.float32) c_w = submob.get_center().astype(np.float32) - offset = R_full @ c_w - c_w # shape (3,) - fill_cubics = fill_cubics + offset # broadcast (N,4,3)+(3,) + offset = R_full @ c_w - c_w # shape (3,) + fill_cubics = fill_cubics + offset # broadcast (N,4,3)+(3,) stroke_cubics = stroke_cubics + offset # Fetch current colors every frame (they change during animations). - fill_rgba = submob.get_fill_rgbas() + fill_rgba = submob.get_fill_rgbas() stroke_rgba = submob.get_stroke_rgbas() - fill_color = (fill_rgba[0].astype(np.float32) - if fill_rgba.shape[0] > 0 - else np.zeros(4, dtype=np.float32)) - stroke_color = (stroke_rgba[0].astype(np.float32) - if stroke_rgba.shape[0] > 0 - else np.zeros(4, dtype=np.float32)) - stroke_width = (float(submob.get_stroke_width()) - if stroke_rgba.shape[0] > 0 - else 0.0) + fill_color = ( + fill_rgba[0].astype(np.float32) + if fill_rgba.shape[0] > 0 + else np.zeros(4, dtype=np.float32) + ) + stroke_color = ( + stroke_rgba[0].astype(np.float32) + if stroke_rgba.shape[0] > 0 + else np.zeros(4, dtype=np.float32) + ) + stroke_width = ( + float(submob.get_stroke_width()) if stroke_rgba.shape[0] > 0 else 0.0 + ) # Skip entirely invisible objects (both fill and stroke transparent). - if fill_color[3] < 0.001 and (stroke_color[3] < 0.001 or stroke_width < 0.001): + if fill_color[3] < 0.001 and ( + stroke_color[3] < 0.001 or stroke_width < 0.001 + ): continue # 0 = nonzero (default), 1 = evenodd (set by SVG parser) @@ -851,7 +999,7 @@ def collect_frame_data( fill_color=fill_color, stroke_color=stroke_color, stroke_width=stroke_width, - fill_curve_start=0, # assigned below after all objects are collected + fill_curve_start=0, # assigned below after all objects are collected stroke_curve_start=0, # assigned below view_matrix=view_matrix, proj_matrix=proj_matrix, @@ -867,8 +1015,9 @@ def collect_frame_data( continue is_3d = getattr(submob, "shade_in_3d", False) - draw_plan.append(("fill_stroke_3d" if is_3d else "fill_stroke_2d", - len(fs_parts))) + draw_plan.append( + ("fill_stroke_3d" if is_3d else "fill_stroke_2d", len(fs_parts)) + ) fs_parts.append(quad_verts) all_fill_cubics.append(fill_cubics) all_stroke_cubics.append(stroke_cubics) @@ -885,19 +1034,19 @@ def collect_frame_data( # stroke_cubics_obj0, stroke_cubics_obj1, ...] # Quads output layout: [fill_quads_obj0, fill_quads_obj1, ..., # stroke_quads_obj0, stroke_quads_obj1, ...] - total_fill_cubics = sum(n_fill_cubics_per) + total_fill_cubics = sum(n_fill_cubics_per) total_stroke_cubics = sum(n_stroke_cubics_per) - n_cubics_total = total_fill_cubics + total_stroke_cubics + n_cubics_total = total_fill_cubics + total_stroke_cubics - fill_global = 0 # running fill cubic index + fill_global = 0 # running fill cubic index stroke_global = total_fill_cubics # stroke cubics follow all fill cubics for i, part in enumerate(fs_parts): - part["fill_curve_start"] = fill_global * 4 - part["n_fill_curves"] = n_fill_cubics_per[i] * 4 + part["fill_curve_start"] = fill_global * 4 + part["n_fill_curves"] = n_fill_cubics_per[i] * 4 part["stroke_curve_start"] = stroke_global * 4 - part["n_stroke_curves"] = n_stroke_cubics_per[i] * 4 - fill_global += n_fill_cubics_per[i] + part["n_stroke_curves"] = n_stroke_cubics_per[i] * 4 + fill_global += n_fill_cubics_per[i] stroke_global += n_stroke_cubics_per[i] # ── Upload vertex data ─────────────────────────────────────────────── @@ -911,11 +1060,11 @@ def collect_frame_data( if n_cubics_total > 0: # Build flat float32 array: [all fill cubics..., all stroke cubics...] - fill_arrays = [c for c in all_fill_cubics if len(c) > 0] + fill_arrays = [c for c in all_fill_cubics if len(c) > 0] stroke_arrays = [c for c in all_stroke_cubics if len(c) > 0] - all_arrays = fill_arrays + stroke_arrays - all_cubics = np.concatenate(all_arrays, axis=0) # (N, 4, 3) - cubics_flat = all_cubics.astype(np.float32).ravel() # N*12 floats + all_arrays = fill_arrays + stroke_arrays + all_cubics = np.concatenate(all_arrays, axis=0) # (N, 4, 3) + cubics_flat = all_cubics.astype(np.float32).ravel() # N*12 floats cubics_buf = device.create_buffer_with_data( data=cubics_flat.tobytes(), @@ -923,7 +1072,7 @@ def collect_frame_data( ) renderer.frame_vbos.append(cubics_buf) - quads_size = n_cubics_total * 36 * 4 # 4 quads × 9 floats × 4 bytes + quads_size = n_cubics_total * 36 * 4 # 4 quads × 9 floats × 4 bytes quads_out_buf = device.create_buffer( size=max(quads_size, 16), # WebGPU minimum binding size usage=wgpu.BufferUsage.STORAGE, @@ -932,7 +1081,7 @@ def collect_frame_data( # Params uniform (n_cubics, padded to 16 bytes for WebGPU alignment). params_bytes = struct.pack("<4I", n_cubics_total, 0, 0, 0) - params_buf = device.create_buffer_with_data( + params_buf = device.create_buffer_with_data( data=params_bytes, usage=wgpu.BufferUsage.UNIFORM, ) @@ -941,17 +1090,48 @@ def collect_frame_data( compute_bg = device.create_bind_group( layout=renderer._compute_bgl, entries=[ - {"binding": 0, "resource": {"buffer": cubics_buf, "offset": 0, "size": cubics_buf.size}}, - {"binding": 1, "resource": {"buffer": quads_out_buf, "offset": 0, "size": quads_out_buf.size}}, - {"binding": 2, "resource": {"buffer": params_buf, "offset": 0, "size": 16}}, + { + "binding": 0, + "resource": { + "buffer": cubics_buf, + "offset": 0, + "size": cubics_buf.size, + }, + }, + { + "binding": 1, + "resource": { + "buffer": quads_out_buf, + "offset": 0, + "size": quads_out_buf.size, + }, + }, + { + "binding": 2, + "resource": {"buffer": params_buf, "offset": 0, "size": 16}, + }, ], ) render_bg = device.create_bind_group( layout=renderer._fill_stroke_bgl, entries=[ - {"binding": 0, "resource": {"buffer": camera_uniform_buf, "offset": 0, "size": camera_uniform_buf.size}}, - {"binding": 1, "resource": {"buffer": quads_out_buf, "offset": 0, "size": quads_out_buf.size}}, + { + "binding": 0, + "resource": { + "buffer": camera_uniform_buf, + "offset": 0, + "size": camera_uniform_buf.size, + }, + }, + { + "binding": 1, + "resource": { + "buffer": quads_out_buf, + "offset": 0, + "size": quads_out_buf.size, + }, + }, ], ) @@ -970,7 +1150,14 @@ def collect_frame_data( _smooth_surface_normals(new_slices) # Cache each newly-tessellated mob's smoothed parts. - for mob, geom_hash, color_hash, start, end, stroke_per_part_new in _new_surface_mobs: + for ( + mob, + geom_hash, + color_hash, + start, + end, + stroke_per_part_new, + ) in _new_surface_mobs: parts_for_mob = surface_parts[start:end] if not parts_for_mob: continue @@ -981,9 +1168,9 @@ def collect_frame_data( # so index-range-only filtering incorrectly includes VMobject entries # whose fs_parts index happens to fall inside [start, end). draw_cmds_for_mob = [ - cmd for cmd, idx in draw_plan - if start <= idx < end - and cmd in ("surface_opaque", "surface_oit") + cmd + for cmd, idx in draw_plan + if start <= idx < end and cmd in ("surface_opaque", "surface_oit") ] # Cache as a SINGLE concatenated array so a hit can copy the # whole mob's geometry in one numpy operation. stroke_half_px @@ -993,20 +1180,26 @@ def collect_frame_data( big_template = np.concatenate(parts_for_mob, axis=0) big_template["stroke_half_px"] = 0.0 # Part boundary offsets as numpy arrays (avoid Python list ops on hit). - sizes = np.array([len(p) for p in parts_for_mob], dtype=np.intp) + sizes = np.array([len(p) for p in parts_for_mob], dtype=np.intp) starts = np.concatenate([[0], np.cumsum(sizes[:-1])]).astype(np.intp) - ends = starts + sizes + ends = starts + sizes # Precompute stroke metadata as numpy arrays for vectorised hit path. - sw_arr_c = np.array([sw for sw, _ in stroke_per_part_new], - dtype=np.float32) - sa_arr_c = np.array([alpha for _, alpha in stroke_per_part_new], - dtype=np.float32) - has_sw_c = (sw_arr_c > 0.0) & (sa_arr_c > 0.001) + sw_arr_c = np.array( + [sw for sw, _ in stroke_per_part_new], dtype=np.float32 + ) + sa_arr_c = np.array( + [alpha for _, alpha in stroke_per_part_new], dtype=np.float32 + ) + has_sw_c = (sw_arr_c > 0.0) & (sa_arr_c > 0.001) _surface_mob_cache[mob] = ( - geom_hash, color_hash, + geom_hash, + color_hash, big_template, - starts, ends, - sw_arr_c, sa_arr_c, has_sw_c, + starts, + ends, + sw_arr_c, + sa_arr_c, + has_sw_c, draw_cmds_for_mob, ) @@ -1094,9 +1287,9 @@ def draw_frame_data( run_first_vertex = -1 continue - arr = fd.fs_parts[idx] + arr = fd.fs_parts[idx] byte_offset = fd.fs_byte_offsets[idx] - first_vert = byte_offset // _FILL_STROKE_STRIDE + first_vert = byte_offset // _FILL_STROKE_STRIDE if run_first_vertex < 0: # Start a new run. @@ -1134,9 +1327,9 @@ def draw_frame_data( run_first_vertex = -1 continue - arr = fd.fs_parts[idx] + arr = fd.fs_parts[idx] byte_offset = fd.fs_byte_offsets[idx] - first_vert = byte_offset // _FILL_STROKE_STRIDE + first_vert = byte_offset // _FILL_STROKE_STRIDE if run_first_vertex < 0: run_first_vertex = first_vert @@ -1168,9 +1361,9 @@ def draw_frame_data( run_first_vertex = -1 continue - arr = fd.surface_parts[idx] + arr = fd.surface_parts[idx] byte_offset = fd.surface_byte_offsets[idx] - first_vert = byte_offset // _SURFACE_COMBINED_STRIDE + first_vert = byte_offset // _SURFACE_COMBINED_STRIDE if run_first_vertex < 0: run_first_vertex = first_vert @@ -1232,9 +1425,9 @@ def draw_frame_data_subcam( run_vertex_count = 0 run_first_vertex = -1 continue - arr = fd.fs_parts[idx] + arr = fd.fs_parts[idx] byte_offset = fd.fs_byte_offsets[idx] - first_vert = byte_offset // _FILL_STROKE_STRIDE + first_vert = byte_offset // _FILL_STROKE_STRIDE if run_first_vertex < 0: run_first_vertex = first_vert run_vertex_count = len(arr) @@ -1262,9 +1455,9 @@ def draw_frame_data_subcam( run_vertex_count = 0 run_first_vertex = -1 continue - arr = fd.fs_parts[idx] + arr = fd.fs_parts[idx] byte_offset = fd.fs_byte_offsets[idx] - first_vert = byte_offset // _FILL_STROKE_STRIDE + first_vert = byte_offset // _FILL_STROKE_STRIDE if run_first_vertex < 0: run_first_vertex = first_vert run_vertex_count = len(arr) @@ -1292,9 +1485,9 @@ def draw_frame_data_subcam( run_vertex_count = 0 run_first_vertex = -1 continue - arr = fd.surface_parts[idx] + arr = fd.surface_parts[idx] byte_offset = fd.surface_byte_offsets[idx] - first_vert = byte_offset // _SURFACE_COMBINED_STRIDE + first_vert = byte_offset // _SURFACE_COMBINED_STRIDE if run_first_vertex < 0: run_first_vertex = first_vert run_vertex_count = len(arr) @@ -1360,7 +1553,7 @@ def _collect_cubics( """ nppcc = vmobject.n_points_per_cubic_curve - fill_cubics_list: list[np.ndarray] = [] + fill_cubics_list: list[np.ndarray] = [] stroke_cubics_list: list[np.ndarray] = [] for subpath in vmobject.get_subpaths(): @@ -1382,7 +1575,7 @@ def _collect_cubics( # b0 = last, b1 = last + (first-last)/3, # b2 = last + 2*(first-last)/3, b3 = first. first = b0s[0] - last = b3s[-1] + last = b3s[-1] if not np.allclose(first, last, atol=1e-6): diff = first - last closing = np.array( @@ -1394,12 +1587,16 @@ def _collect_cubics( if not fill_cubics_list and not stroke_cubics_list: return None - fill_cubics = (np.concatenate(fill_cubics_list, axis=0) - if fill_cubics_list - else np.empty((0, 4, 3), dtype=np.float32)) - stroke_cubics = (np.concatenate(stroke_cubics_list, axis=0) - if stroke_cubics_list - else np.empty((0, 4, 3), dtype=np.float32)) + fill_cubics = ( + np.concatenate(fill_cubics_list, axis=0) + if fill_cubics_list + else np.empty((0, 4, 3), dtype=np.float32) + ) + stroke_cubics = ( + np.concatenate(stroke_cubics_list, axis=0) + if stroke_cubics_list + else np.empty((0, 4, 3), dtype=np.float32) + ) return fill_cubics, stroke_cubics @@ -1460,17 +1657,17 @@ def _build_fill_stroke_quad( pm = proj_matrix.astype(np.float32) R, t = vm[:3, :3], vm[:3, 3] - pts_v = (R @ anchors.T).T + t # (N, 3) view space + pts_v = (R @ anchors.T).T + t # (N, 3) view space avg_z_v = float(pts_v[:, 2].mean()) # Perspective divide → NDC. - ones = np.ones((len(pts_v), 1), dtype=np.float32) - clips = (pm @ np.hstack([pts_v, ones]).T).T # (N, 4) - w = clips[:, 3:4] - w_s = np.where(np.abs(w) > 1e-8, w, np.sign(w + 1e-38) * 1e-8) - ndcs = clips[:, :2] / w_s # (N, 2) NDC + ones = np.ones((len(pts_v), 1), dtype=np.float32) + clips = (pm @ np.hstack([pts_v, ones]).T).T # (N, 4) + w = clips[:, 3:4] + w_s = np.where(np.abs(w) > 1e-8, w, np.sign(w + 1e-38) * 1e-8) + ndcs = clips[:, :2] / w_s # (N, 2) NDC - PAD = 0.05 + PAD = 0.05 ndc_min = ndcs.min(axis=0) - PAD ndc_max = ndcs.max(axis=0) + PAD @@ -1496,14 +1693,18 @@ def _build_fill_stroke_quad( y1_v = (float(ndc_max[1]) * avg_clip_w - float(pm[1, 3])) * inv_py corners_v = np.array( - [[x0_v, y0_v, avg_z_v], [x1_v, y0_v, avg_z_v], - [x0_v, y1_v, avg_z_v], [x1_v, y1_v, avg_z_v]], + [ + [x0_v, y0_v, avg_z_v], + [x1_v, y0_v, avg_z_v], + [x0_v, y1_v, avg_z_v], + [x1_v, y1_v, avg_z_v], + ], dtype=np.float32, ) - R_inv = R.T - t_inv = -(R_inv @ t) + R_inv = R.T + t_inv = -(R_inv @ t) corners_w = (R_inv @ corners_v.T).T + t_inv # (4, 3) world space - quad_pos = corners_w[[0, 1, 2, 1, 3, 2]] # (6, 3) two CCW triangles + quad_pos = corners_w[[0, 1, 2, 1, 3, 2]] # (6, 3) two CCW triangles # ── Per-vertex fill colours (gradient support) ──────────────────────────── # If fill_rgbas has >1 colour row, interpolate along the gradient axis. @@ -1515,7 +1716,7 @@ def _build_fill_stroke_quad( and gradient_end is not None ): gs = np.asarray(gradient_start, dtype=np.float32) - ge = np.asarray(gradient_end, dtype=np.float32) + ge = np.asarray(gradient_end, dtype=np.float32) axis = ge - gs axis_len2 = float(np.dot(axis, axis)) if axis_len2 > 1e-12: @@ -1525,10 +1726,10 @@ def _build_fill_stroke_quad( ) # (4,) n_stops = fill_rgbas.shape[0] # Interpolate: t_corners maps to colour stop indices. - idx_f = t_corners * (n_stops - 1) # float indices + idx_f = t_corners * (n_stops - 1) # float indices idx_lo = np.floor(idx_f).astype(int).clip(0, n_stops - 2) idx_hi = idx_lo + 1 - frac = (idx_f - idx_lo)[:, None] # (4, 1) + frac = (idx_f - idx_lo)[:, None] # (4, 1) corner_colors = ( fill_rgbas[idx_lo].astype(np.float32) * (1.0 - frac) + fill_rgbas[idx_hi].astype(np.float32) * frac @@ -1548,7 +1749,7 @@ def _build_fill_stroke_quad( and stroke_gradient_end is not None ): sgs = np.asarray(stroke_gradient_start, dtype=np.float32) - sge = np.asarray(stroke_gradient_end, dtype=np.float32) + sge = np.asarray(stroke_gradient_end, dtype=np.float32) s_axis = sge - sgs s_axis_len2 = float(np.dot(s_axis, s_axis)) if s_axis_len2 > 1e-12: @@ -1556,10 +1757,10 @@ def _build_fill_stroke_quad( np.dot(corners_w - sgs, s_axis) / s_axis_len2, 0.0, 1.0 ) # (4,) n_stops = stroke_rgbas.shape[0] - idx_f = t_corners * (n_stops - 1) + idx_f = t_corners * (n_stops - 1) idx_lo = np.floor(idx_f).astype(int).clip(0, n_stops - 2) idx_hi = idx_lo + 1 - frac = (idx_f - idx_lo)[:, None] + frac = (idx_f - idx_lo)[:, None] corner_colors = ( stroke_rgbas[idx_lo].astype(np.float32) * (1.0 - frac) + stroke_rgbas[idx_hi].astype(np.float32) * frac @@ -1570,19 +1771,19 @@ def _build_fill_stroke_quad( else: per_vertex_stroke = np.broadcast_to(stroke_color, (6, 4)).copy() - n_fill_quads = len(fill_cubics) * 4 # 4 quadratics per cubic + n_fill_quads = len(fill_cubics) * 4 # 4 quadratics per cubic n_stroke_quads = len(stroke_cubics) * 4 verts = np.empty(6, dtype=_FILL_STROKE_DTYPE) - verts["in_pos"] = quad_pos - verts["in_fill_color"] = per_vertex_fill - verts["in_stroke_color"] = per_vertex_stroke - verts["stroke_half_ndc"] = stroke_half_ndc - verts["fill_curve_start"] = fill_curve_start - verts["n_fill_curves"] = n_fill_quads + verts["in_pos"] = quad_pos + verts["in_fill_color"] = per_vertex_fill + verts["in_stroke_color"] = per_vertex_stroke + verts["stroke_half_ndc"] = stroke_half_ndc + verts["fill_curve_start"] = fill_curve_start + verts["n_fill_curves"] = n_fill_quads verts["stroke_curve_start"] = stroke_curve_start - verts["n_stroke_curves"] = n_stroke_quads - verts["fill_rule"] = fill_rule + verts["n_stroke_curves"] = n_stroke_quads + verts["fill_rule"] = fill_rule return verts @@ -1626,25 +1827,29 @@ def _collect_surface_geometry( if fill_rgba.shape[0] == 0 or fill_rgba[0, 3] == 0: return None - fill_color = fill_rgba[0].astype(np.float32) - stroke_rgba = vmobject.get_stroke_rgbas() - stroke_color = (stroke_rgba[0].astype(np.float32) - if stroke_rgba.shape[0] > 0 - else np.zeros(4, dtype=np.float32)) - stroke_width = float(vmobject.get_stroke_width()) if stroke_rgba.shape[0] > 0 else 0.0 + fill_color = fill_rgba[0].astype(np.float32) + stroke_rgba = vmobject.get_stroke_rgbas() + stroke_color = ( + stroke_rgba[0].astype(np.float32) + if stroke_rgba.shape[0] > 0 + else np.zeros(4, dtype=np.float32) + ) + stroke_width = ( + float(vmobject.get_stroke_width()) if stroke_rgba.shape[0] > 0 else 0.0 + ) nppcc = vmobject.n_points_per_cubic_curve - all_verts: list[np.ndarray] = [] + all_verts: list[np.ndarray] = [] all_normals: list[np.ndarray] = [] - all_bary: list[np.ndarray] = [] + all_bary: list[np.ndarray] = [] for subpath in vmobject.get_subpaths(): n_curves = len(subpath) // nppcc if n_curves < 2: continue anchors = subpath[0::nppcc] - last = subpath[n_curves * nppcc - 1 : n_curves * nppcc] + last = subpath[n_curves * nppcc - 1 : n_curves * nppcc] if len(last) and not np.allclose(anchors[-1], last[0], atol=1e-6): anchors = np.vstack([anchors, last]) @@ -1656,8 +1861,8 @@ def _collect_surface_geometry( v0 = anchors[0] - centroid v1 = anchors[1] - centroid raw_normal = np.cross(v1, v0).astype(np.float64) - norm_len = np.linalg.norm(raw_normal) - normal = ( + norm_len = np.linalg.norm(raw_normal) + normal = ( (raw_normal / norm_len).astype(np.float32) if norm_len > 1e-9 else np.array([0.0, 0.0, 1.0], dtype=np.float32) @@ -1682,9 +1887,9 @@ def _collect_surface_geometry( if not all_verts: return None - verts = np.concatenate(all_verts, axis=0) + verts = np.concatenate(all_verts, axis=0) normals = np.concatenate(all_normals, axis=0) - bary = np.concatenate(all_bary, axis=0) + bary = np.concatenate(all_bary, axis=0) n_total = len(verts) # Compute stroke_half_px: half the wireframe line width in screen pixels. @@ -1695,23 +1900,23 @@ def _collect_surface_geometry( pm = proj_matrix.astype(np.float32) vm = view_matrix.astype(np.float32) R, t = vm[:3, :3], vm[:3, 3] - pts_v = (R @ verts.T).T + t # (N, 3) view space + pts_v = (R @ verts.T).T + t # (N, 3) view space avg_z_v = float(pts_v[:, 2].mean()) avg_clip_w = float(pm[3, 2] * avg_z_v + pm[3, 3]) avg_clip_w = avg_clip_w if abs(avg_clip_w) > 1e-8 else 1.0 stroke_half_ndc = float(0.004 * stroke_width * abs(pm[0, 0]) / abs(avg_clip_w)) - stroke_half_px = stroke_half_ndc * config.pixel_width * 0.5 + stroke_half_px = stroke_half_ndc * config.pixel_width * 0.5 attrs = np.empty(n_total, dtype=_SURFACE_COMBINED_DTYPE) - attrs["in_vert"] = verts - attrs["in_normal"] = normals - attrs["in_fill_color"] = fill_color - attrs["in_stroke_color"] = stroke_color - attrs["in_bary"] = bary - attrs["stroke_half_px"] = stroke_half_px - attrs["diffuse_strength"] = float(diffuse_strength) - attrs["specular_strength"] = float(specular_strength) - attrs["specular_exponent"] = float(specular_exponent) + attrs["in_vert"] = verts + attrs["in_normal"] = normals + attrs["in_fill_color"] = fill_color + attrs["in_stroke_color"] = stroke_color + attrs["in_bary"] = bary + attrs["stroke_half_px"] = stroke_half_px + attrs["diffuse_strength"] = float(diffuse_strength) + attrs["specular_strength"] = float(specular_strength) + attrs["specular_exponent"] = float(specular_exponent) return attrs @@ -1720,25 +1925,23 @@ def _smooth_surface_normals(surface_parts: list[np.ndarray]) -> None: if not surface_parts: return - all_verts = np.concatenate([p["in_vert"] for p in surface_parts], axis=0) - all_norms = np.concatenate([p["in_normal"] for p in surface_parts], axis=0) + all_verts = np.concatenate([p["in_vert"] for p in surface_parts], axis=0) + all_norms = np.concatenate([p["in_normal"] for p in surface_parts], axis=0) PREC = 1e-5 quantized = np.round(all_verts.astype(np.float64) / PREC).astype(np.int64) _, inverse = np.unique(quantized, axis=0, return_inverse=True) n_unique = int(inverse.max()) + 1 - smooth = np.zeros((n_unique, 3), dtype=np.float64) + smooth = np.zeros((n_unique, 3), dtype=np.float64) np.add.at(smooth, inverse, all_norms.astype(np.float64)) lengths = np.linalg.norm(smooth, axis=1, keepdims=True) lengths = np.where(lengths < 1e-9, 1.0, lengths) - smooth = (smooth / lengths).astype(np.float32) + smooth = (smooth / lengths).astype(np.float32) idx = 0 for part in surface_parts: n = len(part) part["in_normal"] = smooth[inverse[idx : idx + n]] idx += n - - diff --git a/manim/scene/scene.py b/manim/scene/scene.py index 60220399e5..6e79eb5a37 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -48,9 +48,9 @@ from ..constants import * from ..manager import Manager from ..renderer.cairo_renderer import CairoRenderer -from ..renderer.webgpu.webgpu_renderer import WebGPURenderer from ..renderer.opengl_renderer import OpenGLCamera, OpenGLMobject, OpenGLRenderer from ..renderer.shader import Object3D +from ..renderer.webgpu.webgpu_renderer import WebGPURenderer from ..utils import opengl, space_ops from ..utils.exceptions import RerunSceneException from ..utils.family import extract_mobject_family_members @@ -212,12 +212,12 @@ def __init__( elif config.renderer == RendererType.CAIRO: if renderer is None: renderer = CairoRenderer( - # TODO: Is it a suitable approach to make an instance of - # the self.camera_class here? - camera_class=self.camera_class, - skip_animations=self.skip_animations, - ) - + # TODO: Is it a suitable approach to make an instance of + # the self.camera_class here? + camera_class=self.camera_class, + skip_animations=self.skip_animations, + ) + self.renderer: CairoRenderer | OpenGLRenderer | WebGPURenderer = renderer self.renderer.init_scene(self) @@ -1364,7 +1364,9 @@ def play_internal(self, skip_rendering: bool = False) -> None: self.time_progression.close() def check_interactive_embed_is_valid(self) -> bool: - assert isinstance(self.renderer, OpenGLRenderer) or isinstance(self.renderer, WebGPURenderer) + assert isinstance(self.renderer, OpenGLRenderer) or isinstance( + self.renderer, WebGPURenderer + ) if config["force_window"]: return True if self.skip_animation_preview: @@ -1418,6 +1420,7 @@ def construct(self): from manim.renderer.webgpu.webgpu_interactive import ( interactive_embed as _webgpu_embed, ) + currentframe: FrameType = inspect.currentframe() # type: ignore[assignment] local_namespace = currentframe.f_back.f_locals # type: ignore[union-attr] rerun = _webgpu_embed(self, self.renderer, local_namespace) diff --git a/manim/scene/scene_file_writer.py b/manim/scene/scene_file_writer.py index 0b0b0ada0e..37232a22eb 100644 --- a/manim/scene/scene_file_writer.py +++ b/manim/scene/scene_file_writer.py @@ -53,8 +53,8 @@ from av.stream import Stream from manim.renderer.cairo_renderer import CairoRenderer - from manim.renderer.webgpu.webgpu_renderer import WebGPURenderer from manim.renderer.opengl_renderer import OpenGLRenderer + from manim.renderer.webgpu.webgpu_renderer import WebGPURenderer from manim.typing import PixelArray, StrPath diff --git a/manim/scene/three_d_scene.py b/manim/scene/three_d_scene.py index 1210e5182b..2fd3e9d651 100644 --- a/manim/scene/three_d_scene.py +++ b/manim/scene/three_d_scene.py @@ -8,13 +8,12 @@ import warnings from collections.abc import Iterable, Sequence -from manim.mobject.three_d.light_source import AmbientLight, LightSource - import numpy as np from manim.mobject.geometry.line import Line from manim.mobject.graphing.coordinate_systems import ThreeDAxes from manim.mobject.opengl.opengl_mobject import OpenGLMobject +from manim.mobject.three_d.light_source import AmbientLight, LightSource from manim.mobject.three_d.three_dimensions import Sphere from manim.mobject.value_tracker import ValueTracker @@ -76,9 +75,9 @@ def add(self, *mobjects): for old in existing: super().remove(old) self._ambient_light = mob - else: # do not allow LightSource to be added for Cairo or OpenGL renderer + else: # do not allow LightSource to be added for Cairo or OpenGL renderer mobjects = [mob for mob in mobjects if not isinstance(mob, LightSource)] - + return super().add(*mobjects) def set_camera_orientation( @@ -386,10 +385,14 @@ def update_cam(m, alpha): if focal_distance is not None: start_fd = cam.focal_distance - anims.append(UpdateFromAlphaFunc( - cam, - lambda m, a, _s=start_fd: setattr(m, "focal_distance", _s + a * (focal_distance - _s)), - )) + anims.append( + UpdateFromAlphaFunc( + cam, + lambda m, a, _s=start_fd: setattr( + m, "focal_distance", _s + a * (focal_distance - _s) + ), + ) + ) self.play(*anims + added_anims, **kwargs)