This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git


The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
     new 233de2b767 feat(Geometry): add scene light objects
233de2b767 is described below

commit 233de2b767a55ffdd505a5a6bae2181079ad0396
Author: jsorel <[email protected]>
AuthorDate: Thu Jul 23 17:15:55 2026 +0200

    feat(Geometry): add scene light objects
---
 .../org/apache/sis/geometries/scene/SceneNode.java |  21 +++
 .../sis/geometries/scene/lights/Directional.java   | 107 +++++++++++++
 .../apache/sis/geometries/scene/lights/HDRI.java   | 128 +++++++++++++++
 .../apache/sis/geometries/scene/lights/Light.java  |  77 +++++++++
 .../apache/sis/geometries/scene/lights/Point.java  | 131 ++++++++++++++++
 .../apache/sis/geometries/scene/lights/Quad.java   | 157 +++++++++++++++++++
 .../apache/sis/geometries/scene/lights/Ring.java   | 172 +++++++++++++++++++++
 .../apache/sis/geometries/scene/lights/Spot.java   | 154 ++++++++++++++++++
 8 files changed, 947 insertions(+)

diff --git 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/SceneNode.java
 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/SceneNode.java
index bf4f42ef66..5c241d7302 100644
--- 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/SceneNode.java
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/SceneNode.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sis.geometries.scene;
 
