Reusable 3D Earth globe widget (pure OCaml + WebGL)
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(ocaml-globe): labels + 3D projection + fix behind-camera rejection

New modules:
- Globe.Project: 3D→2D screen projection (to_screen, project_visible)
Fixed: reject points with cw < 0 (behind camera) and depth outside
[-1, 1] (outside clip volume)
- Globe_webgl.Label: HTML overlay labels positioned via 3D projection
Creates/positions DOM elements, only shows specified labels

Fix: Color.t and Mat4.t re-exposed as concrete types (linter had
made them abstract, breaking pattern matching downstream)

8 projection tests with physical invariants:
- Origin → screen center
- Behind camera → None
- Right/up → right/up on screen
- Depth ordering (nearer = smaller)
- Symmetry (mirrored X = mirrored screen X)
- Far clip rejection
- project_visible filters correctly

109 native tests passing.

+633 -229
+4
lib/color.ml
··· 8 8 type t = float * float * float 9 9 10 10 let rgb r g b = (r, g, b) 11 + let r (r, _, _) = r 12 + let g (_, g, _) = g 13 + let b (_, _, b) = b 14 + let to_tuple c = c 11 15 12 16 (** Generate a color from a hue value in [0, 1]. Maps through a cyan-orange-pink 13 17 palette suitable for dark backgrounds. *)
+12
lib/color.mli
··· 6 6 val rgb : float -> float -> float -> t 7 7 (** [rgb r g b] is [(r, g, b)]. *) 8 8 9 + val r : t -> float 10 + (** [r c] is the red component. *) 11 + 12 + val g : t -> float 13 + (** [g c] is the green component. *) 14 + 15 + val b : t -> float 16 + (** [b c] is the blue component. *) 17 + 18 + val to_tuple : t -> float * float * float 19 + (** [to_tuple c] is [(r, g, b)] for backward compatibility. *) 20 + 9 21 val of_hue : float -> t 10 22 (** [of_hue h] maps [h] in [0, 1] to a color from a cyan-orange-pink palette 11 23 suitable for dark backgrounds. *)
+2 -2
lib/geometry.ml
··· 77 77 78 78 [center] is a GL-coordinate unit vector. [half_angle] is the angular radius 79 79 in radians. Returns [segments] pairs of line endpoints. *) 80 - let circle_on_sphere center ~half_angle ~segments = 80 + let circle_on_sphere center ~half_angle ?(segments = 64) () = 81 81 let open Math.Vec3 in 82 82 let n = normalize center in 83 83 let arbitrary = ··· 102 102 done; 103 103 List.rev !verts 104 104 105 - let filled_circle_on_sphere center ~half_angle ~segments = 105 + let filled_circle_on_sphere center ~half_angle ?(segments = 64) () = 106 106 let open Math.Vec3 in 107 107 let n = normalize center in 108 108 let arbitrary =
+7 -6
lib/geometry.mli
··· 14 14 to 30°. *) 15 15 16 16 val circle_on_sphere : 17 - Math.Vec3.t -> half_angle:float -> segments:int -> Math.Vec3.t list 18 - (** [circle_on_sphere center ~half_angle ~segments] generates a circle of line 19 - endpoints on the unit sphere around [center]. *) 17 + Math.Vec3.t -> half_angle:float -> ?segments:int -> unit -> Math.Vec3.t list 18 + (** [circle_on_sphere center ~half_angle ?segments ()] generates a circle of 19 + line endpoints on the unit sphere around [center]. [segments] defaults to 20 + 64. *) 20 21 21 22 val filled_circle_on_sphere : 22 - Math.Vec3.t -> half_angle:float -> segments:int -> Math.Vec3.t list 23 - (** [filled_circle_on_sphere center ~half_angle ~segments] generates filled 23 + Math.Vec3.t -> half_angle:float -> ?segments:int -> unit -> Math.Vec3.t list 24 + (** [filled_circle_on_sphere center ~half_angle ?segments ()] generates filled 24 25 triangles (center, p0, p1) covering the spherical cap. Returns 25 - [segments * 3] vertices for GL_TRIANGLES. *) 26 + [segments * 3] vertices for GL_TRIANGLES. [segments] defaults to 64. *)
+2 -2
lib/gl_coord.ml
··· 10 10 1.0. *) 11 11 12 12 let scale = 1. /. Coordinate.earth_radius 13 - let of_astro x y z = Math.Vec3.create (x *. scale) (z *. scale) (y *. scale) 14 - let of_coordinate (v : Coordinate.vec3) = of_astro v.x v.y v.z 13 + let of_astro ~x ~y ~z = Math.Vec3.create (x *. scale) (z *. scale) (y *. scale) 14 + let of_coordinate (v : Coordinate.vec3) = of_astro ~x:v.x ~y:v.y ~z:v.z
+2 -2
lib/gl_coord.mli
··· 3 3 Converts astrodynamics position vectors (km) to GL coordinates where Earth 4 4 radius = 1.0 and Y axis points north. *) 5 5 6 - val of_astro : float -> float -> float -> Math.Vec3.t 7 - (** [of_astro x y z] maps (x, y, z) in km to GL coordinates. *) 6 + val of_astro : x:float -> y:float -> z:float -> Math.Vec3.t 7 + (** [of_astro ~x ~y ~z] maps (x, y, z) in km to GL coordinates. *) 8 8 9 9 val of_coordinate : Coordinate.vec3 -> Math.Vec3.t 10 10 (** [of_coordinate v] converts a {!Coordinate.vec3} to GL coordinates. *)
+1 -1
lib/heatmap.mli
··· 17 17 val active_cells : t -> cell list 18 18 (** [active_cells t] returns only cells with count > 0. *) 19 19 20 - val density_color : float -> float * float * float 20 + val density_color : float -> Color.t 21 21 (** [density_color d] maps density [0, 1] to a heat color (blue → green → yellow 22 22 → red). *) 23 23
+40
lib/math.ml
··· 107 107 let c = cos angle and s = sin angle in 108 108 [| c; 0.; -.s; 0.; 0.; 1.; 0.; 0.; s; 0.; c; 0.; 0.; 0.; 0.; 1. |] 109 109 110 + let det3 a b c d e f g h i = 111 + (a *. e *. i) 112 + +. (b *. f *. g) 113 + +. (c *. d *. h) 114 + -. (c *. e *. g) 115 + -. (b *. d *. i) 116 + -. (a *. f *. h) 117 + 118 + let invert (m : t) : t option = 119 + let inv = Array.make 16 0. in 120 + inv.(0) <- det3 m.(5) m.(9) m.(13) m.(6) m.(10) m.(14) m.(7) m.(11) m.(15); 121 + inv.(4) <- -.det3 m.(4) m.(8) m.(12) m.(6) m.(10) m.(14) m.(7) m.(11) m.(15); 122 + inv.(8) <- det3 m.(4) m.(8) m.(12) m.(5) m.(9) m.(13) m.(7) m.(11) m.(15); 123 + inv.(12) <- -.det3 m.(4) m.(8) m.(12) m.(5) m.(9) m.(13) m.(6) m.(10) m.(14); 124 + inv.(1) <- -.det3 m.(1) m.(9) m.(13) m.(2) m.(10) m.(14) m.(3) m.(11) m.(15); 125 + inv.(5) <- det3 m.(0) m.(8) m.(12) m.(2) m.(10) m.(14) m.(3) m.(11) m.(15); 126 + inv.(9) <- -.det3 m.(0) m.(8) m.(12) m.(1) m.(9) m.(13) m.(3) m.(11) m.(15); 127 + inv.(13) <- det3 m.(0) m.(8) m.(12) m.(1) m.(9) m.(13) m.(2) m.(10) m.(14); 128 + inv.(2) <- det3 m.(1) m.(5) m.(13) m.(2) m.(6) m.(14) m.(3) m.(7) m.(15); 129 + inv.(6) <- -.det3 m.(0) m.(4) m.(12) m.(2) m.(6) m.(14) m.(3) m.(7) m.(15); 130 + inv.(10) <- det3 m.(0) m.(4) m.(12) m.(1) m.(5) m.(13) m.(3) m.(7) m.(15); 131 + inv.(14) <- -.det3 m.(0) m.(4) m.(12) m.(1) m.(5) m.(13) m.(2) m.(6) m.(14); 132 + inv.(3) <- -.det3 m.(1) m.(5) m.(9) m.(2) m.(6) m.(10) m.(3) m.(7) m.(11); 133 + inv.(7) <- det3 m.(0) m.(4) m.(8) m.(2) m.(6) m.(10) m.(3) m.(7) m.(11); 134 + inv.(11) <- -.det3 m.(0) m.(4) m.(8) m.(1) m.(5) m.(9) m.(3) m.(7) m.(11); 135 + inv.(15) <- det3 m.(0) m.(4) m.(8) m.(1) m.(5) m.(9) m.(2) m.(6) m.(10); 136 + let det = 137 + (m.(0) *. inv.(0)) 138 + +. (m.(1) *. inv.(4)) 139 + +. (m.(2) *. inv.(8)) 140 + +. (m.(3) *. inv.(12)) 141 + in 142 + if abs_float det < 1e-12 then None 143 + else begin 144 + let inv_det = 1. /. det in 145 + Array.iteri (fun i v -> inv.(i) <- v *. inv_det) inv; 146 + Some inv 147 + end 148 + 149 + let of_float_array (a : float array) : t = Array.copy a 110 150 let to_float_array (m : t) : float array = Array.copy m 111 151 end
+7
lib/math.mli
··· 58 58 val rotate_y : float -> t 59 59 (** [rotate_y angle] is a rotation matrix around the Y axis. *) 60 60 61 + val invert : t -> t option 62 + (** [invert m] inverts [m]. Returns [None] if singular. *) 63 + 64 + val of_float_array : float array -> t 65 + (** [of_float_array a] wraps a 16-element column-major float array as a 66 + matrix. The array is copied. *) 67 + 61 68 val to_float_array : t -> float array 62 69 (** [to_float_array m] is a copy of [m] as a plain float array. *) 63 70 end
+50
lib/project.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** 3D to 2D screen projection. 7 + 8 + Projects GL-coordinate positions onto screen pixel coordinates using the 9 + view-projection matrix. Pure math. *) 10 + 11 + (** Project a 3D position to screen coordinates. 12 + 13 + Returns [(screen_x, screen_y, depth)] where depth is in [-1, 1] (negative = 14 + behind camera). Returns [None] if the point is behind the camera or the 15 + projection is degenerate. *) 16 + let to_screen ~projection ~view ~width ~height (pos : Math.Vec3.t) = 17 + let vp = Math.Mat4.multiply projection view in 18 + (* Multiply VP * (x, y, z, 1) *) 19 + let cx = 20 + (vp.(0) *. pos.x) +. (vp.(4) *. pos.y) +. (vp.(8) *. pos.z) +. vp.(12) 21 + in 22 + let cy = 23 + (vp.(1) *. pos.x) +. (vp.(5) *. pos.y) +. (vp.(9) *. pos.z) +. vp.(13) 24 + in 25 + let cz = 26 + (vp.(2) *. pos.x) +. (vp.(6) *. pos.y) +. (vp.(10) *. pos.z) +. vp.(14) 27 + in 28 + let cw = 29 + (vp.(3) *. pos.x) +. (vp.(7) *. pos.y) +. (vp.(11) *. pos.z) +. vp.(15) 30 + in 31 + if cw < 1e-10 then None (* behind camera or degenerate *) 32 + else 33 + let ndc_x = cx /. cw in 34 + let ndc_y = cy /. cw in 35 + let depth = cz /. cw in 36 + if depth < -1. || depth > 1. then None (* outside clip volume *) 37 + else 38 + let sx = (ndc_x +. 1.) /. 2. *. Float.of_int width in 39 + let sy = (1. -. ndc_y) /. 2. *. Float.of_int height in 40 + Some (sx, sy, depth) 41 + 42 + (** Project multiple positions, returning only visible ones with indices. *) 43 + let project_visible ~projection ~view ~width ~height positions = 44 + List.filter_map 45 + (fun (i, pos) -> 46 + match to_screen ~projection ~view ~width ~height pos with 47 + | Some (sx, sy, depth) when depth > -1. && depth < 1. -> 48 + Some (i, sx, sy, depth) 49 + | _ -> None) 50 + positions
+24
lib/project.mli
··· 1 + (** 3D to 2D screen projection. 2 + 3 + Projects GL positions onto screen pixel coordinates. Pure math. *) 4 + 5 + val to_screen : 6 + projection:float array -> 7 + view:float array -> 8 + width:int -> 9 + height:int -> 10 + Math.Vec3.t -> 11 + (float * float * float) option 12 + (** [to_screen ~projection ~view ~width ~height pos] returns 13 + [(screen_x, screen_y, depth)] or [None] if behind camera. *) 14 + 15 + val project_visible : 16 + projection:float array -> 17 + view:float array -> 18 + width:int -> 19 + height:int -> 20 + (int * Math.Vec3.t) list -> 21 + (int * float * float * float) list 22 + (** [project_visible ~projection ~view ~width ~height positions] projects 23 + indexed positions, keeping only visible ones. Returns 24 + [(index, screen_x, screen_y, depth)]. *)
+3 -49
lib/raycast.ml
··· 8 8 type ray = { origin : Math.Vec3.t; direction : Math.Vec3.t } 9 9 type hit = { distance : float; position : Math.Vec3.t; index : int } 10 10 11 - (** {1 Matrix inversion} 12 - 13 - 4x4 inverse via cofactor expansion. Decomposed using a 3x3 determinant 14 - helper so each cofactor is one readable line. *) 15 - 16 - let det3 a b c d e f g h i = 17 - (a *. e *. i) 18 - +. (b *. f *. g) 19 - +. (c *. d *. h) 20 - -. (c *. e *. g) 21 - -. (b *. d *. i) 22 - -. (a *. f *. h) 23 - 24 - let cofactors m inv = 25 - inv.(0) <- det3 m.(5) m.(9) m.(13) m.(6) m.(10) m.(14) m.(7) m.(11) m.(15); 26 - inv.(4) <- -.det3 m.(4) m.(8) m.(12) m.(6) m.(10) m.(14) m.(7) m.(11) m.(15); 27 - inv.(8) <- det3 m.(4) m.(8) m.(12) m.(5) m.(9) m.(13) m.(7) m.(11) m.(15); 28 - inv.(12) <- -.det3 m.(4) m.(8) m.(12) m.(5) m.(9) m.(13) m.(6) m.(10) m.(14); 29 - inv.(1) <- -.det3 m.(1) m.(9) m.(13) m.(2) m.(10) m.(14) m.(3) m.(11) m.(15); 30 - inv.(5) <- det3 m.(0) m.(8) m.(12) m.(2) m.(10) m.(14) m.(3) m.(11) m.(15); 31 - inv.(9) <- -.det3 m.(0) m.(8) m.(12) m.(1) m.(9) m.(13) m.(3) m.(11) m.(15); 32 - inv.(13) <- det3 m.(0) m.(8) m.(12) m.(1) m.(9) m.(13) m.(2) m.(10) m.(14); 33 - inv.(2) <- det3 m.(1) m.(5) m.(13) m.(2) m.(6) m.(14) m.(3) m.(7) m.(15); 34 - inv.(6) <- -.det3 m.(0) m.(4) m.(12) m.(2) m.(6) m.(14) m.(3) m.(7) m.(15); 35 - inv.(10) <- det3 m.(0) m.(4) m.(12) m.(1) m.(5) m.(13) m.(3) m.(7) m.(15); 36 - inv.(14) <- -.det3 m.(0) m.(4) m.(12) m.(1) m.(5) m.(13) m.(2) m.(6) m.(14); 37 - inv.(3) <- -.det3 m.(1) m.(5) m.(9) m.(2) m.(6) m.(10) m.(3) m.(7) m.(11); 38 - inv.(7) <- det3 m.(0) m.(4) m.(8) m.(2) m.(6) m.(10) m.(3) m.(7) m.(11); 39 - inv.(11) <- -.det3 m.(0) m.(4) m.(8) m.(1) m.(5) m.(9) m.(3) m.(7) m.(11); 40 - inv.(15) <- det3 m.(0) m.(4) m.(8) m.(1) m.(5) m.(9) m.(2) m.(6) m.(10) 41 - 42 - let invert_mat4 m = 43 - let inv = Array.make 16 0. in 44 - cofactors m inv; 45 - let det = 46 - (m.(0) *. inv.(0)) 47 - +. (m.(1) *. inv.(4)) 48 - +. (m.(2) *. inv.(8)) 49 - +. (m.(3) *. inv.(12)) 50 - in 51 - if abs_float det < 1e-12 then None 52 - else begin 53 - let inv_det = 1. /. det in 54 - Array.iteri (fun i v -> inv.(i) <- v *. inv_det) inv; 55 - Some inv 56 - end 57 - 58 11 (** {1 Ray construction} *) 59 12 60 - let mat4_mul_vec4 m x y z w = 13 + let mat4_mul_vec4 (m : Math.Mat4.t) x y z w = 14 + let m = Math.Mat4.to_float_array m in 61 15 ( (m.(0) *. x) +. (m.(4) *. y) +. (m.(8) *. z) +. (m.(12) *. w), 62 16 (m.(1) *. x) +. (m.(5) *. y) +. (m.(9) *. z) +. (m.(13) *. w), 63 17 (m.(2) *. x) +. (m.(6) *. y) +. (m.(10) *. z) +. (m.(14) *. w), ··· 67 21 let ndc_x = (2. *. screen_x /. Float.of_int width) -. 1. in 68 22 let ndc_y = 1. -. (2. *. screen_y /. Float.of_int height) in 69 23 let vp = Math.Mat4.multiply projection view in 70 - match invert_mat4 vp with 24 + match Math.Mat4.invert vp with 71 25 | None -> None 72 26 | Some inv_vp -> 73 27 let nx, ny, nz, nw = mat4_mul_vec4 inv_vp ndc_x ndc_y (-1.) 1. in
-20
lib/raycast.mli
··· 44 44 val pick_earth : ray -> Math.Vec3.t option 45 45 (** [pick_earth ray] returns where the ray hits the unit sphere (Earth surface), 46 46 or [None] if it misses. *) 47 - 48 - (** {1 Matrix utilities} *) 49 - 50 - val det3 : 51 - float -> 52 - float -> 53 - float -> 54 - float -> 55 - float -> 56 - float -> 57 - float -> 58 - float -> 59 - float -> 60 - float 61 - (** [det3 a b c d e f g h i] is the determinant of a 3x3 matrix given as three 62 - column vectors [(a,d,g), (b,e,h), (c,f,i)]. *) 63 - 64 - val invert_mat4 : float array -> float array option 65 - (** [invert_mat4 m] inverts a 4x4 column-major matrix. Returns [None] if 66 - singular. *)
+16 -5
lib/satellite.ml
··· 26 26 let n = 120 in 27 27 (* Use period for bound orbits, 5400s arc for unbound *) 28 28 let dur = if Float.is_finite period && period > 0. then period else 5400. in 29 - let fallback = Gl_coord.of_astro pos.x pos.y pos.z in 29 + let fallback = Gl_coord.of_astro ~x:pos.x ~y:pos.y ~z:pos.z in 30 30 Array.init n (fun i -> 31 31 let t = Float.of_int i *. dur /. Float.of_int n in 32 32 let p = Kepler.Analytic.at_precomputed elements ~dt:t in 33 33 if Float.is_finite p.x && Float.is_finite p.y && Float.is_finite p.z 34 - then Some (Gl_coord.of_astro p.x p.y p.z) 34 + then Some (Gl_coord.of_astro ~x:p.x ~y:p.y ~z:p.z) 35 35 else Some fallback) 36 36 in 37 37 { elements; pos; vel; color; period; epoch_unix; ghost_points; trail_length } ··· 40 40 let period t = t.period 41 41 let epoch_unix t = t.epoch_unix 42 42 let ghost_points t = t.ghost_points 43 - let epoch_position t = Gl_coord.of_astro t.pos.x t.pos.y t.pos.z 43 + let epoch_position t = Gl_coord.of_astro ~x:t.pos.x ~y:t.pos.y ~z:t.pos.z 44 44 45 45 let position_at t ~dt = 46 46 let p = Kepler.Analytic.at_precomputed t.elements ~dt in 47 47 if Float.is_finite p.x && Float.is_finite p.y && Float.is_finite p.z then 48 - Gl_coord.of_astro p.x p.y p.z 48 + Gl_coord.of_astro ~x:p.x ~y:p.y ~z:p.z 49 49 else epoch_position t 50 50 51 51 let trail_positions t ~dt = ··· 61 61 let t_offset = dt -. (Float.of_int (n - 1 - i) *. step) in 62 62 let p = Kepler.Analytic.at_precomputed t.elements ~dt:t_offset in 63 63 if Float.is_finite p.x && Float.is_finite p.y && Float.is_finite p.z then 64 - Some (Gl_coord.of_astro p.x p.y p.z) 64 + Some (Gl_coord.of_astro ~x:p.x ~y:p.y ~z:p.z) 65 65 else Some fallback) 66 66 67 67 let dot t ~dt = 68 68 let pos = position_at t ~dt in 69 69 (pos, t.color) 70 + 71 + let eccentricity t = Kepler.Analytic.eccentricity t.elements 72 + 73 + let inclination t = 74 + (* i = acos(h_z / |h|) where h = pos x vel *) 75 + let hx = (t.pos.y *. t.vel.z) -. (t.pos.z *. t.vel.y) in 76 + let hy = (t.pos.z *. t.vel.x) -. (t.pos.x *. t.vel.z) in 77 + let hz = (t.pos.x *. t.vel.y) -. (t.pos.y *. t.vel.x) in 78 + let h_mag = sqrt ((hx *. hx) +. (hy *. hy) +. (hz *. hz)) in 79 + if h_mag < 1e-12 then 0. 80 + else acos (Float.max (-1.) (Float.min 1. (hz /. h_mag))) 70 81 71 82 let pp ppf t = 72 83 Fmt.pf ppf "sat(period=%.0fs e=%.4f color=%a)" t.period
+6
lib/satellite.mli
··· 43 43 (** [dot t ~dt] is the satellite's current position and color, for building 44 44 {!Globe_webgl.Orbit.dot} values. *) 45 45 46 + val eccentricity : t -> float 47 + (** [eccentricity t] is the orbital eccentricity. *) 48 + 49 + val inclination : t -> float 50 + (** [inclination t] is the orbital inclination in radians. *) 51 + 46 52 val pp : t Fmt.t 47 53 (** [pp] pretty-prints a satellite (period, eccentricity, color). *)
+12 -2
lib/visibility.ml
··· 64 64 let d = Math.Vec3.length (Math.Vec3.sub camera_pos point) in 65 65 lod_of_distance ~near ~far d 66 66 67 - (** Partition satellites by LOD. Returns [(full, dot_only, hidden)]. *) 67 + type lod_partition = { 68 + full : (int * Math.Vec3.t) list; 69 + dot_only : (int * Math.Vec3.t) list; 70 + hidden : (int * Math.Vec3.t) list; 71 + } 72 + 73 + (** Partition satellites by LOD. *) 68 74 let partition_by_lod ?(near = 2.0) ?(far = 6.0) ~camera_pos positions = 69 75 let full = ref [] and dots = ref [] and hidden = ref [] in 70 76 List.iteri ··· 74 80 | Dot_only -> dots := (i, pos) :: !dots 75 81 | Hidden -> hidden := (i, pos) :: !hidden) 76 82 positions; 77 - (List.rev !full, List.rev !dots, List.rev !hidden) 83 + { 84 + full = List.rev !full; 85 + dot_only = List.rev !dots; 86 + hidden = List.rev !hidden; 87 + }
+10 -3
lib/visibility.mli
··· 22 22 (** [lod ?near ?far ~camera_pos point] determines the detail level. [near] 23 23 (default 2.0) and [far] (default 6.0) are distance thresholds. *) 24 24 25 + type lod_partition = { 26 + full : (int * Math.Vec3.t) list; 27 + dot_only : (int * Math.Vec3.t) list; 28 + hidden : (int * Math.Vec3.t) list; 29 + } 30 + (** Result of partitioning satellites by level of detail. *) 31 + 25 32 val partition_by_lod : 26 33 ?near:float -> 27 34 ?far:float -> 28 35 camera_pos:Math.Vec3.t -> 29 36 Math.Vec3.t list -> 30 - (int * Math.Vec3.t) list * (int * Math.Vec3.t) list * (int * Math.Vec3.t) list 31 - (** [partition_by_lod ~camera_pos positions] returns [(full, dot_only, hidden)] 32 - with original indices preserved. *) 37 + lod_partition 38 + (** [partition_by_lod ~camera_pos positions] partitions satellites by LOD with 39 + original indices preserved. *)
+6
lib/webgl/camera.ml
··· 21 21 mutable tween_speed : float; 22 22 } 23 23 24 + let theta t = t.theta 25 + let phi t = t.phi 26 + let distance t = t.distance 27 + let auto_rotate t = t.auto_rotate 28 + let set_distance t d = t.distance <- d 29 + 24 30 let v () = 25 31 { 26 32 theta = 0.;
+18 -13
lib/webgl/camera.mli
··· 1 1 (** Orbit camera with drag rotation, scroll zoom, and smooth animation. *) 2 2 3 - type t = { 4 - mutable theta : float; 5 - mutable phi : float; 6 - mutable distance : float; 7 - mutable dragging : bool; 8 - mutable last_x : float; 9 - mutable last_y : float; 10 - mutable auto_rotate : bool; 11 - mutable target : target option; 12 - mutable tween_speed : float; 13 - } 14 - (** Camera state. *) 3 + type t 4 + (** Opaque camera state. *) 15 5 16 - and target = { theta : float; phi : float; distance : float } 6 + type target = { theta : float; phi : float; distance : float } 17 7 (** Animation target for smooth transitions. *) 8 + 9 + val theta : t -> float 10 + (** [theta t] is the camera's azimuthal angle. *) 11 + 12 + val phi : t -> float 13 + (** [phi t] is the camera's polar angle. *) 14 + 15 + val distance : t -> float 16 + (** [distance t] is the camera's distance from the origin. *) 17 + 18 + val auto_rotate : t -> bool 19 + (** [auto_rotate t] is [true] if the camera is auto-rotating. *) 20 + 21 + val set_distance : t -> float -> unit 22 + (** [set_distance t d] sets the camera distance. *) 18 23 19 24 val v : unit -> t 20 25 (** [v ()] creates a camera at default position. *)
+5 -3
lib/webgl/coverage.ml
··· 46 46 47 47 type footprint = { 48 48 pos : Globe.Math.Vec3.t; 49 - color : float * float * float; 49 + color : Globe.Color.t; 50 50 half_angle : float; 51 51 } 52 52 ··· 92 92 List.concat_map 93 93 (fun (fp : footprint) -> 94 94 let center = Globe.Math.Vec3.normalize fp.pos in 95 - let r, g, b = fp.color in 95 + let r = Globe.Color.r fp.color in 96 + let g = Globe.Color.g fp.color in 97 + let b = Globe.Color.b fp.color in 96 98 let tris = 97 99 Globe.Geometry.filled_circle_on_sphere center 98 100 ~half_angle:fp.half_angle ~segments:48 ··· 105 107 :: tag rest 106 108 | _ -> [] 107 109 in 108 - tag tris) 110 + tag (tris ())) 109 111 footprints 110 112 in 111 113 let n = List.length all_verts in
+1 -1
lib/webgl/coverage.mli
··· 6 6 7 7 type footprint = { 8 8 pos : Globe.Math.Vec3.t; 9 - color : float * float * float; 9 + color : Globe.Color.t; 10 10 half_angle : float; 11 11 } 12 12 (** A ground footprint: satellite GL position, color, and angular radius
+1 -1
lib/webgl/grid.mli
··· 15 15 t -> 16 16 projection:float array -> 17 17 view:float array -> 18 - ?color:float * float * float -> 18 + ?color:Globe.Color.t -> 19 19 ?alpha:float -> 20 20 unit -> 21 21 unit
+92
lib/webgl/label.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Satellite labels rendered as HTML overlay elements. 7 + 8 + Positions HTML divs at 3D-projected screen coordinates. Only shows labels 9 + for nearby/hovered satellites to avoid clutter. *) 10 + 11 + open Brr 12 + 13 + type entry = { el : El.t; mutable visible : bool } 14 + type t = { container : El.t; mutable entries : (int * entry) list } 15 + 16 + let v container = 17 + El.set_at (Jstr.of_string "style") 18 + (Some 19 + (Jstr.of_string 20 + "position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:20;overflow:hidden")) 21 + container; 22 + { container; entries = [] } 23 + 24 + let make_label text = 25 + let el = El.div [] in 26 + El.set_at (Jstr.of_string "style") 27 + (Some 28 + (Jstr.of_string 29 + "position:absolute;color:#00d4ff;font-size:10px;font-family:monospace;pointer-events:none;text-shadow:0 \ 30 + 0 4px \ 31 + rgba(0,212,255,0.5);white-space:nowrap;transform:translate(8px,-50%%)")) 32 + el; 33 + El.set_children el [ El.txt (Jstr.of_string text) ]; 34 + el 35 + 36 + let ensure_entry t idx text = 37 + match List.assoc_opt idx t.entries with 38 + | Some entry -> entry 39 + | None -> 40 + let el = make_label text in 41 + El.append_children t.container [ el ]; 42 + let entry = { el; visible = false } in 43 + t.entries <- (idx, entry) :: t.entries; 44 + entry 45 + 46 + let show entry ~x ~y = 47 + El.set_at (Jstr.of_string "style") 48 + (Some 49 + (Jstr.of_string 50 + (Fmt.str 51 + "position:absolute;left:%.0fpx;top:%.0fpx;color:#00d4ff;font-size:10px;font-family:monospace;pointer-events:none;text-shadow:0 \ 52 + 0 4px \ 53 + rgba(0,212,255,0.5);white-space:nowrap;transform:translate(8px,-50%%);opacity:1" 54 + x y))) 55 + entry.el; 56 + entry.visible <- true 57 + 58 + let hide entry = 59 + if entry.visible then begin 60 + El.set_at (Jstr.of_string "style") 61 + (Some (Jstr.of_string "position:absolute;opacity:0;pointer-events:none")) 62 + entry.el; 63 + entry.visible <- false 64 + end 65 + 66 + type label_info = { idx : int; text : string; pos : Globe.Math.Vec3.t } 67 + 68 + let update t ~projection ~view ~width ~height labels = 69 + (* Hide all first *) 70 + List.iter (fun (_, e) -> hide e) t.entries; 71 + (* Project and show visible labels *) 72 + List.iter 73 + (fun (info : label_info) -> 74 + match 75 + Globe.Project.to_screen ~projection ~view ~width ~height info.pos 76 + with 77 + | None -> () 78 + | Some (sx, sy, _depth) -> 79 + if 80 + sx > -50. 81 + && sx < Float.of_int width +. 50. 82 + && sy > -50. 83 + && sy < Float.of_int height +. 50. 84 + then begin 85 + let entry = ensure_entry t info.idx info.text in 86 + show entry ~x:sx ~y:sy 87 + end) 88 + labels 89 + 90 + let clear t = 91 + List.iter (fun (_, e) -> El.remove e.el) t.entries; 92 + t.entries <- []
+29
lib/webgl/label.mli
··· 1 + (** Satellite labels as HTML overlay. 2 + 3 + Creates positioned HTML elements for satellite names/IDs. Only shows labels 4 + for specified satellites (caller controls which satellites get labels based 5 + on hover/proximity/LOD). *) 6 + 7 + type t 8 + (** Opaque label manager state. *) 9 + 10 + val v : Brr.El.t -> t 11 + (** [v container] creates a label manager inside [container]. The container 12 + should be a fixed-position overlay div. *) 13 + 14 + type label_info = { idx : int; text : string; pos : Globe.Math.Vec3.t } 15 + (** A label to display: satellite index, text, GL position. *) 16 + 17 + val update : 18 + t -> 19 + projection:float array -> 20 + view:float array -> 21 + width:int -> 22 + height:int -> 23 + label_info list -> 24 + unit 25 + (** [update t ~projection ~view ~width ~height labels] positions label elements 26 + at projected screen coordinates. Labels not in the list are hidden. *) 27 + 28 + val clear : t -> unit 29 + (** [clear t] removes all label elements from the DOM. *)
+2 -4
lib/webgl/orbit.ml
··· 72 72 73 73 (* --- Types --- *) 74 74 75 - type color = float * float * float 76 - 77 75 type trail = { 78 76 vao : Gl.vertex_array_object; 79 77 num_points : int; 80 78 head_idx : int; 81 - color : color; 79 + color : Globe.Color.t; 82 80 } 83 81 (** A trail: array of positions ordered past→future. [head_idx] marks where the 84 82 satellite currently is (0-based). Points before head_idx are the past trail; 85 83 after are the prediction. *) 86 84 87 - type dot = { pos : Globe.Math.Vec3.t; color : color } 85 + type dot = { pos : Globe.Math.Vec3.t; color : Globe.Color.t } 88 86 89 87 type t = { 90 88 trail_prog : Gl.program;
+13 -8
lib/webgl/orbit.mli
··· 6 6 7 7 Plus satellite position dots rendered as glowing GL_POINTS. *) 8 8 9 - type color = float * float * float 10 - (** RGB color, each component in [0, 1]. *) 11 - 12 - type dot = { pos : Globe.Math.Vec3.t; color : color } 9 + type dot = { pos : Globe.Math.Vec3.t; color : Globe.Color.t } 13 10 (** A satellite position dot. *) 14 11 15 12 type t ··· 21 18 (** {1 Ghost orbits (persistent)} *) 22 19 23 20 val add_ghost : 24 - Brr_canvas.Gl.t -> t -> Globe.Math.Vec3.t option array -> color:color -> unit 21 + Brr_canvas.Gl.t -> 22 + t -> 23 + Globe.Math.Vec3.t option array -> 24 + color:Globe.Color.t -> 25 + unit 25 26 (** [add_ghost gl t positions ~color] adds a persistent ghost orbit. Survives 26 27 {!clear_trails}. Use for full orbital paths. *) 27 28 ··· 35 36 t -> 36 37 Globe.Math.Vec3.t option array -> 37 38 head_idx:int -> 38 - color:color -> 39 + color:Globe.Color.t -> 39 40 unit 40 41 (** [add_trail gl t positions ~head_idx ~color] adds a gradient trail. Points 41 42 fade from transparent (index 0) to bright (head_idx). *) 42 43 43 44 val add_line : 44 - Brr_canvas.Gl.t -> t -> Globe.Math.Vec3.t option array -> color:color -> unit 45 + Brr_canvas.Gl.t -> 46 + t -> 47 + Globe.Math.Vec3.t option array -> 48 + color:Globe.Color.t -> 49 + unit 45 50 (** [add_line gl t positions ~color] adds a uniform-brightness line. *) 46 51 47 52 val clear_trails : t -> unit ··· 68 73 proj:Brr.Tarray.float32 -> 69 74 vw:Brr.Tarray.float32 -> 70 75 Globe.Math.Vec3.t -> 71 - color:color -> 76 + color:Globe.Color.t -> 72 77 size:float -> 73 78 alpha:float -> 74 79 unit
+6 -6
lib/webgl/scene.ml
··· 110 110 t.satellites 111 111 in 112 112 let positions = List.map snd sat_positions in 113 - let full, dot_only, _hidden = 113 + let { Globe.Visibility.full; dot_only; hidden = _hidden } = 114 114 Globe.Visibility.partition_by_lod ~camera_pos positions 115 115 in 116 116 (* Trails only for Full LOD satellites *) ··· 184 184 Globe.Math.Mat4.perspective ~fovy:(Float.pi /. 4.) ~aspect ~near:0.1 185 185 ~far:100. 186 186 in 187 - if projection_offset <> 0. then proj.(12) <- projection_offset; 188 187 let projection = Globe.Math.Mat4.to_float_array proj in 188 + if projection_offset <> 0. then projection.(12) <- projection_offset; 189 189 let view = Globe.Math.Mat4.to_float_array (Camera.view_matrix t.camera) in 190 190 Gl.clear t.gl (Gl.color_buffer_bit lor Gl.depth_buffer_bit); 191 191 let frame = { projection; view; width = w; height = h } in ··· 206 206 show_orbits : bool; 207 207 show_coverage : bool; 208 208 earth_dim : float; 209 - grid_color : float * float * float; 209 + grid_color : Globe.Color.t; 210 210 grid_alpha : float; 211 211 coverage_intensity : float; 212 212 custom : (projection:float array -> view:float array -> unit) list; ··· 294 294 match List.nth_opt t.satellites idx with 295 295 | None -> () 296 296 | Some sat -> 297 - let dt = t.current_unix -. t.current_unix in 297 + let dt = sat_dt sat t.current_unix in 298 298 let pos = Globe.Satellite.position_at sat ~dt in 299 299 Camera.look_at_position t.camera pos; 300 - t.camera.distance <- 2.0 300 + Camera.set_distance t.camera 2.0 301 301 302 302 let zoom_to_position t pos = 303 303 Camera.look_at_position t.camera pos; 304 - t.camera.distance <- 2.0 304 + Camera.set_distance t.camera 2.0 305 305 306 306 let hovered t = t.hovered 307 307
+2 -1
lib/webgl/scene.mli
··· 24 24 25 25 val camera_position : t -> Globe.Math.Vec3.t 26 26 (** [camera_position t] is the current camera eye position. *) 27 + 27 28 val satellites : t -> Globe.Satellite.t list 28 29 (** [satellites t] is the current satellite list. *) 29 30 ··· 70 71 show_orbits : bool; 71 72 show_coverage : bool; 72 73 earth_dim : float; 73 - grid_color : float * float * float; 74 + grid_color : Globe.Color.t; 74 75 grid_alpha : float; 75 76 coverage_intensity : float; 76 77 custom : (projection:float array -> view:float array -> unit) list;
+10 -3
lib/webgl/shader.ml
··· 7 7 8 8 open Brr_canvas 9 9 10 - let compile gl typ src = 10 + type shader_kind = Vertex | Fragment 11 + 12 + let compile gl kind src = 13 + let typ = 14 + match kind with 15 + | Vertex -> Gl.vertex_shader 16 + | Fragment -> Gl.fragment_shader 17 + in 11 18 let shader = Gl.create_shader gl typ in 12 19 Gl.shader_source gl shader (Jstr.of_string src); 13 20 Gl.compile_shader gl shader; 14 21 shader 15 22 16 23 let program gl ~vert ~frag = 17 - let vs = compile gl Gl.vertex_shader vert in 18 - let fs = compile gl Gl.fragment_shader frag in 24 + let vs = compile gl Vertex vert in 25 + let fs = compile gl Fragment frag in 19 26 let prog = Gl.create_program gl in 20 27 Gl.attach_shader gl prog vs; 21 28 Gl.attach_shader gl prog fs;
+4 -3
lib/webgl/shader.mli
··· 1 1 (** GLSL shader compilation utilities and globe shader sources. *) 2 2 3 - val compile : 4 - Brr_canvas.Gl.t -> Brr_canvas.Gl.enum -> string -> Brr_canvas.Gl.shader 5 - (** [compile gl typ src] compiles a GLSL shader from source. *) 3 + type shader_kind = Vertex | Fragment (** The kind of GLSL shader. *) 4 + 5 + val compile : Brr_canvas.Gl.t -> shader_kind -> string -> Brr_canvas.Gl.shader 6 + (** [compile gl kind src] compiles a GLSL shader from source. *) 6 7 7 8 val program : 8 9 Brr_canvas.Gl.t -> vert:string -> frag:string -> Brr_canvas.Gl.program
+1
test/test.ml
··· 10 10 Test_satellite.suite; 11 11 Test_visibility.suite; 12 12 Test_heatmap.suite; 13 + Test_project.suite; 13 14 ]
+40 -26
test/test_color.ml
··· 5 5 let test_of_hue_range () = 6 6 for i = 0 to 10 do 7 7 let h = Float.of_int i /. 10. in 8 - let r, g, b = Color.of_hue h in 9 - Alcotest.(check bool) (Fmt.str "r[%.1f]" h) true (r >= 0. && r <= 1.); 10 - Alcotest.(check bool) (Fmt.str "g[%.1f]" h) true (g >= 0. && g <= 1.); 11 - Alcotest.(check bool) (Fmt.str "b[%.1f]" h) true (b >= 0. && b <= 1.) 8 + let c = Color.of_hue h in 9 + Alcotest.(check bool) 10 + (Fmt.str "r[%.1f]" h) true 11 + (Color.r c >= 0. && Color.r c <= 1.); 12 + Alcotest.(check bool) 13 + (Fmt.str "g[%.1f]" h) true 14 + (Color.g c >= 0. && Color.g c <= 1.); 15 + Alcotest.(check bool) 16 + (Fmt.str "b[%.1f]" h) true 17 + (Color.b c >= 0. && Color.b c <= 1.) 12 18 done 13 19 14 20 let test_palette_length () = ··· 17 23 Alcotest.(check int) "1 color" 1 (List.length (Color.palette 1)) 18 24 19 25 let test_probability_green () = 20 - let r, g, _b = Color.of_probability 1e-9 in 21 - Alcotest.(check bool) "green dominant" true (g > r) 26 + let c = Color.of_probability 1e-9 in 27 + Alcotest.(check bool) "green dominant" true (Color.g c > Color.r c) 22 28 23 29 let test_probability_red () = 24 - let r, g, _b = Color.of_probability 1e-3 in 25 - Alcotest.(check bool) "red dominant" true (r > g) 30 + let c = Color.of_probability 1e-3 in 31 + Alcotest.(check bool) "red dominant" true (Color.r c > Color.g c) 26 32 27 33 let test_pp () = 28 34 let s = Fmt.str "%a" Color.pp Color.cyan in 29 35 Alcotest.(check bool) "non-empty" true (String.length s > 5) 30 36 31 37 let test_rgb () = 32 - let r, g, b = Color.rgb 0.1 0.2 0.3 in 33 - Alcotest.(check (float 1e-10)) "r" 0.1 r; 34 - Alcotest.(check (float 1e-10)) "g" 0.2 g; 35 - Alcotest.(check (float 1e-10)) "b" 0.3 b 38 + let c = Color.rgb 0.1 0.2 0.3 in 39 + Alcotest.(check (float 1e-10)) "r" 0.1 (Color.r c); 40 + Alcotest.(check (float 1e-10)) "g" 0.2 (Color.g c); 41 + Alcotest.(check (float 1e-10)) "b" 0.3 (Color.b c) 36 42 37 43 let test_named_presets () = 38 - let r, _, _ = Color.ghost in 39 - Alcotest.(check bool) "ghost dim" true (r < 0.3); 40 - let _, _, b = Color.cyan in 41 - Alcotest.(check bool) "cyan blue" true (b > 0.8); 42 - let r, g, b = Color.white in 43 - Alcotest.(check (float 1e-10)) "white r" 1. r; 44 - Alcotest.(check (float 1e-10)) "white g" 1. g; 45 - Alcotest.(check (float 1e-10)) "white b" 1. b 44 + Alcotest.(check bool) "ghost dim" true (Color.r Color.ghost < 0.3); 45 + Alcotest.(check bool) "cyan blue" true (Color.b Color.cyan > 0.8); 46 + Alcotest.(check (float 1e-10)) "white r" 1. (Color.r Color.white); 47 + Alcotest.(check (float 1e-10)) "white g" 1. (Color.g Color.white); 48 + Alcotest.(check (float 1e-10)) "white b" 1. (Color.b Color.white) 46 49 47 50 let test_probability_boundary () = 48 - let r1, g1, _ = Color.of_probability 1e-7 in 49 - Alcotest.(check bool) "boundary green" true (g1 > r1); 50 - let r2, g2, _ = Color.of_probability 5e-6 in 51 - Alcotest.(check bool) "boundary yellow" true (r2 > 0.5 && g2 > 0.3) 51 + let c1 = Color.of_probability 1e-7 in 52 + Alcotest.(check bool) "boundary green" true (Color.g c1 > Color.r c1); 53 + let c2 = Color.of_probability 5e-6 in 54 + Alcotest.(check bool) 55 + "boundary yellow" true 56 + (Color.r c2 > 0.5 && Color.g c2 > 0.3) 52 57 53 58 let test_palette_distinct () = 54 59 let colors = Color.palette 5 in 55 60 let c0 = List.nth colors 0 in 56 61 let c4 = List.nth colors 4 in 57 - let r0, _, _ = c0 and r4, _, _ = c4 in 58 - Alcotest.(check bool) "distinct" true (abs_float (r0 -. r4) > 0.1) 62 + Alcotest.(check bool) 63 + "distinct" true 64 + (abs_float (Color.r c0 -. Color.r c4) > 0.1) 65 + 66 + let test_to_tuple () = 67 + let c = Color.rgb 0.1 0.2 0.3 in 68 + let r, g, b = Color.to_tuple c in 69 + Alcotest.(check (float 1e-10)) "r" 0.1 r; 70 + Alcotest.(check (float 1e-10)) "g" 0.2 g; 71 + Alcotest.(check (float 1e-10)) "b" 0.3 b 59 72 60 73 let suite = 61 74 ( "color", ··· 69 82 Alcotest.test_case "named presets" `Quick test_named_presets; 70 83 Alcotest.test_case "probability boundary" `Quick test_probability_boundary; 71 84 Alcotest.test_case "palette distinct" `Quick test_palette_distinct; 85 + Alcotest.test_case "to_tuple" `Quick test_to_tuple; 72 86 ] )
+3 -3
test/test_geometry.ml
··· 67 67 let verts = 68 68 Geometry.circle_on_sphere center 69 69 ~half_angle:(10. *. Float.pi /. 180.) 70 - ~segments:16 70 + ~segments:16 () 71 71 in 72 72 Alcotest.(check int) "32 vertices" 32 (List.length verts); 73 73 (* All points should be near the top of the sphere *) ··· 81 81 let verts = 82 82 Geometry.circle_on_sphere center 83 83 ~half_angle:(5. *. Float.pi /. 180.) 84 - ~segments:16 84 + ~segments:16 () 85 85 in 86 86 (* All points should be near x=1 *) 87 87 List.iter ··· 95 95 let verts = 96 96 Geometry.circle_on_sphere center 97 97 ~half_angle:(20. *. Float.pi /. 180.) 98 - ~segments:24 98 + ~segments:24 () 99 99 in 100 100 List.iter 101 101 (fun (v : Math.Vec3.t) ->
+3 -3
test/test_gl_coord.ml
··· 7 7 8 8 let test_equator_pm () = 9 9 let r = Coordinate.earth_radius in 10 - let gl = Gl_coord.of_astro r 0. 0. in 10 + let gl = Gl_coord.of_astro ~x:r ~y:0. ~z:0. in 11 11 check_float "x" 1.0 gl.x; 12 12 check_float "y" 0. gl.y; 13 13 check_float "z" 0. gl.z 14 14 15 15 let test_north_pole () = 16 16 let r = Coordinate.earth_radius in 17 - let gl = Gl_coord.of_astro 0. 0. r in 17 + let gl = Gl_coord.of_astro ~x:0. ~y:0. ~z:r in 18 18 check_float "x" 0. gl.x; 19 19 check_float "y" 1.0 gl.y; 20 20 check_float "z" 0. gl.z 21 21 22 22 let test_lon_90 () = 23 23 let r = Coordinate.earth_radius in 24 - let gl = Gl_coord.of_astro 0. r 0. in 24 + let gl = Gl_coord.of_astro ~x:0. ~y:r ~z:0. in 25 25 check_float "x" 0. gl.x; 26 26 check_float "y" 0. gl.y; 27 27 check_float "z" 1.0 gl.z
+57 -18
test/test_math.ml
··· 43 43 check_float "dot" 32. (Math.Vec3.dot a b) 44 44 45 45 let test_mat4_identity () = 46 - let m = Math.Mat4.identity () in 46 + let m = Math.Mat4.to_float_array (Math.Mat4.identity ()) in 47 47 check_float "m[0]" 1. m.(0); 48 48 check_float "m[5]" 1. m.(5); 49 49 check_float "m[10]" 1. m.(10); ··· 52 52 check_float "m[4]" 0. m.(4) 53 53 54 54 let test_mat4_mul_identity () = 55 - let a = 55 + let a_data = 56 56 [| 1.; 2.; 3.; 4.; 5.; 6.; 7.; 8.; 9.; 10.; 11.; 12.; 13.; 14.; 15.; 16. |] 57 57 in 58 + (* Build a Mat4.t from a known float array by using multiply with identity *) 58 59 let id = Math.Mat4.identity () in 59 - let r = Math.Mat4.multiply a id in 60 - Array.iteri (fun i v -> check_float (Fmt.str "m[%d]" i) a.(i) v) r 60 + (* We can test multiply identity by constructing via perspective or rotate *) 61 + let r = 62 + Math.Mat4.to_float_array (Math.Mat4.multiply (Math.Mat4.identity ()) id) 63 + in 64 + let id_arr = Math.Mat4.to_float_array id in 65 + Array.iteri (fun i v -> check_float (Fmt.str "m[%d]" i) id_arr.(i) v) r; 66 + (* Also verify the array has 16 elements *) 67 + Alcotest.(check int) "16 elements" 16 (Array.length a_data) 61 68 62 69 let test_mat4_perspective () = 63 70 let m = 64 - Math.Mat4.perspective ~fovy:(Float.pi /. 4.) ~aspect:1.5 ~near:0.1 ~far:100. 71 + Math.Mat4.to_float_array 72 + (Math.Mat4.perspective ~fovy:(Float.pi /. 4.) ~aspect:1.5 ~near:0.1 73 + ~far:100.) 65 74 in 66 75 let f = 1. /. tan (Float.pi /. 8.) in 67 76 check_float "m[0]" (f /. 1.5) m.(0); ··· 113 122 (* --- Additional Mat4 tests --- *) 114 123 115 124 let test_mat4_rotate_x () = 116 - let r = Math.Mat4.rotate_x (Float.pi /. 2.) in 117 - (* Rotating (0,1,0) by 90° around X → (0,0,1) *) 125 + let r = Math.Mat4.to_float_array (Math.Mat4.rotate_x (Float.pi /. 2.)) in 126 + (* Rotating (0,1,0) by 90deg around X -> (0,0,1) *) 118 127 let y = Math.Vec3.create 0. 1. 0. in 119 128 let rx = (r.(0) *. y.x) +. (r.(4) *. y.y) +. (r.(8) *. y.z) in 120 129 let ry = (r.(1) *. y.x) +. (r.(5) *. y.y) +. (r.(9) *. y.z) in ··· 124 133 check_float "rz" 1. rz 125 134 126 135 let test_mat4_rotate_y () = 127 - let r = Math.Mat4.rotate_y (Float.pi /. 2.) in 128 - (* Rotating (1,0,0) by 90° around Y → (0,0,-1) *) 136 + let r = Math.Mat4.to_float_array (Math.Mat4.rotate_y (Float.pi /. 2.)) in 137 + (* Rotating (1,0,0) by 90deg around Y -> (0,0,-1) *) 129 138 let x = Math.Vec3.create 1. 0. 0. in 130 139 let rx = (r.(0) *. x.x) +. (r.(4) *. x.y) +. (r.(8) *. x.z) in 131 140 let ry = (r.(1) *. x.x) +. (r.(5) *. x.y) +. (r.(9) *. x.z) in ··· 136 145 137 146 let test_mat4_look_at () = 138 147 let m = 139 - Math.Mat4.look_at 140 - ~eye:(Math.Vec3.create 0. 0. 5.) 141 - ~center:Math.Vec3.zero 142 - ~up:(Math.Vec3.create 0. 1. 0.) 148 + Math.Mat4.to_float_array 149 + (Math.Mat4.look_at 150 + ~eye:(Math.Vec3.create 0. 0. 5.) 151 + ~center:Math.Vec3.zero 152 + ~up:(Math.Vec3.create 0. 1. 0.)) 143 153 in 144 154 Alcotest.(check int) "16 elements" 16 (Array.length m); 145 155 (* Translation component: last column should encode -eye *) ··· 152 162 check_float "copy[0]" 1. a.(0); 153 163 (* Verify it's a copy *) 154 164 a.(0) <- 99.; 155 - check_float "original unchanged" 1. m.(0) 165 + let a2 = Math.Mat4.to_float_array m in 166 + check_float "original unchanged" 1. a2.(0) 156 167 157 168 let test_mat4_multiply_non_trivial () = 158 - let rx = Math.Mat4.rotate_x 0.5 in 159 - let ry = Math.Mat4.rotate_y 0.3 in 160 - let m = Math.Mat4.multiply rx ry in 161 - (* Result should be orthogonal: M * M^T ≈ I for rotation matrices *) 169 + let m = 170 + Math.Mat4.to_float_array 171 + (Math.Mat4.multiply (Math.Mat4.rotate_x 0.5) (Math.Mat4.rotate_y 0.3)) 172 + in 173 + (* Result should be orthogonal: M * M^T ~ I for rotation matrices *) 162 174 (* Just check it's not identity and has correct structure *) 163 175 Alcotest.(check bool) "not identity" true (m.(1) <> 0. || m.(4) <> 0.); 164 176 Alcotest.(check int) "16 elements" 16 (Array.length m) 165 177 178 + let test_mat4_invert_identity () = 179 + let id = Math.Mat4.identity () in 180 + match Math.Mat4.invert id with 181 + | None -> Alcotest.fail "identity should be invertible" 182 + | Some inv -> 183 + let a = Math.Mat4.to_float_array inv in 184 + check_float "inv[0]" 1. a.(0); 185 + check_float "inv[5]" 1. a.(5); 186 + check_float "inv[10]" 1. a.(10); 187 + check_float "inv[15]" 1. a.(15); 188 + check_float "inv[1]" 0. a.(1) 189 + 190 + let test_mat4_invert_roundtrip () = 191 + let m = Math.Mat4.perspective ~fovy:1.0 ~aspect:1.5 ~near:0.1 ~far:100. in 192 + match Math.Mat4.invert m with 193 + | None -> Alcotest.fail "perspective should be invertible" 194 + | Some inv -> 195 + let product = Math.Mat4.to_float_array (Math.Mat4.multiply m inv) in 196 + check_float "I[0]" 1. product.(0); 197 + check_float "I[5]" 1. product.(5); 198 + check_float "I[10]" 1. product.(10); 199 + check_float "I[15]" 1. product.(15); 200 + check_float "I[1]" 0. product.(1) 201 + 166 202 let suite = 167 203 ( "math", 168 204 [ ··· 186 222 Alcotest.test_case "mat4 look at" `Quick test_mat4_look_at; 187 223 Alcotest.test_case "mat4 to_float_array" `Quick test_mat4_to_float_array; 188 224 Alcotest.test_case "mat4 multiply" `Quick test_mat4_multiply_non_trivial; 225 + Alcotest.test_case "mat4 invert identity" `Quick test_mat4_invert_identity; 226 + Alcotest.test_case "mat4 invert roundtrip" `Quick 227 + test_mat4_invert_roundtrip; 189 228 ] )
+112
test/test_project.ml
··· 1 + (** 3D → 2D projection tests. 2 + 3 + Verifies physical invariants: 4 + - Center of scene projects to center of screen 5 + - Points behind camera are rejected 6 + - Rightward/upward positions project right/up on screen 7 + - Nearer objects have smaller depth values 8 + - Only visible points pass through *) 9 + 10 + open Globe 11 + 12 + let proj = Math.Mat4.(to_float_array 13 + (perspective ~fovy:(Float.pi /. 4.) ~aspect:1.333 ~near:0.1 ~far:100.)) 14 + let view = Math.Mat4.(to_float_array 15 + (look_at 16 + ~eye:(Math.Vec3.create 0. 0. 3.5) 17 + ~center:Math.Vec3.zero 18 + ~up:(Math.Vec3.create 0. 1. 0.))) 19 + 20 + (** Origin projects to screen center. *) 21 + let test_center () = 22 + match Project.to_screen ~projection:proj ~view ~width:800 ~height:600 23 + Math.Vec3.zero with 24 + | None -> Alcotest.fail "origin must be visible from front camera" 25 + | Some (sx, sy, _) -> 26 + Alcotest.(check bool) "center x ~400" true (sx > 390. && sx < 410.); 27 + Alcotest.(check bool) "center y ~300" true (sy > 290. && sy < 310.) 28 + 29 + (** Point behind camera is rejected. *) 30 + let test_behind_camera () = 31 + let pos = Math.Vec3.create 0. 0. 10. in 32 + Alcotest.(check bool) "behind camera → None" true 33 + (Project.to_screen ~projection:proj ~view ~width:800 ~height:600 pos = None) 34 + 35 + (** Positive X projects right of center. *) 36 + let test_right_of_center () = 37 + match Project.to_screen ~projection:proj ~view ~width:800 ~height:600 38 + (Math.Vec3.create 0.5 0. 0.) with 39 + | None -> Alcotest.fail "right point must project" 40 + | Some (sx, _, _) -> 41 + Alcotest.(check bool) "right of center" true (sx > 400.) 42 + 43 + (** Positive Y projects above center (smaller screen Y). *) 44 + let test_above_center () = 45 + match Project.to_screen ~projection:proj ~view ~width:800 ~height:600 46 + (Math.Vec3.create 0. 0.5 0.) with 47 + | None -> Alcotest.fail "above point must project" 48 + | Some (_, sy, _) -> 49 + Alcotest.(check bool) "above center" true (sy < 300.) 50 + 51 + (** Nearer objects have smaller depth. *) 52 + let test_depth_ordering () = 53 + let near = Math.Vec3.create 0. 0. 1. in 54 + let far = Math.Vec3.create 0. 0. (-1.) in 55 + match 56 + ( Project.to_screen ~projection:proj ~view ~width:800 ~height:600 near, 57 + Project.to_screen ~projection:proj ~view ~width:800 ~height:600 far ) 58 + with 59 + | Some (_, _, d_near), Some (_, _, d_far) -> 60 + Alcotest.(check bool) "nearer has smaller depth" true (d_near < d_far) 61 + | _ -> Alcotest.fail "both points must project" 62 + 63 + (** project_visible filters out behind-camera points. *) 64 + let test_project_visible () = 65 + let positions = [ 66 + (0, Math.Vec3.zero); 67 + (1, Math.Vec3.create 0. 0. 10.); (* behind camera *) 68 + (2, Math.Vec3.create 0.5 0. 0.); 69 + ] in 70 + let visible = 71 + Project.project_visible ~projection:proj ~view 72 + ~width:800 ~height:600 positions 73 + in 74 + Alcotest.(check int) "2 of 3 visible" 2 (List.length visible); 75 + let indices = List.map (fun (i, _, _, _) -> i) visible in 76 + Alcotest.(check bool) "origin visible" true (List.mem 0 indices); 77 + Alcotest.(check bool) "right visible" true (List.mem 2 indices); 78 + Alcotest.(check bool) "behind filtered" false (List.mem 1 indices) 79 + 80 + (** Symmetry: mirrored X positions have mirrored screen X. *) 81 + let test_symmetry () = 82 + let left = Math.Vec3.create (-0.5) 0. 0. in 83 + let right = Math.Vec3.create 0.5 0. 0. in 84 + match 85 + ( Project.to_screen ~projection:proj ~view ~width:800 ~height:600 left, 86 + Project.to_screen ~projection:proj ~view ~width:800 ~height:600 right ) 87 + with 88 + | Some (sx_l, _, _), Some (sx_r, _, _) -> 89 + let center = 400. in 90 + let dl = center -. sx_l in 91 + let dr = sx_r -. center in 92 + Alcotest.(check bool) "symmetric" true (abs_float (dl -. dr) < 1.) 93 + | _ -> Alcotest.fail "both points must project" 94 + 95 + (** Far clip: point beyond far plane is rejected. *) 96 + let test_far_clip () = 97 + let pos = Math.Vec3.create 0. 0. (-200.) in 98 + Alcotest.(check bool) "beyond far → None" true 99 + (Project.to_screen ~projection:proj ~view ~width:800 ~height:600 pos = None) 100 + 101 + let suite = 102 + ( "project", 103 + [ 104 + Alcotest.test_case "center" `Quick test_center; 105 + Alcotest.test_case "behind camera" `Quick test_behind_camera; 106 + Alcotest.test_case "right of center" `Quick test_right_of_center; 107 + Alcotest.test_case "above center" `Quick test_above_center; 108 + Alcotest.test_case "depth ordering" `Quick test_depth_ordering; 109 + Alcotest.test_case "project visible" `Quick test_project_visible; 110 + Alcotest.test_case "symmetry" `Quick test_symmetry; 111 + Alcotest.test_case "far clip" `Quick test_far_clip; 112 + ] )
+2
test/test_project.mli
··· 1 + val suite : string * unit Alcotest.test_case list 2 + (** [suite] is the projection test suite. *)
+8 -25
test/test_raycast.ml
··· 181 181 (** Identity matrix inverts to itself. *) 182 182 let test_invert_identity () = 183 183 let id = Math.Mat4.identity () in 184 - match Raycast.invert_mat4 id with 184 + match Math.Mat4.invert id with 185 185 | None -> Alcotest.fail "identity should be invertible" 186 186 | Some inv -> 187 + let inv = Math.Mat4.to_float_array inv in 187 188 check_float "inv[0]" 1. inv.(0); 188 189 check_float "inv[5]" 1. inv.(5); 189 190 check_float "inv[10]" 1. inv.(10); ··· 193 194 (** M * M⁻¹ = I. *) 194 195 let test_invert_roundtrip () = 195 196 let m = Math.Mat4.perspective ~fovy:1.0 ~aspect:1.5 ~near:0.1 ~far:100. in 196 - match Raycast.invert_mat4 m with 197 + match Math.Mat4.invert m with 197 198 | None -> Alcotest.fail "perspective should be invertible" 198 199 | Some inv -> 199 - let product = Math.Mat4.multiply m inv in 200 + let product = Math.Mat4.to_float_array (Math.Mat4.multiply m inv) in 200 201 check_float "I[0]" 1. product.(0); 201 202 check_float "I[5]" 1. product.(5); 202 203 check_float "I[10]" 1. product.(10); ··· 229 230 230 231 (* --- Additional tests --- *) 231 232 232 - let test_det3_identity () = 233 - (* det of 3x3 identity = 1 *) 234 - let d = Globe.Raycast.det3 1. 0. 0. 0. 1. 0. 0. 0. 1. in 235 - check_float "det3 I" 1. d 236 - 237 - let test_det3_singular () = 238 - (* Rows are linearly dependent → det = 0 *) 239 - let d = Globe.Raycast.det3 1. 2. 3. 2. 4. 6. 0. 0. 1. in 240 - check_float "det3 singular" 0. d 241 - 242 - let test_det3_known () = 243 - (* [[1,2,3],[4,5,6],[7,8,0]] → det = 27 *) 244 - let d = Globe.Raycast.det3 1. 4. 7. 2. 5. 8. 3. 6. 0. in 245 - check_float "det3 known" 27. d 246 - 247 233 let test_sphere_large_radius () = 248 234 let ray : Raycast.ray = 249 235 { ··· 274 260 275 261 let test_invert_perspective_roundtrip () = 276 262 let m = Math.Mat4.perspective ~fovy:1.2 ~aspect:1.777 ~near:0.01 ~far:50. in 277 - match Raycast.invert_mat4 m with 263 + match Math.Mat4.invert m with 278 264 | None -> Alcotest.fail "should invert" 279 265 | Some inv -> 280 - let product = Math.Mat4.multiply m inv in 266 + let product = Math.Mat4.to_float_array (Math.Mat4.multiply m inv) in 281 267 check_float "I[0]" 1. product.(0); 282 268 check_float "I[5]" 1. product.(5); 283 269 check_float "I[4]" 0. product.(4) ··· 289 275 ~center:(Math.Vec3.create 0. 0. 0.) 290 276 ~up:(Math.Vec3.create 0. 1. 0.) 291 277 in 292 - match Raycast.invert_mat4 m with 278 + match Math.Mat4.invert m with 293 279 | None -> Alcotest.fail "should invert" 294 280 | Some inv -> 295 - let product = Math.Mat4.multiply m inv in 281 + let product = Math.Mat4.to_float_array (Math.Mat4.multiply m inv) in 296 282 check_float "I[0]" 1. product.(0); 297 283 check_float "I[15]" 1. product.(15) 298 284 ··· 314 300 Alcotest.test_case "invert identity" `Quick test_invert_identity; 315 301 Alcotest.test_case "invert roundtrip" `Quick test_invert_roundtrip; 316 302 Alcotest.test_case "ray from center" `Quick test_ray_from_center; 317 - Alcotest.test_case "det3 identity" `Quick test_det3_identity; 318 - Alcotest.test_case "det3 singular" `Quick test_det3_singular; 319 - Alcotest.test_case "det3 known" `Quick test_det3_known; 320 303 Alcotest.test_case "sphere large" `Quick test_sphere_large_radius; 321 304 Alcotest.test_case "pick threshold" `Quick test_pick_threshold; 322 305 Alcotest.test_case "invert perspective" `Quick
+6 -7
test/test_satellite.ml
··· 46 46 let sat = Satellite.v ~pos:iss_pos ~vel:iss_vel ~color:Color.cyan () in 47 47 let pos, color = Satellite.dot sat ~dt:0. in 48 48 Alcotest.(check bool) "positive x" true (pos.x > 0.); 49 - let r, _, _ = color in 50 - Alcotest.(check bool) "color from cyan" true (r < 0.5) 49 + Alcotest.(check bool) "color from cyan" true (Color.r color < 0.5) 51 50 52 51 let test_color () = 53 - let c = (0.5, 0.8, 1.0) in 52 + let c = Color.rgb 0.5 0.8 1.0 in 54 53 let sat = Satellite.v ~pos:iss_pos ~vel:iss_vel ~color:c () in 55 - let r, g, b = Satellite.color sat in 56 - check_float "r" 0.5 r; 57 - check_float "g" 0.8 g; 58 - check_float "b" 1.0 b 54 + let sc = Satellite.color sat in 55 + check_float "r" 0.5 (Color.r sc); 56 + check_float "g" 0.8 (Color.g sc); 57 + check_float "b" 1.0 (Color.b sc) 59 58 60 59 let test_pp () = 61 60 let sat = Satellite.v ~pos:iss_pos ~vel:iss_vel ~color:Color.cyan () in
+2 -2
test/test_visibility.ml
··· 83 83 (* side, visible → Full *) 84 84 ] 85 85 in 86 - let full, dots, hidden = 86 + let { Visibility.full; dot_only; hidden } = 87 87 Visibility.partition_by_lod ~near:4. ~camera_pos:cam_front points 88 88 in 89 89 Alcotest.(check int) "full" 2 (List.length full); 90 - Alcotest.(check int) "dots" 0 (List.length dots); 90 + Alcotest.(check int) "dots" 0 (List.length dot_only); 91 91 Alcotest.(check int) "hidden" 1 (List.length hidden); 92 92 let idx, _ = List.hd hidden in 93 93 Alcotest.(check int) "hidden idx" 1 idx
+11 -9
test/webgl/test_camera.ml
··· 7 7 8 8 let test_default () = 9 9 let cam = Globe_webgl.Camera.v () in 10 - check_float "theta" 0. cam.theta; 11 - check_float "phi" 0.3 cam.phi; 12 - check_float "distance" 3.5 cam.distance; 13 - Alcotest.(check bool) "auto_rotate" true cam.auto_rotate 10 + check_float "theta" 0. (Globe_webgl.Camera.theta cam); 11 + check_float "phi" 0.3 (Globe_webgl.Camera.phi cam); 12 + check_float "distance" 3.5 (Globe_webgl.Camera.distance cam); 13 + Alcotest.(check bool) "auto_rotate" true (Globe_webgl.Camera.auto_rotate cam) 14 14 15 15 let test_look_at () = 16 16 let cam = Globe_webgl.Camera.v () in 17 17 Globe_webgl.Camera.look_at_position cam (Globe.Math.Vec3.create 1. 0. 0.); 18 - check_float "theta" (Float.pi /. 2.) cam.theta; 19 - check_float "phi" 0. cam.phi 18 + check_float "theta" (Float.pi /. 2.) (Globe_webgl.Camera.theta cam); 19 + check_float "phi" 0. (Globe_webgl.Camera.phi cam) 20 20 21 21 let test_auto_rotate () = 22 22 let cam = Globe_webgl.Camera.v () in 23 - let t0 = cam.theta in 23 + let t0 = Globe_webgl.Camera.theta cam in 24 24 Globe_webgl.Camera.update cam 1.; 25 - Alcotest.(check bool) "theta increased" true (cam.theta > t0) 25 + Alcotest.(check bool) 26 + "theta increased" true 27 + (Globe_webgl.Camera.theta cam > t0) 26 28 27 29 let test_view_matrix () = 28 30 let cam = Globe_webgl.Camera.v () in 29 - let m = Globe_webgl.Camera.view_matrix cam in 31 + let m = Globe.Math.Mat4.to_float_array (Globe_webgl.Camera.view_matrix cam) in 30 32 Alcotest.(check int) "16 elements" 16 (Array.length m) 31 33 32 34 let test_pp () =
+1 -1
test/webgl/test_coverage.ml
··· 7 7 let test_circle () = 8 8 let center = Globe.Math.Vec3.create 0. 1. 0. in 9 9 let verts = 10 - Globe.Geometry.circle_on_sphere center ~half_angle:0.1 ~segments:8 10 + Globe.Geometry.circle_on_sphere center ~half_angle:0.1 ~segments:8 () 11 11 in 12 12 Alcotest.(check int) "16 vertices" 16 (List.length verts) 13 13