Skip to content

Commit 39ee1c2

Browse files
committed
Add implementation of ellipsoid
1 parent 684bc2e commit 39ee1c2

5 files changed

Lines changed: 163 additions & 46 deletions

File tree

NEWS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
We started keeping track of changes in the `NEWS.md` file after version 0.1.0.
44

5+
# PlantGeomPrimitives 1.0.1 (2026-06-16)
6+
7+
Implement `Ellipsoid` as a primitive discretize into rectangular patches defined by fixed longitude and
8+
latitude increments. The user specifies the number of longitude and latitude increments rather than the
9+
total number of triangles.
10+
511
# PlantGeomPrimitives 1.0.1 (2026-06-11)
612

713
The rotation functions were still expecting angles in radians rather than degrees. The

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name = "PlantGeomPrimitives"
22
uuid = "7eef3cc5-4580-4ff0-8f6f-933507db6664"
33
authors = ["Alejandro Morales Sierra <alejandro.moralessierra@wur.nl> and contributors"]
4-
version = "1.0.1"
4+
version = "1.0.2"
55

66
[deps]
77
ColorTypes = "3da002f7-5984-5a60-b8a6-cbb66c0b333f"

src/Primitives/Ellipsoid.jl

Lines changed: 90 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,106 @@
11
### This file contains public API ###
22

3-
function equal_solid_angles(n)
4-
# Distribution sectors along zenith angles
5-
Δθ = π / 2 / n
6-
uθₗ = 0.0:Δθ/2-Δθ
7-
uθᵤ = Δθ:Δθ:π/2
8-
9-
# Calculate number of azimuth sectors and ΔΦ per zenith ring
10-
fac = cos.(uθₗ) - cos.(uθᵤ)
11-
n2 = n * n
12-
ns = round.(fac ./ sum(fac) .* n2)
13-
sum(ns) != n2 && (ns[end] = ns[end] + n2 - sum(ns))
14-
ΔΦs = 2π ./ ns
15-
16-
# Generate coordinates of all sectors
17-
c = 1
18-
θₗ = Vector{Float32}(undef, n2)
19-
θᵤ = Vector{Float32}(undef, n2)
20-
Φₗ = Vector{Float32}(undef, n2)
21-
Φᵤ = Vector{Float32}(undef, n2)
22-
for i = 1:n
23-
ΔΦ = ΔΦs[i]
24-
for j = 1:ns[i]
25-
θₗ[c] = Δθ * (i - 1)
26-
θᵤ[c] = Δθ * i
27-
Φₗ[c] = ΔΦ * (j - 1)
28-
Φᵤ[c] = ΔΦ * j
29-
c += 1
3+
function vertex_sphere(θ, φ, trans)
4+
FT = eltype(trans.linear)
5+
sθ, cθ = sincos(FT(θ))
6+
sφ, cφ = sincos(FT(φ))
7+
trans(Vec{FT}(sθ * cφ, sθ * sφ, 1 + cθ))
8+
end
9+
10+
struct EllipsoidVertices{FT,TT}
11+
n::Int
12+
::FT
13+
::FT
14+
trans::TT
15+
end
16+
function EllipsoidVertices(n, trans)
17+
FT = eltype(trans.linear)
18+
EllipsoidVertices(n, FT/ n), FT(2π / n), trans)
19+
end
20+
21+
function Base.iterate(c::EllipsoidVertices{FT,TT}, i::Int = 1)::Union{Nothing,Tuple{Vec{FT},Int64}} where {FT,TT}
22+
n = c.n
23+
i > 6n * (n - 1) && return nothing
24+
ntop = 3n
25+
nb = 6n # vertices per middle band
26+
if i <= ntop
27+
# Top cap: n fan triangles from north pole to first latitude ring
28+
j = div(i - 1, 3)
29+
v = mod(i - 1, 3) + 1
30+
v == 1 && (return c.trans(Vec{FT}(0, 0, 2)), i + 1)
31+
v == 2 && (return vertex_sphere(c.dθ, j * c.dφ, c.trans), i + 1)
32+
v == 3 && (return vertex_sphere(c.dθ, (j + 1) * c.dφ, c.trans), i + 1)
33+
elseif i <= ntop + (n - 2) * nb
34+
# Middle bands: (n-2) latitude bands, each with n quads split into 2 triangles
35+
im = i - ntop - 1
36+
iband = div(im, nb)
37+
iw = mod(im, nb)
38+
θu = (iband + 1) * c.# upper ring (smaller θ)
39+
θl = (iband + 2) * c.# lower ring (larger θ)
40+
if iw < 3n
41+
# First triangle of each quad: (top-left, bottom-left, bottom-right)
42+
j = div(iw, 3)
43+
v = mod(iw, 3) + 1
44+
v == 1 && (return vertex_sphere(θu, j * c.dφ, c.trans), i + 1)
45+
v == 2 && (return vertex_sphere(θl, j * c.dφ, c.trans), i + 1)
46+
v == 3 && (return vertex_sphere(θl, (j + 1) * c.dφ, c.trans), i + 1)
47+
else
48+
# Second triangle of each quad: (top-left, bottom-right, top-right)
49+
iw2 = iw - 3n
50+
j = div(iw2, 3)
51+
v = mod(iw2, 3) + 1
52+
v == 1 && (return vertex_sphere(θu, j * c.dφ, c.trans), i + 1)
53+
v == 2 && (return vertex_sphere(θl, (j + 1) * c.dφ, c.trans), i + 1)
54+
v == 3 && (return vertex_sphere(θu, (j + 1) * c.dφ, c.trans), i + 1)
3055
end
56+
else
57+
# Bottom cap: n fan triangles from last latitude ring to south pole (reversed winding)
58+
ib = i - ntop - (n - 2) * nb - 1
59+
j = div(ib, 3)
60+
v = mod(ib, 3) + 1
61+
θlast = (n - 1) * c.
62+
v == 1 && (return c.trans(Vec{FT}(0, 0, 0)), i + 1)
63+
v == 2 && (return vertex_sphere(θlast, (j + 1) * c.dφ, c.trans), i + 1)
64+
v == 3 && (return vertex_sphere(θlast, j * c.dφ, c.trans), i + 1)
3165
end
32-
33-
(θₗ, θᵤ, Φₗ, Φᵤ, ns)
3466
end
3567