+import org.apache.sis.geometries.scene.lights.Light;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -104,6 +105,7 @@ public class SceneNode {
 
     private CoordinateReferenceSystem crs = Geometries.RIGHT_HAND_3D;
     private Camera camera;
+    private Light light;
     private Model model;
     private String name;
     private Feature feature;
@@ -252,6 +254,20 @@ public class SceneNode {
         this.camera = camera;
     }
 
+    /**
+     * @return light attached, may be null
+     */
+    public Light getLight() {
+        return light;
+    }
+
+    /**
+     * @param light to attach, may be null
+     */
+    public void setLight(Light light) {
+        this.light = light;
+    }
+
     /**
      * @return skin attached, may be null
      */
@@ -415,6 +431,7 @@ public class SceneNode {
         sb.append('(');
         if (model != null) sb.append(" Model ");
         if (camera != null) sb.append(" Camera ");
+        if (light != null) sb.append(" Light ");
         if (feature != null) sb.append(" Feature ");
         sb.append(" Children[").append(children.size()).append("] ");
         sb.append(')');
@@ -426,6 +443,7 @@ public class SceneNode {
         int hash = 7;
         hash = 97 * hash + Objects.hashCode(this.parentToNode);
         hash = 97 * hash + Objects.hashCode(this.camera);
+        hash = 97 * hash + Objects.hashCode(this.light);
         hash = 97 * hash + Objects.hashCode(this.model);
         hash = 97 * hash + Objects.hashCode(this.name);
         return hash;
@@ -455,6 +473,9 @@ public class SceneNode {
         if (!Objects.equals(this.camera, other.camera)) {
             return false;
         }
+        if (!Objects.equals(this.light, other.light)) {
+            return false;
+        }
         if (!Objects.equals(this.model, other.model)) {
             return false;
         }
diff --git 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/Directional.java
 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/Directional.java
new file mode 100644
index 0000000000..5ac377c368
--- /dev/null
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/Directional.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.geometries.scene.lights;
+
+import java.util.Objects;
+import org.apache.sis.util.ArgumentChecks;
+
+/**
+ * A light which is thought to be infinitely far away, so its rays arrive
+ * (almost) parallel, such as sunlight.
+ *
+ * Matches glTF {@code KHR_lights_punctual} type {@code "directional"}
+ * (illuminance in lux, lm/m2) and Khronos ANARI {@code directional} light.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class Directional extends Light {
+
+    private double intensity = 1.0;
+    private double angularDiameter = 0.0;
+
+    public Directional() {
+    }
+
+    /**
+     * Illuminance arriving at a surface facing the light, in lux (lm/m2).
+     * Default is 1.0.
+     * @return intensity
+     */
+    public double getIntensity() {
+        return intensity;
+    }
+
+    /**
+     * @param intensity illuminance in lux, must not be negative
+     */
+    public void setIntensity(double intensity) {
+        ArgumentChecks.ensurePositive("intensity", intensity);
+        this.intensity = intensity;
+    }
+
+    /**
+     * Apparent size (angle in radians) of the light. A value greater than
+     * zero enables soft shadows. Default is 0.0.
+     * Not part of glTF {@code KHR_lights_punctual}, ANARI-specific.
+     * @return angular diameter in radians
+     */
+    public double getAngularDiameter() {
+        return angularDiameter;
+    }
+
+    /**
+     * @param angularDiameter angle in radians, must not be negative
+     */
+    public void setAngularDiameter(double angularDiameter) {
+        ArgumentChecks.ensurePositive("angularDiameter", angularDiameter);
+        this.angularDiameter = angularDiameter;
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 5;
+        hash = 61 * hash + Objects.hashCode(getColor());
+        hash = 61 * hash + Boolean.hashCode(isVisible());
+        hash = 61 * hash + Double.hashCode(this.intensity);
+        hash = 61 * hash + Double.hashCode(this.angularDiameter);
+        return hash;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        final Directional other = (Directional) obj;
+        if (Double.compare(this.intensity, other.intensity) != 0) {
+            return false;
+        }
+        if (Double.compare(this.angularDiameter, other.angularDiameter) != 0) {
+            return false;
+        }
+        if (this.isVisible() != other.isVisible()) {
+            return false;
+        }
+        return Objects.equals(this.getColor(), other.getColor());
+    }
+}
diff --git 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/HDRI.java
 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/HDRI.java
new file mode 100644
index 0000000000..251c9ac406
--- /dev/null
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/HDRI.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.geometries.scene.lights;
+
+import java.awt.image.RenderedImage;
+import java.util.Objects;
+import org.apache.sis.util.ArgumentChecks;
+
+/**
+ * A textured light source surrounding the scene, illuminating it from
+ * infinity. The environment map orientation follows the node's local axes
+ * (local -Z is the direction the center of the texture is mapped to, local
+ * +Y is up), the same convention as {@link Point}/{@link Spot} placement.
+ *
+ * Has no glTF {@code KHR_lights_punctual} equivalent; added to reach
+ * feature parity with the Khronos ANARI {@code hdri} light.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class HDRI extends Light {
+
+    private RenderedImage radiance;
+    private double scale = 1.0;
+    private String layout = "equirectangular";
+
+    public HDRI() {
+    }
+
+    /**
+     * Environment map, typically HDR with values &gt; 1: the amount of
+     * light emitted by a point on the light source in a direction, in W/sr/m2.
+     * @return radiance image, may be null
+     */
+    public RenderedImage getRadiance() {
+        return radiance;
+    }
+
+    /**
+     * @param radiance environment map, may be null
+     */
+    public void setRadiance(RenderedImage radiance) {
+        this.radiance = radiance;
+    }
+
+    /**
+     * Scale factor applied to the radiance image values. Default is 1.0.
+     * @return scale
+     */
+    public double getScale() {
+        return scale;
+    }
+
+    /**
+     * @param scale scale factor
+     */
+    public void setScale(double scale) {
+        this.scale = scale;
+    }
+
+    /**
+     * Pixel layout of the environment map. Default is "equirectangular",
+     * currently the only value supported by ANARI.
+     * @return layout, never null
+     */
+    public String getLayout() {
+        return layout;
+    }
+
+    /**
+     * @param layout not null
+     */
+    public void setLayout(String layout) {
+        ArgumentChecks.ensureNonNull("layout", layout);
+        this.layout = layout;
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 5;
+        hash = 83 * hash + Objects.hashCode(getColor());
+        hash = 83 * hash + Boolean.hashCode(isVisible());
+        hash = 83 * hash + Objects.hashCode(this.radiance);
+        hash = 83 * hash + Double.hashCode(this.scale);
+        hash = 83 * hash + Objects.hashCode(this.layout);
+        return hash;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        final HDRI other = (HDRI) obj;
+        if (Double.compare(this.scale, other.scale) != 0) {
+            return false;
+        }
+        if (this.isVisible() != other.isVisible()) {
+            return false;
+        }
+        if (!Objects.equals(this.layout, other.layout)) {
+            return false;
+        }
+        if (!Objects.equals(this.radiance, other.radiance)) {
+            return false;
+        }
+        return Objects.equals(this.getColor(), other.getColor());
+    }
+}
diff --git 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/Light.java
 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/Light.java
new file mode 100644
index 0000000000..e5b75f2cdd
--- /dev/null
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/Light.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.geometries.scene.lights;
+
+import java.awt.Color;
+import org.apache.sis.util.ArgumentChecks;
+
+/**
+ * A light emits illumination into the scene. Position and orientation are
+ * not carried by the light itself but by the {@link SceneNode} it is
+ * attached to (translation is the light position, local -Z is the emission
+ * direction, local +Y is up), the same way a {@link Camera} is placed.
+ *
+ * {@link Directional}, {@link Point} and {@link Spot} follow the Khronos
+ * glTF {@code KHR_lights_punctual} extension naming and defaults.
+ * {@link Quad}, {@link Ring} and {@link HDRI} have no glTF punctual-light
+ * equivalent; they exist to reach feature parity with the Khronos ANARI
+ * light model (area and environment lighting).
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public abstract sealed class Light permits Directional, Point, Spot, Quad, 
Ring, HDRI {
+
+    private Color color = Color.WHITE;
+    private boolean visible = true;
+
+    Light() {
+    }
+
+    /**
+     * Unitless color factor which filters the emitted light.
+     * Default is WHITE (1,1,1).
+     * @return light color, never null.
+     */
+    public Color getColor() {
+        return color;
+    }
+
+    /**
+     * @param color light color, not null
+     */
+    public void setColor(Color color) {
+        ArgumentChecks.ensureNonNull("color", color);
+        this.color = color;
+    }
+
+    /**
+     * Whether the light itself can be directly seen (only meaningful for
+     * area lights such as {@link Quad} and {@link Ring}).
+     * Default value is true.
+     * @return visible
+     */
+    public boolean isVisible() {
+        return visible;
+    }
+
+    /**
+     * @param visible whether the light can be directly seen
+     */
+    public void setVisible(boolean visible) {
+        this.visible = visible;
+    }
+}
diff --git 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/Point.java
 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/Point.java
new file mode 100644
index 0000000000..bb09971669
--- /dev/null
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/Point.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.geometries.scene.lights;
+
+import java.util.Objects;
+import org.apache.sis.util.ArgumentChecks;
+
+/**
+ * A light emitting uniformly in all directions from a single point.
+ *
+ * Matches glTF {@code KHR_lights_punctual} type {@code "point"} (luminous
+ * intensity in candela, lm/sr, with an optional attenuation {@code range}
+ * hint) and Khronos ANARI {@code point} light.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class Point extends Light {
+
+    private double intensity = 1.0;
+    private Double range;
+    private double radius = 0.0;
+
+    public Point() {
+    }
+
+    /**
+     * Luminous intensity emitted by the light, in candela (lm/sr).
+     * Default is 1.0.
+     * @return intensity
+     */
+    public double getIntensity() {
+        return intensity;
+    }
+
+    /**
+     * @param intensity candela, must not be negative
+     */
+    public void setIntensity(double intensity) {
+        ArgumentChecks.ensurePositive("intensity", intensity);
+        this.intensity = intensity;
+    }
+
+    /**
+     * Hint for the distance cutoff at which the light's intensity may be
+     * considered to be zero. Not physically based. Default is null (no 
cutoff).
+     * @return range, or null if not set
+     */
+    public Double getRange() {
+        return range;
+    }
+
+    /**
+     * @param range distance cutoff hint, must be positive if not null
+     */
+    public void setRange(Double range) {
+        if (range != null) {
+            ArgumentChecks.ensureStrictlyPositive("range", range);
+        }
+        this.range = range;
+    }
+
+    /**
+     * The size of the point light, turning it into a sphere light for soft
+     * shadows. Default is 0.0.
+     * Not part of glTF {@code KHR_lights_punctual}, ANARI-specific.
+     * @return radius
+     */
+    public double getRadius() {
+        return radius;
+    }
+
+    /**
+     * @param radius must not be negative
+     */
+    public void setRadius(double radius) {
+        ArgumentChecks.ensurePositive("radius", radius);
+        this.radius = radius;
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 5;
+        hash = 67 * hash + Objects.hashCode(getColor());
+        hash = 67 * hash + Boolean.hashCode(isVisible());
+        hash = 67 * hash + Double.hashCode(this.intensity);
+        hash = 67 * hash + Objects.hashCode(this.range);
+        hash = 67 * hash + Double.hashCode(this.radius);
+        return hash;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        final Point other = (Point) obj;
+        if (Double.compare(this.intensity, other.intensity) != 0) {
+            return false;
+        }
+        if (Double.compare(this.radius, other.radius) != 0) {
+            return false;
+        }
+        if (this.isVisible() != other.isVisible()) {
+            return false;
+        }
+        if (!Objects.equals(this.range, other.range)) {
+            return false;
+        }
+        return Objects.equals(this.getColor(), other.getColor());
+    }
+}
diff --git 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/Quad.java
 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/Quad.java
