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

jsorel 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 7c3fba2ec4 feat(Geometry): add scene model as geometry aggregation tree
7c3fba2ec4 is described below

commit 7c3fba2ec4a678eb561d1793be017829975fe9dc
Author: jsorel <[email protected]>
AuthorDate: Wed Feb 25 14:06:18 2026 +0100

    feat(Geometry): add scene model as geometry aggregation tree
---
 .../org/apache/sis/geometries/scene/Camera.java    | 242 +++++++++
 .../org/apache/sis/geometries/scene/Material.java  | 597 +++++++++++++++++++++
 .../org/apache/sis/geometries/scene/Model.java     | 216 ++++++++
 .../org/apache/sis/geometries/scene/Sampler.java   | 171 ++++++
 .../org/apache/sis/geometries/scene/SceneNode.java | 480 +++++++++++++++++
 .../org/apache/sis/geometries/scene/Surface.java   | 107 ++++
 .../org/apache/sis/geometries/scene/Texture.java   | 142 +++++
 .../apache/sis/geometries/scene/package-info.java  |  27 +
 8 files changed, 1982 insertions(+)

diff --git 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/Camera.java
 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/Camera.java
new file mode 100644
index 0000000000..449083e2d6
--- /dev/null
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/Camera.java
@@ -0,0 +1,242 @@
+/*
+ * 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;
+
+import java.util.Objects;
+
+/**
+ * Base class for Camera types.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public abstract sealed class Camera permits Camera.Orthographic, 
Camera.Perspective {
+
+    Camera(){
+
+    }
+
+    /**
+    * Orthographic camera definition.
+    */
+   public static final class Orthographic extends Camera {
+
+       private double xmag;
+       private double ymag;
+       private double zfar;
+       private double znear;
+
+       /**
+        * @return The floating-point horizontal magnification of the view.Must 
not be zero.
+        */
+       public double getXmag() {
+           return xmag;
+       }
+
+       /**
+        * @param xmag The floating-point horizontal magnification of the 
view.Must not be zero.
+        */
+       public void setXmag(double xmag) {
+           this.xmag = xmag;
+       }
+
+       /**
+        * @return The floating-point vertical magnification of the view.Must 
not be zero.
+        */
+       public double getYmag() {
+           return ymag;
+       }
+
+       /**
+        * @param ymag The floating-point vertical magnification of the 
view.Must not be zero.
+        */
+       public void setYmag(double ymag) {
+           this.ymag = ymag;
+       }
+
+       /**
+        * @return The floating-point distance to the far clipping plane.`zfar` 
must be greater than `znear`.
+        */
+       public double getZfar() {
+           return zfar;
+       }
+
+       /**
+        * @param zfar The floating-point distance to the far clipping 
plane.`zfar` must be greater than `znear`.
+        */
+       public void setZfar(double zfar) {
+           this.zfar = zfar;
+       }
+
+       /**
+        * @return The floating-point distance to the near clipping plane.
+        */
+       public double getZnear() {
+           return znear;
+       }
+
+       /**
+        * @param znear The floating-point distance to the near clipping plane.
+        */
+       public void setZnear(double znear) {
+           this.znear = znear;
+       }
+
+       @Override
+       public int hashCode() {
+           int hash = 7;
+           hash = 53 * hash + (int) (Double.doubleToLongBits(this.xmag) ^ 
(Double.doubleToLongBits(this.xmag) >>> 32));
+           hash = 53 * hash + (int) (Double.doubleToLongBits(this.ymag) ^ 
(Double.doubleToLongBits(this.ymag) >>> 32));
+           hash = 53 * hash + (int) (Double.doubleToLongBits(this.zfar) ^ 
(Double.doubleToLongBits(this.zfar) >>> 32));
+           hash = 53 * hash + (int) (Double.doubleToLongBits(this.znear) ^ 
(Double.doubleToLongBits(this.znear) >>> 32));
+           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 Orthographic other = (Orthographic) obj;
+           if (Double.doubleToLongBits(this.xmag) != 
Double.doubleToLongBits(other.xmag)) {
+               return false;
+           }
+           if (Double.doubleToLongBits(this.ymag) != 
Double.doubleToLongBits(other.ymag)) {
+               return false;
+           }
+           if (Double.doubleToLongBits(this.zfar) != 
Double.doubleToLongBits(other.zfar)) {
+               return false;
+           }
+           if (Double.doubleToLongBits(this.znear) != 
Double.doubleToLongBits(other.znear)) {
+               return false;
+           }
+           return true;
+       }
+
+   }
+
+   /**
+    * Perspective camera definition.
+    */
+   public static final class Perspective extends Camera {
+
+       private Double aspectRatio;
+       private double yfov;
+       private Double zfar;
+       private double znear;
+
+       /**
+        * @return The floating-point aspect ratio of the field of view.
+        */
+       public Double getAspectRatio() {
+           return aspectRatio;
+       }
+
+       /**
+        * @param aspectRatio The floating-point aspect ratio of the field of 
view.
+        */
+       public void setAspectRatio(Double aspectRatio) {
+           this.aspectRatio = aspectRatio;
+       }
+
+       /**
+        * @return The floating-point vertical field of view in 
radians.(Required)
+        */
+       public double getYfov() {
+           return yfov;
+       }
+
+       /**
+        * @param yfov The floating-point vertical field of view in 
radians.(Required)
+        */
+       public void setYfov(double yfov) {
+           this.yfov = yfov;
+       }
+
+       /**
+        * @return The floating-point distance to the far clipping plane.
+        */
+       public Double getZfar() {
+           return zfar;
+       }
+
+       /**
+        * @param zfar The floating-point distance to the far clipping plane.
+        */
+       public void setZfar(Double zfar) {
+           this.zfar = zfar;
+       }
+
+       /**
+        * @return The floating-point distance to the near clipping 
plane.(Required)
+        */
+       public double getZnear() {
+           return znear;
+       }
+
+       /**
+        * @param znear The floating-point distance to the near clipping 
plane.(Required)
+        */
+       public void setZnear(double znear) {
+           this.znear = znear;
+       }
+
+       @Override
+       public int hashCode() {
+           int hash = 7;
+           hash = 71 * hash + Objects.hashCode(this.aspectRatio);
+           hash = 71 * hash + (int) (Double.doubleToLongBits(this.yfov) ^ 
(Double.doubleToLongBits(this.yfov) >>> 32));
+           hash = 71 * hash + Objects.hashCode(this.zfar);
+           hash = 71 * hash + (int) (Double.doubleToLongBits(this.znear) ^ 
(Double.doubleToLongBits(this.znear) >>> 32));
+           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 Perspective other = (Perspective) obj;
+           if (Double.doubleToLongBits(this.yfov) != 
Double.doubleToLongBits(other.yfov)) {
+               return false;
+           }
+           if (Double.doubleToLongBits(this.znear) != 
Double.doubleToLongBits(other.znear)) {
+               return false;
+           }
+           if (!Objects.equals(this.aspectRatio, other.aspectRatio)) {
+               return false;
+           }
+           if (!Objects.equals(this.zfar, other.zfar)) {
+               return false;
+           }
+           return true;
+       }
+
+   }
+
+}
diff --git 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/Material.java
 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/Material.java