36-
# Every combination of theta and phi angles yields a vertex
37-
# Then the vertex are mapped to the other hemisphere
38-
# This means that we cannot have vertices at theta = 0
68+
Base.length(c::EllipsoidVertices) = 6c.n * (c.n - 1)
69+
Base.eltype(::Type{EllipsoidVertices{FT,TT}}) where {FT,TT} = Vec{FT}
3970

40-
# Step 1: Turn the function above into an interator that generates
41-
# vertices in both hemispheres (following a reasonable ordering)
4271

43-
# Step 2: Create the rules to connect these vertices into triangles
72+
"""
73+
Ellipsoid(;length = 1.0, width = 1.0, height = 1.0, n = 20)
4474
45-
# Step 3: Create the normals to each face (average theta and phi??)
75+
Create a solid ellipsoid with dimensions given by `length`, `width` and `height`,
76+
with the base on the z = 0 plane, discretized using a UV latitude-longitude mesh
77+
with `n` latitude and azimuth subdivisions.
4678
79+
# Arguments
80+
- `length = 1.0`: The length of the ellipsoid along the z axis.
81+
- `width = 1.0`: The width of the ellipsoid along the y axis.
82+
- `height = 1.0`: The height of the ellipsoid along the x axis.
83+
- `n = 20`: Resolution (number of latitude and azimuth subdivisions). Must be >= 3.
84+
The mesh has `2n(n-1)` triangles.
4785
48-
# Scaled ellipsoid
49-
function Ellipsoid(l::Number, w::Number, h::Number, n::Number)
50-
@error "Ellipsoid not implemented yet"
86+
# Examples
87+
```jldoctest
88+
julia> Ellipsoid(;length = 1.0, width = 1.0, height = 1.0, n = 20);
89+
```
90+
"""
91+
function Ellipsoid(; length::FT = 1.0, width::FT = 1.0, height::FT = 1.0, n::Int = 20) where {FT}
92+
trans = CT.LinearMap(CT.SDiagonal(height / FT(2), width / FT(2), length / FT(2)))
93+
Ellipsoid(trans; n = n)
5194
end
5295

53-
# Create a ellipsoid from affine transformation
54-
function Ellipsoid(trans::CT.AbstractAffineMap, nt::Number)
55-
@error "Ellipsoid not implemented yet"
96+
# Create an ellipsoid from affine transformation
97+
function Ellipsoid(trans::CT.AbstractAffineMap; n::Int = 20)
98+
@assert n >= 3 "n must be at least 3"
99+
Primitive(trans, x -> EllipsoidVertices(n, x))
56100
end
57101

58-
# Create a ellipsoid from affine transformation and add it in-place to existing mesh
59-
function Ellipsoid(m::Mesh, trans::CT.AbstractAffineMap, nt::Int)
60-
@error "Ellipsoid not implemented yet"
102+
# Create an ellipsoid from affine transformation and add it in-place to existing mesh
103+
function Ellipsoid!(m::Mesh, trans::CT.AbstractAffineMap; n::Int = 20)
104+
@assert n >= 3 "n must be at least 3"
105+
Primitive!(m, trans, x -> EllipsoidVertices(n, x))
61106
end