new file mode 100644
index 0000000000..df92a9e9ef
--- /dev/null
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/Quad.java
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.geometries.scene.lights;
+
+import java.util.Objects;
+import org.apache.sis.util.ArgumentChecks;
+
+/**
+ * A planar, procedural rectangular area light source lying in the node's
+ * local XY plane (width along local X, height along local Y), emitting
+ * uniformly toward one or both sides.
+ *
+ * Has no glTF {@code KHR_lights_punctual} equivalent; added to reach
+ * feature parity with the Khronos ANARI {@code quad} light.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class Quad extends Light {
+
+    /**
+     * Side into which light is emitted.
+     */
+    public static enum Side {
+        FRONT,
+        BACK,
+        BOTH
+    }
+
+    private double width = 1.0;
+    private double height = 1.0;
+    private double intensity = 1.0;
+    private Side side = Side.FRONT;
+
+    public Quad() {
+    }
+
+    /**
+     * Extent of the quad along the node's local X axis. Default is 1.0.
+     * @return width
+     */
+    public double getWidth() {
+        return width;
+    }
+
+    /**
+     * @param width must be strictly positive
+     */
+    public void setWidth(double width) {
+        ArgumentChecks.ensureStrictlyPositive("width", width);
+        this.width = width;
+    }
+
+    /**
+     * Extent of the quad along the node's local Y axis. Default is 1.0.
+     * @return height
+     */
+    public double getHeight() {
+        return height;
+    }
+
+    /**
+     * @param height must be strictly positive
+     */
+    public void setHeight(double height) {
+        ArgumentChecks.ensureStrictlyPositive("height", height);
+        this.height = height;
+    }
+
+    /**
+     * The overall amount of light emitted by the light in a direction, in
+     * W/sr. Default is 1.0.
+     * @return intensity
+     */
+    public double getIntensity() {
+        return intensity;
+    }
+
+    /**
+     * @param intensity must not be negative
+     */
+    public void setIntensity(double intensity) {
+        ArgumentChecks.ensurePositive("intensity", intensity);
+        this.intensity = intensity;
+    }
+
+    /**
+     * Side into which light is emitted. Default is FRONT.
+     * @return side, never null
+     */
+    public Side getSide() {
+        return side;
+    }
+
+    /**
+     * @param side not null
+     */
+    public void setSide(Side side) {
+        ArgumentChecks.ensureNonNull("side", side);
+        this.side = side;
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 5;
+        hash = 73 * hash + Objects.hashCode(getColor());
+        hash = 73 * hash + Boolean.hashCode(isVisible());
+        hash = 73 * hash + Double.hashCode(this.width);
+        hash = 73 * hash + Double.hashCode(this.height);
+        hash = 73 * hash + Double.hashCode(this.intensity);
+        hash = 73 * hash + Objects.hashCode(this.side);
+        return hash;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        final Quad other = (Quad) obj;
+        if (Double.compare(this.width, other.width) != 0) {
+            return false;
+        }
+        if (Double.compare(this.height, other.height) != 0) {
+            return false;
+        }
+        if (Double.compare(this.intensity, other.intensity) != 0) {
+            return false;
+        }
+        if (this.isVisible() != other.isVisible()) {
+            return false;
+        }
+        if (this.side != other.side) {
+            return false;
+        }
+        return Objects.equals(this.getColor(), other.getColor());
+    }
+}
diff --git 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/Ring.java
 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/Ring.java