new file mode 100644
index 0000000000..257f22268c
--- /dev/null
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/Material.java
@@ -0,0 +1,597 @@
+/*
+ * 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;
+
+import java.awt.Color;
+import java.util.HashMap;
+import java.util.Objects;
+import org.apache.sis.util.ArgumentChecks;
+
+/**
+ * A material is a set of properties defining a visual representation.
+ * Materials on there own do not suffice to obtain the visual aspect
+ * of a model, they must be combined with a rendering technique.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public class Material extends HashMap<String,Object> {
+
+    public static final String ALPHA_MODE_OPAQUE = "OPAQUE";
+    public static final String ALPHA_MODE_MASK = "MASK";
+    public static final String ALPHA_MODE_BLEND = "BLEND";
+
+    // PBR rendering technique 
/////////////////////////////////////////////////
+
+    /** Boolean, default is false */
+    public static final String DOUBLESIDED = "doubleSided";
+    /** Double, The alpha cutoff value of the material. default is 0.5 */
+    public static final String ALPHACUTOFF = "alphaCutoff";
+    /** String alpha mode. default is opaque*/
+    public static final String ALPHAMODE = "alphaMode";
+    /** Color */
+    public static final String EMISSIVEFACTOR = "emissiveFactor";
+    /** Texture */
+    public static final String EMISSIVETEXTURE = "emissiveTexture";
+    /** Texture */
+    public static final String OCCLUSIONTEXTURE = "occlusionTexture";
+    public static final String OCCLUSIONSTRENGTH = "occlusionStrength";
+    /** Texture */
+    public static final String NORMALTEXTURE = "normalTexture";
+    public static final String NORMALSCALE = "normalScale";
+
+
+    /** Color */
+    public static final String PBR_BASECOLORFACTOR = "baseColorFactor";
+    /** Texture */
+    public static final String PBR_BASECOLORTEXTURE = "baseColorTexture";
+    /** Number */
+    public static final String PBR_METALLICFACTOR = "metallicFactor";
+    /** Number */
+    public static final String PBR_ROUGHNESSFACTOR = "roughnessFactor";
+    /** Texture */
+    public static final String PBR_METALLICROUGHNESSTEXTURE = 
"metallicRoughnessTexture";
+
+    // PBR rendering technique 
/////////////////////////////////////////////////
+
+    /** Color */
+    public static final String PBRSG_DIFFUSEFACTOR = "diffuseFactor";
+    /** Texture */
+    public static final String PBRSG_DIFFUSETEXTURE = "diffuseTexture";
+    /** Color */
+    public static final String PBRSG_SPECULARFACTOR = "specularFactor";
+    /** Scalar */
+    public static final String PBRSG_GLOSSINESSFACTOR = "glossinessFactor";
+    /** Texture */
+    public static final String PBRSG_SPECULAR_GLOSSINESS_TEXTURE = 
"specularGlossinessTexture";
+
+
+    // Blinn-Phong rendering technique 
/////////////////////////////////////////
+
+    /** Color */
+    public static final String BP_AMBIANTFACTOR = "ambiantFactor";
+    /** Texture */
+    public static final String BP_AMBIANTTEXTURE = "ambiantTexture";
+    /** Color */
+    public static final String BP_DIFFUSEFACTOR = "diffuseFactor";
+    /** Texture */
+    public static final String BP_DIFFUSETEXTURE = "diffuseTexture";
+    /** Color */
+    public static final String BP_SPECULARFACTOR = "specularFactor";
+    /** Texture */
+    public static final String BP_SPECULARTEXTURE = "specularTexture";
+
+    // Unlit rendering technique /////////////////////////////////////////
+    // See 
https://github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_materials_unlit/README.md
+
+    /** Boolean */
+    public static final String LIGHTS_UNLIT = "unlit";
+
+    // See 
https://github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_materials_ior/README.md
+    /** Double */
+    public static final String IOR = "ior";
+
+    // See 
https://github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_materials_transmission/README.md
+    /** Double */
+    public static final String TRANSMISSIONFACTOR = "transmissionFactor";
+    /** Texture */
+    public static final String TRANSMISSIONTEXTURE = "transmissionTexture";
+
+    // See 
https://github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_materials_volume/README.md
+    /** Double */
+    public static final String THICKNESSFACTOR = "thicknessFactor";
+    /** Texture */
+    public static final String THICKNESSTEXTURE = "thicknessTexture";
+    /** Double */
+    public static final String ATTENUATIONDISTANCE = "attenuationDistance";
+    /** Color */
+    public static final String ATTENUATIONCOLOR = "attenuationColor";
+
+
+    private String id;
+
+    public Material() {
+
+    }
+
+    /**
+     * @return material identifier
+     */
+    public String getIdentifier() {
+        return id;
+    }
+
+    /**
+     * @param id set material identifier
+     */
+    public void setIdentifier(String id) {
+        this.id = id;
+    }
+
+    /**
+     * Default is OPAQUE
+     * @return alphaMode
+     */
+    public String getAlphaMode() {
+        String str = (String) get(ALPHAMODE);
+        return (str == null) ? ALPHA_MODE_OPAQUE : str;
+    }
+
+    /**
+     * Default is OPAQUE
+     * @param alphaMode
+     */
+    public void setAlphaMode(String alphaMode) {
+        put(ALPHAMODE, alphaMode);
+    }
+
+    /**
+     * Default value is 0.5
+     * @return alpha cutoff
+     */
+    public double getAlphaCutoff() {
+        Number ac = (Number) get(ALPHACUTOFF);
+        return (ac == null) ? 0.5 : ac.doubleValue();
+    }
+
+    /**
+     * Default value is 0.5
+     * @param cutoff
+     */
+    public void setAlphaCutoff(double cutoff) {
+        ArgumentChecks.ensureBetween("alpha cutoff", 0.0, 1.0, cutoff);
+        put(ALPHACUTOFF, cutoff);
+    }
+
+    /**
+     * Default value is false
+     * @return double sided.
+     */
+    public boolean isDoubleSided() {
+        return Boolean.TRUE.equals(get(DOUBLESIDED));
+    }
+
+    /**
+     * Default value is false
+     * @param doublesided
+     */
+    public void setDoubleSided(boolean doublesided) {
+        put(DOUBLESIDED, doublesided);
+    }
+
+    /**
+     * Default is BLACK (0,0,0)
+     * @return emmisive factor
+     */
+    public Color getEmissiveFactor() {
+        Color color = (Color) get(EMISSIVEFACTOR);
+        return (color == null) ? Color.BLACK : color;
+    }
+
+    /**
+     * Default is BLACK (0,0,0)
+     * @param color emmisive factor
+     */
+    public void setEmissiveFactor(Color color) {
+        put(EMISSIVEFACTOR, color);
+    }
+
+    public Texture getEmissiveTexture() {
+        return (Texture) get(EMISSIVETEXTURE);
+    }
+
+    public void setEmissiveTexture(Texture texture) {
+        put(EMISSIVETEXTURE, texture);
+    }
+
+    public Texture getOcclusionTexture() {
+        return (Texture) get(OCCLUSIONTEXTURE);
+    }
+
+    public void setOcclusionTexture(Texture texture) {
+        put(OCCLUSIONTEXTURE, texture);
+    }
+
+    /**
+     * Default is 1.0
+     * @return occlusion strength
+     */
+    public double getOcclusionStrength() {
+        Number strength = (Number) get(OCCLUSIONSTRENGTH);
+        return (strength == null) ? 1.0 : strength.doubleValue();
+    }
+
+    /**
+     * Default is 1.0
+     * @param strength occlusion strength between 0.0 and 1.0
+     */
+    public void setOcclusionStrength(double strength) {
+        ArgumentChecks.ensureBetween("occlusion strength", 0.0, 1.0, strength);
+        put(OCCLUSIONSTRENGTH, strength);
+    }
+
+    public Texture getNormalTexture() {
+        return (Texture) get(NORMALTEXTURE);
+    }
+
+    public void setNormalTexture(Texture texture) {
+        put(NORMALTEXTURE, texture);
+    }
+
+    /**
+     * Default is 1.0
+     * @return normal scale
+     */
+    public double getNormalScale() {
+        Number scale = (Number) get(NORMALSCALE);
+        return (scale == null) ? 1.0 : scale.doubleValue();
+    }
+
+    /**
+     * Default is 1.0
+     * @param scale normal scale
+     */
+    public void setNormalScale(double scale) {
+        ArgumentChecks.ensureBetween("normal scale", 0.0, 1.0, scale);
+        put(NORMALSCALE, scale);
+    }
+
+    /**
+     * Default is WHITE (1,1,1)
+     * @return pbr base color factor
+     */
+    public Color getPBRBaseColorFactor() {
+        Color color = (Color) get(PBR_BASECOLORFACTOR);
+        return (color == null) ? Color.WHITE : color;
+    }
+
+    /**
+     * Default is WHITE (1,1,1)
+     * @param color pbr base color factor
+     */
+    public void setPBRBaseColorFactor(Color color) {
+        put(PBR_BASECOLORFACTOR, color);
+    }
+
+    public Texture getPBRBaseColorTexture() {
+        return (Texture) get(PBR_BASECOLORTEXTURE);
+    }
+
+    public void setPBRBaseColorTexture(Texture texture) {
+        put(PBR_BASECOLORTEXTURE, texture);
+    }
+
+    /**
+     * Default is 1.0
+     * @return between 0.0 and 1.0
+     */
+    public double getPBRMetallicFactor() {
+        Number factor = (Number) get(PBR_METALLICFACTOR);
+        return (factor == null) ? 1.0 : factor.doubleValue();
+    }
+
+    /**
+     * Default is 1.0
+     * @param factor between 0.0 and 1.0
+     */
+    public void setPBRMetallicFactor(double factor) {
+        ArgumentChecks.ensureBetween("pbr metallic factor", 0.0, 1.0, factor);
+        put(PBR_METALLICFACTOR, factor);
+    }
+
+    /**
+     * Default is 1.0
+     * @return between 0.0 and 1.0
+     */
+    public double getPBRRoughnessFactor() {
+        Number roughness = (Number) get(PBR_ROUGHNESSFACTOR);
+        return (roughness == null) ? 1.0 : roughness.doubleValue();
+    }
+
+    /**
+     * Default is 1.0
+     * @param roughness  between 0.0 and 1.0
+     */
+    public void setPBRRoughnessFactor(double roughness) {
+        ArgumentChecks.ensureBetween("PBR roughness factor", 0.0, 1.0, 
roughness);
+        put(PBR_ROUGHNESSFACTOR, roughness);
+    }
+
+    public Texture getPBRMetallicRoughnessTexture() {
+        return (Texture) get(PBR_METALLICROUGHNESSTEXTURE);
+    }
+
+    public void setPBRMetallicRoughnessTexture(Texture texture) {
+        put(PBR_METALLICROUGHNESSTEXTURE, texture);
+    }
+
+    /**
+     * Default is WHITE (1,1,1,1)
+     * @return diffuse factor
+     */
+    public Color getPBRSGDiffuseFactor() {
+        Color color = (Color) get(PBRSG_DIFFUSEFACTOR);
+        return (color == null) ? Color.WHITE : color;
+    }
+
+    /**
+     * Default is WHITE (1,1,1,1)
+     * @param color diffuse factor
+     */
+    public void setPBRSGDiffuseFactor(Color color) {
+        put(PBRSG_DIFFUSEFACTOR, color);
+    }
+
+    /**
+     * Default is null
+     * @return diffuse texture
+     */
+    public Texture getPBRSGDiffuseTexture() {
+        return (Texture) get(PBRSG_DIFFUSETEXTURE);
+    }
+
+    /**
+     * Default is null
+     * @param texture diffuse
+     */
+    public void setPBRSGDiffuseTexture(Texture texture) {
+        put(PBRSG_DIFFUSETEXTURE, texture);
+    }
+
+    /**
+     * Default is 1.0
+     * @return between 0.0 and 1.0
+     */
+    public double getPBRSGGlossinessFactor() {
+        Number factor = (Number) get(PBRSG_GLOSSINESSFACTOR);
+        return (factor == null) ? 1.0 : factor.doubleValue();
+    }
+
+    /**
+     * Default is 1.0
+     * @param factor between 0.0 and 1.0
+     */
+    public void setPBRSGGlossinessFactor(double factor) {
+        ArgumentChecks.ensureBetween("pbrsg glossiness factor", 0.0, 1.0, 
factor);
+        put(PBRSG_GLOSSINESSFACTOR, factor);
+    }
+
+    /**
+     * Default is WHITE (1,1,1)
+     * @return specular factor
+     */
+    public Color getPBRSGSpecularFactor() {
+        Color color = (Color) get(PBRSG_SPECULARFACTOR);
+        return (color == null) ? Color.WHITE : color;
+    }
+
+    /**
+     * Default is WHITE (1,1,1)
+     * @param color specular factor
+     */
+    public void setPBRSGSpecularFactor(Color color) {
+        put(PBRSG_SPECULARFACTOR, color);
+    }
+
+    /**
+     * Default is null
+     * @return specular glossiness texture
+     */
+    public Texture getPBRSGSpecularGlossinessTexture() {
+        return (Texture) get(PBRSG_SPECULAR_GLOSSINESS_TEXTURE);
+    }
+
+    /**
+     * Default is null
+     * @param texture specular glossiness
+     */
+    public void setPBRSGSpecularGlossinessTexture(Texture texture) {
+        put(PBRSG_SPECULAR_GLOSSINESS_TEXTURE, texture);
+    }
+
+    /**
+     * Default value is false
+     * @return true if lights should be disabled for this material.
+     */
+    public boolean isUnlit() {
+        return Boolean.TRUE.equals(get(LIGHTS_UNLIT));
+    }
+
+    /**
+     * Default value is false
+     * @param unlit if lights should be disabled for this material.
+     */
+    public void setUnlit(boolean unlit) {
+        put(LIGHTS_UNLIT, unlit);
+    }
+
+    /**
+     * Default value is 1.5
+     * @return index of refraction
+     */
+    public double getIOR() {
+        Number ac = (Number) get(IOR);
+        return (ac == null) ? 1.5 : ac.doubleValue();
+    }
+
+    /**
+     * Default value is 1.5
+     * @param ior
+     */
+    public void setIOR(double ior) {
+        ArgumentChecks.ensureBetween("ior", 1.0, 100.0, ior);
+        put(IOR, ior);
+    }
+
+    /**
+     * Default value is 0.0
+     * @return transmission factor
+     */
+    public double getTransmissionFactor() {
+        Number ac = (Number) get(TRANSMISSIONFACTOR);
+        return (ac == null) ? 0.0 : ac.doubleValue();
+    }
+
+    /**
+     * Default value is 0.0
+     * @param tf
+     */
+    public void setTransmissionFactor(double tf) {
+        put(TRANSMISSIONFACTOR, tf);
+    }
+
+    /**
+     * Default is null
+     * @return transmission texture
+     */
+    public Texture getTransmissionTexture() {
+        return (Texture) get(TRANSMISSIONTEXTURE);
+    }
+
+    /**
+     * Default is null
+     * @param texture transmission
+     */
+    public void setTransmissionTexture(Texture texture) {
+        put(TRANSMISSIONTEXTURE, texture);
+    }
+
+    /**
+     * Default value is 0.0
+     * @return thickness factor
+     */
+    public double getThicknessFactor() {
+        Number ac = (Number) get(THICKNESSFACTOR);
+        return (ac == null) ? 0.0 : ac.doubleValue();
+    }
+
+    /**
+     * Default value is 0.0
+     * @param tf
+     */
+    public void setThicknessFactor(double tf) {
+        put(THICKNESSFACTOR, tf);
+    }
+
+    /**
+     * Default is null
+     * @return thickness texture
+     */
+    public Texture getThicknessTexture() {
+        return (Texture) get(THICKNESSTEXTURE);
+    }
+
+    /**
+     * Default is null
+     * @param texture thickness
+     */
+    public void setThicknessTexture(Texture texture) {
+        put(THICKNESSTEXTURE, texture);
+    }
+
+    /**
+     * Default value is +Infinity
+     * @return volume attenuation distance
+     */
+    public double getAttenuationDistance() {
+        Number ac = (Number) get(ATTENUATIONDISTANCE);
+        return (ac == null) ? Double.POSITIVE_INFINITY : ac.doubleValue();
+    }
+
+    /**
+     * Default value is +Infinity
+     * @param ad volume attenuation distance
+     */
+    public void setAttenuationDistance(double ad) {
+        put(ATTENUATIONDISTANCE, ad);
+    }
+
+    /**
+     * Default is WHITE (1,1,1)
+     * @return volume attenuation color
+     */
+    public Color getAttenuationColor() {
+        Color color = (Color) get(ATTENUATIONCOLOR);
+        return (color == null) ? Color.WHITE : color;
+    }
+
+    /**
+     * Default is WHITE (1,1,1)
+     * @param color volume attenuation color
+     */
+    public void setAttenuationColor(Color color) {
+        put(ATTENUATIONCOLOR, color);
+    }
+
+    public final class Matte extends Material {
+        //todo separate parameters to different type of materials
+    }
+
+    public final class BlinnPhong extends Material {
+        //todo separate parameters to different type of materials
+    }
+
+    public final class PhysicallyBased extends Material {
+        //todo separate parameters to different type of materials
+    }
+
+
+    @Override
+    public int hashCode() {
+        int hash = 5;
+        hash = 83 * hash + Objects.hashCode(this.id);
+        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 Material other = (Material) obj;
+        if (!Objects.equals(this.id, other.id)) {
+            return false;
+        }
+        return super.equals(obj);
+    }
+
+}
diff --git 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/Model.java
 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/Model.java