test/runtests.jl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ end
5858
@testset "solid_cone" begin
5959
include("test_solid_cone.jl")
6060
end
61+
@testset "ellipsoid" begin
62+
include("test_ellipsoid.jl")
63+
end
6164
@testset "transformations" begin
6265
include("test_transformations.jl")
6366
end

test/test_ellipsoid.jl

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,70 @@ using Test
33
import CoordinateTransformations: SDiagonal, LinearMap
44

55
let
6+
n = 20
67

8+
# Standard Ellipsoid primitive
9+
e = G.Ellipsoid(length = 2.0, width = 1.0, height = 1.0, n = n)
10+
@test e isa G.Mesh
11+
@test G.nvertices(e) == 6n * (n - 1)
12+
@test G.ntriangles(e) == div(G.nvertices(e), 3)
13+
@test length(G.normals(e)) == G.ntriangles(e)
714

15+
# Area test: sphere of diameter 2 (length=width=height=2) -> area = 4π
16+
e_sphere = G.Ellipsoid(length = 2.0, width = 2.0, height = 2.0, n = n)
17+
@test abs(G.area(e_sphere) / (4π) - 1.0) < 0.02
818

19+
# Outward normals: each face normal must point away from the ellipsoid center
20+
center = G.Vec(0.0, 0.0, 1.0) # canonical (0,0,1) mapped by SDiagonal(1,1,1)
21+
for i in 1:G.ntriangles(e_sphere)
22+
tri = G.get_triangle(e_sphere, i)
23+
centroid = (tri[1] .+ tri[2] .+ tri[3]) ./ 3
24+
@test sum(G.normals(e_sphere)[i] .* (centroid .- center)) > 0
25+
end
26+
27+
# Float32 variant
28+
e32 = G.Ellipsoid(length = 2.0f0, width = 1.0f0, height = 1.0f0, n = n)
29+
@test e32 isa G.Mesh
30+
@test G.nvertices(e32) == 6n * (n - 1)
31+
@test G.ntriangles(e32) == div(G.nvertices(e32), 3)
32+
@test length(G.normals(e32)) == G.ntriangles(e32)
33+
34+
# Merging two meshes
35+
e2 = G.Ellipsoid(length = 3.0, width = 0.5, height = 0.5, n = n)
36+
function foo()
37+
e = G.Ellipsoid(length = 2.0, width = 1.0, height = 1.0, n = n)
38+
e2 = G.Ellipsoid(length = 3.0, width = 0.5, height = 0.5, n = n)
39+
G.Mesh([e, e2])
40+
end
41+
m = foo()
42+
@test G.nvertices(m) == G.nvertices(e) + G.nvertices(e2)
43+
@test G.ntriangles(m) == G.ntriangles(e) + G.ntriangles(e2)
44+
@test abs(G.area(m) - (G.area(e) + G.area(e2))) < 1e-10
45+
46+
# Affine map equivalence: keyword constructor and explicit LinearMap must agree
47+
scale = LinearMap(SDiagonal(0.5 / 2, 0.5 / 2, 0.5 / 2))
48+
e3 = G.Ellipsoid(scale; n = n)
49+
e4 = G.Ellipsoid(length = 0.5, width = 0.5, height = 0.5, n = n)
50+
@test G.normals(e3) == G.normals(e4)
51+
@test G.vertices(e3) == G.vertices(e4)
52+
53+
# In-place Ellipsoid!
54+
function foo2()
55+
m2 = G.Ellipsoid(length = 2.0, width = 1.0, height = 1.0, n = n)
56+
scale2 = LinearMap(SDiagonal(0.25 / 2, 0.5 / 2, 1.5 / 2))
57+
G.Ellipsoid!(m2, scale2; n = n)
58+
m2
59+
end
60+
m2 = foo2()
61+
e5 = G.Ellipsoid(length = 1.5, width = 0.5, height = 0.25, n = n)
62+
@test G.nvertices(m2) == G.nvertices(e) + G.nvertices(e5)
63+
@test G.ntriangles(m2) == G.ntriangles(e) + G.ntriangles(e5)
964
end
65+
66+
67+
#= import GLMakie
68+
import PlantViz as PV
69+
e = G.Ellipsoid(length = 1.0, width = 1.0, height = 1.0, n = n)
70+
G.add_property!(e, :colors, RGB(1.0, 0.0, 0.0))
71+
PV.render(e, normals = true)
72+
=#

0 commit comments

Comments
 (0)