new file mode 100644
index 0000000000..a27fb9aa4f
--- /dev/null
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/Ring.java
@@ -0,0 +1,172 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.geometries.scene.lights;
+
+import java.util.Objects;
+import org.apache.sis.util.ArgumentChecks;
+
+/**
+ * A disk or ring shaped area light emitting into a cone of directions along
+ * the node's local -Z axis, with a cosine falloff.
+ *
+ * Has no glTF {@code KHR_lights_punctual} equivalent; added to reach
+ * feature parity with the Khronos ANARI {@code ring} light.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class Ring extends Light {
+
+    private double radius = 1.0;
+    private double innerRadius = 0.0;
+    private double openingAngle = Math.PI;
+    private double falloffAngle = 0.1;
+    private double intensity = 1.0;
+
+    public Ring() {
+    }
+
+    /**
+     * The outer radius of the ring (a disk when innerRadius is 0). Default is 
1.0.
+     * @return radius
+     */
+    public double getRadius() {
+        return radius;
+    }
+
+    /**
+     * @param radius must not be negative
+     */
+    public void setRadius(double radius) {
+        ArgumentChecks.ensurePositive("radius", radius);
+        this.radius = radius;
+    }
+
+    /**
+     * Turns the disk into a ring; must be smaller than {@link #getRadius() }.
+     * Default is 0.0.
+     * @return inner radius
+     */
+    public double getInnerRadius() {
+        return innerRadius;
+    }
+
+    /**
+     * @param innerRadius must not be negative
+     */
+    public void setInnerRadius(double innerRadius) {
+        ArgumentChecks.ensurePositive("innerRadius", innerRadius);
+        this.innerRadius = innerRadius;
+    }
+
+    /**
+     * Full opening angle (radians) of the cone of directions; outside this
+     * cone there is no illumination. Default is PI.
+     * @return opening angle in radians
+     */
+    public double getOpeningAngle() {
+        return openingAngle;
+    }
+
+    /**
+     * @param openingAngle radians, must be in [0, PI]
+     */
+    public void setOpeningAngle(double openingAngle) {
+        ArgumentChecks.ensureBetween("openingAngle", 0.0, Math.PI, 
openingAngle);
+        this.openingAngle = openingAngle;
+    }
+
+    /**
+     * Size (radians) of the region between the rim of the illumination cone
+     * and full intensity; should be smaller than half of the opening angle.
+     * Default is 0.1.
+     * @return falloff angle in radians
+     */
+    public double getFalloffAngle() {
+        return falloffAngle;
+    }
+
+    /**
+     * @param falloffAngle must not be negative
+     */
+    public void setFalloffAngle(double falloffAngle) {
+        ArgumentChecks.ensurePositive("falloffAngle", falloffAngle);
+        this.falloffAngle = falloffAngle;
+    }
+
+    /**
+     * The overall amount of light emitted by the light in a direction, in
+     * W/sr. Default is 1.0.
+     * @return intensity
+     */
+    public double getIntensity() {
+        return intensity;
+    }
+
+    /**
+     * @param intensity must not be negative
+     */
+    public void setIntensity(double intensity) {
+        ArgumentChecks.ensurePositive("intensity", intensity);
+        this.intensity = intensity;
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 5;
+        hash = 79 * hash + Objects.hashCode(getColor());
+        hash = 79 * hash + Boolean.hashCode(isVisible());
+        hash = 79 * hash + Double.hashCode(this.radius);
+        hash = 79 * hash + Double.hashCode(this.innerRadius);
+        hash = 79 * hash + Double.hashCode(this.openingAngle);
+        hash = 79 * hash + Double.hashCode(this.falloffAngle);
+        hash = 79 * hash + Double.hashCode(this.intensity);
+        return hash;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        final Ring other = (Ring) obj;
+        if (Double.compare(this.radius, other.radius) != 0) {
+            return false;
+        }
+        if (Double.compare(this.innerRadius, other.innerRadius) != 0) {
+            return false;
+        }
+        if (Double.compare(this.openingAngle, other.openingAngle) != 0) {
+            return false;
+        }
+        if (Double.compare(this.falloffAngle, other.falloffAngle) != 0) {
+            return false;
+        }
+        if (Double.compare(this.intensity, other.intensity) != 0) {
+            return false;
+        }
+        if (this.isVisible() != other.isVisible()) {
+            return false;
+        }
+        return Objects.equals(this.getColor(), other.getColor());
+    }
+}
diff --git 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/Spot.java
 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/Spot.java