new file mode 100644
index 0000000000..4a1efeba06
--- /dev/null
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/Model.java
@@ -0,0 +1,216 @@
+/*
+ * 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;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.sis.geometries.Geometries;
+import org.apache.sis.geometries.Geometry;
+import org.apache.sis.geometries.mesh.MeshPrimitive;
+import org.apache.sis.geometry.GeneralEnvelope;
+import org.apache.sis.util.ArgumentChecks;
+import org.opengis.geometry.Envelope;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+
+/**
+ * A 3D model is attached to a scene node with geometric and rendering 
definitions.
+ *
+ * GLTF name this a Mesh.
+ * ANARI has no equivalent for it.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class Model {
+
+    private CoordinateReferenceSystem crs;
+    private String name;
+    private final List<Double> morphWeights = new ArrayList<>();
+    private final List<Surface> components = new ArrayList<>();
+    //user properties
+    private Map<String,Object> properties;
+
+    /**
+     * Create a geometry with right hand 3D CRS.
+     */
+    public Model() {
+        this(Geometries.RIGHT_HAND_3D);
+    }
+
+    public Model(CoordinateReferenceSystem crs) {
+        this.crs = crs;
+    }
+
+    /**
+     * @param geometries must contain at least one geometry
+     */
+    public Model(Collection<? extends Geometry> geometries) {
+        this(geometries.toArray(MeshPrimitive[]::new));
+    }
+
+    /**
+     * @param geometries must contain at least one geometry
+     */
+    public Model(Geometry ... geometries) {
+        this(geometries[0].getCoordinateReferenceSystem());
+        for (Geometry p : geometries) {
+            this.components.add(new Surface(p));
+        }
+    }
+
+    /**
+     * @param geometries must contain at least one surface
+     */
+    public Model(Surface ... surfaces) {
+        this(surfaces[0].getGeometry().getCoordinateReferenceSystem());
+        for (Surface p : surfaces) {
+            this.components.add(p);
+        }
+    }
+
+    /**
+     * @return model name
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * @param name model name
+     */
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    /**
+     * @return scene node coordinate system, never null.
+     */
+    public CoordinateReferenceSystem getCoordinateReferenceSystem() {
+        return crs;
+    }
+
+    /**
+     * This method will modify :
+     * - model surface crs if defined
+     * @param crs
+     */
+    public void setCoordinateReferenceSystem(CoordinateReferenceSystem crs) {
+        ArgumentChecks.ensureNonNull("crs", crs);
+        for (Surface surface : components) {
+            surface.getGeometry().setCoordinateReferenceSystem(crs);
+        }
+        this.crs = crs;
+    }
+
+    /**
+     *
+     * @return Model envelope, never null but can be set to NaN
+     */
+    public Envelope getEnvelope() {
+        GeneralEnvelope e = null;
+        for (Surface ms : components) {
+            GeneralEnvelope me = new 
GeneralEnvelope(ms.getGeometry().getEnvelope());
+            //we ignore the model crs, often undefined
+            me.setCoordinateReferenceSystem(getCoordinateReferenceSystem());
+            if (!me.isAllNaN()) {
+                if (e == null) {
+                    e = me;
+                } else {
+                    e.add(me);
+                }
+            }
+        }
+        if (e == null) {
+            e = new GeneralEnvelope(getCoordinateReferenceSystem());
+            e.setToNaN();
+        }
+        return e;
+    }
+
+    /**
+     * @return Model morph target weights.
+     */
+    public List<Double> getMorphWeights() {
+        return morphWeights;
+    }
+
+    public List<Surface> getComponents() {
+        return components;
+    }
+
+    /**
+     * Set given material on all primitives.
+     * @param material
+     */
+    public void setMaterial(Material material) {
+        for (Surface p : getComponents()) {
+            p.setMaterial(material);
+        }
+    }
+
+    /**
+     * Map of properties for user needs.
+     * Those informations may be lost in model processes.
+     *
+     * @return Map, never null.
+     */
+    public synchronized Map<String, Object> userProperties() {
+        if (properties == null) {
+            properties = new HashMap<>();
+        }
+        return properties;
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 5;
+        hash = 59 * hash + Objects.hashCode(this.name);
+        hash = 59 * hash + Objects.hashCode(this.morphWeights);
+        return hash + super.hashCode();
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        final Model other = (Model) obj;
+        if (!Objects.equals(this.name, other.name)) {
+            return false;
+        }
+        if (!Objects.equals(this.morphWeights, other.morphWeights)) {
+            return false;
+        }
+        if (!Objects.equals(this.components, other.components)) {
+            return false;
+        }
+        if (!Objects.equals(this.crs, other.crs)) {
+            return false;
+        }
+        return true;
+    }
+
+}
diff --git 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/Sampler.java
 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/Sampler.java
