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 > 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());
+ }
+}