new file mode 100644
index 0000000000..03b9375488
--- /dev/null
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/lights/Spot.java
@@ -0,0 +1,154 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.geometries.scene.lights;
+
+import java.util.Objects;
+import org.apache.sis.util.ArgumentChecks;
+
+/**
+ * A light emitting into a cone of directions along the node's local -Z axis.
+ *
+ * Matches glTF {@code KHR_lights_punctual} type {@code "spot"} (luminous
+ * intensity in candela, lm/sr, with {@code innerConeAngle}/
+ * {@code outerConeAngle} and an optional attenuation {@code range} hint)
+ * and Khronos ANARI {@code spot} light.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class Spot extends Light {
+
+    private double intensity = 1.0;
+    private Double range;
+    private double innerConeAngle = 0.0;
+    private double outerConeAngle = Math.PI / 4.0;
+
+    public Spot() {
+    }
+
+    /**
+     * Luminous intensity emitted by the light, in candela (lm/sr).
+     * Default is 1.0.
+     * @return intensity
+     */
+    public double getIntensity() {
+        return intensity;
+    }
+
+    /**
+     * @param intensity candela, must not be negative
+     */
+    public void setIntensity(double intensity) {
+        ArgumentChecks.ensurePositive("intensity", intensity);
+        this.intensity = intensity;
+    }
+
+    /**
+     * Hint for the distance cutoff at which the light's intensity may be
+     * considered to be zero. Not physically based. Default is null (no 
cutoff).
+     * @return range, or null if not set
+     */
+    public Double getRange() {
+        return range;
+    }
+
+    /**
+     * @param range distance cutoff hint, must be positive if not null
+     */
+    public void setRange(Double range) {
+        if (range != null) {
+            ArgumentChecks.ensureStrictlyPositive("range", range);
+        }
+        this.range = range;
+    }
+
+    /**
+     * Angle (radians) from the spot's central axis where the intensity
+     * begins to fall off toward {@link #getOuterConeAngle() }.
+     * Default is 0.0. Must be in [0, outerConeAngle].
+     * @return inner cone angle in radians
+     */
+    public double getInnerConeAngle() {
+        return innerConeAngle;
+    }
+
+    /**
+     * @param innerConeAngle radians, must be in [0, outerConeAngle]
+     */
+    public void setInnerConeAngle(double innerConeAngle) {
+        ArgumentChecks.ensureBetween("innerConeAngle", 0.0, outerConeAngle, 
innerConeAngle);
+        this.innerConeAngle = innerConeAngle;
+    }
+
+    /**
+     * Angle (radians) from the spot's central axis where the intensity
+     * reaches zero. Default is PI/4. Must be in [innerConeAngle, PI/2].
+     * @return outer cone angle in radians
+     */
+    public double getOuterConeAngle() {
+        return outerConeAngle;
+    }
+
+    /**
+     * @param outerConeAngle radians, must be in [innerConeAngle, PI/2]
+     */
+    public void setOuterConeAngle(double outerConeAngle) {
+        ArgumentChecks.ensureBetween("outerConeAngle", innerConeAngle, Math.PI 
/ 2.0, outerConeAngle);
+        this.outerConeAngle = outerConeAngle;
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 5;
+        hash = 71 * hash + Objects.hashCode(getColor());
+        hash = 71 * hash + Boolean.hashCode(isVisible());
+        hash = 71 * hash + Double.hashCode(this.intensity);
+        hash = 71 * hash + Objects.hashCode(this.range);
+        hash = 71 * hash + Double.hashCode(this.innerConeAngle);
+        hash = 71 * hash + Double.hashCode(this.outerConeAngle);
+        return hash;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        final Spot other = (Spot) obj;
+        if (Double.compare(this.intensity, other.intensity) != 0) {
+            return false;
+        }
+        if (Double.compare(this.innerConeAngle, other.innerConeAngle) != 0) {
+            return false;
+        }
+        if (Double.compare(this.outerConeAngle, other.outerConeAngle) != 0) {
+            return false;
+        }
+        if (this.isVisible() != other.isVisible()) {
+            return false;
+        }
+        if (!Objects.equals(this.range, other.range)) {
+            return false;
+        }
+        return Objects.equals(this.getColor(), other.getColor());
+    }
+}


Reply via email to