new file mode 100644
index 0000000000..ad05ac0cb7
--- /dev/null
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/Sampler.java
@@ -0,0 +1,171 @@
+/*
+ * 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;
+
+import java.util.Objects;
+import org.apache.sis.util.ArgumentChecks;
+
+/**
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class Sampler {
+
+    public static enum Wrap {
+        CLAMP_TO_EDGE,
+        MIRRORED_REPEAT,
+        REPEAT
+    }
+
+    public static enum MagFilter {
+        /** system defined */
+        AUTO,
+        NEAREST,
+        LINEAR,
+    }
+
+    public static enum MinFilter {
+        /** system defined */
+        AUTO,
+        NEAREST,
+        LINEAR,
+        NEAREST_MIPMAP_NEAREST,
+        LINEAR_MIPMAP_NEAREST,
+        NEAREST_MIPMAP_LINEAR,
+        LINEAR_MIPMAP_LINEAR
+    }
+
+    /**
+     * Magnification filter.
+     */
+    private MagFilter magFilter = MagFilter.AUTO;
+    /**
+     * Minification filter.
+     */
+    private MinFilter minFilter = MinFilter.AUTO;
+    /**
+     * s wrapping mode.
+     * default value : REPEAT
+     */
+    private Wrap wrapS = Wrap.REPEAT;
+    /**
+     * t wrapping mode.
+     * default value : REPEAT
+     */
+    private Wrap wrapT = Wrap.REPEAT;
+
+    public Sampler() {
+    }
+
+    /**
+     * Magnification filter.
+     */
+    public MagFilter getMagFilter() {
+        return magFilter;
+    }
+
+    /**
+     * Magnification filter.
+     */
+    public void setMagFilter(MagFilter magFilter) {
+        ArgumentChecks.ensureNonNull("mag Filter", magFilter);
+        this.magFilter = magFilter;
+    }
+
+    /**
+     * Minification filter.
+     */
+    public MinFilter getMinFilter() {
+        return minFilter;
+    }
+
+    /**
+     * Minification filter.
+     */
+    public void setMinFilter(MinFilter minFilter) {
+        ArgumentChecks.ensureNonNull("min Filter", minFilter);
+        this.minFilter = minFilter;
+    }
+
+    /**
+     * s wrapping mode.
+     */
+    public Wrap getWrapS() {
+        return wrapS;
+    }
+
+    /**
+     * s wrapping mode.
+     */
+    public void setWrapS(Wrap wrapS) {
+        ArgumentChecks.ensureNonNull("wrap s", wrapS);
+        this.wrapS = wrapS;
+    }
+
+    /**
+     * t wrapping mode.
+     */
+    public Wrap getWrapT() {
+        return wrapT;
+    }
+
+    /**
+     * t wrapping mode.
+     */
+    public void setWrapT(Wrap wrapT) {
+        ArgumentChecks.ensureNonNull("wrap t", wrapT);
+        this.wrapT = wrapT;
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 5;
+        hash = 97 * hash + Objects.hashCode(this.magFilter);
+        hash = 97 * hash + Objects.hashCode(this.minFilter);
+        hash = 97 * hash + Objects.hashCode(this.wrapS);
+        hash = 97 * hash + Objects.hashCode(this.wrapT);
+        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 Sampler other = (Sampler) obj;
+        if (this.magFilter != other.magFilter) {
+            return false;
+        }
+        if (this.minFilter != other.minFilter) {
+            return false;
+        }
+        if (this.wrapS != other.wrapS) {
+            return false;
+        }
+        if (this.wrapT != other.wrapT) {
+            return false;
+        }
+        return true;
+    }
+
+}
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
new file mode 100644
index 0000000000..feb3ff13c8
--- /dev/null
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/SceneNode.java
@@ -0,0 +1,480 @@
+/*
+ * 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;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import org.apache.sis.geometries.Geometries;
+import org.apache.sis.geometries.math.Similarity3D;
+import org.apache.sis.geometry.Envelopes;
+import org.apache.sis.geometry.GeneralEnvelope;
+import org.apache.sis.measure.NumberRange;
+import org.apache.sis.referencing.operation.transform.LinearTransform;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.Utilities;
+import org.opengis.feature.Feature;
+import org.opengis.geometry.Envelope;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.opengis.referencing.operation.NoninvertibleTransformException;
+import org.opengis.referencing.operation.TransformException;
+
+/**
+ * A scene node.
+ * This class is used by scenograph rendering pipelines.
+ * Each node has a transform and possible childrens.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class SceneNode {
+
+    private final Similarity3D parentToNode = new Similarity3D();
+    private SceneNode parent = null;
+    private final List<SceneNode> children = new 
NotifiedCheckedList<SceneNode>(){
+        @Override
+        protected void notifyAdd(SceneNode item, int index) {
+            makeChild(item);
+        }
+
+        @Override
+        protected void notifyAdd(Collection<? extends SceneNode> items, 
NumberRange<Integer> range) {
+            for (SceneNode n : items) {
+                makeChild(n);
+            }
+        }
+
+        @Override
+        protected void notifyChange(SceneNode oldItem, SceneNode newItem, int 
index) {
+            unmakeChild(oldItem);
+            makeChild(newItem);
+        }
+
+        @Override
+        protected void notifyRemove(SceneNode item, int index) {
+            unmakeChild(item);
+        }
+
+        @Override
+        protected void notifyRemove(Collection<? extends SceneNode> items, 
NumberRange<Integer> range) {
+            for (SceneNode n : items) {
+                unmakeChild(n);
+            }
+        }
+
+        private void makeChild(SceneNode node) {
+            if 
(!Utilities.equalsIgnoreMetadata(getCoordinateReferenceSystem(), 
node.getCoordinateReferenceSystem())) {
+                super.remove(node);
+                throw new IllegalArgumentException("Child node coordinate 
system do not match scene coordinate system");
+            }
+
+            final SceneNode oldParent = node.getParent();
+            if (oldParent != null) oldParent.getChildren().remove(node);
+            node.setParent(SceneNode.this);
+        }
+
+        private void unmakeChild(SceneNode node) {
+            node.setParent(null);
+        }
+    };
+
+    private CoordinateReferenceSystem crs = Geometries.RIGHT_HAND_3D;
+    private Camera camera;
+    private Model model;
+    private String name;
+    private Feature feature;
+    //user properties
+    private Map<String,Object> properties;
+
+    /**
+     * Build a 3D Right handed coordinate reference system.
+     */
+    public SceneNode() {
+    }
+
+    /**
+     * Build scene node with given system.
+     * @param crs not null, must have three dimensions
+     */
+    public SceneNode(CoordinateReferenceSystem crs) {
+        setCoordinateReferenceSystem(crs);
+    }
+
+    /**
+     * Build scene node with given model.
+     * Model coordinate system is copied to scene node.
+     * @param model, not null
+     */
+    public SceneNode(Model model) {
+        ArgumentChecks.ensureNonNull("model", model);
+        setCoordinateReferenceSystem(model.getCoordinateReferenceSystem());
+        this.model = model;
+    }
+
+    /**
+     * @return node name
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * @param name node name
+     */
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    /**
+     * @return scene node coordinate system, never null.
+     */
+    public CoordinateReferenceSystem getCoordinateReferenceSystem() {
+        return crs;
+    }
+
+    /**
+     * This method will modify :
+     * - model crs if defined
+     * - children nodes coordinate system recursively.
+     * @param crs
+     */
+    public void setCoordinateReferenceSystem(CoordinateReferenceSystem crs) {
+        if (parent != null) {
+            throw new IllegalArgumentException("Changing CRS can only be 
called on root node");
+        }
+        ArgumentChecks.ensureNonNull("crs", crs);
+        ArgumentChecks.ensureCountBetween("dimension", true, 3, 3, 
crs.getCoordinateSystem().getDimension());
+        setCrsNoCheck(crs);
+    }
+
+    private void setCrsNoCheck(CoordinateReferenceSystem crs) {
+        if (model != null) {
+            model.setCoordinateReferenceSystem(crs);
+        }
+        for (SceneNode sn : children) {
+            sn.setCrsNoCheck(crs);
+        }
+        this.crs = crs;
+    }
+
+    /**
+     * Get transform from parent node to this node.
+     *
+     * @return Transform, never null.
+     */
+    public Similarity3D getTransform() {
+        return parentToNode;
+    }
+
+    /**
+     * @return parent node
+     */
+    public SceneNode getParent() {
+        return parent;
+    }
+
+    /**
+     * Internal use only.
+     * Called by the new parent node only.
+     */
+    public void setParent(SceneNode parent) {
+        this.parent = parent;
+    }
+
+    /**
+     * @return modifiable list of children nodes.
+     */
+    public List<SceneNode> getChildren() {
+        return children;
+    }
+
+    /**
+     * @return 3D model attached, may be null
+     */
+    public Model getModel() {
+        return model;
+    }
+
+    /**
+     * @param model 3D model to attach, may be null
+     *        model must have the same crs as the scene node.
+     */
+    public void setModel(Model model) {
+        if (model != null) {
+            if 
(!Utilities.equalsIgnoreMetadata(getCoordinateReferenceSystem(), 
model.getCoordinateReferenceSystem())) {
+                throw new IllegalArgumentException("Model coordinate system do 
not match scene coordinate system");
+            }
+        }
+        this.model = model;
+    }
+
+    /**
+     * @return camera attached, may be null
+     */
+    public Camera getCamera() {
+        return camera;
+    }
+
+    /**
+     * @param camera to attach, may be null
+     */
+    public void setCamera(Camera camera) {
+        this.camera = camera;
+    }
+
+    /**
+     * @return Feature this node represent
+     *         used to attach atttributes on scene models.
+     */
+    public Feature getFeature() {
+        return feature;
+    }
+
+    /**
+     * @param feature Feature this node represent.
+     */
+    public void setFeature(Feature feature) {
+        this.feature = feature;
+    }
+
+    /**
+     * Get this scene envelope, including child nodes.
+     *
+     * @param applyTransform true to transform the envelope with the node 
transform.
+     *                    the envelope will be in the parent coordinate system.
+     * @return node envelope, can be null if no model 3d are attached in the 
graph.
+     */
+    public Optional<Envelope> getEnvelope(boolean applyTransform) throws 
NoninvertibleTransformException, TransformException {
+
+        GeneralEnvelope e = null;
+
+        // add model envelope
+        final Model model = getModel();
+        if (model != null) {
+            Envelope me = model.getEnvelope();
+            e = new GeneralEnvelope(me);
+            //we ignore the model crs, often undefined
+            e.setCoordinateReferenceSystem(getCoordinateReferenceSystem());
+            if (e.isAllNaN()) {
+                e = null;
+            }
+        }
+
+        // concatenate child node envelopes
+        for (SceneNode sn : children) {
+            Optional<Envelope> envelope = sn.getEnvelope(true);
+            if (envelope.isPresent()) {
+                final Envelope ce = envelope.get();
+                if 
(!Utilities.equalsIgnoreMetadata(getCoordinateReferenceSystem(), 
ce.getCoordinateReferenceSystem())) {
+                    throw new IllegalArgumentException("A child node 
coordinate system do not match scene coordinate system");
+                }
+                if (e == null) {
+                    e = new GeneralEnvelope(ce);
+                } else {
+                    e.add(ce);
+                }
+            }
+        }
+
+        // apply transform
+        if (applyTransform && e != null) {
+            final Similarity3D transform = getTransform();
+            if (!transform.isIdentity()) {
+                LinearTransform trs = 
MathTransforms.linear(transform.toMatrix());
+                e.setEnvelope(Envelopes.transform(trs, e));
+            }
+        }
+
+        return Optional.ofNullable(e);
+    }
+
+    /**
+     * Map of properties for user needs.
+     * Those informations may be lost in node processes.
+     *
+     * @return Map, never null.
+     */
+    public synchronized Map<String, Object> userProperties() {
+        if (properties == null) {
+            properties = new HashMap<>();
+        }
+        return properties;
+    }
+
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder("Node");
+        if (name != null) sb.append(' ').append(name).append(' ');
+        sb.append('(');
+        if (model != null) sb.append(" Model ");
+        if (camera != null) sb.append(" Camera ");
+        if (feature != null) sb.append(" Feature ");
+        sb.append(" Children[").append(children.size()).append("] ");
+        sb.append(')');
+        return sb.toString();
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 7;
+        hash = 97 * hash + Objects.hashCode(this.parentToNode);
+        hash = 97 * hash + Objects.hashCode(this.camera);
+        hash = 97 * hash + Objects.hashCode(this.model);
+        hash = 97 * hash + Objects.hashCode(this.name);
+        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 SceneNode other = (SceneNode) obj;
+        if (!Objects.equals(this.name, other.name)) {
+            return false;
+        }
+        if (!Objects.equals(this.parentToNode, other.parentToNode)) {
+            return false;
+        }
+        if (!Objects.equals(this.children, other.children)) {
+            return false;
+        }
+        if (!Objects.equals(this.camera, other.camera)) {
+            return false;
+        }
+        if (!Objects.equals(this.model, other.model)) {
+            return false;
+        }
+        return true;
+    }
+
+    private abstract class NotifiedCheckedList<E> extends ArrayList<E>{
+
+        public NotifiedCheckedList() {
+            super();
+        }
+
+        public NotifiedCheckedList(final int capacity) {
+            super(capacity);
+        }
+
+        protected abstract void notifyAdd(final E item, int index);
+
+        protected abstract void notifyAdd(final Collection<? extends E> items, 
NumberRange<Integer> range);
+
+        protected abstract void notifyChange(final E oldItem, E newItem, int 
index);
+
+        protected abstract void notifyRemove(final E item, int index);
+
+        protected abstract void notifyRemove(final Collection<? extends E> 
items, NumberRange<Integer> range);
+
+        @Override
+        public boolean add(final E element) throws IllegalArgumentException, 
UnsupportedOperationException {
+            if(element == null) return false;
+            final boolean added = super.add(element);
+            if (added) {
+                final int index = super.size() - 1;
+                notifyAdd(element, index);
+            }
+            return added;
+        }
+
+        @Override
+        public void add(final int index, final E element) throws 
IllegalArgumentException, UnsupportedOperationException {
+            super.add(index, element);
+            notifyAdd(element, index);
+        }
+
+        @Override
+        public E set(int index, E element) throws IllegalArgumentException, 
UnsupportedOperationException {
+            final E old = super.set(index, element);
+            notifyChange(old, element, index);
+            return old;
+        }
+
+        @Override
+        public boolean addAll(final Collection<? extends E> collection) throws 
IllegalArgumentException, UnsupportedOperationException {
+            final int startIndex = super.size();
+            final boolean added = super.addAll(collection);
+            if (added) {
+                notifyAdd(collection, NumberRange.create(startIndex, true, 
super.size()-1, true) );
+            }
+            return added;
+        }
+
+        @Override
+        public boolean addAll(final int index, final Collection<? extends E> 
collection) throws IllegalArgumentException, UnsupportedOperationException {
+            final boolean added = super.addAll(index, collection);
+            if (added) {
+                notifyAdd(collection, NumberRange.create(index, true, index + 
collection.size(), true) );
+            }
+            return added;
+        }
+
+        @Override
+        public boolean remove(final Object o) throws 
UnsupportedOperationException {
+            final int index = super.indexOf(o);
+            if (index >= 0) {
+                super.remove(index);
+                notifyRemove((E) o, index );
+                return true;
+            }
+            return false;
+        }
+
+        @Override
+        public E remove(final int index) throws UnsupportedOperationException {
+            final E removed = super.remove(index);
+            notifyRemove(removed, index );
+            return removed;
+        }
+
+        @Override
+        public boolean removeAll(final Collection<?> c) throws 
UnsupportedOperationException {
+            //TODO handle remove by collection events if possible
+            // to avoid several calls to remove
+            boolean valid = false;
+            for(final Object i : c){
+                final boolean val = remove(i);
+                if(val) valid = val;
+            }
+            return valid;
+        }
+
+        @Override
+        public void clear() throws UnsupportedOperationException {
+            if(!isEmpty()){
+                final Collection<E> copy = new ArrayList<E>(this);
+                final NumberRange<Integer> range = NumberRange.create(0, true, 
copy.size()-1, true);
+                super.clear();
+                notifyRemove(copy, range);
+            }
+        }
+
+    }
+
+}
diff --git 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/Surface.java
 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/Surface.java
new file mode 100644
index 0000000000..e4daf2b40d
--- /dev/null
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/Surface.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;
+
+import java.util.Objects;
+import org.apache.sis.geometries.Geometry;
+import org.apache.sis.util.ArgumentChecks;
+
+/**
+ * GLTF name this a Primitive.
+ * ANARI name this a Surface.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class Surface {
+
+    private Geometry geometry;
+    private Material material;
+
+    public Surface(Geometry geometry) {
+        ArgumentChecks.ensureNonNull("geometry", geometry);
+        this.geometry = geometry;
+        this.material = new Material();
+    }
+
+    public Surface(Geometry geometry, Material material) {
+        ArgumentChecks.ensureNonNull("geometry", geometry);
+        ArgumentChecks.ensureNonNull("material", material);
+        this.geometry = geometry;
+        this.material = material;
+    }
+
+    /**
+     * Get geometry definition.
+     *
+     * @return Geometry never null
+     */
+    public Geometry getGeometry() {
+        return geometry;
+    }
+
+    public void setGeometry(Geometry geometry) {
+        ArgumentChecks.ensureNonNull("geometry", geometry);
+        this.geometry = geometry;
+    }
+
+    /**
+     * Get material definition.
+     *
+     * @return Material never null
+     */
+    public Material getMaterial() {
+        return material;
+    }
+
+    /**
+     * Set material definition.
+     * @param material not null;
+     */
+    public void setMaterial(Material material) {
+        ArgumentChecks.ensureNonNull("material", material);
+        this.material = material;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        final Surface other = (Surface) obj;
+        if (!Objects.equals(this.geometry, other.geometry)) {
+            return false;
+        }
+        if (!Objects.equals(this.material, other.material)) {
+            return false;
+        }
+        return true;
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 7;
+        hash = 71 * hash + Objects.hashCode(this.geometry);
+        hash = 71 * hash + Objects.hashCode(this.material);
+        return hash;
+    }
+}
diff --git 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/Texture.java
 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/Texture.java
new file mode 100644
index 0000000000..01837a028a
--- /dev/null
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/Texture.java
@@ -0,0 +1,142 @@
+/*
+ * 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;
+
+import java.awt.Point;
+import java.awt.image.ColorModel;
+import java.awt.image.RenderedImage;
+import java.awt.image.SampleModel;
+import java.util.Arrays;
+import java.util.Objects;
+import org.apache.sis.geometries.AttributesType;
+import org.apache.sis.image.PixelIterator;
+
+/**
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class Texture {
+
+    private RenderedImage image;
+    private String texCoord = AttributesType.ATT_TEXCOORD_0;
+    private Sampler sampler = new Sampler();
+
+    /**
+     * @return loaded image
+     */
+    public RenderedImage getImage() {
+        return image;
+    }
+
+    /**
+     * @param image loaded image
+     */
+    public void setImage(RenderedImage image) {
+        this.image = image;
+    }
+
+    /**
+     * @return The name texture's TEXCOORD attribute used for texture 
coordinate mapping.
+     */
+    public String getTexCoord() {
+        return texCoord;
+    }
+
+    /**
+     * @param texCoord The name texture's TEXCOORD attribute used for texture 
coordinate mapping.
+     */
+    public void setTexCoord(String texCoord) {
+        this.texCoord = texCoord;
+    }
+
+    public Sampler getSampler() {
+        return sampler;
+    }
+
+    public void setSampler(Sampler sampler) {
+        this.sampler = sampler;
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 5;
+        hash = 97 * hash + Objects.hashCode(this.image);
+        hash = 97 * hash + Objects.hashCode(this.texCoord);
+        hash = 97 * hash + Objects.hashCode(this.sampler);
+        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 Texture other = (Texture) obj;
+        if (!Objects.equals(this.texCoord, other.texCoord)) {
+            return false;
+        }
+        if (!( (this.image == other.image) || (this.image != null && 
compare(this.image, other.image)))) {
+            return false;
+        }
+        if (!Objects.equals(this.sampler, other.sampler)) {
+            return false;
+        }
+        return true;
+    }
+
+    private static boolean compare(RenderedImage expected, RenderedImage 
result) {
+        if (expected == result) {
+            return true;
+        }
+        final ColorModel expectedCm = expected.getColorModel();
+        final ColorModel resultCm = result.getColorModel();
+        if (!expectedCm.equals(resultCm)) {
+            return false;
+        }
+        final SampleModel expectedSm = expected.getSampleModel();
+        final SampleModel resultSm = result.getSampleModel();
+        if (!expectedSm.equals(resultSm)) {
+            return false;
+        }
+
+        final PixelIterator ite1 = PixelIterator.create(expected);
+        final PixelIterator ite2 = PixelIterator.create(result);
+        if (!ite1.getDomain().equals(ite2.getDomain())) {
+            return false;
+        }
+        final double[] pixel1 = new double[ite1.getNumBands()];
+        final double[] pixel2 = new double[ite2.getNumBands()];
+
+        pixelLoop:
+        while (ite1.next()) {
+            final Point position = ite1.getPosition();
+            ite2.moveTo(position.x, position.y);
+            ite1.getPixel(pixel1);
+            ite2.getPixel(pixel2);
+            if (!Arrays.equals(pixel1, pixel2)) {
+                return false;
+            }
+        }
+        return true;
+    }
+}
diff --git 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/package-info.java
 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/package-info.java
new file mode 100644
index 0000000000..e52ef5bdb7
--- /dev/null
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/scene/package-info.java
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+
+/**
+ * This package contains a simplified version of a scene model.
+ *
+ * Based on specifications :
+ * <ul>
+ * <li>Khronos GLTF-2 
https://github.com/KhronosGroup/glTF/tree/main/specification/2.0</li>
+ * <li>Khronos ANARI https://github.com/KhronosGroup/ANARI-SDK</li>
+ * </ul>
+ */
+package org.apache.sis.geometries.scene;

Reply via email to