You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by js...@apache.org on 2026/10/21 07:02:02 UTC

(sis) branch geoapi-4.0 updated (5a027d23dc -> f2a9a868bb)

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

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


    from 5a027d23dc Avoid an exception about mismatched datum when there is no datum shift information.
     new 024fa636e6 feat(Geometry): implement Prism.toExtrusion()
     new fc8a1ac943 feat(Math): add Vector.swizzle and Maths.removeWithin
     new 0f3587edc5 feat(Geometry): separate earclipping and holeremover as two separate classes
     new 32c5356683 feat(Geometry): fix hashcode error in Vector class, add DataPoints.subset
     new 13e5ba92f8 feat(Math): add Maths.isConvex/inCone, Distance.nearest(line,point)
     new 7f9bdfd15d feat(Geometry): simplify EarClipping
     new 7c618a7feb refactor(NDRange): move intersection with tolerance on NDRange class
     new 6607faa528 fix(EarClipping): preserve normal direction in EarClipping
     new a81b6f8cf2 feat(Geometry): improve earclipping, add contains and intersections tests
     new ca2f8e59f3 test(Geometry): add solid tests
     new 3337cb71ba feat(Geometry): add draft topology interfaces
     new 30ea2c5c0f feat(Geometry): base topology implementation
     new f2a9a868bb refactor(Vector): remove SampleSystem property

The 13 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../org.apache.sis.geometry/main/module-info.java  |    1 +
 .../main/org/apache/sis/geometries/BBox.java       |    6 +-
 .../main/org/apache/sis/geometries/DataPoints.java |   48 +-
 .../main/org/apache/sis/geometries/Geometries.java |    5 +-
 .../org/apache/sis/geometries/GeometryFactory.java |   41 +-
 .../main/org/apache/sis/geometries/HyperPlane.java |    7 +-
 .../main/org/apache/sis/geometries/OBBox.java      |   18 +-
 .../main/org/apache/sis/geometries/Plane.java      |    7 +-
 .../main/org/apache/sis/geometries/Point.java      |    4 +-
 .../main/org/apache/sis/geometries/Prism.java      |   27 +
 .../apache/sis/geometries/adapter/JTSAdapter.java  |    2 +-
 .../org/apache/sis/geometries/adapter/SIS.java     |   19 +-
 .../org/apache/sis/geometries/adapter/Wrapper.java |   10 +-
 .../main/org/apache/sis/geometries/cs/Bearing.java |   22 +
 .../org/apache/sis/geometries/index/KdTree.java    |    5 +-
 .../internal/shared/AbstractGeometry.java          |   10 +-
 .../internal/shared/ArrayDataPoints.java           |   35 +
 .../internal/shared/BSplineEvaluator.java          |    2 +-
 .../internal/shared/CurveLinearizer.java           |   12 +-
 .../internal/shared/DefaultArcByCenterPoint.java   |    2 +-
 .../geometries/internal/shared/DefaultBearing.java |   13 +-
 .../geometries/internal/shared/DefaultCapsule.java |   30 +-
 .../internal/shared/DefaultCylinder.java           |   30 +-
 .../internal/shared/DefaultNurbCurve.java          |    7 +-
 .../internal/shared/DefaultNurbSurface.java        |   33 +-
 .../geometries/internal/shared/DefaultPoint.java   |    5 +-
 .../geometries/internal/shared/DefaultPrism.java   |  314 ++++
 .../sis/geometries/internal/shared/DefaultRay.java |    9 +-
 .../internal/shared/SinglePositionDataPoints.java  |   21 +-
 .../internal/shared/topology/ComplexBuilder.java   |  678 ++++++++
 .../internal/shared/topology/ComplexEditor.java    | 1091 +++++++++++++
 .../internal/shared/topology/ComplexGeometry.java  |  475 ++++++
 .../internal/shared/topology/ComplexValidator.java |  415 +++++
 .../shared/topology/DefaultExpression.java         |  278 ++++
 .../internal/shared/topology/IndexedComplex.java   | 1637 ++++++++++++++++++++
 .../internal/shared/topology/IndexedEdge.java      |  124 ++
 .../internal/shared/topology/IndexedFace.java      |  129 ++
 .../internal/shared/topology/IndexedNode.java      |  105 ++
 .../internal/shared/topology/IndexedSolid.java     |  115 ++
 .../internal/shared/topology/IndexedTopo.java      |  150 ++
 .../internal/shared/topology/NodePoints.java       |  385 +++++
 .../internal/shared/topology/package-info.java     |   37 +
 .../apache/sis/geometries/operation/Contains.java  |  141 +-
 .../apache/sis/geometries/operation/Distance.java  |   94 ++
 .../geometries/operation/GeometryProcessor.java    |    4 +
 .../sis/geometries/operation/Intersection.java     |  572 ++++++-
 .../sis/geometries/operation/ToPrimitive.java      |   63 +-
 .../apache/sis/geometries/operation/Transform.java |    6 +-
 .../simplify/greedyinsert/TINBuilder.java          |   40 +-
 .../operation/simplify/greedyinsert/WTriangle.java |    9 +-
 .../operation/triangulate/EarClipping.java         |  736 +++------
 .../operation/triangulate/HoleToExterior.java      |  303 ++++
 .../operation/triangulate/delaunay/Delaunay.java   |    2 +-
 .../org/apache/sis/geometries/package-info.java    |    3 +-
 .../solid/polyhedron/AbstractPolyhedron.java       |    4 +-
 .../org/apache/sis/geometries/surface/Capsule.java |   30 +-
 .../apache/sis/geometries/surface/Cylinder.java    |   48 +-
 .../apache/sis/geometries/surface/PreparedTIN.java |   20 +-
 .../apache/sis/geometries/surface/Triangle.java    |    4 +-
 .../apache/sis/geometries/topology/Complex.java    |  116 ++
 .../ComplexData.java}                              |   42 +-
 .../sis/geometries/topology/DirectedEdge.java      |  101 ++
 .../sis/geometries/topology/DirectedFace.java      |   97 ++
 .../sis/geometries/topology/DirectedNode.java      |   97 ++
 .../sis/geometries/topology/DirectedSolid.java     |   98 ++
 .../sis/geometries/topology/DirectedTopo.java      |   94 ++
 .../org/apache/sis/geometries/topology/Edge.java   |   87 ++
 .../apache/sis/geometries/topology/Expression.java |  194 +++
 .../sis/geometries/topology/ExpressionTerm.java    |   69 +
 .../org/apache/sis/geometries/topology/Face.java   |  104 ++
 .../org/apache/sis/geometries/topology/Node.java   |   75 +
 .../apache/sis/geometries/topology/Primitive.java  |  140 ++
 .../ConicArc.java => topology/PrimitiveData.java}  |   18 +-
 .../org/apache/sis/geometries/topology/Solid.java  |   87 ++
 .../apache/sis/geometries/topology/Topology.java   |  167 ++
 .../sis/geometries/topology/TopologyData.java      |   72 +
 .../sis/geometries/topology/package-info.java      |   65 +
 .../main/org/apache/sis/maths/AbstractCursor.java  |    1 -
 .../main/org/apache/sis/maths/AbstractVector.java  |   54 +-
 .../main/org/apache/sis/maths/Array.java           |    4 +-
 .../main/org/apache/sis/maths/ArrayFactory.java    |    6 +-
 .../org/apache/sis/maths/ArrayFactoryJava.java     |    4 +-
 .../main/org/apache/sis/maths/Maths.java           |  163 ++
 .../main/org/apache/sis/maths/NDArray.java         |    2 +-
 .../main/org/apache/sis/maths/NDRange.java         |  114 +-
 .../main/org/apache/sis/maths/ReadOnly.java        |   49 +-
 .../org/apache/sis/maths/Sample.java}              |   19 +-
 .../main/org/apache/sis/maths/Vector1D.java        |  294 +---
 .../main/org/apache/sis/maths/Vector2D.java        |  310 +---
 .../main/org/apache/sis/maths/Vector3D.java        |  329 +---
 .../main/org/apache/sis/maths/Vector4D.java        |  347 +----
 .../main/org/apache/sis/maths/VectorND.java        |   80 +-
 .../org/apache/sis/maths/VectorUnmodifiable.java   |   12 -
 .../main/org/apache/sis/maths/Vectors.java         |  189 +--
 .../org/apache/sis/geometries/DataPointsTest.java  |   51 +-
 .../test/org/apache/sis/geometries/PrismTest.java  |  211 +++
 .../org/apache/sis/geometries/cs/BearingTest.java  |   27 +-
 .../internal/shared/ArrayDataPointsTest.java       |   15 +
 .../internal/shared/ArraySequenceTest.java         |   26 +-
 .../internal/shared/CurveLinearizerTest.java       |    6 +-
 .../shared/topology/ComplexBuilderTest.java        |  517 +++++++
 .../shared/topology/ComplexEditorTest.java         |  323 ++++
 .../shared/topology/DefaultExpressionTest.java     |  157 ++
 .../internal/shared/topology/NodePointsTest.java   |  152 ++
 .../sis/geometries/mesh/PrimitiveVisitorTest.java  |   14 +-
 .../sis/geometries/operation/Contains3DTest.java   |  182 +++
 .../sis/geometries/operation/DistanceTest.java     |   24 +
 .../geometries/operation/Intersection3DTest.java   |  264 ++++
 .../sis/geometries/operation/ToPrimitiveTest.java  |  129 +-
 .../operation/polyhedron/SolidFixtures.java        |  393 +++++
 .../operation/polyhedron/SolidFixturesTest.java    |  137 ++
 .../operation/triangulate/EarClippingTest.java     |  235 +++
 .../operation/triangulate/HoleToExteriorTest.java  |  207 +++
 .../org/apache/sis/maths/AbstractVectorTest.java   |   45 +-
 .../test/org/apache/sis/maths/MathsTest.java       |  156 +-
 .../test/org/apache/sis/maths/NDRangeTest.java     |   50 +-
 .../sis/referencing/dggs/AbstractDggrsTest.java    |    2 +-
 117 files changed, 13006 insertions(+), 2460 deletions(-)
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/ComplexBuilder.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/ComplexEditor.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/ComplexGeometry.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/ComplexValidator.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/DefaultExpression.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedComplex.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedEdge.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedFace.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedNode.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedSolid.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedTopo.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/NodePoints.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/package-info.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/HoleToExterior.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Complex.java
 copy incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/{curve/SplineCurveForm.java => topology/ComplexData.java} (54%)
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedEdge.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedFace.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedNode.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedSolid.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedTopo.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Edge.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Expression.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/ExpressionTerm.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Face.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Node.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Primitive.java
 copy incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/{curve/ConicArc.java => topology/PrimitiveData.java} (60%)
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Solid.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Topology.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/TopologyData.java
 create mode 100644 incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/package-info.java
 copy incubator/src/org.apache.sis.geometry/{test/org/apache/sis/maths/ArrayNdTest.java => main/org/apache/sis/maths/Sample.java} (71%)
 create mode 100644 incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/topology/ComplexBuilderTest.java
 create mode 100644 incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/topology/ComplexEditorTest.java
 create mode 100644 incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/topology/DefaultExpressionTest.java
 create mode 100644 incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/topology/NodePointsTest.java
 create mode 100644 incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/Contains3DTest.java
 create mode 100644 incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/Intersection3DTest.java
 create mode 100644 incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/polyhedron/SolidFixtures.java
 create mode 100644 incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/polyhedron/SolidFixturesTest.java
 create mode 100644 incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/triangulate/EarClippingTest.java
 create mode 100644 incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/triangulate/HoleToExteriorTest.java


(sis) 01/13: feat(Geometry): implement Prism.toExtrusion()

Posted by js...@apache.org.
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

commit 024fa636e6f768381f6d29d3ce9902feb9e39eab
Author: jsorel <jo...@geomatys.com>
AuthorDate: Fri Oct 16 10:41:05 2026 +0200

    feat(Geometry): implement Prism.toExtrusion()
---
 .../main/org/apache/sis/geometries/Prism.java      |  27 ++
 .../geometries/internal/shared/DefaultPrism.java   | 321 +++++++++++++++++++++
 .../geometries/operation/GeometryProcessor.java    |   4 +
 .../test/org/apache/sis/geometries/PrismTest.java  | 211 ++++++++++++++
 .../sis/geometries/operation/ToPrimitiveTest.java  |  39 +++
 5 files changed, 602 insertions(+)

diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/Prism.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/Prism.java
index 51a38718ee..0b0779d589 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/Prism.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/Prism.java
@@ -76,4 +76,31 @@ public sealed interface Prism extends Geometry
      */
     SingleCRS getExtrusionCrs();
 
+    /**
+     * Returns the geometry covered by the extrusion of the base, which depends on the type of the base:
+     *
+     * <ul>
+     *   <li>A point becomes the line string going from the lower limit to the upper limit.</li>
+     *   <li>A line string becomes a polyhedral surface made of one quadrilateral for each segment.</li>
+     *   <li>A polygon becomes a polyhedron whose exterior shell is made of the polygon at the lower limit,
+     *       the polygon at the upper limit, and one quadrilateral for each segment of its rings.
+     *       All the faces are oriented outward.</li>
+     *   <li>A geometry collection becomes the collection of the extrusions of its elements,
+     *       which shall be of the above types or collections of them.</li>
+     * </ul>
+     *
+     * <p>If the reference system of the base contains the extrusion reference system, the extrusion
+     * replaces the coordinate of the corresponding axis. Otherwise the extrusion adds a dimension,
+     * and the result is in the compound of the two reference systems.</p>
+     *
+     * <p>Each position of the result is a copy of a position of the base, with the extrusion
+     * coordinate set to one of the limits. The other attributes of that position are copied unchanged.</p>
+     *
+     * @return the extrusion of the base.
+     * @throws UnsupportedOperationException if the base or one of its elements is of another type,
+     *         if the extrusion reference system has more than one dimension,
+     *         or if the extrusion of a polygon would not be 3-dimensional.
+     */
+    Geometry toExtrusion();
+
 }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultPrism.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultPrism.java
index f035562b4a..fdda173d48 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultPrism.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultPrism.java
@@ -16,14 +16,36 @@
  */
 package org.apache.sis.geometries.internal.shared;
 
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import org.opengis.geometry.Envelope;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.opengis.referencing.crs.SingleCRS;
 import org.opengis.util.FactoryException;
+import org.apache.sis.geometries.DataPoints;
+import org.apache.sis.geometries.DataPointsType;
+import org.apache.sis.geometries.Geometries;
 import org.apache.sis.geometries.Geometry;
+import org.apache.sis.geometries.GeometryCollection;
+import org.apache.sis.geometries.GeometryFactory;
+import org.apache.sis.geometries.GeometryType;
+import org.apache.sis.geometries.Point;
 import org.apache.sis.geometries.Prism;
+import org.apache.sis.geometries.curve.LineString;
+import org.apache.sis.geometries.curve.LinearRing;
+import org.apache.sis.geometries.solid.Polyhedron;
+import org.apache.sis.geometries.surface.Polygon;
+import org.apache.sis.geometries.surface.PolyhedralSurface;
 import org.apache.sis.geometry.GeneralEnvelope;
+import org.apache.sis.maths.Array;
+import org.apache.sis.maths.NDArrays;
+import org.apache.sis.maths.SampleSystem;
+import org.apache.sis.maths.Vector;
+import org.apache.sis.maths.Vectors;
 import org.apache.sis.measure.NumberRange;
 import org.apache.sis.referencing.CRS;
 import org.apache.sis.util.Utilities;
@@ -141,6 +163,305 @@ public non-sealed class DefaultPrism extends AbstractGeometry implements Prism {
         return envelope;
     }
 
+    /**
+     * Returns the geometry covered by the extrusion of the base.
+     */
+    @Override
+    public Geometry toExtrusion() {
+        return new Extrusion().extrude(base);
+    }
+
+    /**
+     * Builds the extrusion of the base and of its elements. This class holds the reference system of the
+     * extruded geometries and the axis on which the extrusion is done, which are the same for all elements.
+     */
+    private final class Extrusion {
+        /**
+         * Factory of the extruded geometries.
+         */
+        private final GeometryFactory factory = GeometryFactory.DEFAULT;
+
+        /**
+         * Reference system of the extruded geometries.
+         */
+        private final CoordinateReferenceSystem crs;
+
+        /**
+         * Index of the extrusion axis in {@link #crs}, which is the dimension of the base if the extrusion adds a dimension.
+         */
+        private final int axis;
+
+        /**
+         * Lower and upper limits of the extrusion.
+         */
+        private final double min, max;
+
+        /**
+         * Finds the reference system and the axis of the extrusion.
+         * The extrusion is along an axis of the base if the extrusion reference system is a component
+         * of the base one. Otherwise the extrusion adds a dimension after the dimensions of the base.
+         */
+        Extrusion() {
+            if (extrusionCrs != null && extrusionCrs.getCoordinateSystem().getDimension() != 1) {
+                throw new UnsupportedOperationException("The extrusion of a prism is computed along a single axis only.");
+            }
+            final CoordinateReferenceSystem baseCRS = base.getCoordinateReferenceSystem();
+            final int baseDimension = baseCRS.getCoordinateSystem().getDimension();
+            CoordinateReferenceSystem target = null;
+            int index = baseDimension;
+            if (extrusionCrs != null) {
+                int offset = 0;
+                for (final SingleCRS component : CRS.getSingleComponents(baseCRS)) {
+                    if (Utilities.equalsIgnoreMetadata(component, extrusionCrs)) {
+                        target = baseCRS;
+                        index  = offset;
+                        break;
+                    }
+                    offset += component.getCoordinateSystem().getDimension();
+                }
+            }
+            if (target == null) {
+                if (extrusionCrs == null) {
+                    target = Geometries.getUndefinedCRS(baseDimension + 1);
+                } else {
+                    try {
+                        target = CRS.compound(baseCRS, extrusionCrs);
+                    } catch (FactoryException e) {
+                        throw new IllegalStateException("Cannot combine the base and extrusion reference systems.", e);
+                    }
+                }
+            }
+            crs  = target;
+            axis = index;
+            min  = extrusionRange.getMinDouble();
+            max  = extrusionRange.getMaxDouble();
+        }
+
+        /**
+         * Returns the extrusion of the given geometry, which is the base or one of its elements.
+         */
+        Geometry extrude(final Geometry geometry) {
+            if (geometry instanceof Point point) {
+                return extrude(point);
+            } else if (geometry instanceof LineString line) {
+                return extrude(line);
+            } else if (geometry instanceof Polygon polygon) {
+                return extrude(polygon);
+            } else if (geometry instanceof GeometryCollection<?> collection) {
+                return extrude(collection);
+            }
+            throw new UnsupportedOperationException("Cannot extrude a " + geometry.getGeometryType() + '.');
+        }
+
+        /**
+         * Returns the extrusion of a point, which is the line string from the lower limit to the upper limit.
+         */
+        private LineString extrude(final Point point) {
+            return factory.createLineString(copy(point.getDataPoints(), new int[] {0, 0}, new double[] {min, max}));
+        }
+
+        /**
+         * Returns the extrusion of a line string, which is a polyhedral surface
+         * made of one quadrilateral for each segment of the line string.
+         */
+        private PolyhedralSurface<Polygon> extrude(final LineString line) {
+            final DataPoints points = line.getDataPoints();
+            final int[] order = new int[points.size()];
+            Arrays.setAll(order, (i) -> i);
+            final var faces = new ArrayList<Polygon>();
+            walls(points, order, faces);
+            return factory.createPolyhedralSurface(crs, faces.toArray(Polygon[]::new));
+        }
+
+        /**
+         * Returns the extrusion of a polygon, which is a polyhedron whose exterior shell is made of the polygon
+         * at the lower limit, the polygon at the upper limit and the walls of the extrusion of its rings.
+         *
+         * <p>The rings of the polygon are first ordered so that, seen from the upper side of the extrusion axis,
+         * the exterior one is counterclockwise and the holes are clockwise. The upper face then keeps that order,
+         * the lower face reverses it, and the walls are built as for line strings, which makes all the faces
+         * oriented outward.</p>
+         */
+        private Polyhedron extrude(final Polygon polygon) {
+            if (crs.getCoordinateSystem().getDimension() != 3) {
+                throw new UnsupportedOperationException("The extrusion of a polygon is computed in 3 dimensions only.");
+            }
+            if (polygon.isEmpty()) {
+                return factory.createPolyhedron(factory.createMultiPolygon(crs), List.of());
+            }
+            /*
+             * The two other axes, in increasing order, followed by the extrusion axis form a right-handed
+             * system if they are an even permutation of (0, 1, 2), which is the case unless the extrusion
+             * axis is in the middle. In a left-handed system, the counterclockwise order is reversed.
+             */
+            final int h0 = (axis == 0) ? 1 : 0;
+            final int h1 = (axis == 2) ? 1 : 2;
+            final boolean rightHanded = (axis != 1);
+            final var rings = new ArrayList<DataPoints>();
+            rings.add(polygon.getExteriorRing().getDataPoints());
+            for (final LinearRing hole : polygon.getInteriorRings()) {
+                rings.add(hole.getDataPoints());
+            }
+            final var orders = new ArrayList<int[]>(rings.size());
+            for (int k = 0; k < rings.size(); k++) {
+                orders.add(order(rings.get(k), h0, h1, (k == 0) == rightHanded));
+            }
+            final var faces = new ArrayList<Polygon>();
+            faces.add(cap(rings, orders, min, true));
+            faces.add(cap(rings, orders, max, false));
+            for (int k = 0; k < rings.size(); k++) {
+                walls(rings.get(k), orders.get(k), faces);
+            }
+            return factory.createPolyhedron(factory.createMultiPolygon(crs, faces.toArray(Polygon[]::new)), List.of());
+        }
+
+        /**
+         * Returns the collection of the extrusions of the elements of the given collection.
+         */
+        private GeometryCollection<Geometry> extrude(final GeometryCollection<?> collection) {
+            final var elements = new Geometry[collection.getNumGeometries()];
+            for (int i = 0; i < elements.length; i++) {
+                elements[i] = extrude(collection.getGeometryN(i));
+            }
+            final GeometryType type = switch (collection.getElementType()) {
+                case POINT                  -> GeometryType.LINESTRING;
+                case LINESTRING, LINEARRING -> GeometryType.POLYHEDRALSURFACE;
+                case POLYGON, TRIANGLE      -> GeometryType.POLYHEDRON;
+                default                     -> GeometryType.GEOMETRY;
+            };
+            return factory.createGeometryCollection(type, crs, elements);
+        }
+
+        /**
+         * Adds to the given list the walls of the extrusion of the given positions. The wall of the segment
+         * going from <var>a</var> to <var>b</var> goes from the lower to the upper limit along <var>b</var>,
+         * then back to the lower limit along <var>a</var>.
+         *
+         * @param  points  the positions to extrude.
+         * @param  order   indices of the positions in the order of the segments.
+         * @param  faces   where to add the walls.
+         */
+        private void walls(final DataPoints points, final int[] order, final List<Polygon> faces) {
+            for (int i = 1; i < order.length; i++) {
+                final int a = order[i-1];
+                final int b = order[i];
+                if (!isRepeated(points, a, b)) {
+                    faces.add(factory.createPolygon(factory.createLinearRing(copy(points,
+                            new int[]    {a,   b,   b,   a,   a},
+                            new double[] {min, min, max, max, min})), null));
+                }
+            }
+        }
+
+        /**
+         * Returns whether the two given positions have the same coordinates, the extrusion one excepted.
+         * The extrusion of the segment joining such positions has no area.
+         */
+        private boolean isRepeated(final DataPoints points, final int a, final int b) {
+            final double[] pa = points.getPosition(a).toArrayDouble();
+            final double[] pb = points.getPosition(b).toArrayDouble();
+            for (int d = 0; d < pa.length; d++) {
+                if (d != axis && pa[d] != pb[d]) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        /**
+         * Returns the polygon at the given extrusion value, as a face of its extrusion.
+         *
+         * @param  rings    positions of the exterior ring of the polygon followed by those of its holes.
+         * @param  orders   order of the positions of each ring for a face oriented toward the upper limit.
+         * @param  value    the extrusion value of all positions of the face.
+         * @param  reverse  whether to reverse the orders, for a face oriented toward the lower limit.
+         */
+        private Polygon cap(final List<DataPoints> rings, final List<int[]> orders, final double value, final boolean reverse) {
+            final var faceRings = new ArrayList<LinearRing>(rings.size());
+            for (int k = 0; k < rings.size(); k++) {
+                final int[] order = orders.get(k);
+                final int n = order.length;
+                final int[] indices = new int[n];
+                for (int i = 0; i < n; i++) {
+                    indices[i] = order[reverse ? n - 1 - i : i];
+                }
+                final double[] values = new double[n];
+                Arrays.fill(values, value);
+                faceRings.add(factory.createLinearRing(copy(rings.get(k), indices, values)));
+            }
+            return factory.createPolygon(faceRings.get(0), faceRings.subList(1, faceRings.size()));
+        }
+
+        /**
+         * Returns copies of the given positions of the source with the given extrusion values.
+         * The position at index <var>i</var> of the returned {@code DataPoints} is a copy of the
+         * position at index {@code indices[i]} of the source, with all its attributes, except
+         * that its coordinate on the extrusion axis is {@code values[i]}.
+         *
+         * @param  source   positions to copy.
+         * @param  indices  indices in the source of the positions to copy.
+         * @param  values   extrusion value of each copied position.
+         */
+        private DataPoints copy(final DataPoints source, final int[] indices, final double[] values) {
+            final DataPointsType type = source.getType();
+            final SampleSystem system = SampleSystem.of(crs);
+            final Map<String,Array> attributes = new LinkedHashMap<>();
+            for (final String name : type.getAttributeNames()) {
+                final boolean isPosition = DataPointsType.ATT_POSITION.equals(name);
+                attributes.put(name, NDArrays.of(isPosition ? system : type.getAttributeSystem(name),
+                                                 type.getAttributeType(name), indices.length));
+            }
+            final Array positions = attributes.get(DataPointsType.ATT_POSITION);
+            final Vector<?> position = Vectors.create(system, type.getAttributeType(DataPointsType.ATT_POSITION));
+            final int dimension = source.getDimension();
+            for (int i = 0; i < indices.length; i++) {
+                final Vector<?> p = source.getPosition(indices[i]);
+                for (int d = 0; d < dimension; d++) {
+                    position.set(d, p.get(d));
+                }
+                position.set(axis, values[i]);
+                positions.set(i, position);
+                for (final Map.Entry<String,Array> entry : attributes.entrySet()) {
+                    if (entry.getValue() != positions) {
+                        entry.getValue().set(i, source.getAttribute(indices[i], entry.getKey()));
+                    }
+                }
+            }
+            return factory.createDataPoints(attributes);
+        }
+    }
+
+    /**
+     * Returns the indices of the positions of the given closed ring, in their order or in reverse order,
+     * so that the ring is counterclockwise or clockwise in the plane of the given axes.
+     *
+     * @param  ring              positions of the ring, the last one being equal to the first one.
+     * @param  h0                index of the first axis of the plane.
+     * @param  h1                index of the second axis of the plane.
+     * @param  counterclockwise  whether the returned order shall be counterclockwise.
+     */
+    private static int[] order(final DataPoints ring, final int h0, final int h1, final boolean counterclockwise) {
+        final int n = ring.size();
+        double area = 0;
+        Vector<?> p = ring.getPosition(n - 1);
+        double px = p.get(h0);
+        double py = p.get(h1);
+        for (int i = 0; i < n; i++) {
+            p = ring.getPosition(i);
+            final double x = p.get(h0);
+            final double y = p.get(h1);
+            area += px * y - x * py;
+            px = x;
+            py = y;
+        }
+        final boolean reverse = (area > 0) != counterclockwise;
+        final int[] order = new int[n];
+        for (int i = 0; i < n; i++) {
+            order[i] = reverse ? n - 1 - i : i;
+        }
+        return order;
+    }
+
     @Override
     public Geometry getBoundary() {
         throw new UnsupportedOperationException("Not supported yet.");
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/GeometryProcessor.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/GeometryProcessor.java
index 917acead48..3ba5b37bc4 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/GeometryProcessor.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/GeometryProcessor.java
@@ -37,6 +37,7 @@ import org.apache.sis.geometries.Geometry;
 import org.apache.sis.geometries.GeometryCollection;
 import org.apache.sis.geometries.GeometryType;
 import org.apache.sis.geometries.Point;
+import org.apache.sis.geometries.Prism;
 import org.apache.sis.geometries.curve.LineString;
 import org.apache.sis.geometries.curve.LinearRing;
 import org.apache.sis.geometries.mesh.MeshPrimitive;
@@ -499,6 +500,9 @@ public final class GeometryProcessor {
             return ToPrimitive.toPrimitive(cdt);
         } else if (geom instanceof Polyhedron cdt) {
             return ToPrimitive.toPrimitive(cdt);
+        } else if (geom instanceof Prism cdt) {
+            //a prism is converted to the line string, surface or solid covered by its extrusion.
+            return toPrimitive(cdt.toExtrusion(), spacing, offset);
         } else if (geom instanceof GeometryCollection<?> cdt) {
             final GeometryType elementType = cdt.getElementType();
             switch (elementType) {
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/PrismTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/PrismTest.java
index 14fffc6f7d..19afe25154 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/PrismTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/PrismTest.java
@@ -16,11 +16,30 @@
  */
 package org.apache.sis.geometries;
 
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.opengis.referencing.crs.VerticalCRS;
+import org.opengis.util.FactoryException;
 import org.apache.sis.geometries.curve.LineString;
+import org.apache.sis.geometries.curve.LinearRing;
+import org.apache.sis.geometries.solid.Polyhedron;
+import org.apache.sis.geometries.surface.Polygon;
+import org.apache.sis.geometries.surface.PolyhedralSurface;
+import org.apache.sis.maths.Array;
+import org.apache.sis.maths.NDArrays;
+import org.apache.sis.maths.Vector;
 import org.apache.sis.measure.NumberRange;
+import org.apache.sis.referencing.CRS;
+import org.apache.sis.referencing.CommonCRS;
 
 // Test dependencies
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 
@@ -103,6 +122,198 @@ public class PrismTest extends GeometryTest {
         assertEquals(2, surfacePrism.getTopologicDimension(), "Extruding a curve gives a surface.");
     }
 
+    /**
+     * Name of the attribute holding {@code x + 10*y} at each position, for checking that it is copied.
+     */
+    private static final String ATT_TEST = "TEST";
+
+    /**
+     * Returns the given positions, each one with a {@value #ATT_TEST} attribute.
+     */
+    private static DataPoints points(final CoordinateReferenceSystem crs, final double... coordinates) {
+        final int dimension = crs.getCoordinateSystem().getDimension();
+        final double[] values = new double[coordinates.length / dimension];
+        for (int i = 0; i < values.length; i++) {
+            values[i] = coordinates[i * dimension] + 10 * coordinates[i * dimension + 1];
+        }
+        final Map<String,Array> attributes = new LinkedHashMap<>();
+        attributes.put(DataPointsType.ATT_POSITION, NDArrays.of(crs, coordinates));
+        attributes.put(ATT_TEST, NDArrays.of(1, values));
+        return GeometryFactory.DEFAULT.createDataPoints(attributes);
+    }
+
+    /**
+     * Returns a ring of the given positions, each one with a {@value #ATT_TEST} attribute.
+     */
+    private static LinearRing ring(final CoordinateReferenceSystem crs, final double... coordinates) {
+        return GeometryFactory.DEFAULT.createLinearRing(points(crs, coordinates));
+    }
+
+    /**
+     * Returns a square polygon of the given size, whose positions have a {@value #ATT_TEST} attribute.
+     */
+    private static Polygon square(final double x, final double y, final double size) {
+        return GeometryFactory.DEFAULT.createPolygon(ring(CRS_2D,
+                x, y,  x + size, y,  x + size, y + size,  x, y + size,  x, y), null);
+    }
+
+    /**
+     * Returns the prism of the given base, extruded from the given lower limit to the given upper limit.
+     */
+    private static Prism prism(final Geometry base, final double min, final double max) {
+        return GeometryFactory.DEFAULT.createPrism(base, NumberRange.create(min, true, max, true), null);
+    }
+
+    /**
+     * Returns the volume enclosed by the exterior shell of the given polyhedron,
+     * positive if its faces are oriented outward.
+     */
+    private static double signedVolume(final Polyhedron polyhedron) {
+        final GeometryCollection<Polygon> shell = polyhedron.getExteriorShell();
+        double volume = 0;
+        for (int f = 0; f < shell.getNumGeometries(); f++) {
+            final Polygon face = shell.getGeometryN(f);
+            final var rings = new ArrayList<LinearRing>();
+            rings.add(face.getExteriorRing());
+            rings.addAll(face.getInteriorRings());
+            for (final LinearRing ring : rings) {
+                final DataPoints points = ring.getDataPoints();
+                final Vector<?> a = points.getPosition(0);
+                for (int i = 2; i < points.size(); i++) {
+                    volume += a.dot(points.getPosition(i-1).cross(points.getPosition(i))) / 6;
+                }
+            }
+        }
+        return volume;
+    }
+
+    /**
+     * Verifies that each position of the given extrusion has the {@value #ATT_TEST} attribute of the base,
+     * and that its coordinate on the extrusion axis is one of the given limits.
+     */
+    private static void assertPositions(final Geometry extrusion, final int axis, final double min, final double max) {
+        final DataPoints points = extrusion.getDataPoints();
+        assertTrue(points.size() > 0);
+        for (int i = 0; i < points.size(); i++) {
+            final Vector<?> p = points.getPosition(i);
+            final int h0 = (axis == 0) ? 1 : 0;
+            final int h1 = (axis == 2) ? 1 : 2;
+            assertEquals(p.get(h0) + 10 * p.get(h1), points.getAttribute(i, ATT_TEST).get(0), TOLERANCE);
+            final double value = p.get(axis);
+            assertTrue(value == min || value == max, "Not an extrusion limit: " + value);
+        }
+    }
+
+    /**
+     * The prism of a point becomes the line string joining the two limits.
+     */
+    @Test
+    public void testToExtrusionOfPoint() {
+        final Point point = GeometryFactory.DEFAULT.createPoint(points(CRS_2D, 1, 2));
+        final LineString line = assertInstanceOf(LineString.class, prism(point, 0, 5).toExtrusion());
+        assertEquals(3, line.getDimension());
+        final DataPoints points = line.getDataPoints();
+        assertEquals(2, points.size());
+        assertArrayEquals(new double[] {1, 2, 0}, points.getPosition(0).toArrayDouble());
+        assertArrayEquals(new double[] {1, 2, 5}, points.getPosition(1).toArrayDouble());
+        assertPositions(line, 2, 0, 5);
+    }
+
+    /**
+     * The prism of a line string becomes a polyhedral surface of one quadrilateral by segment.
+     * A repeated position adds no quadrilateral.
+     */
+    @Test
+    public void testToExtrusionOfLineString() {
+        final LineString base = GeometryFactory.DEFAULT.createLineString(points(CRS_2D, 0,0, 2,0, 2,0, 2,1));
+        final PolyhedralSurface<?> surface = assertInstanceOf(PolyhedralSurface.class, prism(base, 0, 3).toExtrusion());
+        assertEquals(2, surface.getNumPatches());
+        assertEquals(2*3 + 1*3, surface.getArea().getValue().doubleValue(), TOLERANCE);
+        for (int i = 0; i < surface.getNumPatches(); i++) {
+            assertPositions(surface.getPatchN(i), 2, 0, 3);
+        }
+    }
+
+    /**
+     * The prism of a collection becomes the collection of the extrusions of its elements.
+     */
+    @Test
+    public void testToExtrusionOfCollection() {
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        final GeometryCollection<?> polyhedra = assertInstanceOf(GeometryCollection.class,
+                prism(f.createMultiPolygon(CRS_2D, square(0, 0, 1), square(3, 0, 2)), 0, 2).toExtrusion());
+        assertEquals(GeometryType.POLYHEDRON, polyhedra.getElementType());
+        assertEquals(2, polyhedra.getNumGeometries());
+        assertEquals(1*1*2, signedVolume(assertInstanceOf(Polyhedron.class, polyhedra.getGeometryN(0))), TOLERANCE);
+        assertEquals(2*2*2, signedVolume(assertInstanceOf(Polyhedron.class, polyhedra.getGeometryN(1))), TOLERANCE);
+
+        final GeometryCollection<?> mixed = assertInstanceOf(GeometryCollection.class, prism(f.createGeometryCollection(CRS_2D,
+                f.createPoint(points(CRS_2D, 1, 2)),
+                f.createLineString(points(CRS_2D, 0,0, 2,0)),
+                square(5, 5, 1)), 0, 1).toExtrusion());
+        assertEquals(GeometryType.GEOMETRY, mixed.getElementType());
+        assertEquals(3, mixed.getCoordinateReferenceSystem().getCoordinateSystem().getDimension());
+        assertInstanceOf(LineString.class,        mixed.getGeometryN(0));
+        assertInstanceOf(PolyhedralSurface.class, mixed.getGeometryN(1));
+        assertInstanceOf(Polyhedron.class,        mixed.getGeometryN(2));
+        for (int i = 0; i < mixed.getNumGeometries(); i++) {
+            assertPositions(mixed.getGeometryN(i), 2, 0, 1);
+        }
+    }
+
+    /**
+     * The prism of a square becomes a box whose faces are oriented outward,
+     * whatever the orientation of the square.
+     */
+    @Test
+    public void testToExtrusionOfPolygon() {
+        for (final boolean clockwise : new boolean[] {false, true}) {
+            final LinearRing square = clockwise ? ring(CRS_2D, 0,0, 0,2, 2,2, 2,0, 0,0)
+                                                : ring(CRS_2D, 0,0, 2,0, 2,2, 0,2, 0,0);
+            final Prism prism = GeometryFactory.DEFAULT.createPrism(
+                    GeometryFactory.DEFAULT.createPolygon(square, null), NumberRange.create(1., true, 4., true), null);
+            final Polyhedron polyhedron = (Polyhedron) prism.toExtrusion();
+            assertEquals(3, polyhedron.getDimension());
+            assertEquals(6, polyhedron.getExteriorShell().getNumGeometries(), "Expected 2 caps and 4 walls.");
+            assertTrue(polyhedron.getInteriorShells().isEmpty());
+            assertEquals(12, signedVolume(polyhedron), TOLERANCE);
+            assertPositions(polyhedron, 2, 1, 4);
+        }
+    }
+
+    /**
+     * The holes of the base become tunnels through the polyhedron, still oriented outward.
+     */
+    @Test
+    public void testToExtrusionOfPolygonWithHole() {
+        final Polygon base = GeometryFactory.DEFAULT.createPolygon(ring(CRS_2D, 0,0, 4,0, 4,4, 0,4, 0,0),
+                List.of(ring(CRS_2D, 1,1, 3,1, 3,3, 1,3, 1,1)));
+        final Prism prism = GeometryFactory.DEFAULT.createPrism(base, NumberRange.create(0., true, 2., true), null);
+        final Polyhedron polyhedron = (Polyhedron) prism.toExtrusion();
+        final GeometryCollection<Polygon> shell = polyhedron.getExteriorShell();
+        assertEquals(10, shell.getNumGeometries(), "Expected 2 caps and 8 walls.");
+        assertEquals(1, shell.getGeometryN(0).getNumInteriorRing(), "The lower cap shall keep the hole.");
+        assertEquals((16 - 4) * 2, signedVolume(polyhedron), TOLERANCE);
+        assertPositions(polyhedron, 2, 0, 2);
+    }
+
+    /**
+     * When the reference system of the base contains the extrusion one,
+     * the extrusion replaces the coordinate on the corresponding axis.
+     */
+    @Test
+    public void testToExtrusionAlongBaseAxis() throws FactoryException {
+        final VerticalCRS vertical = CommonCRS.Vertical.MEAN_SEA_LEVEL.crs();
+        final CoordinateReferenceSystem crs = CRS.compound(CRS_2D, vertical);
+        final Polygon base = GeometryFactory.DEFAULT.createPolygon(ring(crs, 0,0,5, 2,0,5, 2,3,5, 0,3,5, 0,0,5), null);
+        final Prism prism = GeometryFactory.DEFAULT.createPrism(base, NumberRange.create(-1., true, 1., true), vertical);
+        final Polyhedron polyhedron = (Polyhedron) prism.toExtrusion();
+        assertEquals(crs, polyhedron.getCoordinateReferenceSystem());
+        assertEquals(6, polyhedron.getExteriorShell().getNumGeometries());
+        assertEquals(12, signedVolume(polyhedron), TOLERANCE);
+        assertPositions(polyhedron, 2, -1, 1);
+    }
+
     @Test
     @Disabled("Not implemented yet.")
     public void testGetBoundaryType() {
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/ToPrimitiveTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/ToPrimitiveTest.java
index dd13ecd158..f239dcd651 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/ToPrimitiveTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/ToPrimitiveTest.java
@@ -24,6 +24,7 @@ import org.apache.sis.geometries.Point;
 import org.apache.sis.geometries.DataPoints;
 import org.apache.sis.geometries.DataPointsType;
 import org.apache.sis.geometries.GeometryCollection;
+import org.apache.sis.geometries.Prism;
 import org.apache.sis.geometries.curve.LineString;
 import org.apache.sis.geometries.curve.LinearRing;
 import org.apache.sis.geometries.surface.Polygon;
@@ -35,6 +36,7 @@ import org.apache.sis.geometries.mesh.MeshPrimitive;
 import org.apache.sis.geometries.mesh.MultiMeshPrimitive;
 import org.apache.sis.maths.NDArrays;
 import org.apache.sis.maths.Array;
+import org.apache.sis.measure.NumberRange;
 import org.apache.sis.measure.Quantities;
 import org.apache.sis.measure.Units;
 import org.apache.sis.referencing.CommonCRS;
@@ -334,6 +336,43 @@ public class ToPrimitiveTest {
         }
     }
 
+    /**
+     * The prism of a polygon is a solid, which becomes the triangles of the faces of its polyhedron.
+     */
+    @Test
+    public void testPrism() {
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        final Polygon square = f.createPolygon(f.createLinearRing(f.createDataPoints(
+                NDArrays.of(CommonCRS.WGS84.normalizedGeographic(), new double[] {0,0, 2,0, 2,2, 0,2, 0,0}))), null);
+        final Prism prism = f.createPrism(square, NumberRange.create(0., true, 3., true), null);
+        final Geometry result = new GeometryProcessor().toPrimitive(prism, null, null);
+        assertTrue(result instanceof MeshPrimitive.Triangles);
+        final MeshPrimitive primitive = (MeshPrimitive) result;
+        assertEquals(3, primitive.getCoordinateReferenceSystem().getCoordinateSystem().getDimension());
+        assertEquals(36, primitive.getPositions().getLength(), "Expected 2 triangles per face.");
+        assertEquals(2*4 + 4*6, trianglesArea3D(primitive), 1E-9);
+    }
+
+    /**
+     * The prism of a point becomes a line strip, and the prism of a line string the triangles of its walls.
+     */
+    @Test
+    public void testPrismOfPointAndLineString() {
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        final CoordinateReferenceSystem crs = CommonCRS.WGS84.normalizedGeographic();
+        final NumberRange<Double> range = NumberRange.create(0., true, 3., true);
+
+        final Geometry strip = new GeometryProcessor().toPrimitive(f.createPrism(f.createPoint(crs, 1, 2), range, null), null, null);
+        assertTrue(strip instanceof MeshPrimitive.LineStrip);
+        assertEquals(2, ((MeshPrimitive) strip).getPositions().getLength());
+
+        final LineString line = f.createLineString(f.createDataPoints(NDArrays.of(crs, new double[] {0,0, 2,0, 2,1})));
+        final Geometry walls = new GeometryProcessor().toPrimitive(f.createPrism(line, range, null), null, null);
+        assertTrue(walls instanceof MeshPrimitive.Triangles);
+        assertEquals(12, ((MeshPrimitive) walls).getPositions().getLength(), "Expected 2 triangles per segment.");
+        assertEquals(2*3 + 1*3, trianglesArea3D((MeshPrimitive) walls), 1E-9);
+    }
+
     private static LinearRing ring3D(final CoordinateReferenceSystem crs, final double... coordinates) {
         return GeometryFactory.DEFAULT.createLinearRing(
                 GeometryFactory.DEFAULT.createDataPoints(NDArrays.of(crs, coordinates)));


(sis) 02/13: feat(Math): add Vector.swizzle and Maths.removeWithin

Posted by js...@apache.org.
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

commit fc8a1ac943516ab477cc6b7bd495130b2138f997
Author: jsorel <jo...@geomatys.com>
AuthorDate: Fri Oct 16 11:46:47 2026 +0200

    feat(Math): add Vector.swizzle and Maths.removeWithin
---
 .../apache/sis/geometries/operation/Distance.java  | 52 ++++++++++++++++++++++
 .../main/org/apache/sis/maths/Maths.java           | 32 +++++++++++++
 .../main/org/apache/sis/maths/ReadOnly.java        | 18 ++++++++
 .../org/apache/sis/maths/AbstractVectorTest.java   | 37 +++++++++++++++
 4 files changed, 139 insertions(+)

diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Distance.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Distance.java
index a12587e66f..379ede03bc 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Distance.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Distance.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sis.geometries.operation;
 
+import java.util.List;
 import org.apache.sis.geometries.Point;
 import org.apache.sis.maths.Maths;
 import org.apache.sis.maths.Vectors;
@@ -136,6 +137,57 @@ public final class Distance {
         return Vectors.dot(t,t);
     }
 
+    /**
+     * Find nearest points between two lines.
+     * Only the two first coordinates of the positions are used.
+     *
+     * @param line1Coords line 1 coordinates
+     * @param buffer1 buffer to store nearest point on line 1, of length 2
+     * @param line2Coords line 2 coordinates
+     * @param buffer2 buffer to store nearest point on line 2, of length 2
+     * @param ratio size 2, will store for each line the ratio [0..1] of the nearest point
+     *        between the start and end points of the segment where it is
+     * @param offset size 2, will store for each line the index of the segment where the nearest point is
+     * @param epsilon tolerance factor
+     */
+    public static void nearest(List<? extends Vector<?>> line1Coords, double[] buffer1,
+                               List<? extends Vector<?>> line2Coords, double[] buffer2,
+                               double[] ratio, int[] offset, double epsilon){
+
+        double distance = Double.MAX_VALUE;
+
+        final double[] tempRatio = new double[2];
+        final double[] tempC1 = new double[buffer1.length];
+        final double[] tempC2 = new double[buffer1.length];
+
+        final int nb1 = line1Coords.size()-1;
+        final int nb2 = line2Coords.size()-1;
+
+        for(int i=0;i<nb1;i++){
+            final Vector<?> s1 = line1Coords.get(i);
+            final Vector<?> e1 = line1Coords.get(i+1);
+            for(int k=0;k<nb2;k++){
+                final Vector<?> s2 = line2Coords.get(k);
+                final Vector<?> e2 = line2Coords.get(k+1);
+
+                final double dist = Math.sqrt(distanceSquare(
+                                new double[]{s1.get(0),s1.get(1)}, new double[]{e1.get(0),e1.get(1)}, tempC1,
+                                new double[]{s2.get(0),s2.get(1)}, new double[]{e2.get(0),e2.get(1)}, tempC2,
+                                tempRatio,epsilon));
+                if(dist<distance){
+                    //keep informations
+                    distance = dist;
+                    offset[0] = i;
+                    offset[1] = k;
+                    ratio[0] = tempRatio[0];
+                    ratio[1] = tempRatio[1];
+                    System.arraycopy(tempC1, 0, buffer1, 0, tempC1.length);
+                    System.arraycopy(tempC2, 0, buffer2, 0, tempC2.length);
+                }
+            }
+        }
+    }
+
     /**
      * Point to point distance.
      */
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java
index f00c32484b..fb90d5880b 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java
@@ -1278,4 +1278,36 @@ public final class Maths {
                 }
         }
     }
+
+    /**
+     * Remove element at index.
+     * Move elements after index by -1.
+     * Last element is set to null
+     *
+     * @param values original array
+     * @param index removed element index
+     * @return removed object
+     */
+    public static Object removeWithin(Object[] values, int index) {
+        final Object removedValue = values[index];
+        System.arraycopy(values, index+1, values, index, values.length-index-1);
+        values[values.length-1] = null; //remove reference
+        return removedValue;
+    }
+
+    /**
+     * Remove element at index.
+     * Move elements after index by -1.
+     * Last element is set to zero
+     *
+     * @param values original array
+     * @param index removed element index
+     * @return removed value
+     */
+    public static int removeWithin(int[] values, int index) {
+        final int removedValue = values[index];
+        System.arraycopy(values, index+1, values, index, values.length-index-1);
+        values[values.length-1] = 0;
+        return removedValue;
+    }
 }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/ReadOnly.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/ReadOnly.java
index 33d3a4db8d..9ff8cffef5 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/ReadOnly.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/ReadOnly.java
@@ -347,6 +347,24 @@ public final class ReadOnly {
             }
             return v;
         }
+
+        /**
+         * Create a new tuple with the values of the given axes, in the given order.
+         * Axes can be omitted or repeated, for example {@code swizzle(1, 0)} swaps the
+         * two first values and {@code swizzle(0, 0, 0)} repeats the first one.
+         * Vector CRS will be lost.
+         *
+         * @param axes indices of the values of this tuple to copy, one per dimension of the result
+         * @return Vector of dimension {@code axes.length}
+         * @throws IndexOutOfBoundsException if an axis index is not valid
+         */
+        default org.apache.sis.maths.Vector<?> swizzle(int ... axes) throws IndexOutOfBoundsException {
+            final org.apache.sis.maths.Vector<?> v = Vectors.create(axes.length, getDataType());
+            for (int i = 0; i < axes.length; i++) {
+                v.set(i, get(axes[i]));
+            }
+            return v;
+        }
     }
 
     public static interface Affine<T extends Affine<T>> extends Transform {
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/AbstractVectorTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/AbstractVectorTest.java
index e61ef375c6..78bbd04e5e 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/AbstractVectorTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/AbstractVectorTest.java
@@ -68,6 +68,7 @@ public abstract class AbstractVectorTest {
         testToArray(tuple);
         testEquality(tuple);
         testCopyDataType(tuple);
+        testSwizzle(tuple);
 
         switch (tuple.getDataType()) {
             case BYTE :
@@ -265,6 +266,42 @@ public abstract class AbstractVectorTest {
         }
     }
 
+    /**
+     * Test the creation of a tuple from some axes of another one.
+     */
+    private void testSwizzle(Vector<?> tuple) {
+        final int dim = tuple.getDimension();
+        for (int i = 0; i < dim; i++) {
+            tuple.set(i, i+1);
+        }
+        //reverse order
+        final int[] reverse = new int[dim];
+        final double[] expected = new double[dim];
+        for (int i = 0; i < dim; i++) {
+            reverse[i] = dim - 1 - i;
+            expected[i] = dim - i;
+        }
+        Vector<?> swizzled = tuple.swizzle(reverse);
+        assertEquals(tuple.getDataType(), swizzled.getDataType());
+        assertArrayEquals(expected, swizzled.toArrayDouble(), TOLERANCE);
+
+        //repeated and omitted axes
+        swizzled = tuple.swizzle(dim - 1, 0, dim - 1);
+        assertArrayEquals(new double[] {dim, 1, dim}, swizzled.toArrayDouble(), TOLERANCE);
+
+        //the result is independent of the original tuple
+        swizzled.set(1, 100);
+        assertEquals(1, tuple.get(0), TOLERANCE);
+
+        //test out of range
+        try {
+            tuple.swizzle(dim);
+            fail(UNVALID_INDEX_EXPECTED);
+        } catch (IndexOutOfBoundsException ex) {
+            //ok
+        }
+    }
+
     private void testExtremum(Vector<?> tuple, double value) {
 
         for (int i=0;i<tuple.getDimension();i++) {


(sis) 03/13: feat(Geometry): separate earclipping and holeremover as two separate classes

Posted by js...@apache.org.
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

commit 0f3587edc5376a7d23f338b883a84de00eab4a2c
Author: jsorel <jo...@geomatys.com>
AuthorDate: Fri Oct 16 14:56:04 2026 +0200

    feat(Geometry): separate earclipping and holeremover as two separate classes
---
 .../sis/geometries/operation/ToPrimitive.java      |  63 +-
 .../operation/triangulate/EarClipping.java         | 634 ++++++---------------
 .../operation/triangulate/HoleToExterior.java      | 420 ++++++++++++++
 .../sis/geometries/operation/ToPrimitiveTest.java  |  94 ++-
 .../operation/triangulate/HoleToExteriorTest.java  | 178 ++++++
 5 files changed, 899 insertions(+), 490 deletions(-)

diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/ToPrimitive.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/ToPrimitive.java
index 39f7bb5d6a..b1579b30d6 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/ToPrimitive.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/ToPrimitive.java
@@ -19,8 +19,10 @@ package org.apache.sis.geometries.operation;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import javax.measure.Quantity;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.geometries.DataPoints;
@@ -38,7 +40,6 @@ import org.apache.sis.geometries.surface.Polygon;
 import org.apache.sis.geometries.surface.PolyhedralSurface;
 import org.apache.sis.maths.Array;
 import org.apache.sis.maths.NDArrays;
-import org.apache.sis.maths.Vector;
 
 
 /**
@@ -122,30 +123,56 @@ public final class ToPrimitive {
     }
 
     /**
-     * Triangulates the given polygons into a single mesh of triangles, without index.
+     * Triangulates the given polygons into a single indexed mesh of triangles.
+     * The mesh holds the attributes which are common to all the polygons.
      */
     private static MeshPrimitive.Triangles triangles(final CoordinateReferenceSystem crs, final List<Polygon> faces) {
-        final int dimension = crs.getCoordinateSystem().getDimension();
         final EarClipping triangulator = new EarClipping();
-        final List<double[]> triangles = new ArrayList<>();
+        final List<EarClipping.Triangulation> triangulations = new ArrayList<>(faces.size());
+        final Set<String> names = new LinkedHashSet<>();
+        names.add(DataPointsType.ATT_POSITION);
+        int vertexCount = 0, indexCount = 0;
         for (final Polygon face : faces) {
-            //the returned list is reused by the next call, so the coordinates are copied now.
-            for (final Vector<?>[] triangle : triangulator.triangulate(face)) {
-                final double[] coordinates = new double[3 * dimension];
-                for (int v = 0; v < 3; v++) {
-                    for (int d = 0; d < dimension; d++) {
-                        coordinates[v * dimension + d] = triangle[v].get(d);
-                    }
-                }
-                triangles.add(coordinates);
+            final EarClipping.Triangulation triangulation = triangulator.triangulateIndexed(face);
+            final List<String> faceNames = triangulation.vertices().getType().getAttributeNames();
+            if (triangulations.isEmpty()) {
+                names.addAll(faceNames);
+            } else {
+                names.retainAll(faceNames);
             }
+            triangulations.add(triangulation);
+            vertexCount += triangulation.vertices().size();
+            indexCount  += triangulation.triangles().length;
         }
-        final double[] positions = new double[triangles.size() * 3 * dimension];
-        for (int i = 0; i < triangles.size(); i++) {
-            System.arraycopy(triangles.get(i), 0, positions, i * 3 * dimension, 3 * dimension);
-        }
+        /*
+         * The vertices of all faces are appended in a single array for each attribute,
+         * and the indices of each face are shifted by the number of vertices before it.
+         */
         final MeshPrimitive.Triangles primitive = new MeshPrimitive.Triangles();
-        primitive.setPositions(NDArrays.of(crs, positions));
+        if (triangulations.isEmpty()) {
+            primitive.setPositions(NDArrays.of(crs, new double[0]));
+            return primitive;
+        }
+        final DataPointsType type = triangulations.get(0).vertices().getType();
+        final int[] index = new int[indexCount];
+        for (final String name : names) {
+            final Array array = NDArrays.of(type.getAttributeSystem(name), type.getAttributeType(name), vertexCount);
+            int offset = 0;
+            for (final EarClipping.Triangulation triangulation : triangulations) {
+                final DataPoints vertices = triangulation.vertices();
+                array.set(offset, vertices.getAttributeArray(name), 0, vertices.size());
+                offset += vertices.size();
+            }
+            primitive.setAttribute(name, array);
+        }
+        int vertexOffset = 0, indexOffset = 0;
+        for (final EarClipping.Triangulation triangulation : triangulations) {
+            for (final int i : triangulation.triangles()) {
+                index[indexOffset++] = vertexOffset + i;
+            }
+            vertexOffset += triangulation.vertices().size();
+        }
+        primitive.setIndex(NDArrays.packIntegerDataType(NDArrays.of(1, index)));
         return primitive;
     }
 
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/EarClipping.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/EarClipping.java
index 70023f1917..5f85efcbbd 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/EarClipping.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/EarClipping.java
@@ -18,19 +18,18 @@ package org.apache.sis.geometries.operation.triangulate;
 
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Comparator;
+import java.util.HashMap;
 import java.util.List;
-import java.util.stream.Collectors;
-import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import java.util.Map;
+import org.apache.sis.geometries.DataPoints;
 import org.apache.sis.geometries.DataPointsType;
-import org.apache.sis.geometries.curve.LineString;
+import org.apache.sis.geometries.GeometryFactory;
 import org.apache.sis.geometries.mesh.MeshPrimitive;
 import org.apache.sis.geometries.surface.Polygon;
 import org.apache.sis.maths.Array;
 import org.apache.sis.maths.Maths;
 import org.apache.sis.maths.NDArrays;
 import org.apache.sis.maths.Vector;
-import static org.apache.sis.maths.Vectors.*;
 import org.apache.sis.util.ArraysExt;
 
 
@@ -40,109 +39,137 @@ import org.apache.sis.util.ArraysExt;
  * Triangulation using ear clipping algorithm.
  * http://en.wikipedia.org/wiki/Polygon_triangulation
  *
+ * <p>The holes of the polygon are first joined to its exterior ring by {@link HoleToExterior},
+ * then the ring is triangulated. The triangulation works on the indices of the positions of
+ * the ring: the triangles reference the vertices instead of copying their positions, so that
+ * all the attributes of the vertices are kept.</p>
+ *
  * @author Johann Sorel
  */
 public class EarClipping {
 
-    private static final class SimplePolygon{
-        private LineString outter;
-        private final List<LineString> inners = new ArrayList();
-        /**
-         * direction of the outter loop
-         */
-        private boolean outterIsClockwise;
+    /**
+     * Result of a triangulation: the vertices of the triangles, and the triangles as indices of those vertices.
+     *
+     * @param vertices   the vertices used by the triangles, with all the attributes of the polygon.
+     * @param triangles  the indices in {@code vertices} of the three corners of each triangle, one triangle after the other.
+     */
+    public record Triangulation(DataPoints vertices, int[] triangles) {
+    }
+
+    /**
+     * Values of all the attributes of a vertex, for finding the vertices which are identical.
+     */
+    private record Key(double[] values) {
+        @Override public boolean equals(final Object other) {
+            return (other instanceof Key k) && Arrays.equals(values, k.values);
+        }
+
+        @Override public int hashCode() {
+            return Arrays.hashCode(values);
+        }
     }
 
-    private final List<Vector[]> triangles = new ArrayList<>();
+    /**
+     * Type of the angle made by the ring at a vertex, computed when first needed.
+     */
+    private enum Angle {
+        UNKNOWN,
+        CONVEX,
+        CONCAVE
+    }
 
     /**
-     * A path can be composed of multiple parts.
-     * Each part is a closed polyline with inner holes.
+     * Positions of the ring to triangulate, the last one at the same position as the first one.
      */
-    private final List parts = new ArrayList();
+    private DataPoints source;
 
-    /** number of coordinates, may be inferior to coords length. */
-    private int nbCoords;
-    /** current coordinates to triangulate. */
-    private Vector[] coords;
     /**
-     * store the type of angle made by the coordinate
-     * avoid recalculate it each time.
-     * 0 : not calculated
-     * 1 : convex
-     * 2 : concave
+     * Positions of the ring, with their coordinates in the order used by the triangulation.
+     * The triangulation works on the two first coordinates, in the plane where the polygon has its largest area.
      */
-    private int[] coordType;
+    private Vector<?>[] working;
 
     /**
-     * Order of the coordinates used by the triangulation, as indices of the coordinates of the
-     * polygon: the triangulation works on the two first ones. The polygon is projected on the
-     * coordinate plane where it has its largest area, so that a vertical polygon is not degenerate.
-     * {@code null} if the polygon is triangulated in its (x,y) plane, without reordering.
+     * Indices of the three vertices of each triangle found so far.
      */
-    private int[] axes;
+    private final List<int[]> triangles = new ArrayList<>();
+
+    /** number of coordinates, may be inferior to coords length. */
+    private int nbCoords;
+    /** indices of the vertices to triangulate. */
+    private int[] coords;
+    /**
+     * The type of angle made by the ring at each vertex to triangulate,
+     * stored to avoid computing it each time.
+     */
+    private Angle[] coordType;
 
     //current segment analyzed
     private int indexPrevious;
     private int index;
     private int indexNext;
-    private Vector t1; //previous point
-    private Vector t2; //point at index
-    private Vector t3; //next point
+    private Vector<?> t1; //previous point
+    private Vector<?> t2; //point at index
+    private Vector<?> t3; //next point
 
     private void reset(){
         //reset values
+        source = null;
+        working = null;
         triangles.clear();
         nbCoords = 0;
         coords = null;
         coordType = null;
-        parts.clear();
     }
 
     /**
+     * Triangulates the given polygon.
      *
      * @param geometry geometry to triangulate
-     * @return List of Coordinate[] for each triangle.
+     * @return the vertices of the triangles and their indices.
      */
-    public List<Vector[]> triangulate(Polygon geometry) {
-
+    public Triangulation triangulateIndexed(Polygon geometry) {
         reset();
-
-        final SimplePolygon part = new SimplePolygon();
-        //copy collection to avoid modifications
-        part.outter = geometry.getExteriorRing();
-
-        final int nbHole = geometry.getNumInteriorRing();
-
-        for(int i=0;i<nbHole;i++){
-            final LineString inner = geometry.getInteriorRingN(i);
-            part.inners.add(inner);
+        source = HoleToExterior.apply(geometry).getExteriorRing().getDataPoints();
+        final int[] axes = workingAxes(source.getAttributeArray(DataPointsType.ATT_POSITION));
+        working = new Vector<?>[source.size()];
+        for (int i = 0; i < working.length; i++) {
+            working[i] = source.getPosition(i).swizzle(axes);
         }
+        run();
+        return result();
+    }
 
-        axes = workingAxes(geometry.getExteriorRing().getDataPoints().getAttributeArray(DataPointsType.ATT_POSITION));
-        run(part);
-
-        if (axes != null) {
-            //triangles share their vertices, so each one is restored in a new vector.
-            for (final Vector[] triangle : triangles) {
-                for (int i = 0; i < triangle.length; i++) {
-                    triangle[i] = fromWorking(triangle[i]);
-                }
-            }
+    /**
+     * Triangulates the given polygon into an indexed mesh holding all the attributes of the polygon.
+     */
+    public MeshPrimitive.Triangles toMesh(Polygon polygon) {
+        final Triangulation triangulation = triangulateIndexed(polygon);
+        final DataPoints vertices = triangulation.vertices();
+        final MeshPrimitive.Triangles mesh = new MeshPrimitive.Triangles();
+        for (final String name : vertices.getType().getAttributeNames()) {
+            mesh.setAttribute(name, vertices.getAttributeArray(name));
         }
-        return triangles;
+        mesh.setIndex(NDArrays.packIntegerDataType(NDArrays.of(1, triangulation.triangles())));
+        return mesh;
     }
 
     /**
      * Returns the order of the coordinates to use for triangulating a polygon with the given positions,
-     * or {@code null} if the polygon can be triangulated in its (x,y) plane. The axis along which the
-     * normal of the polygon is the largest, computed with Newell's method, is moved after the two
-     * axes of the projection plane.
+     * to be given to {@link Vector#swizzle(int...)}. The triangulation works on the two first coordinates
+     * of that order. The axis along which the normal of the polygon is the largest, computed with Newell's
+     * method, is moved after the two axes of the projection plane, so that a vertical polygon is not
+     * degenerate. If the polygon can be triangulated in its (x,y) plane, the order is unchanged.
      */
-    private static int[] workingAxes(final Array positions) {
+    static int[] workingAxes(final Array positions) {
         final int dim = positions.getDimension();
+        final int[] axes = new int[dim];
+        for (int i = 0; i < dim; i++) {
+            axes[i] = i;
+        }
         if (dim < 3) {
-            return null;
+            return axes;
         }
         final double[] c = positions.toArrayDouble();
         final int n = c.length / dim;
@@ -155,140 +182,86 @@ public class EarClipping {
         }
         nx = Math.abs(nx); ny = Math.abs(ny); nz = Math.abs(nz);
         if (nz >= nx && nz >= ny) {
-            return null;
+            return axes;
         }
         final int drop = (nx >= ny) ? 0 : 1;
-        final int[] axes = new int[dim];
         int k = 0;
         for (int i = 0; i < 3; i++) {
             if (i != drop) axes[k++] = i;
         }
-        axes[k++] = drop;
-        for (int i = 3; i < dim; i++) {
-            axes[k++] = i;
-        }
+        axes[k] = drop;
         return axes;
     }
 
     /**
-     * Returns a copy of the given position with its coordinates in the order used by the triangulation.
+     * Builds the result from the triangles found. Only the vertices used by a triangle are kept, in the
+     * order of the ring, and the vertices having the same values for all attributes are merged. Those are
+     * the last position of the ring, which repeats the first one, and the positions repeated by the cuts
+     * joining the holes.
      */
-    private Vector<?> toWorking(final Vector<?> position) {
-        final Vector<?> copy = position.copy();
-        if (axes != null) {
-            for (int i = 0; i < axes.length; i++) {
-                copy.set(i, position.get(axes[i]));
+    private Triangulation result() {
+        final int count = working.length;
+        final boolean[] used = new boolean[count];
+        for (final int[] triangle : triangles) {
+            for (final int vertex : triangle) {
+                used[vertex] = true;
             }
         }
-        return copy;
-    }
-
-    /**
-     * Returns a copy of the given position with its coordinates restored in the order of the polygon.
-     */
-    private Vector<?> fromWorking(final Vector<?> position) {
-        final Vector<?> copy = position.copy();
-        for (int i = 0; i < axes.length; i++) {
-            copy.set(axes[i], position.get(i));
+        final DataPointsType type = source.getType();
+        final List<String> names = type.getAttributeNames();
+        final Map<Key,Integer> distinct = new HashMap<>();
+        final List<Integer> kept = new ArrayList<>();
+        final int[] remap = new int[count];
+        for (int i = 0; i < count; i++) {
+            if (used[i]) {
+                double[] values = new double[0];
+                for (final String name : names) {
+                    final double[] value = source.getAttribute(i, name).toArrayDouble();
+                    values = Arrays.copyOf(values, values.length + value.length);
+                    System.arraycopy(value, 0, values, values.length - value.length, value.length);
+                }
+                final Integer previous = distinct.putIfAbsent(new Key(values), kept.size());
+                if (previous != null) {
+                    remap[i] = previous;
+                } else {
+                    remap[i] = kept.size();
+                    kept.add(i);
+                }
+            }
         }
-        return copy;
+        final DataPoints vertices = GeometryFactory.DEFAULT.createDataPoints(type, kept.size());
+        for (int i = 0; i < kept.size(); i++) {
+            for (final String name : names) {
+                vertices.setAttribute(i, name, source.getAttribute(kept.get(i), name));
+            }
+        }
+        final int[] indices = new int[triangles.size() * 3];
+        for (int i = 0, k = 0; i < triangles.size(); i++) {
+            for (final int vertex : triangles.get(i)) {
+                indices[k++] = remap[vertex];
+            }
+        }
+        return new Triangulation(vertices, indices);
     }
 
     /**
-     * TODO not efficient, improve performances.
+     * Triangulates the ring.
      */
-    public MeshPrimitive.Triangles toMesh(Polygon polygon) {
-        final List<Vector[]> list = triangulate(polygon);
-
-        final CoordinateReferenceSystem crs = polygon.getCoordinateReferenceSystem();
-        final int dimension = crs.getCoordinateSystem().getDimension();
-        final Array positions = NDArrays.of(crs, new double[list.size()*3*dimension]);
-        for (int i = 0, k = 0, n = list.size(); i < n; i++, k+=3) {
-            final Vector[] t = list.get(i);
-            positions.set(k+0, t[0]);
-            positions.set(k+1, t[1]);
-            positions.set(k+2, t[2]);
+    private void run() {
+        if (working.length == 0) {
+            return;
         }
-
-        final MeshPrimitive.Triangles triangles = new MeshPrimitive.Triangles();
-        triangles.setPositions(positions);
-
-        return triangles;
-    }
-
-    private void run(SimplePolygon part){
-
-        //build a single geometry linking inner holes.
-        //the array stream reuses a single vector for all positions, so each one is copied.
-        final List<Vector<?>> borderCoords = new ArrayList<>();
-        part.outter.getDataPoints().getAttributeArray(DataPointsType.ATT_POSITION).stream(false)
-                .map(this::toWorking).forEach(borderCoords::add);
-
-        //copy the holes in the same way, in mutable lists since their last point is removed below,
-        //and sort them by decreasing minimum x value.
-        final List<List<Vector<?>>> holes = new ArrayList<>();
-        for (final LineString inner : part.inners) {
-            holes.add(inner.getDataPoints().getAttributeArray(DataPointsType.ATT_POSITION).stream(false)
-                    .map(this::toWorking).collect(Collectors.toCollection(ArrayList::new)));
-        }
-        holes.sort(Comparator.comparingDouble((List<Vector<?>> h) ->
-                h.stream().mapToDouble((v) -> v.get(0)).min().orElse(Double.NaN)).reversed());
-
-        //attach holes to the main geometry
-        for (final List<Vector<?>> loop : holes) {
-            //we must find the minimum x coordinate in the inner loop
-            int index = 0;
-            Vector min = loop.get(index);
-            for(int k=1,p=loop.size();k<p;k++){
-                Vector candidate = (Vector) loop.get(k);
-                if (candidate.get(0) < min.get(0)) {
-                    min = candidate;
-                    index = k;
-                }
-            }
-
-            //now find the closest point on the outter loop
-            final List line2 = new ArrayList();
-            line2.add(min);line2.add(min);
-            final double[] buffer1 = new double[2];
-            final double[] buffer2 = new double[2];
-            final double[] ratio = new double[2];
-            final int[] offset = new int[2];
-            nearest(borderCoords, buffer1, line2, buffer2, ratio, offset, 0.000000001);
-
-            //the cut starts on the outter loop segment nearest to the hole. The point found above has only
-            //the two working coordinates, all coordinates are interpolated along that segment.
-            final Vector<?> start = borderCoords.get(offset[0]);
-            final Vector<?> end   = borderCoords.get(offset[0] + 1);
-            final double length = Math.hypot(end.get(0) - start.get(0), end.get(1) - start.get(1));
-            final double ratioAlong = (length > 0) ? Math.hypot(buffer1[0] - start.get(0), buffer1[1] - start.get(1)) / length : 0;
-            final Vector<?> bridge = start.copy();
-            for (int d = 0; d < bridge.getDimension(); d++) {
-                bridge.set(d, start.get(d) + ratioAlong * (end.get(d) - start.get(d)));
-            }
-
-            //make a cut from outter loop to inner loop
-            int insertIndex = offset[0]+1;
-            borderCoords.add(insertIndex, bridge);
-            insertIndex++;
-            //remove the duplicateion inner loop end point
-            loop.remove(loop.size()-1);
-            for(int k=index,p=loop.size();k<p;k++,insertIndex++){
-                borderCoords.add(insertIndex,loop.get(k));
-            }
-            for(int k=0;k<=index;k++,insertIndex++){
-                borderCoords.add(insertIndex,loop.get(k));
-            }
-            borderCoords.add(insertIndex, bridge.copy());
-
+        final List<Integer> border = new ArrayList<>(working.length);
+        for (int i = 0; i < working.length; i++) {
+            border.add(i);
         }
 
         //remove any neighor points overlaping
-        Vector t = borderCoords.get(0);
-        for(int i=1,n=borderCoords.size();i<n;i++){
-            Vector candidate = borderCoords.get(i);
+        Vector<?> t = working[border.get(0)];
+        for(int i=1,n=border.size();i<n;i++){
+            final Vector<?> candidate = working[border.get(i)];
             if(candidate.equals(t)){
-                borderCoords.remove(i);
+                border.remove(i);
                 i--;
                 n--;
             }else{
@@ -296,16 +269,13 @@ public class EarClipping {
             }
         }
 
-
-
-        final boolean clockwise = Maths.isClockWise(borderCoords);
-
-        nbCoords = borderCoords.size();
-        coordType = new int[nbCoords];
-        coords = borderCoords.toArray(Vector[]::new);
+        nbCoords = border.size();
+        coordType = new Angle[nbCoords];
+        Arrays.fill(coordType, Angle.UNKNOWN);
+        coords = border.stream().mapToInt(Integer::intValue).toArray();
 
         //flip coordinates if not clockwise
-        if(!clockwise){
+        if(!Maths.isClockWise(Arrays.stream(coords).mapToObj((i) -> working[i]).toList())){
             ArraysExt.reverse(coords);
         }
 
@@ -317,18 +287,18 @@ public class EarClipping {
                 indexPrevious = (index==0) ? nbCoords-2 : index-1 ;
                 indexNext = index+1;
 
-                t1 = coords[indexPrevious];
-                t2 = coords[index];
-                t3 = coords[indexNext];
+                t1 = working[coords[indexPrevious]];
+                t2 = working[coords[index]];
+                t3 = working[coords[indexNext]];
 
-                if(isConvex() && isEar()){
+                if(isConvex(index) && isEar()){
                     //reset angle type, we remove a point so it might change
-                    coordType[indexPrevious]=0;
-                    coordType[indexNext]=0;
+                    coordType[indexPrevious]=Angle.UNKNOWN;
+                    coordType[indexNext]=Angle.UNKNOWN;
 
-                    triangles.add(new Vector[]{t1,t2,t3});
-                    removeWithin(coords, index);
-                    removeWithin(coordType, index);
+                    triangles.add(new int[]{coords[indexPrevious], coords[index], coords[indexNext]});
+                    Maths.removeWithin(coords, index);
+                    Maths.removeWithin(coordType, index);
                     nbCoords--;
                     nbcut++;
                     //we do not increment since we have remove the point
@@ -346,39 +316,26 @@ public class EarClipping {
     }
 
     /**
-     * @return true if  currentsegment is convex
-     */
-    private boolean isConvex(){
-        if(coordType[index]==0){
-            //calculate angle type
-            final double side = Maths.lineSide(t1, t3, t2);
-            coordType[index] = side>0 ? 1 : 2;
-        }
-        return coordType[index] == 1;
-    }
-
-    /**
-     * @return true if segment is convex
+     * @return true if the ring is convex at the vertex of given index
      */
     private boolean isConvex(int idx){
-        if(coordType[idx]==0){
+        if(coordType[idx]==Angle.UNKNOWN){
             //calculate angle type
-            final Vector s1 = coords[(idx==0) ? (nbCoords-2) : (idx-1)];
-            final Vector s2 = coords[idx];
-            final Vector s3 = coords[idx+1];
+            final Vector<?> s1 = working[coords[(idx==0) ? (nbCoords-2) : (idx-1)]];
+            final Vector<?> s2 = working[coords[idx]];
+            final Vector<?> s3 = working[coords[idx+1]];
             final double side = Maths.lineSide(s1, s3, s2);
-            coordType[idx] = side>0 ? 1 : 2;
+            coordType[idx] = side>0 ? Angle.CONVEX : Angle.CONCAVE;
         }
-        return coordType[idx] == 1;
+        return coordType[idx] == Angle.CONVEX;
     }
 
     /**
      * Check this segment triangle is an ear.
      * Does not contain any other point.
-     * @return
-     * @throws OperationException
+     * @return true if no other vertex is inside the triangle
      */
-    private boolean isEar() throws IllegalArgumentException {
+    private boolean isEar() {
         final double[] a = new double[]{t1.get(0), t1.get(1)};
         final double[] b = new double[]{t2.get(0), t2.get(1)};
         final double[] c = new double[]{t3.get(0), t3.get(1)};
@@ -388,7 +345,8 @@ public class EarClipping {
             if (!isConvex(i)){
                 //check it's not one of the current segment
                 if(i!=index && i!=indexNext && i!=indexPrevious){
-                    final double[] p = new double[]{coords[i].get(0),coords[i].get(1)};
+                    final Vector<?> v = working[coords[i]];
+                    final double[] p = new double[]{v.get(0),v.get(1)};
                     //a hole bridge duplicates its end points, a copy located on a triangle vertex does not block the ear
                     if (Arrays.equals(p, a) || Arrays.equals(p, b) || Arrays.equals(p, c)) {
                         continue;
@@ -403,250 +361,4 @@ public class EarClipping {
 
         return true;
     }
-
-
-    /**
-     * Remove element at index.
-     * Move elements after index by -1.
-     * Last element is set to null
-     *
-     * @param values original array
-     * @param index removed element index
-     * @return removed object
-     */
-    public static Object removeWithin(Object[] values, int index) {
-        final Object removedValue = values[index];
-        if(index+1 < values.length){
-            copy(values, index+1, values.length-index-1, values, index);
-        }
-        values[values.length-1] = null; //remove reference
-        return removedValue;
-    }
-
-    /**
-     * Remove element at index.
-     * Move elements after index by -1.
-     * Last element is set to zero
-     *
-     * @param values original array
-     * @param index removed element index
-     * @return removed value
-     */
-    public static int removeWithin(int[] values, int index) {
-        final int removedValue = values[index];
-        if(index+1 < values.length){
-            copy(values, index+1, values.length-index-1, values, index);
-        }
-        values[values.length-1] = 0;
-        return removedValue;
-    }
-
-    /**
-     * Copy a portion of given source array of length elements from soffset index, to
-     * the target array beginning to toffset index.
-     * If needed, the returned array is padded with zeros to obtain the required
-     * length.
-     *
-     * @param source not null
-     * @param soffset offset to start copying
-     * @param length number of elemnts to copy
-     * @param target array where to insert new elements
-     * @param toffset start insert index
-     * @return the target array with copied values.
-     */
-    public static Object[] copy(Object[] source, int soffset, int length, Object[] target, int toffset){
-        for(int i=soffset;i<soffset+length;i++,toffset++){
-            target[toffset] = source[i];
-        }
-        return target;
-    }
-
-    /**
-     * Copy a portion of given source array of length elements from soffset index, to
-     * the target array beginning to toffset index.
-     * If needed, the returned array is padded with zeros to obtain the required
-     * length.
-     *
-     * @param source not null
-     * @param soffset offset to start copying
-     * @param length number of elemnts to copy
-     * @param target array where to insert new elements
-     * @param toffset start insert index
-     * @return the target array with copied values.
-     */
-    public static int[] copy(int[] source, int soffset, int length, int[] target, int toffset){
-        for(int i=soffset;i<soffset+length;i++,toffset++){
-            target[toffset] = source[i];
-        }
-        return target;
-    }
-
-    /**
-     * Copy a portion of given source array of length elements from soffset index, to
-     * the target array beginning to toffset index.
-     * If needed, the returned array is padded with zeros to obtain the required
-     * length.
-     *
-     * @param source not null
-     * @param soffset offset to start copying
-     * @param length number of elemnts to copy
-     * @param target array where to insert new elements
-     * @param toffset start insert index
-     * @return the target array with copied values.
-     */
-    public static double[] copy(double[] source, int soffset, int length, double[] target, int toffset){
-        for(int i=soffset;i<soffset+length;i++,toffset++){
-            target[toffset] = source[i];
-        }
-        return target;
-    }
-
-    /**
-     * Find nearest points between two lines.
-     * <br>
-     * See Distance.
-     *
-     * @param line1Coords line 1 coordinates
-     * @param buffer1 buffer to store nearest point on line 1
-     * @param line2Coords line 2 coordinates
-     * @param buffer2 buffer to store nearest point on line 2
-     * @param ratio neareast points ratio between line start and end
-     * @param offset : will store the segment offset of the nearest points
-     * @param epsilon tolerance factor
-     */
-    public static void nearest(List<Vector<?>> line1Coords, double[] buffer1,
-                                List<Vector<?>> line2Coords, double[] buffer2,
-                                double[] ratio, int[] offset, double epsilon){
-
-        double distance = Double.MAX_VALUE;
-
-        final double[] tempRatio = new double[2];
-        final double[] tempC1 = new double[buffer1.length];
-        final double[] tempC2 = new double[buffer1.length];
-
-        final int nb1 = line1Coords.size()-1;
-        final int nb2 = line2Coords.size()-1;
-
-        for(int i=0;i<nb1;i++){
-            final Vector<?> s1 = line1Coords.get(i);
-            final Vector<?> e1 = line1Coords.get(i+1);
-            for(int k=0;k<nb2;k++){
-                final Vector<?> s2 = line2Coords.get(k);
-                final Vector<?> e2 = line2Coords.get(k+1);
-
-                final double dist = Math.sqrt(distanceSquare(
-                                new double[]{s1.get(0),s1.get(1)}, new double[]{e1.get(0),e1.get(1)}, tempC1,
-                                new double[]{s2.get(0),s2.get(1)}, new double[]{e2.get(0),e2.get(1)}, tempC2,
-                                tempRatio,epsilon));
-                if(dist<distance){
-                    //keep informations
-                    distance = dist;
-                    offset[0] = i;
-                    offset[1] = k;
-                    copy(tempC1, 0, tempC1.length, buffer1, 0);
-                    copy(tempC2, 0, tempC2.length, buffer2, 0);
-                }
-            }
-        }
-
-    }
-
-    /**
-     * Calculate square distance between two line segments.
-     *
-     * Adapted from book : Real-TimeCollision Detection by Christer Ericson
-     * (ClosestPtSegmentSegment p.149)
-     *
-     * @param line1Start line 1 start point
-     * @param line1End line 1 start point
-     * @param buffer1 closest point on line 1
-     * @param line2Start line 1 start point
-     * @param line2End line 1 start point
-     * @param buffer2 closest point on line 2
-     * @param ratio size 2 , for each line,
-     *  ratio [0..1] of the closest point position between start and end points.
-     * @param epsilon tolerance
-     * @return distance
-     */
-    public static double distanceSquare(double[] line1Start, double[] line1End, double[] buffer1,
-                                   double[] line2Start, double[] line2End, double[] buffer2,
-                                   double[] ratio, double epsilon) {
-        final double[] d1 = subtract(line1End,line1Start); // Direction vector of segment S1
-        final double[] d2 = subtract(line2End,line2Start); // Direction vector of segment S2
-        final double[] r = subtract(line1Start, line2Start);
-        final double a = dot(d1, d1); // Squared length of segment S1, always nonnegative
-        final double e = dot(d2, d2); // Squared length of segment S2, always nonnegative
-        final double f = dot(d2, r);
-        // Check if either or both segments degenerate into points
-        if (a <= epsilon && e <= epsilon) {
-            // Both segments degenerate into points
-            ratio[0] = 0;
-            ratio[1] = 0;
-            copy(line1Start,0,line1Start.length,buffer1,0);
-            copy(line2Start,0,line2Start.length,buffer2,0);
-            final double[] t = subtract(buffer1,buffer2);
-            return dot(t,t);
-        }
-        if (a <= epsilon) {
-            // First segment degenerates into a point
-            ratio[0] = 0;
-            ratio[1] = clip( f/e, 0, 1);
-        } else {
-            final double c = dot(d1, r);
-            if (e <= epsilon) {
-                // Second segment degenerates into a point
-                ratio[0] = clip( -c/a, 0, 1);
-                ratio[1] = 0;
-            } else {
-                // The general nondegenerate case starts here
-                final double b = dot(d1, d2);
-                final double denom = a*e-b*b; // Always nonnegative
-                // If segments not parallel, compute closest point on L1 to L2 and
-                // clamp to segment S1. Else pick arbitrary s (here 0)
-                if (denom != 0d) {
-                    ratio[0] = clip((b*f - c*e) / denom, 0.0f, 1.0f);
-                } else {
-                    ratio[0] = 0;
-                }
-                // Compute point on L2 closest to S1(s) using
-                // t = Dot((P1 + D1*s) - P2,D2) / Dot(D2,D2) = (b*s + f) / e
-                ratio[1] = (b*ratio[0] + f) / e;
-
-                //If t in [0,1] done. Else clamp t, recompute s for the new value
-                //of t using s = Dot((P2 + D2*t) - P1,D1) / Dot(D1,D1)= (t*b - c) / a
-                //and clamp s to [0, 1]
-                if(ratio[1] < 0){
-                    ratio[1] = 0;
-                    ratio[0] = clip(-c / a, 0.0f, 1.0f);
-                }else if(ratio[1] > 1){
-                    ratio[1] = 1;
-                    ratio[0] = clip((b - c) / a, 0.0f, 1.0f);
-                }
-
-            }
-        }
-
-        add(line1Start, scale(d1, ratio[0]), buffer1);
-        add(line2Start, scale(d2, ratio[1]), buffer2);
-        final double[] t = subtract(buffer1,buffer2);
-        return dot(t,t);
-    }
-
-    /**
-     * Clip returns:
-     * <ul>
-     * <li>min if the value is lower than min</li>
-     * <li>value if the value is between min and max</li>
-     * <li>max if the value is higher than max</li>
-     * </ul>
-     * @param val the value
-     * @param min minimum value
-     * @param max maximum value
-     * @return clipped value
-     */
-    public static double clip(double val, double min, double max){
-        if(val<min) return min;
-        if(val>max) return max;
-        return val;
-    }
 }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/HoleToExterior.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/HoleToExterior.java
new file mode 100644
index 0000000000..4ee173c257
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/HoleToExterior.java
@@ -0,0 +1,420 @@
+/*
+ * 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.operation.triangulate;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import org.apache.sis.geometries.DataPoints;
+import org.apache.sis.geometries.DataPointsType;
+import org.apache.sis.geometries.GeometryFactory;
+import org.apache.sis.geometries.curve.LinearRing;
+import org.apache.sis.geometries.operation.Distance;
+import org.apache.sis.geometries.surface.Polygon;
+import org.apache.sis.maths.Maths;
+import org.apache.sis.maths.Vector;
+
+
+/**
+ * Joins the holes of a polygon to its exterior ring, which gives a polygon without hole covering the same area.
+ *
+ * <p>The holes are first oriented in the opposite direction of the exterior ring, then joined from the
+ * rightmost one to the leftmost one. Each hole is joined by a cut going from a vertex of the ring to the
+ * rightmost position of the hole, around the hole, and back to the same vertex of the ring. The vertex
+ * of the ring is one which can be reached without crossing any edge, found as described by David Eberly
+ * in <cite>Triangulation by Ear Clipping</cite>. The two sides of a cut are made of the same positions,
+ * so the resulting ring touches itself along the cut, and no position is added.</p>
+ *
+ * <p>If no such vertex is found, which may happen with an invalid polygon, the cut starts at a new
+ * position on the nearest segment of the ring, whose attributes are interpolated along that segment.</p>
+ *
+ * <p>The positions are compared in the coordinate plane where the polygon has its largest area,
+ * so that a vertical polygon is not degenerate. This is the first stage of {@link EarClipping}.</p>
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class HoleToExterior {
+
+    /**
+     * Positions of the polygon, of the exterior ring followed by those of the holes.
+     * The vertex of index <var>i</var> is the position <var>i</var> of this {@code DataPoints}
+     * if <var>i</var> is smaller than its size, or a vertex added for a cut otherwise.
+     */
+    private final DataPoints source;
+
+    /**
+     * Positions of all the vertices, with their coordinates in the order used for comparing them.
+     */
+    private final List<Vector<?>> working = new ArrayList<>();
+
+    /**
+     * For each vertex added to start a cut, the indices of the positions of the polygon it is interpolated from.
+     * The vertex at index <var>i</var> in this list has the index {@code source.size() + i}.
+     */
+    private final List<int[]> addedIndices = new ArrayList<>();
+
+    /**
+     * For each vertex added to start a cut, the weights of the positions of the polygon it is interpolated from.
+     */
+    private final List<double[]> addedWeights = new ArrayList<>();
+
+    /**
+     * Prepares the joining of the holes of the given polygon.
+     */
+    private HoleToExterior(final Polygon polygon) {
+        source = polygon.getDataPoints();
+        final int[] axes = EarClipping.workingAxes(
+                polygon.getExteriorRing().getDataPoints().getAttributeArray(DataPointsType.ATT_POSITION));
+        for (int i = 0, n = source.size(); i < n; i++) {
+            working.add(source.getPosition(i).swizzle(axes));
+        }
+    }
+
+    /**
+     * Returns a polygon without hole covering the same area as the given polygon.
+     * The exterior ring of the returned polygon is the exterior ring of the given polygon with the holes
+     * joined to it. Its positions are those of the given polygon, with all their attributes, some of them
+     * repeated at both ends of the cuts. Positions are added only for the cuts which could not start
+     * from a vertex.
+     *
+     * @param  polygon  the polygon whose holes are to be joined to the exterior ring.
+     * @return the given polygon if it has no hole, or a new polygon without hole otherwise.
+     */
+    public static Polygon apply(final Polygon polygon) {
+        if (polygon.getExteriorRing().getDataPoints().isEmpty()) {
+            return polygon;
+        }
+        for (final LinearRing hole : polygon.getInteriorRings()) {
+            if (!hole.getDataPoints().isEmpty()) {
+                return new HoleToExterior(polygon).join(polygon);
+            }
+        }
+        return polygon;
+    }
+
+    /**
+     * Joins the holes of the given polygon, which is the polygon given to the constructor.
+     */
+    private Polygon join(final Polygon polygon) {
+        //the positions of the polygon are those of the exterior ring followed by those of the holes.
+        int start = polygon.getExteriorRing().getDataPoints().size();
+        final List<Integer> border = range(0, start);
+        final List<List<Integer>> holes = new ArrayList<>();
+        for (final LinearRing hole : polygon.getInteriorRings()) {
+            final int size = hole.getDataPoints().size();
+            if (size != 0) {
+                holes.add(range(start, size));
+            }
+            start += size;
+        }
+
+        //the holes shall turn in the opposite direction of the exterior ring, so that the joined ring has a single orientation.
+        final double orientation = Math.signum(area(border));
+        for (final List<Integer> loop : holes) {
+            if (Math.signum(area(loop)) == orientation) {
+                Collections.reverse(loop);
+            }
+        }
+
+        //sort the holes by decreasing maximum x value: the region on the right of a hole is then
+        //bounded by the exterior ring and by the holes already joined to it.
+        holes.sort(Comparator.comparingDouble((List<Integer> h) ->
+                h.stream().mapToDouble(this::x).max().orElse(Double.NaN)).reversed());
+
+        //attach holes to the main geometry
+        for (final List<Integer> loop : holes) {
+            //remove the duplicated end point of the hole, the cut comes back to the start point instead.
+            loop.remove(loop.size()-1);
+            if (loop.isEmpty()) {
+                continue;
+            }
+            //the cut starts from the hole position of maximum x.
+            int index = 0;
+            for (int k = 1; k < loop.size(); k++) {
+                if (x(loop.get(k)) > x(loop.get(index))) {
+                    index = k;
+                }
+            }
+            final int rightmost = loop.get(index);
+            final List<Integer> cut = new ArrayList<>(loop.size() + 2);
+            cut.addAll(loop.subList(index, loop.size()));
+            cut.addAll(loop.subList(0, index + 1));
+
+            final int visible = visibleVertex(border, rightmost, orientation);
+            if (visible >= 0) {
+                //the cut goes from a position of the ring to the hole and back, no position is added.
+                cut.add(border.get(visible));
+                border.addAll(visible + 1, cut);
+            } else {
+                //no visible position, which may happen with an invalid polygon. The cut starts on the nearest
+                //segment of the ring, at a new position whose coordinates and attributes are interpolated.
+                final Vector<?> m = working.get(rightmost);
+                final double[] buffer1 = new double[2];
+                final double[] buffer2 = new double[2];
+                final double[] ratio = new double[2];
+                final int[] offset = new int[2];
+                Distance.nearest(positions(border), buffer1, List.of(m, m), buffer2, ratio, offset, 0.000000001);
+                final int bridge = interpolate(border.get(offset[0]), border.get(offset[0] + 1), ratio[0]);
+                cut.add(0, bridge);
+                cut.add(bridge);
+                border.addAll(offset[0] + 1, cut);
+            }
+        }
+
+        //build the ring, with the attributes of the positions of the polygon or interpolated for the added ones.
+        final DataPointsType type = source.getType();
+        final DataPoints ring = GeometryFactory.DEFAULT.createDataPoints(type, border.size());
+        for (int i = 0; i < border.size(); i++) {
+            final int vertex = border.get(i);
+            final int added = vertex - source.size();
+            for (final String name : type.getAttributeNames()) {
+                ring.setAttribute(i, name, (added < 0) ? source.getAttribute(vertex, name)
+                        : source.interpolateAttribute(name, addedIndices.get(added), addedWeights.get(added)));
+            }
+        }
+        final GeometryFactory factory = GeometryFactory.DEFAULT;
+        return factory.createPolygon(factory.createLinearRing(ring), null);
+    }
+
+    /**
+     * Returns the position in the ring of a vertex which can be joined to the given hole vertex by a segment
+     * crossing no edge, or -1 if none is found. This is the search of mutually visible vertices described by
+     * David Eberly in <cite>Triangulation by Ear Clipping</cite>: a ray is cast from the hole vertex toward
+     * increasing x, which first reaches the ring at a vertex or inside a segment. In the later case, the end
+     * of the segment of largest x is visible unless a reflex vertex of the ring is inside the triangle made
+     * of the hole vertex, the point reached and that end. Then the reflex vertex closest in angle to the ray
+     * is visible.
+     *
+     * @param  border       the ring, the last position being the same vertex as the first one.
+     * @param  hole         the hole vertex of maximum x.
+     * @param  orientation  1 if the ring is counterclockwise, -1 if it is clockwise.
+     * @return position in {@code border} of a visible vertex, or -1.
+     */
+    private int visibleVertex(final List<Integer> border, final int hole, final double orientation) {
+        final double mx = x(hole), my = y(hole);
+        double ix = Double.POSITIVE_INFINITY;
+        int hit = -1;               // position of the vertex reached by the ray, if any.
+        int edge = -1;              // position of the start of the segment reached by the ray, if any.
+        for (int i = 1; i < border.size(); i++) {
+            final int a = border.get(i-1), b = border.get(i);
+            final double ay = y(a), by = y(b);
+            for (final int k : new int[] {i-1, i}) {
+                final int v = border.get(k);
+                if (y(v) == my && x(v) >= mx && x(v) < ix) {
+                    ix = x(v); hit = k; edge = -1;
+                }
+            }
+            if ((ay < my && by > my) || (ay > my && by < my)) {
+                final double x = x(a) + (my - ay) * (x(b) - x(a)) / (by - ay);
+                if (x >= mx && x < ix) {
+                    ix = x; hit = -1; edge = i-1;
+                }
+            }
+        }
+        if (hit >= 0) {
+            return occurrence(border, hit, hole, orientation);
+        }
+        if (edge < 0) {
+            return -1;
+        }
+        int best = (x(border.get(edge + 1)) > x(border.get(edge))) ? edge + 1 : edge;
+        final int end = border.get(best);
+        final double[] m = {mx, my}, reached = {ix, my}, p = {x(end), y(end)};
+        double bestAngle = angle(hole, end);
+        double bestDistance = distance(hole, end);
+        for (int k = 0; k < border.size() - 1; k++) {
+            final int v = border.get(k);
+            if ((x(v) == p[0] && y(v) == p[1]) || !isReflex(border, k, orientation)
+                    || !Maths.inTriangle(m, reached, p, new double[] {x(v), y(v)})) {
+                continue;
+            }
+            final double angle = angle(hole, v);
+            final double distance = distance(hole, v);
+            if (angle < bestAngle || (angle == bestAngle && distance < bestDistance)) {
+                best = k;
+                bestAngle = angle;
+                bestDistance = distance;
+            }
+        }
+        return occurrence(border, best, hole, orientation);
+    }
+
+    /**
+     * Returns the position in the ring of the vertex at the given position, or of another occurrence of it
+     * such as those added by previous cuts, from which the segment toward the hole vertex enters the polygon.
+     */
+    private int occurrence(final List<Integer> border, int position, final int hole, final double orientation) {
+        if (position == border.size() - 1) {
+            position = 0;           // the last position is the same vertex as the first one.
+        }
+        final int vertex = border.get(position);
+        if (inCone(border, position, hole, orientation)) {
+            return position;
+        }
+        for (int k = 0; k < border.size() - 1; k++) {
+            final int v = border.get(k);
+            if (x(v) == x(vertex) && y(v) == y(vertex) && inCone(border, k, hole, orientation)) {
+                return k;
+            }
+        }
+        return position;
+    }
+
+    /**
+     * Returns whether the segment from the vertex at the given position of the ring toward the given point
+     * starts inside the polygon, which is between the two segments of the ring at that vertex.
+     * This is the {@code InCone} test of Joseph O'Rourke in <cite>Computational Geometry in C</cite>.
+     */
+    private boolean inCone(final List<Integer> border, final int position, final int point, final double orientation) {
+        final int previous = border.get(position == 0 ? border.size() - 2 : position - 1);
+        final int vertex   = border.get(position);
+        final int next     = border.get(position + 1);
+        if (orientation * cross(vertex, next, previous) >= 0) {
+            return orientation * cross(vertex, point, previous) > 0
+                && orientation * cross(point, vertex, next) > 0;
+        }
+        return !(orientation * cross(vertex, point, next) >= 0
+              && orientation * cross(point, vertex, previous) >= 0);
+    }
+
+    /**
+     * Returns whether the ring turns in the opposite direction of its orientation at the given position.
+     */
+    private boolean isReflex(final List<Integer> border, final int position, final double orientation) {
+        final int previous = border.get(position == 0 ? border.size() - 2 : position - 1);
+        return orientation * cross(previous, border.get(position), border.get(position + 1)) < 0;
+    }
+
+    /**
+     * Returns the cross product of the vectors from <var>p</var> to <var>q</var> and from <var>p</var> to <var>r</var>,
+     * positive if <var>r</var> is on the left of the line from <var>p</var> to <var>q</var>.
+     */
+    private double cross(final int p, final int q, final int r) {
+        return (x(q) - x(p)) * (y(r) - y(p)) - (y(q) - y(p)) * (x(r) - x(p));
+    }
+
+    /**
+     * Returns the angle between the increasing x direction and the segment between the two given vertices.
+     */
+    private double angle(final int from, final int to) {
+        return Math.abs(Math.atan2(y(to) - y(from), x(to) - x(from)));
+    }
+
+    /**
+     * Returns the squared distance between the two given vertices.
+     */
+    private double distance(final int from, final int to) {
+        final double dx = x(to) - x(from), dy = y(to) - y(from);
+        return dx*dx + dy*dy;
+    }
+
+    /**
+     * Returns twice the signed area of the given ring, positive if it is counterclockwise.
+     */
+    private double area(final List<Integer> ring) {
+        double area = 0;
+        for (int i = 0, n = ring.size(); i < n; i++) {
+            final int a = ring.get(i), b = ring.get((i + 1) % n);
+            area += x(a) * y(b) - x(b) * y(a);
+        }
+        return area;
+    }
+
+    /**
+     * Returns the first working coordinate of the given vertex.
+     */
+    private double x(final int vertex) {
+        return working.get(vertex).get(0);
+    }
+
+    /**
+     * Returns the second working coordinate of the given vertex.
+     */
+    private double y(final int vertex) {
+        return working.get(vertex).get(1);
+    }
+
+    /**
+     * Returns the indices from {@code start} inclusive to {@code start + count} exclusive, in a mutable list.
+     */
+    private static List<Integer> range(final int start, final int count) {
+        final List<Integer> indices = new ArrayList<>(count);
+        for (int i = 0; i < count; i++) {
+            indices.add(start + i);
+        }
+        return indices;
+    }
+
+    /**
+     * Returns the working positions of the given vertices.
+     */
+    private List<Vector<?>> positions(final List<Integer> vertices) {
+        final List<Vector<?>> positions = new ArrayList<>(vertices.size());
+        for (final int vertex : vertices) {
+            positions.add(working.get(vertex));
+        }
+        return positions;
+    }
+
+    /**
+     * Adds a vertex on the segment joining the two given vertices, and returns its index.
+     * Its working position is interpolated linearly, and its attributes will be interpolated
+     * from the positions of the polygon the two vertices are made of.
+     *
+     * @param  start  index of the vertex at ratio 0.
+     * @param  end    index of the vertex at ratio 1.
+     * @param  ratio  position of the new vertex between the two vertices, from 0 to 1.
+     * @return index of the new vertex.
+     */
+    private int interpolate(final int start, final int end, final double ratio) {
+        final Vector<?> p0 = working.get(start);
+        final Vector<?> p1 = working.get(end);
+        final Vector<?> position = p0.copy();
+        for (int d = 0; d < position.getDimension(); d++) {
+            position.set(d, p0.get(d) + ratio * (p1.get(d) - p0.get(d)));
+        }
+        final int[] i0 = sourceIndices(start), i1 = sourceIndices(end);
+        final double[] w0 = sourceWeights(start), w1 = sourceWeights(end);
+        final int[] indices = Arrays.copyOf(i0, i0.length + i1.length);
+        System.arraycopy(i1, 0, indices, i0.length, i1.length);
+        final double[] weights = new double[indices.length];
+        for (int i = 0; i < w0.length; i++) weights[i] = (1 - ratio) * w0[i];
+        for (int i = 0; i < w1.length; i++) weights[i0.length + i] = ratio * w1[i];
+        working.add(position);
+        addedIndices.add(indices);
+        addedWeights.add(weights);
+        return working.size() - 1;
+    }
+
+    /**
+     * Returns the indices of the positions of the polygon the given vertex is interpolated from.
+     */
+    private int[] sourceIndices(final int vertex) {
+        final int added = vertex - source.size();
+        return (added < 0) ? new int[] {vertex} : addedIndices.get(added);
+    }
+
+    /**
+     * Returns the weights of the positions of the polygon the given vertex is interpolated from.
+     */
+    private double[] sourceWeights(final int vertex) {
+        final int added = vertex - source.size();
+        return (added < 0) ? new double[] {1} : addedWeights.get(added);
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/ToPrimitiveTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/ToPrimitiveTest.java
index f239dcd651..1bd3e9c2c6 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/ToPrimitiveTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/ToPrimitiveTest.java
@@ -16,7 +16,9 @@
  */
 package org.apache.sis.geometries.operation;
 
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.geometries.Geometry;
 import org.apache.sis.geometries.GeometryFactory;
@@ -136,10 +138,62 @@ public class ToPrimitiveTest {
         assertTrue(result instanceof MeshPrimitive);
         final MeshPrimitive primitive = (MeshPrimitive) result;
         assertEquals(MeshPrimitive.Type.TRIANGLES, primitive.getType());
-        assertEquals(6, primitive.getPositions().getLength(), "Expected 2 triangles.");
+        assertEquals(6, primitive.getIndex().getLength(), "Expected 2 triangles.");
         assertEquals(4, trianglesArea(primitive), 1E-12);
     }
 
+    /**
+     * The triangles of a polygon reference its vertices, which keep all their attributes.
+     * The vertices added to join a hole have their attributes interpolated, so a linear
+     * function of the position keeps its value at every vertex.
+     */
+    @Test
+    public void testPolygonAttributes() {
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        final Polygon square = f.createPolygon(attributedRing(0,0, 2,0, 2,2, 0,2, 0,0), null);
+        final MeshPrimitive mesh = (MeshPrimitive) new GeometryProcessor().toPrimitive(square, null, null);
+        assertEquals(4, mesh.getPositions().getLength(), "The closing position shall not be a separate vertex.");
+        assertEquals(6, mesh.getIndex().getLength(), "Expected 2 triangles.");
+        assertAttribute(mesh);
+
+        final Polygon holed = f.createPolygon(attributedRing(0,0, 10,0, 10,10, 0,10, 0,0),
+                List.of(attributedRing(2,3, 2,5, 4,5, 4,3, 2,3)));
+        final MeshPrimitive holedMesh = (MeshPrimitive) new GeometryProcessor().toPrimitive(holed, null, null);
+        assertEquals(96, trianglesArea(holedMesh), 1E-9);
+        assertAttribute(holedMesh);
+    }
+
+    /**
+     * Name of the attribute holding {@code x + 10*y} at each position, for checking that it is kept.
+     */
+    private static final String ATT_TEST = "TEST";
+
+    /**
+     * Creates a ring in the two-dimensional test CRS, with a {@value #ATT_TEST} attribute.
+     */
+    private static LinearRing attributedRing(final double... coordinates) {
+        final double[] values = new double[coordinates.length / 2];
+        for (int i = 0; i < values.length; i++) {
+            values[i] = coordinates[2*i] + 10 * coordinates[2*i + 1];
+        }
+        final Map<String,Array> attributes = new LinkedHashMap<>();
+        attributes.put(DataPointsType.ATT_POSITION, NDArrays.of(CRS2D, coordinates));
+        attributes.put(ATT_TEST, NDArrays.of(1, values));
+        return GeometryFactory.DEFAULT.createLinearRing(GeometryFactory.DEFAULT.createDataPoints(attributes));
+    }
+
+    /**
+     * Verifies that the {@value #ATT_TEST} attribute of each vertex of the given mesh is {@code x + 10*y}.
+     */
+    private static void assertAttribute(final MeshPrimitive mesh) {
+        final double[] c = mesh.getPositions().toArrayDouble();
+        final double[] values = mesh.getAttribute(ATT_TEST).toArrayDouble();
+        assertEquals(c.length / 2, values.length);
+        for (int i = 0; i < values.length; i++) {
+            assertEquals(c[2*i] + 10 * c[2*i + 1], values[i], 1E-9);
+        }
+    }
+
     /**
      * Tests the triangulation of a polygon with a hole, which requires to cut the exterior
      * ring toward the hole. The triangles shall cover the polygon area without the hole.
@@ -178,7 +232,7 @@ public class ToPrimitiveTest {
         final MeshPrimitive primitive = (MeshPrimitive) new GeometryProcessor().toPrimitive(polygon, null, null);
         assertEquals(MeshPrimitive.Type.TRIANGLES, primitive.getType());
         assertEquals(3, primitive.getPositions().getDimension());
-        assertEquals(6, primitive.getPositions().getLength(), "Expected 2 triangles.");
+        assertEquals(6, primitive.getIndex().getLength(), "Expected 2 triangles.");
         final double[] c = primitive.getPositions().toArrayDouble();
         for (int i = 0; i < c.length; i += 3) {
             assertEquals(c[i+1] == 0 ? 5 : 7, c[i+2], "z shall follow the polygon plane.");
@@ -252,7 +306,7 @@ public class ToPrimitiveTest {
         assertTrue(result instanceof MeshPrimitive.Triangles);
         final MeshPrimitive primitive = (MeshPrimitive) result;
         assertEquals(3, primitive.getPositions().getDimension());
-        assertEquals(6, primitive.getPositions().getLength(), "Expected 2 triangles.");
+        assertEquals(6, primitive.getIndex().getLength(), "Expected 2 triangles.");
         assertEquals(2 * Math.sqrt(5), trianglesArea3D(primitive), 1E-9);
     }
 
@@ -273,7 +327,7 @@ public class ToPrimitiveTest {
         final Geometry result = new GeometryProcessor().toPrimitive(f.createPolyhedralSurface(crs, faces), null, null);
         assertTrue(result instanceof MeshPrimitive.Triangles);
         final MeshPrimitive primitive = (MeshPrimitive) result;
-        assertEquals(30, primitive.getPositions().getLength(), "Expected 2 triangles per face.");
+        assertEquals(30, primitive.getIndex().getLength(), "Expected 2 triangles per face.");
         assertEquals(20, trianglesArea3D(primitive), 1E-9);
     }
 
@@ -304,7 +358,7 @@ public class ToPrimitiveTest {
         assertTrue(result instanceof MeshPrimitive.Triangles);
         final MeshPrimitive primitive = (MeshPrimitive) result;
         assertEquals(crs, primitive.getCoordinateReferenceSystem());
-        assertEquals(36, primitive.getPositions().getLength(), "Expected 2 triangles per face.");
+        assertEquals(36, primitive.getIndex().getLength(), "Expected 2 triangles per face.");
         assertEquals(24, trianglesArea3D(primitive), 1E-9);
     }
 
@@ -316,7 +370,7 @@ public class ToPrimitiveTest {
         final CoordinateReferenceSystem crs = CommonCRS.WGS84.geographic3D();
         final Polyhedron hollow = GeometryFactory.DEFAULT.createPolyhedron(cube(crs, 0,0,0, 4,4,4), List.of(cube(crs, 1,1,1, 3,3,3)));
         final MeshPrimitive primitive = (MeshPrimitive) new GeometryProcessor().toPrimitive(hollow, null, null);
-        assertEquals(72, primitive.getPositions().getLength(), "Expected 2 triangles per face of both shells.");
+        assertEquals(72, primitive.getIndex().getLength(), "Expected 2 triangles per face of both shells.");
         assertEquals(96 + 24, trianglesArea3D(primitive), 1E-9);
     }
 
@@ -349,7 +403,7 @@ public class ToPrimitiveTest {
         assertTrue(result instanceof MeshPrimitive.Triangles);
         final MeshPrimitive primitive = (MeshPrimitive) result;
         assertEquals(3, primitive.getCoordinateReferenceSystem().getCoordinateSystem().getDimension());
-        assertEquals(36, primitive.getPositions().getLength(), "Expected 2 triangles per face.");
+        assertEquals(36, primitive.getIndex().getLength(), "Expected 2 triangles per face.");
         assertEquals(2*4 + 4*6, trianglesArea3D(primitive), 1E-9);
     }
 
@@ -369,7 +423,7 @@ public class ToPrimitiveTest {
         final LineString line = f.createLineString(f.createDataPoints(NDArrays.of(crs, new double[] {0,0, 2,0, 2,1})));
         final Geometry walls = new GeometryProcessor().toPrimitive(f.createPrism(line, range, null), null, null);
         assertTrue(walls instanceof MeshPrimitive.Triangles);
-        assertEquals(12, ((MeshPrimitive) walls).getPositions().getLength(), "Expected 2 triangles per segment.");
+        assertEquals(12, ((MeshPrimitive) walls).getIndex().getLength(), "Expected 2 triangles per segment.");
         assertEquals(2*3 + 1*3, trianglesArea3D((MeshPrimitive) walls), 1E-9);
     }
 
@@ -382,7 +436,7 @@ public class ToPrimitiveTest {
      * Returns the sum of the areas of the triangles of the given non-indexed three-dimensional mesh.
      */
     private static double trianglesArea3D(final MeshPrimitive primitive) {
-        final double[] c = primitive.getPositions().toArrayDouble();
+        final double[] c = triangleCoordinates(primitive);
         double area = 0;
         for (int i = 0; i + 8 < c.length; i += 9) {
             final double ux = c[i+3] - c[i], uy = c[i+4] - c[i+1], uz = c[i+5] - c[i+2];
@@ -402,10 +456,28 @@ public class ToPrimitiveTest {
     }
 
     /**
-     * Returns the sum of the areas of the triangles of the given non-indexed mesh.
+     * Returns the coordinates of the vertices of the triangles of the given mesh, one triangle after the other.
+     * If the mesh is indexed, the coordinates of a vertex are repeated for each triangle using it.
      */
-    private static double trianglesArea(final MeshPrimitive primitive) {
+    private static double[] triangleCoordinates(final MeshPrimitive primitive) {
         final double[] c = primitive.getPositions().toArrayDouble();
+        if (primitive.getIndex() == null) {
+            return c;
+        }
+        final int dim = primitive.getPositions().getDimension();
+        final int[] index = primitive.getIndex().toArrayInt();
+        final double[] expanded = new double[index.length * dim];
+        for (int i = 0; i < index.length; i++) {
+            System.arraycopy(c, index[i] * dim, expanded, i * dim, dim);
+        }
+        return expanded;
+    }
+
+    /**
+     * Returns the sum of the areas of the triangles of the given mesh.
+     */
+    private static double trianglesArea(final MeshPrimitive primitive) {
+        final double[] c = triangleCoordinates(primitive);
         double area = 0;
         for (int i = 0; i + 5 < c.length; i += 6) {
             area += Math.abs((c[i+2] - c[i]) * (c[i+5] - c[i+1]) - (c[i+4] - c[i]) * (c[i+3] - c[i+1])) / 2;
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/triangulate/HoleToExteriorTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/triangulate/HoleToExteriorTest.java
new file mode 100644
index 0000000000..1b8d9802e5
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/triangulate/HoleToExteriorTest.java
@@ -0,0 +1,178 @@
+/*
+ * 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.operation.triangulate;
+
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.geometries.DataPoints;
+import org.apache.sis.geometries.DataPointsType;
+import org.apache.sis.geometries.GeometryFactory;
+import org.apache.sis.geometries.curve.LinearRing;
+import org.apache.sis.geometries.surface.Polygon;
+import org.apache.sis.maths.Array;
+import org.apache.sis.maths.NDArrays;
+import org.apache.sis.referencing.CommonCRS;
+
+// Test dependencies
+import static org.junit.jupiter.api.Assertions.*;
+import org.junit.jupiter.api.Test;
+
+
+/**
+ * Tests {@link HoleToExterior}.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public class HoleToExteriorTest {
+
+    /**
+     * Name of the attribute holding {@code x + 10*y} at each position, for checking that it is kept.
+     */
+    private static final String ATT_TEST = "TEST";
+
+    /**
+     * Creates a ring of the given positions in the given reference system, with a {@value #ATT_TEST} attribute.
+     */
+    private static LinearRing ring(final CoordinateReferenceSystem crs, final double... coordinates) {
+        final int dimension = crs.getCoordinateSystem().getDimension();
+        final double[] values = new double[coordinates.length / dimension];
+        for (int i = 0; i < values.length; i++) {
+            values[i] = coordinates[i * dimension] + 10 * coordinates[i * dimension + 1];
+        }
+        final Map<String,Array> attributes = new LinkedHashMap<>();
+        attributes.put(DataPointsType.ATT_POSITION, NDArrays.of(crs, coordinates));
+        attributes.put(ATT_TEST, NDArrays.of(1, values));
+        return GeometryFactory.DEFAULT.createLinearRing(GeometryFactory.DEFAULT.createDataPoints(attributes));
+    }
+
+    /**
+     * Returns the area enclosed by the given ring in the plane of the two given axes.
+     * A cut followed in both directions does not change the area.
+     */
+    private static double area(final DataPoints ring, final int h0, final int h1) {
+        double area = 0;
+        for (int i = 1; i < ring.size(); i++) {
+            final double[] p = ring.getPosition(i-1).toArrayDouble();
+            final double[] q = ring.getPosition(i).toArrayDouble();
+            area += p[h0] * q[h1] - q[h0] * p[h1];
+        }
+        return Math.abs(area) / 2;
+    }
+
+    /**
+     * Verifies that the {@value #ATT_TEST} attribute of each position of the given ring is {@code x + 10*y}.
+     */
+    private static void assertAttribute(final DataPoints ring) {
+        for (int i = 0; i < ring.size(); i++) {
+            final double[] p = ring.getPosition(i).toArrayDouble();
+            assertEquals(p[0] + 10 * p[1], ring.getAttribute(i, ATT_TEST).get(0), 1E-9);
+        }
+    }
+
+    /**
+     * A polygon without hole is returned unchanged.
+     */
+    @Test
+    public void testWithoutHole() {
+        final Polygon polygon = GeometryFactory.DEFAULT.createPolygon(
+                ring(CommonCRS.WGS84.normalizedGeographic(), 0,0, 2,0, 2,2, 0,2, 0,0), null);
+        assertSame(polygon, HoleToExterior.apply(polygon));
+    }
+
+    /**
+     * Verifies that each position of the given ring is a position of the given polygon.
+     */
+    private static void assertNoAddedPosition(final Polygon polygon, final DataPoints ring) {
+        final DataPoints positions = polygon.getDataPoints();
+        next: for (int i = 0; i < ring.size(); i++) {
+            final double[] p = ring.getPosition(i).toArrayDouble();
+            for (int j = 0; j < positions.size(); j++) {
+                if (Arrays.equals(p, positions.getPosition(j).toArrayDouble())) {
+                    continue next;
+                }
+            }
+            fail("Added position: " + Arrays.toString(p));
+        }
+    }
+
+    /**
+     * The holes are joined to the exterior ring, which keeps the area of the polygon and the attributes
+     * of its positions. The cuts join vertices of the polygon, so no position is added. The second hole
+     * turns in the same direction as the exterior ring, so it shall be reversed.
+     */
+    @Test
+    public void testWithHoles() {
+        final CoordinateReferenceSystem crs = CommonCRS.WGS84.normalizedGeographic();
+        final Polygon polygon = GeometryFactory.DEFAULT.createPolygon(ring(crs, 0,0, 10,0, 10,10, 0,10, 0,0), List.of(
+                ring(crs, 2,3, 2,5, 4,5, 4,3, 2,3),
+                ring(crs, 6,6, 8,6, 8,8, 6,8, 6,6)));
+        final Polygon joined = HoleToExterior.apply(polygon);
+        assertTrue(joined.getInteriorRings().isEmpty());
+        final DataPoints ring = joined.getExteriorRing().getDataPoints();
+        assertEquals(5 + 2 * (5 + 1), ring.size(), "Each hole adds its positions, its first position again and the ring vertex again.");
+        assertArrayEquals(ring.getPosition(0).toArrayDouble(), ring.getPosition(ring.size() - 1).toArrayDouble(), "The ring shall stay closed.");
+        assertEquals(100 - 4 - 4, area(ring, 0, 1), 1E-9);
+        assertNoAddedPosition(polygon, ring);
+        assertAttribute(ring);
+    }
+
+    /**
+     * A reflex vertex of the exterior ring hides the end of the segment reached by the ray from the hole,
+     * so the cut shall go to that reflex vertex.
+     */
+    @Test
+    public void testHiddenVertex() {
+        final CoordinateReferenceSystem crs = CommonCRS.WGS84.normalizedGeographic();
+        //the ray from the hole vertex (4,5) reaches the segment from (10,0) to (10,10), but the spike
+        //of the exterior ring up to (7,4) hides the vertex (10,0) from the hole.
+        final Polygon polygon = GeometryFactory.DEFAULT.createPolygon(
+                ring(crs, 0,0, 7,0, 7,4, 8,0, 10,0, 10,10, 0,10, 0,0),
+                List.of(ring(crs, 2,4, 2,6, 4,5, 2,4)));
+        final DataPoints ring = HoleToExterior.apply(polygon).getExteriorRing().getDataPoints();
+        assertEquals(8 + 4 + 1, ring.size(), "The hole adds its positions, its first position again and the ring vertex again.");
+        assertEquals(area(polygon.getExteriorRing().getDataPoints(), 0, 1) - 2, area(ring, 0, 1), 1E-9);
+        assertNoAddedPosition(polygon, ring);
+        boolean found = false;
+        for (int i = 1; i < ring.size(); i++) {
+            final double[] p = ring.getPosition(i - 1).toArrayDouble();
+            final double[] q = ring.getPosition(i).toArrayDouble();
+            if (Arrays.equals(p, new double[] {7, 4}) && Arrays.equals(q, new double[] {4, 5})) {
+                found = true;
+            }
+        }
+        assertTrue(found, "The cut shall start from the reflex vertex (7,4).");
+    }
+
+    /**
+     * The hole of a vertical polygon is joined in the plane of the polygon.
+     */
+    @Test
+    public void testVerticalPolygon() {
+        final CoordinateReferenceSystem crs = CommonCRS.WGS84.geographic3D();
+        final Polygon polygon = GeometryFactory.DEFAULT.createPolygon(ring(crs, 0,0,0, 6,0,0, 6,0,4, 0,0,4, 0,0,0),
+                List.of(ring(crs, 2,0,1, 2,0,3, 4,0,3, 4,0,1, 2,0,1)));
+        final DataPoints ring = HoleToExterior.apply(polygon).getExteriorRing().getDataPoints();
+        assertEquals(24 - 4, area(ring, 0, 2), 1E-9);
+        for (int i = 0; i < ring.size(); i++) {
+            assertEquals(0, ring.getPosition(i).get(1), "y shall stay in the wall plane.");
+        }
+        assertAttribute(ring);
+    }
+}


(sis) 08/13: fix(EarClipping): preserve normal direction in EarClipping

Posted by js...@apache.org.
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

commit 6607faa5287f7dcebf70e444753549a0b009266f
Author: jsorel <jo...@geomatys.com>
AuthorDate: Mon Oct 19 09:36:26 2026 +0200

    fix(EarClipping): preserve normal direction in EarClipping
---
 .../operation/triangulate/EarClipping.java         |  97 ++++++++---
 .../main/org/apache/sis/maths/Maths.java           |  29 ++++
 .../operation/triangulate/EarClippingTest.java     | 193 +++++++++++++++++++++
 .../test/org/apache/sis/maths/MathsTest.java       |  46 +++++
 4 files changed, 341 insertions(+), 24 deletions(-)

diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/EarClipping.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/EarClipping.java
index 41c77e90a8..00ed3a038d 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/EarClipping.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/EarClipping.java
@@ -25,6 +25,7 @@ import java.util.stream.IntStream;
 import org.apache.sis.geometries.DataPoints;
 import org.apache.sis.geometries.DataPointsType;
 import org.apache.sis.geometries.mesh.MeshPrimitive;
+import org.apache.sis.geometries.operation.OperationException;
 import org.apache.sis.geometries.surface.Polygon;
 import org.apache.sis.maths.Array;
 import org.apache.sis.maths.Maths;
@@ -44,10 +45,19 @@ import org.apache.sis.util.ArraysExt;
  * the polygon: the triangles reference the vertices instead of copying their positions, so that
  * all the attributes of the vertices are kept.</p>
  *
+ * <p>The triangles have the orientation of the exterior ring of the polygon: seen from any side,
+ * they turn in the same direction as the exterior ring, so a face of a solid keeps its normal.</p>
+ *
  * @author Johann Sorel
  */
 public class EarClipping {
 
+    /**
+     * Largest area, relative to the square of the size of the polygon, enclosed by vertices left without ear.
+     * Those vertices are aligned or along the cuts joining the holes, and the area is the rounding errors.
+     */
+    private static final double AREA_TOLERANCE = 1E-10;
+
     /**
      * Result of a triangulation: the vertices of the triangles, and the triangles as indices of those vertices.
      *
@@ -79,11 +89,17 @@ public class EarClipping {
 
     /**
      * Indices in {@link #source} of the vertices of the ring joining the holes to the exterior ring, without
-     * repeated consecutive positions and without the closing position. The ring is clockwise in the plane
-     * of the triangulation.
+     * repeated consecutive positions and without the closing position. The ring has the orientation of the
+     * exterior ring.
      */
     private int[] ring;
 
+    /**
+     * Whether the {@linkplain #ring} is clockwise in the plane of the triangulation.
+     * The triangles are cut in the direction of the ring, so they have the same orientation.
+     */
+    private boolean clockwise;
+
     /**
      * For each vertex of the {@linkplain #ring}, the previous and next vertices which are not cut yet.
      * Cutting an ear unlinks its vertex from the ring.
@@ -105,8 +121,10 @@ public class EarClipping {
      *
      * @param geometry geometry to triangulate
      * @return the vertices of the triangles and their indices.
+     * @throws OperationException if the polygon cannot be triangulated, which happens with an invalid polygon
+     *         such as a polygon whose exterior ring intersects itself.
      */
-    public Triangulation triangulateIndexed(Polygon geometry) {
+    public Triangulation triangulateIndexed(Polygon geometry) throws OperationException {
         source = geometry.getDataPoints();
         final int[] axes = workingAxes(geometry.getExteriorRing().getDataPoints().getAttributeArray(DataPointsType.ATT_POSITION));
         working = new Vector<?>[source.size()];
@@ -118,8 +136,10 @@ public class EarClipping {
 
     /**
      * Triangulates the given polygon into an indexed mesh holding all the attributes of the polygon.
+     *
+     * @throws OperationException if the polygon cannot be triangulated.
      */
-    public MeshPrimitive.Triangles toMesh(Polygon polygon) {
+    public MeshPrimitive.Triangles toMesh(Polygon polygon) throws OperationException {
         final Triangulation triangulation = triangulateIndexed(polygon);
         final DataPoints vertices = triangulation.vertices();
         final MeshPrimitive.Triangles mesh = new MeshPrimitive.Triangles();
@@ -133,20 +153,16 @@ public class EarClipping {
     /**
      * Returns the order of the coordinates to use for triangulating a polygon with the given positions,
      * to be given to {@link Vector#swizzle(int...)}. The triangulation works on the two first coordinates
-     * of that order. The axis along which the {@linkplain Maths#calculatePolygonNormal(Array) normal of the
-     * polygon} is the largest is moved after the two axes of the projection plane, so that a vertical polygon
-     * is not degenerate. If the polygon can be triangulated in its (x,y) plane, the order is unchanged.
+     * of that order. With three or more dimensions, the three first axes are in the {@linkplain Maths#projectionAxes
+     * projection order} of the {@linkplain Maths#calculatePolygonNormal(Array) normal of the polygon}: the axis along
+     * which the normal is the largest goes after the two axes of the projection plane, so that a vertical polygon is
+     * not degenerate, and the polygon is counterclockwise in that plane. The other axes keep their order.
+     * In two dimensions, the order is unchanged.
      */
     static int[] workingAxes(final Array positions) {
-        final int dim = positions.getDimension();
-        final int[] axes = ArraysExt.range(0, dim);
-        if (dim >= 3) {
-            final double[] normal = Maths.calculatePolygonNormal(positions);
-            final double nx = Math.abs(normal[0]), ny = Math.abs(normal[1]), nz = Math.abs(normal[2]);
-            final int drop = (nz >= nx && nz >= ny) ? 2 : (nx >= ny) ? 0 : 1;
-            //shift the following axes of the plane to the place of the dropped axis, which goes after them.
-            System.arraycopy(axes, drop + 1, axes, drop, 2 - drop);
-            axes[2] = drop;
+        final int[] axes = ArraysExt.range(0, positions.getDimension());
+        if (axes.length >= 3) {
+            System.arraycopy(Maths.projectionAxes(Maths.calculatePolygonNormal(positions)), 0, axes, 0, 3);
         }
         return axes;
     }
@@ -174,14 +190,13 @@ public class EarClipping {
      * ring, which stays a valid polygon, until only one triangle is left.
      *
      * @param joined  indices in {@link #source} of the ring positions, the last one at the location of the first one.
+     * @throws OperationException if the vertices left without ear still enclose an area.
      */
-    private void run(final int[] joined) {
+    private void run(final int[] joined) throws OperationException {
         //remove the repeated consecutive positions, and the closing position which repeats the first one.
         ring = IntStream.range(0, joined.length - 1).filter((i) -> !working[joined[i]].equals(working[joined[i + 1]]))
                         .map((i) -> joined[i]).toArray();
-        if (!Maths.isClockWise(Arrays.stream(joined).mapToObj((i) -> working[i]).toList())) {
-            ArraysExt.reverse(ring);
-        }
+        clockwise = Maths.isClockWise(Arrays.stream(joined).mapToObj((i) -> working[i]).toList());
         final int n = ring.length;
         previous = new int[n];
         next     = new int[n];
@@ -192,9 +207,12 @@ public class EarClipping {
         angles = new Angle[n];
         Arrays.fill(angles, Angle.UNKNOWN);
 
-        //walk around the ring, until a whole turn finds no ear, which should not happen if the geometry is correct.
-        int vertex = 0;
-        for (int remaining = n, attempts = 0; remaining >= 3 && attempts < remaining; vertex = next[vertex]) {
+        /*
+         * Walk around the ring, until a whole turn finds no ear. With a valid polygon, this happens only when
+         * the vertices left enclose no area, for example when they are aligned along the cut joining a hole.
+         */
+        int vertex = 0, remaining = n;
+        for (int attempts = 0; remaining >= 3 && attempts < remaining; vertex = next[vertex]) {
             if (isConvex(vertex) && isEar(vertex)) {
                 triangles.add(new int[] {ring[previous[vertex]], ring[vertex], ring[next[vertex]]});
                 //unlink the vertex, the angles at its neighbors may change.
@@ -208,6 +226,37 @@ public class EarClipping {
                 attempts++;
             }
         }
+        if (remaining >= 3 && !isDegenerate(vertex)) {
+            throw new OperationException("Cannot triangulate the polygon: no ear found among the " + remaining
+                    + " vertices left, the polygon may be invalid, for example with a ring intersecting itself.");
+        }
+    }
+
+    /**
+     * Returns whether the vertices which are not cut yet enclose no area, compared to the size of the polygon.
+     * The area is computed relatively to one of those vertices, for reducing the rounding errors.
+     *
+     * @param start  a vertex which is not cut yet.
+     */
+    private boolean isDegenerate(final int start) {
+        final Vector<?> origin = position(start);
+        final double ox = origin.get(0), oy = origin.get(1);
+        double area = 0;            // twice the signed area.
+        int vertex = start;
+        do {
+            final Vector<?> a = position(vertex), b = position(next[vertex]);
+            area += (a.get(0) - ox) * (b.get(1) - oy) - (b.get(0) - ox) * (a.get(1) - oy);
+            vertex = next[vertex];
+        } while (vertex != start);
+        double xmin = Double.POSITIVE_INFINITY, xmax = Double.NEGATIVE_INFINITY;
+        double ymin = Double.POSITIVE_INFINITY, ymax = Double.NEGATIVE_INFINITY;
+        for (final int index : ring) {
+            final Vector<?> p = working[index];
+            xmin = Math.min(xmin, p.get(0)); xmax = Math.max(xmax, p.get(0));
+            ymin = Math.min(ymin, p.get(1)); ymax = Math.max(ymax, p.get(1));
+        }
+        final double size = Math.max(xmax - xmin, ymax - ymin);
+        return Math.abs(area) / 2 <= AREA_TOLERANCE * size * size;
     }
 
     /**
@@ -222,7 +271,7 @@ public class EarClipping {
      */
     private boolean isConvex(final int vertex) {
         if (angles[vertex] == Angle.UNKNOWN) {
-            final boolean convex = Maths.isConvex(position(previous[vertex]), position(vertex), position(next[vertex]), true);
+            final boolean convex = Maths.isConvex(position(previous[vertex]), position(vertex), position(next[vertex]), clockwise);
             angles[vertex] = convex ? Angle.CONVEX : Angle.CONCAVE;
         }
         return angles[vertex] == Angle.CONVEX;
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java
index 43b573aaa4..185505bc55 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java
@@ -138,6 +138,35 @@ public final class Maths {
         return normal;
     }
 
+    /**
+     * Returns the order of the coordinates for projecting a planar figure on a coordinate plane without changing
+     * its orientation, to be given to {@link Vector#swizzle(int...)}. The projection plane is made of the two first
+     * axes of the returned order. The last axis is the one along which the normal is the largest, so that a figure
+     * perpendicular to another coordinate plane is not degenerate. The two first axes are ordered so that a figure
+     * seen counterclockwise from the side where the normal points is counterclockwise in the projection plane.
+     * For example, a triangle (a,b,c) whose normal (b−a)×(c−a) has the direction of the given normal is on the
+     * left side of {@linkplain #lineSide(ReadOnly.Vector, ReadOnly.Vector, ReadOnly.Vector) line side} once projected,
+     * and a ring is counterclockwise in the projection plane of its {@linkplain #calculatePolygonNormal(Array) normal}.
+     *
+     * <p>The orientation is kept because each component of the normal is twice the area of the figure projected
+     * on the coordinate plane of the two following axes in cyclic order, positive if counterclockwise. A zero
+     * normal gives the (x,y) plane.</p>
+     *
+     * @param normal normal of the plane. Only the three first components are used, and only their direction matters.
+     * @return the order of the three axes: the two axes of the projection plane, then the axis left out.
+     * @throws IllegalArgumentException if the normal has less than 3 components
+     */
+    public static int[] projectionAxes(double[] normal) {
+        if (normal.length < 3) {
+            throw new IllegalArgumentException("The normal shall have at least 3 components, found " + normal.length + ".");
+        }
+        final double nx = Math.abs(normal[0]), ny = Math.abs(normal[1]), nz = Math.abs(normal[2]);
+        final int drop = (nz >= nx && nz >= ny) ? 2 : (nx >= ny) ? 0 : 1;
+        //the cyclic order keeps the handedness, then swapping the plane axes reverses the orientation.
+        final int u = (drop + 1) % 3, v = (drop + 2) % 3;
+        return (normal[drop] < 0) ? new int[] {v, u, drop} : new int[] {u, v, drop};
+    }
+
     public static double[] subtract(float[] A, float[] B){
         return new double[] {
             A[0] - (double) B[0],
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/triangulate/EarClippingTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/triangulate/EarClippingTest.java
new file mode 100644
index 0000000000..a649a46445
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/triangulate/EarClippingTest.java
@@ -0,0 +1,193 @@
+/*
+ * 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.operation.triangulate;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.geometries.DataPoints;
+import org.apache.sis.geometries.DataPointsType;
+import org.apache.sis.geometries.Geometries;
+import org.apache.sis.geometries.GeometryFactory;
+import org.apache.sis.geometries.curve.LinearRing;
+import org.apache.sis.geometries.operation.OperationException;
+import org.apache.sis.geometries.surface.Polygon;
+import org.apache.sis.maths.Maths;
+import org.apache.sis.maths.NDArrays;
+import org.apache.sis.maths.Vectors;
+import org.apache.sis.referencing.CommonCRS;
+
+// Test dependencies
+import static org.junit.jupiter.api.Assertions.*;
+import org.junit.jupiter.api.Test;
+
+
+/**
+ * Tests {@link EarClipping}.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public class EarClippingTest {
+
+    private static final CoordinateReferenceSystem CRS2D = CommonCRS.WGS84.normalizedGeographic();
+
+    private static final CoordinateReferenceSystem CRS3D = Geometries.getUndefinedCRS(3);
+
+    /**
+     * Each face of a box, with its exterior ring in both directions, gives two triangles which turn in the
+     * direction of the ring. The faces perpendicular to every axis are tested, in particular those of normal
+     * along y which were projected on a plane reversing their orientation.
+     */
+    @Test
+    public void testFaceOrientation() {
+        final double x0 = 0, y0 = 0, z0 = 0, x1 = 2, y1 = 3, z1 = 4;
+        final double[][] faces = {
+            {x0,y0,z0, x0,y0,z1, x0,y1,z1, x0,y1,z0, x0,y0,z0},
+            {x1,y0,z0, x1,y1,z0, x1,y1,z1, x1,y0,z1, x1,y0,z0},
+            {x0,y0,z0, x1,y0,z0, x1,y0,z1, x0,y0,z1, x0,y0,z0},
+            {x0,y1,z0, x0,y1,z1, x1,y1,z1, x1,y1,z0, x0,y1,z0},
+            {x0,y0,z0, x0,y1,z0, x1,y1,z0, x1,y0,z0, x0,y0,z0},
+            {x0,y0,z1, x1,y0,z1, x1,y1,z1, x0,y1,z1, x0,y0,z1}
+        };
+        final double[] areas = {12, 12, 8, 8, 6, 6};
+        for (int i = 0; i < faces.length; i++) {
+            assertOriented(polygon(CRS3D, faces[i]), areas[i], 2);
+            assertOriented(polygon(CRS3D, reverse(faces[i], 3)), areas[i], 2);
+        }
+    }
+
+    /**
+     * A polygon with a hole gives triangles which turn in the direction of the exterior ring,
+     * in a vertical plane of normal along y and in an oblique plane, with the ring in both directions.
+     */
+    @Test
+    public void testHoleOrientation() {
+        final double[] exterior = {0,0, 6,0, 6,4, 0,4, 0,0};
+        final double[] hole     = {2,1, 2,3, 4,3, 4,1, 2,1};
+        final double area = 24 - 4;
+        //the plane y = 1, and an oblique plane made of two orthonormal vectors.
+        final double[][][] planes = {
+            {{0, 1, 0}, {1, 0, 0}, {0, 0, 1}},
+            {{5, -2, 3}, {1/3., 2/3., 2/3.}, {2/3., 1/3., -2/3.}}
+        };
+        for (final double[][] plane : planes) {
+            for (final boolean reversed : new boolean[] {false, true}) {
+                final double[] e = toPlane(reversed ? reverse(exterior, 2) : exterior, plane);
+                final double[] h = toPlane(reversed ? reverse(hole, 2) : hole, plane);
+                assertOriented(polygon(CRS3D, e, h), area, 8);
+            }
+        }
+    }
+
+    /**
+     * In two dimensions, the triangles turn in the direction of the exterior ring, whether it is counterclockwise
+     * or clockwise. The polygon is concave, and has aligned positions.
+     */
+    @Test
+    public void testOrientation2D() {
+        final double[] shape = {0,0, 2,0, 4,0, 4,1, 1,1, 1,4, 0,4, 0,0};
+        for (final boolean reversed : new boolean[] {false, true}) {
+            final Polygon polygon = polygon(CRS2D, reversed ? reverse(shape, 2) : shape);
+            final EarClipping.Triangulation triangulation = new EarClipping().triangulateIndexed(polygon);
+            final DataPoints vertices = triangulation.vertices();
+            final int[] triangles = triangulation.triangles();
+            double area = 0;
+            for (int i = 0; i < triangles.length; i += 3) {
+                final double side = Maths.lineSide(vertices.getPosition(triangles[i]),
+                                                   vertices.getPosition(triangles[i+1]),
+                                                   vertices.getPosition(triangles[i+2]));
+                assertTrue(reversed ? side < 0 : side > 0, "The triangle shall turn in the direction of the ring.");
+                area += Math.abs(side) / 2;
+            }
+            assertEquals(7, area, 1E-12);
+        }
+    }
+
+    /**
+     * A ring which intersects itself cannot be triangulated, which shall be reported instead of returning
+     * the triangles found before the failure.
+     */
+    @Test
+    public void testSelfIntersectingRing() {
+        final Polygon bowTie = polygon(CRS2D, new double[] {0,0, 2,2, 2,0, 0,2, 0,0});
+        assertThrows(OperationException.class, () -> new EarClipping().triangulateIndexed(bowTie));
+    }
+
+    /**
+     * Triangulates the given polygon and verifies that each triangle turns in the direction of the exterior ring,
+     * which is that its normal has the direction of the normal of the ring. Also verifies the area and the number
+     * of triangles.
+     */
+    private static void assertOriented(final Polygon polygon, final double area, final int count) {
+        final double[] normal = Maths.calculatePolygonNormal(
+                polygon.getExteriorRing().getDataPoints().getAttributeArray(DataPointsType.ATT_POSITION));
+        final EarClipping.Triangulation triangulation = new EarClipping().triangulateIndexed(polygon);
+        final DataPoints vertices = triangulation.vertices();
+        final int[] triangles = triangulation.triangles();
+        assertEquals(count * 3, triangles.length, "Number of triangles.");
+        double sum = 0;
+        for (int i = 0; i < triangles.length; i += 3) {
+            final double[] a = vertices.getPosition(triangles[i  ]).toArrayDouble();
+            final double[] b = vertices.getPosition(triangles[i+1]).toArrayDouble();
+            final double[] c = vertices.getPosition(triangles[i+2]).toArrayDouble();
+            final double[] n = Vectors.cross(Vectors.subtract(b, a), Vectors.subtract(c, a));
+            assertTrue(Vectors.dot(n, normal) > 0, "The triangle shall turn in the direction of the exterior ring.");
+            sum += Vectors.length(n) / 2;
+        }
+        assertEquals(area, sum, 1E-9);
+    }
+
+    /**
+     * Creates a polygon from the coordinates of its exterior ring followed by those of its holes.
+     */
+    private static Polygon polygon(final CoordinateReferenceSystem crs, final double[] exterior, final double[]... holes) {
+        final List<LinearRing> interiors = new ArrayList<>();
+        for (final double[] hole : holes) {
+            interiors.add(ring(crs, hole));
+        }
+        return GeometryFactory.DEFAULT.createPolygon(ring(crs, exterior), interiors);
+    }
+
+    private static LinearRing ring(final CoordinateReferenceSystem crs, final double... coordinates) {
+        return GeometryFactory.DEFAULT.createLinearRing(GeometryFactory.DEFAULT.createDataPoints(NDArrays.of(crs, coordinates)));
+    }
+
+    /**
+     * Returns the positions in reverse order, each position having the given number of coordinates.
+     */
+    private static double[] reverse(final double[] coordinates, final int dimension) {
+        final double[] reversed = new double[coordinates.length];
+        for (int i = 0; i < coordinates.length; i += dimension) {
+            System.arraycopy(coordinates, i, reversed, coordinates.length - dimension - i, dimension);
+        }
+        return reversed;
+    }
+
+    /**
+     * Converts (u,v) coordinates to three-dimensional coordinates in the plane made of an origin and two vectors.
+     */
+    private static double[] toPlane(final double[] uv, final double[][] plane) {
+        final double[] origin = plane[0], u = plane[1], v = plane[2];
+        final double[] xyz = new double[uv.length / 2 * 3];
+        for (int i = 0, j = 0; i < uv.length; i += 2) {
+            for (int k = 0; k < 3; k++) {
+                xyz[j++] = origin[k] + uv[i] * u[k] + uv[i+1] * v[k];
+            }
+        }
+        return xyz;
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/MathsTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/MathsTest.java
index ab7d3e59eb..140dde08a1 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/MathsTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/MathsTest.java
@@ -16,6 +16,8 @@
  */
 package org.apache.sis.maths;
 
+import java.util.Arrays;
+
 // Test dependencies
 import static org.junit.jupiter.api.Assertions.*;
 import org.junit.jupiter.api.Disabled;
@@ -198,6 +200,50 @@ public class MathsTest {
         assertThrows(IllegalArgumentException.class, () -> Maths.calculatePolygonNormal(NDArrays.of(2, new double[] {0,0, 1,0, 0,1})));
     }
 
+    /**
+     * Tests {@link Maths#projectionAxes(double[])} on the normals of the six faces of a cube.
+     * The axis left out is the one of the normal, and the order of the two others depends on its sign.
+     */
+    @Test
+    public void testProjectionAxes() {
+        assertArrayEquals(new int[] {0, 1, 2}, Maths.projectionAxes(new double[] { 0,  0,  1}));
+        assertArrayEquals(new int[] {1, 0, 2}, Maths.projectionAxes(new double[] { 0,  0, -1}));
+        assertArrayEquals(new int[] {1, 2, 0}, Maths.projectionAxes(new double[] { 1,  0,  0}));
+        assertArrayEquals(new int[] {2, 1, 0}, Maths.projectionAxes(new double[] {-1,  0,  0}));
+        assertArrayEquals(new int[] {2, 0, 1}, Maths.projectionAxes(new double[] { 0,  1,  0}));
+        assertArrayEquals(new int[] {0, 2, 1}, Maths.projectionAxes(new double[] { 0, -1,  0}));
+        assertArrayEquals(new int[] {0, 1, 2}, Maths.projectionAxes(new double[] { 0,  0,  0}), "A zero normal gives the (x,y) plane.");
+        assertArrayEquals(new int[] {0, 1, 2}, Maths.projectionAxes(new double[] { 1,  1,  1}), "Ties leave out z first.");
+        assertThrows(IllegalArgumentException.class, () -> Maths.projectionAxes(new double[] {0, 1}));
+    }
+
+    /**
+     * Tests that {@link Maths#projectionAxes(double[])} keeps the orientation: a triangle whose normal has the
+     * direction given to the method is counterclockwise once projected, and the reversed triangle clockwise.
+     */
+    @Test
+    public void testProjectionAxesOrientation() {
+        final double[][] normals = {
+            {0, 0, 1}, {0, 0, -1}, {1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, {0, -1, 0},
+            {1, 2, 3}, {-3, 1, -2}, {0.5, -4, 0.1}, {-0.2, 0.3, -0.25}
+        };
+        for (final double[] normal : normals) {
+            //two vectors of the plane such as t1 × t2 has the direction of the normal.
+            final double[] other = (Math.abs(normal[0]) < 0.9 * Vectors.length(normal)) ? new double[] {1, 0, 0} : new double[] {0, 1, 0};
+            final double[] t1 = Vectors.cross(normal, other);
+            final double[] t2 = Vectors.cross(normal, t1);
+            final Vector<?> a = vector(0.3, -1.7, 2.1);
+            final Vector<?> b = vector(a.get(0) + t1[0], a.get(1) + t1[1], a.get(2) + t1[2]);
+            final Vector<?> c = vector(a.get(0) + t2[0], a.get(1) + t2[1], a.get(2) + t2[2]);
+            final int[] axes = Maths.projectionAxes(normal);
+            final String message = Arrays.toString(normal);
+            assertTrue(Maths.lineSide(a.swizzle(axes), b.swizzle(axes), c.swizzle(axes)) > 0, message);
+            assertTrue(Maths.lineSide(a.swizzle(axes), c.swizzle(axes), b.swizzle(axes)) < 0, message);
+            assertEquals(Math.abs(normal[axes[2]]), Math.max(Math.abs(normal[0]), Math.max(Math.abs(normal[1]), Math.abs(normal[2]))),
+                         message + ": the axis left out shall be the largest component of the normal.");
+        }
+    }
+
     @Test
     public void testNormalizedByte() {
         assertEquals(-1.0, Maths.normalizedByte(Byte.MIN_VALUE), 0.0);


(sis) 09/13: feat(Geometry): improve earclipping, add contains and intersections tests

Posted by js...@apache.org.
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

commit a81b6f8cf270b81e947a9b5a5067a5b755a05487
Author: jsorel <jo...@geomatys.com>
AuthorDate: Mon Oct 19 10:11:02 2026 +0200

    feat(Geometry): improve earclipping, add contains and intersections tests
---
 .../apache/sis/geometries/operation/Contains.java  | 141 +++++-
 .../sis/geometries/operation/Intersection.java     | 549 +++++++++++++++++++++
 .../operation/triangulate/EarClipping.java         |  65 ++-
 .../main/org/apache/sis/maths/Maths.java           |  28 ++
 .../sis/geometries/operation/Contains3DTest.java   | 182 +++++++
 .../geometries/operation/Intersection3DTest.java   | 264 ++++++++++
 .../operation/triangulate/EarClippingTest.java     |  42 ++
 .../test/org/apache/sis/maths/MathsTest.java       |  28 ++
 8 files changed, 1258 insertions(+), 41 deletions(-)

diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Contains.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Contains.java
index 293338f1bf..ec0c60ddfc 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Contains.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Contains.java
@@ -18,56 +18,72 @@ package org.apache.sis.geometries.operation;
 
 import org.apache.sis.geometries.Point;
 import org.apache.sis.geometries.DataPointsType;
+import org.apache.sis.geometries.GeometryCollection;
 import org.apache.sis.geometries.curve.LineString;
+import org.apache.sis.geometries.operation.triangulate.EarClipping;
+import org.apache.sis.geometries.solid.Polyhedron;
 import org.apache.sis.geometries.surface.Polygon;
 import org.apache.sis.maths.Array;
 import org.apache.sis.maths.Cursor;
 import org.apache.sis.maths.Maths;
-import org.apache.sis.maths.Vectors;
+import org.apache.sis.maths.ReadOnly;
 import org.apache.sis.maths.Vector;
 
 
 /**
- * Constains 2D processors.
+ * Contains processors.
  *
  * @author Johann Sorel (Geomatys)
  */
 public final class Contains {
 
+    /**
+     * The axes of the (x,y) plane, for the tests done in two dimensions.
+     */
+    private static final int[] XY = {0, 1};
+
     private Contains(){}
 
     /**
-     * Test if point is within polygon using Winding Number algorithm.
+     * Test if point is within a ring using Winding Number algorithm, in the plane of the two given axes.
+     * The ring and the point can have more dimensions: for example a ring in a vertical plane can be tested
+     * in the plane given by {@link Maths#projectionAxes(double[])}. A point on the ring gets an arbitrary answer.
      * http://geomalgorithms.com/a03-_inclusion.html
      * http://en.wikipedia.org/wiki/Point_in_polygon
      *
-     * @param ring not null
-     * @param point not null
+     * @param ring positions of the closed ring, the last one at the location of the first one
+     * @param point the point to test
+     * @param axes indices of the two coordinates of the plane in which the test is done
+     * @return whether the point is inside the ring
      */
-    private static boolean contains(Array ring, Vector<?> point) {
+    public static boolean contains(Array ring, ReadOnly.Vector<?> point, int[] axes) {
+        final long n = ring.getLength();
+        if (n == 0) {
+            return false;
+        }
+        final int u = axes[0], v = axes[1];
+        final double px = point.get(u), py = point.get(v);
         final Cursor cursor = ring.cursor();
-
-        int windingNumber = 0;
-        Vector<?> current;
-        Vector<?> previous;
         cursor.moveTo(0);
-        current = cursor.samples();
-        previous = Vectors.create(current.getSampleSystem(), current.getDataType());
-        final double pointY = point.get(1);
-        for (long i = 1, n = ring.getLength(); i < n; i++){
-            previous.set(current);
+        Vector<?> current = cursor.samples();
+        double x = current.get(u), y = current.get(v);
+        int windingNumber = 0;
+        for (long i = 1; i < n; i++){
+            final double previousX = x, previousY = y;
             cursor.moveTo(i);
             current = cursor.samples();
+            x = current.get(u);
+            y = current.get(v);
 
-            if (previous.get(1) <= pointY){
-                if (current.get(1) > pointY){
-                    if (Maths.lineSide(previous, current, point) > 0){
+            if (previousY <= py){
+                if (y > py){
+                    if (Maths.lineSide(previousX, previousY, x, y, px, py) > 0){
                         windingNumber++;
                     }
                 }
             } else {
-                if (current.get(1) <= pointY){
-                    if (Maths.lineSide(previous, current, point) < 0){
+                if (y <= py){
+                    if (Maths.lineSide(previousX, previousY, x, y, px, py) < 0){
                         windingNumber--;
                     }
                 }
@@ -78,6 +94,87 @@ public final class Contains {
         return windingNumber != 0;
     }
 
+    /**
+     * Returns the generalized winding number of a surface made of triangles around a point: the sum of the solid
+     * angles of the triangles seen from the point, divided by 4π. The number is 1 inside a closed surface whose
+     * triangles are oriented outward (counterclockwise seen from outside), −1 if they are oriented inward, and 0
+     * outside. The numbers of several surfaces add: a point inside the void of a solid is inside the outward
+     * surface of the solid and inside the inward surface of the void, which gives 0. The number of a surface
+     * which is not closed is between those values, and the number of a point on a surface is a fraction,
+     * for example ½ on a face.
+     *
+     * <p>The solid angle of each triangle is computed with the formula of Van Oosterom and Strackee.
+     * A triangle whose plane contains the point is ignored: seen from its plane, a triangle has no solid
+     * angle, unless the point is on it, which is the case of a point on the surface.</p>
+     *
+     * @param xyz        the coordinates of the vertices, three per vertex.
+     * @param triangles  indices of the three vertices of each triangle, one triangle after the other.
+     * @param point      the point, of three coordinates.
+     * @return the winding number, close to an integer for a point which is not on a closed surface.
+     *
+     * @see <a href="https://doi.org/10.1145/2461912.2461916">Jacobson et al., Robust inside-outside segmentation using generalized winding numbers</a>
+     */
+    public static double windingNumber(final double[] xyz, final int[] triangles, final double[] point) {
+        double sum = 0;
+        for (int i = 0; i + 2 < triangles.length; i += 3) {
+            final int a = triangles[i] * 3, b = triangles[i+1] * 3, c = triangles[i+2] * 3;
+            final double ax = xyz[a] - point[0], ay = xyz[a+1] - point[1], az = xyz[a+2] - point[2];
+            final double bx = xyz[b] - point[0], by = xyz[b+1] - point[1], bz = xyz[b+2] - point[2];
+            final double cx = xyz[c] - point[0], cy = xyz[c+1] - point[1], cz = xyz[c+2] - point[2];
+            final double la = Math.sqrt(ax*ax + ay*ay + az*az);
+            final double lb = Math.sqrt(bx*bx + by*by + bz*bz);
+            final double lc = Math.sqrt(cx*cx + cy*cy + cz*cz);
+            final double det = ax * (by*cz - bz*cy) + ay * (bz*cx - bx*cz) + az * (bx*cy - by*cx);
+            if (det != 0) {
+                final double den = la*lb*lc + (ax*bx + ay*by + az*bz) * lc + (ax*cx + ay*cy + az*cz) * lb + (bx*cx + by*cy + bz*cz) * la;
+                sum += Math.atan2(det, den);        // half of the solid angle.
+            }
+        }
+        return sum / (2 * Math.PI);
+    }
+
+    /**
+     * Polyhedron contains Point test. The point is inside the polyhedron if it is inside the exterior shell and
+     * outside all the interior shells, as told by the {@linkplain #windingNumber winding number} of each shell.
+     * Each shell is tested alone, so the orientation of its faces does not matter as long as it is consistent.
+     * A point on the boundary of the polyhedron gets an arbitrary answer.
+     *
+     * @throws OperationException if the geometries do not have the same CRS, or are not three-dimensional,
+     *         or if a face cannot be triangulated.
+     */
+    public static boolean contains(Polyhedron polyhedron, Point candidate) throws OperationException {
+        ProcessorUtils.ensureSameCRS(polyhedron, candidate);
+        final double[] point = candidate.getPosition().toArrayDouble();
+        if (point.length != 3) {
+            throw new OperationException("A polyhedron contains a point in 3 dimensions only.");
+        }
+        if (!isInside(polyhedron.getExteriorShell(), point)) {
+            return false;
+        }
+        for (final GeometryCollection<Polygon> shell : polyhedron.getInteriorShells()) {
+            if (isInside(shell, point)) {
+                //point is within a void
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Returns whether the given point is inside the given closed shell, whatever the orientation of its faces.
+     * The winding numbers of the faces are added, each face being split in triangles.
+     */
+    private static boolean isInside(final GeometryCollection<Polygon> shell, final double[] point) {
+        final EarClipping triangulator = new EarClipping();
+        double winding = 0;
+        for (int i = 0, n = shell.getNumGeometries(); i < n; i++) {
+            final EarClipping.Triangulation face = triangulator.triangulateIndexed(shell.getGeometryN(i));
+            final double[] xyz = face.vertices().getAttributeArray(DataPointsType.ATT_POSITION).toArrayDouble();
+            winding += windingNumber(xyz, face.triangles(), point);
+        }
+        return Math.abs(winding) > 0.5;
+    }
+
     /**
      * Polygon contains Point test.
      */
@@ -86,7 +183,7 @@ public final class Contains {
 
         { //check exterior
             final Array coords = polygon.getExteriorRing().getDataPoints().getAttributeArray(DataPointsType.ATT_POSITION);
-            if (!contains(coords, candidate.getPosition())) {
+            if (!contains(coords, candidate.getPosition(), XY)) {
                 //point is outside the exterior ring
                 return false;
             }
@@ -96,7 +193,7 @@ public final class Contains {
             for (int i = 0, n = polygon.getNumInteriorRing(); i < n; i++) {
                 final LineString hole = polygon.getInteriorRingN(i);
                 final Array coords = hole.getDataPoints().getAttributeArray(DataPointsType.ATT_POSITION);
-                if (contains(coords, candidate.getPosition())) {
+                if (contains(coords, candidate.getPosition(), XY)) {
                     //point is within a hole
                     return false;
                 }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Intersection.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Intersection.java
index db21ba0719..44526319ae 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Intersection.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Intersection.java
@@ -18,6 +18,7 @@ package org.apache.sis.geometries.operation;
 
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Comparator;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Optional;
@@ -39,10 +40,12 @@ import org.apache.sis.geometries.surface.PreparedTIN;
 import org.apache.sis.geometries.surface.Triangle;
 import org.apache.sis.maths.Array;
 import org.apache.sis.maths.Cursor;
+import org.apache.sis.maths.Maths;
 import org.apache.sis.maths.Matrices;
 import org.apache.sis.maths.NDArrays;
 import org.apache.sis.maths.ReadOnly;
 import org.apache.sis.maths.Vector;
+import org.apache.sis.maths.Vectors;
 import static org.apache.sis.maths.Maths.clamp;
 
 
@@ -208,6 +211,552 @@ public final class Intersection {
         return intersection;
     }
 
+    /**
+     * Intersection of two segments in a plane. Each point of the intersection is given by its position on both
+     * segments, as a ratio from 0 at the start of the segment to 1 at its end. A ratio is exactly 0 or 1 when the
+     * point is an end of the segment.
+     *
+     * @param type           whether the segments are disjoint, meet at one point, or overlap along their common line.
+     * @param startOnFirst   ratio on the first segment of the single point, or of the start of the overlap, NaN if none.
+     * @param endOnFirst     ratio on the first segment of the end of the overlap, greater than {@code startOnFirst},
+     *                       or {@code startOnFirst} for a single point.
+     * @param startOnSecond  ratio on the second segment of the point at {@code startOnFirst}.
+     * @param endOnSecond    ratio on the second segment of the point at {@code endOnFirst}.
+     *
+     * @see #segmentSegment2D(double[], double[], double[], double[], int[])
+     */
+    public record SegmentSegment(Type type, double startOnFirst, double endOnFirst, double startOnSecond, double endOnSecond) {
+        /**
+         * Kinds of intersection of two segments.
+         */
+        public enum Type {
+            /** The segments do not intersect. */
+            NONE,
+            /** The segments cross or touch at a single point. */
+            POINT,
+            /** The segments are on the same line and share a part of it. */
+            OVERLAP
+        }
+
+        /**
+         * The intersection of disjoint segments.
+         */
+        public static final SegmentSegment NONE = new SegmentSegment(Type.NONE, Double.NaN, Double.NaN, Double.NaN, Double.NaN);
+
+        /**
+         * Returns the intersection made of a single point, at the given ratios on the two segments.
+         */
+        static SegmentSegment point(final double onFirst, final double onSecond) {
+            return new SegmentSegment(Type.POINT, onFirst, onFirst, onSecond, onSecond);
+        }
+    }
+
+    /**
+     * Point where a segment touches a triangle, located on both. Edge <var>i</var> of the triangle goes from
+     * its corner <var>i</var> to its corner (<var>i</var>+1) modulo 3.
+     *
+     * @param type     where the point is on the triangle, or why there is no point.
+     * @param feature  index of the edge or of the corner of the triangle on which the point is, or -1.
+     * @param ratio    position of the point on the segment, from 0 at its start to 1 at its end,
+     *                 exactly 0 or 1 if the point is an end of the segment. NaN if there is no point.
+     *
+     * @see #segmentTriangle(double[], double[], double[], double[], double[], double)
+     * @see #segmentInTriangle2D(double[], double[], double[], double[], double[], int[])
+     */
+    public record SegmentTriangle(Type type, int feature, double ratio) {
+        /**
+         * Where a segment touches a triangle.
+         */
+        public enum Type {
+            /** The segment does not touch the triangle. */
+            NONE,
+            /** The segment lies in the plane of the triangle, and shall be intersected with it in that plane. */
+            COPLANAR,
+            /** The segment touches the triangle inside it. */
+            INTERIOR,
+            /** The segment touches the triangle on the edge given by the feature index. */
+            EDGE,
+            /** The segment touches the triangle on the corner given by the feature index. */
+            VERTEX
+        }
+
+        /**
+         * A segment which does not touch the triangle.
+         */
+        public static final SegmentTriangle NONE = new SegmentTriangle(Type.NONE, -1, Double.NaN);
+
+        /**
+         * A segment in the plane of the triangle.
+         */
+        public static final SegmentTriangle COPLANAR = new SegmentTriangle(Type.COPLANAR, -1, Double.NaN);
+
+        /**
+         * Returns the point inside the triangle, on an edge or on a corner, from the number of edges on which
+         * the point is. This is used after testing the side of the point relative to the three edges.
+         *
+         * @param  zeros      number of edges on which the point is.
+         * @param  zeroEdge   index of an edge on which the point is.
+         * @param  otherEdge  index of an edge on which the point is not.
+         * @param  ratio      position of the point on the segment.
+         * @return the point, or {@code null} if it is on the three edges, which happens with a degenerate triangle.
+         */
+        static SegmentTriangle locate(final int zeros, final int zeroEdge, final int otherEdge, final double ratio) {
+            switch (zeros) {
+                case 0:  return new SegmentTriangle(Type.INTERIOR, -1, ratio);
+                case 1:  return new SegmentTriangle(Type.EDGE, zeroEdge, ratio);
+                case 2:  return new SegmentTriangle(Type.VERTEX, (otherEdge + 2) % 3, ratio);    // corner shared by the two other edges.
+                default: return null;
+            }
+        }
+    }
+
+    /**
+     * End of the intersection of two triangles: a point where an edge of one triangle touches the other triangle.
+     *
+     * @param edgeOfFirst  whether the edge belongs to the first triangle, the point being then on the second triangle.
+     * @param edge         index of the edge, which goes from the corner of same index to the next corner.
+     * @param contact      where the edge touches the other triangle, with the position of the point on the edge.
+     * @param position     coordinates of the point, interpolated on the edge.
+     *
+     * @see #triangleTriangle(double[], double[], double[], double[], double[], double[], double)
+     */
+    public record TriangleEdgeContact(boolean edgeOfFirst, int edge, SegmentTriangle contact, double[] position) {
+    }
+
+    /**
+     * Intersection of two triangles: nothing, a point or a segment, unless the triangles are in the same plane.
+     *
+     * @param type   kind of intersection.
+     * @param start  first end of the intersection, or {@code null} if the triangles are disjoint or coplanar.
+     * @param end    second end of the intersection, the same as {@code start} for a single point.
+     *
+     * @see #triangleTriangle(double[], double[], double[], double[], double[], double[], double)
+     */
+    public record TriangleTriangle(Type type, TriangleEdgeContact start, TriangleEdgeContact end) {
+        /**
+         * Kinds of intersection of two triangles.
+         */
+        public enum Type {
+            /** The triangles do not intersect. */
+            DISJOINT,
+            /** The triangles touch at a single point. */
+            POINT,
+            /** The triangles cross or touch along a segment. */
+            SEGMENT,
+            /** The triangles are in the same plane, and shall be intersected in that plane. */
+            COPLANAR
+        }
+
+        /** Intersection of disjoint triangles. */
+        public static final TriangleTriangle DISJOINT = new TriangleTriangle(Type.DISJOINT, null, null);
+
+        /** Intersection of triangles in the same plane. */
+        public static final TriangleTriangle COPLANAR = new TriangleTriangle(Type.COPLANAR, null, null);
+    }
+
+    /**
+     * Computes the intersection of two segments in the plane of the two given axes. The side tests are robust,
+     * so whether the segments intersect, and whether a point of the intersection is an end of a segment, are exact.
+     * Only the ratios of the points inside the segments are approximations.
+     *
+     * @param  a0    start of the first segment.
+     * @param  a1    end of the first segment.
+     * @param  b0    start of the second segment.
+     * @param  b1    end of the second segment.
+     * @param  axes  indices of the two coordinates of the plane, for example from {@link Maths#projectionAxes(double[])}.
+     * @return the intersection of the two segments.
+     */
+    public static SegmentSegment segmentSegment2D(double[] a0, double[] a1, double[] b0, double[] b1, int[] axes) {
+        final int u = axes[0], v = axes[1];
+        //sides of the ends of each segment relative to the line of the other segment.
+        final double d0 = side(b0, b1, a0, u, v), d1 = side(b0, b1, a1, u, v);
+        final double e0 = side(a0, a1, b0, u, v), e1 = side(a0, a1, b1, u, v);
+        if (sameSide(d0, d1) || sameSide(e0, e1)) {
+            return SegmentSegment.NONE;
+        }
+        if ((d0 == 0 && d1 == 0) || (e0 == 0 && e1 == 0)) {
+            return collinear(a0, a1, b0, b1, u, v);
+        }
+        return SegmentSegment.point(ratio(d0, d1), ratio(e0, e1));
+    }
+
+    /**
+     * Computes the intersection of two segments on the same line. The positions are compared along the axis
+     * where the segments are the longest, so that a line parallel to one of the axes is not degenerate.
+     */
+    private static SegmentSegment collinear(double[] a0, double[] a1, double[] b0, double[] b1, int u, int v) {
+        final int k = (Math.abs(a1[u] - a0[u]) + Math.abs(b1[u] - b0[u]) >= Math.abs(a1[v] - a0[v]) + Math.abs(b1[v] - b0[v])) ? u : v;
+        final double lower = Math.max(Math.min(a0[k], a1[k]), Math.min(b0[k], b1[k]));
+        final double upper = Math.min(Math.max(a0[k], a1[k]), Math.max(b0[k], b1[k]));
+        if (!(lower <= upper)) {
+            return SegmentSegment.NONE;
+        }
+        double startOnFirst = along(a0[k], a1[k], lower), startOnSecond = along(b0[k], b1[k], lower);
+        if (lower == upper) {
+            return SegmentSegment.point(startOnFirst, startOnSecond);
+        }
+        double endOnFirst = along(a0[k], a1[k], upper), endOnSecond = along(b0[k], b1[k], upper);
+        if (startOnFirst > endOnFirst) {
+            //the first segment goes toward decreasing values along the axis.
+            double t = startOnFirst;  startOnFirst  = endOnFirst;  endOnFirst  = t;
+                   t = startOnSecond; startOnSecond = endOnSecond; endOnSecond = t;
+        }
+        return new SegmentSegment(SegmentSegment.Type.OVERLAP, startOnFirst, endOnFirst, startOnSecond, endOnSecond);
+    }
+
+    /**
+     * Returns the position of a value between the values at the start and at the end of a segment along an axis,
+     * exactly 0 or 1 at the ends.
+     */
+    private static double along(final double start, final double end, final double value) {
+        if (value == start) return 0;
+        if (value == end)   return 1;
+        return (value - start) / (end - start);
+    }
+
+    /**
+     * Returns the position of the point where a segment crosses a line or a plane, from the signed distances of
+     * the segment ends to that line or plane, or any values proportional to those distances. The position is
+     * exactly 0 or 1 if an end is on the line or plane.
+     */
+    private static double ratio(final double start, final double end) {
+        if (start == 0) return 0;
+        if (end   == 0) return 1;
+        return clamp(start / (start - end), 0, 1);
+    }
+
+    /**
+     * Returns whether two values are both strictly positive or both strictly negative.
+     */
+    private static boolean sameSide(final double s1, final double s2) {
+        return (s1 > 0 && s2 > 0) || (s1 < 0 && s2 < 0);
+    }
+
+    /**
+     * Returns whether one value is strictly positive and the other one strictly negative.
+     */
+    private static boolean opposite(final double s1, final double s2) {
+        return (s1 > 0 && s2 < 0) || (s1 < 0 && s2 > 0);
+    }
+
+    /**
+     * Returns the robust side of point r relative to the line from p to q, in the plane of the axes u and v.
+     * Positive on the left, negative on the right, zero on the line.
+     */
+    private static double side(final double[] p, final double[] q, final double[] r, final int u, final int v) {
+        return Maths.lineSide(p[u], p[v], q[u], q[v], r[u], r[v]);
+    }
+
+    /**
+     * Returns the normal (b−a)×(c−a) of a triangle, whose length is twice the area of the triangle.
+     */
+    private static double[] normal(final double[] a, final double[] b, final double[] c) {
+        final double ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2];
+        final double vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2];
+        return new double[] {uy*vz - uz*vy, uz*vx - ux*vz, ux*vy - uy*vx};
+    }
+
+    /**
+     * Computes where a segment touches a triangle, in three dimensions. An end of the segment within the tolerance
+     * of the plane of the triangle is on that plane, as decided by {@link Maths#planeSide Maths.planeSide(…)}.
+     * If both ends are on the plane, the segment is {@linkplain SegmentTriangle.Type#COPLANAR coplanar} and its
+     * intersection with the triangle is given by {@link #segmentInTriangle2D segmentInTriangle2D(…)}.
+     *
+     * <p>When the segment crosses the plane, where it crosses the triangle is decided by the robust sides of
+     * its line relative to the three edges, so that a segment crossing an edge or a corner is found exactly on it.
+     * Only the ratio of a point inside the segment is an approximation. When an end of the segment is on the plane,
+     * that end is located in the triangle with robust side tests in the plane.</p>
+     *
+     * @param  s0  start of the segment.
+     * @param  s1  end of the segment.
+     * @param  a   first corner of the triangle.
+     * @param  b   second corner of the triangle.
+     * @param  c   third corner of the triangle.
+     * @param  tolerance  largest distance to the plane of the triangle for considering an end of the segment on it.
+     * @return where the segment touches the triangle.
+     */
+    public static SegmentTriangle segmentTriangle(double[] s0, double[] s1, double[] a, double[] b, double[] c, double tolerance) {
+        final int side0 = Maths.planeSide(a, b, c, s0, tolerance);
+        final int side1 = Maths.planeSide(a, b, c, s1, tolerance);
+        if (side0 == 0 && side1 == 0) {
+            return SegmentTriangle.COPLANAR;
+        }
+        if (side0 == side1) {
+            return SegmentTriangle.NONE;            // both ends strictly on the same side.
+        }
+        final double[][] corners = {a, b, c};
+        final double[] normal = normal(a, b, c);
+        if (side0 == 0 || side1 == 0) {
+            final int[] axes = Maths.projectionAxes(normal);
+            return (side0 == 0) ? locate2D(s0, corners, axes[0], axes[1], 0)
+                                : locate2D(s1, corners, axes[0], axes[1], 1);
+        }
+        /*
+         * The segment crosses the plane between its ends. The sign of the tetrahedron made by the segment and
+         * an edge tells on which side of that edge the line of the segment passes. The line passes inside the
+         * triangle if the three signs are the same, and on an edge or a corner if one or two of them are zero.
+         */
+        int positive = 0, negative = 0, zeros = 0, zeroEdge = -1, otherEdge = -1;
+        for (int i = 0; i < 3; i++) {
+            final double[] p = corners[i], q = corners[(i + 1) % 3];
+            final double sign = ShewchukPredicates.orient3d(s0[0], s0[1], s0[2], s1[0], s1[1], s1[2],
+                                                            p[0], p[1], p[2], q[0], q[1], q[2]);
+            if      (sign > 0) {positive++; otherEdge = i;}
+            else if (sign < 0) {negative++; otherEdge = i;}
+            else               {zeros++;    zeroEdge  = i;}
+        }
+        if (positive != 0 && negative != 0) {
+            return SegmentTriangle.NONE;
+        }
+        final double d0 = normal[0] * (s0[0] - a[0]) + normal[1] * (s0[1] - a[1]) + normal[2] * (s0[2] - a[2]);
+        final double d1 = normal[0] * (s1[0] - a[0]) + normal[1] * (s1[1] - a[1]) + normal[2] * (s1[2] - a[2]);
+        final SegmentTriangle point = SegmentTriangle.locate(zeros, zeroEdge, otherEdge, ratio(d0, d1));
+        return (point != null) ? point : SegmentTriangle.COPLANAR;
+    }
+
+    /**
+     * Locates a point of the plane of a triangle relative to that triangle, with robust side tests in the plane
+     * of the two given axes.
+     *
+     * @param  p        the point to locate.
+     * @param  corners  the three corners of the triangle.
+     * @param  ratio    position of the point on the segment for which the point is located.
+     * @return where the point is on the triangle, or {@link SegmentTriangle#NONE} if outside or if the triangle is degenerate.
+     */
+    private static SegmentTriangle locate2D(final double[] p, final double[][] corners, final int u, final int v, final double ratio) {
+        final double orientation = Math.signum(side(corners[0], corners[1], corners[2], u, v));
+        if (orientation == 0) {
+            return SegmentTriangle.NONE;
+        }
+        int zeros = 0, zeroEdge = -1, otherEdge = -1;
+        for (int i = 0; i < 3; i++) {
+            final double s = orientation * side(corners[i], corners[(i + 1) % 3], p, u, v);
+            if (s < 0) {
+                return SegmentTriangle.NONE;
+            }
+            if (s == 0) {zeros++; zeroEdge = i;}
+            else        {otherEdge = i;}
+        }
+        final SegmentTriangle point = SegmentTriangle.locate(zeros, zeroEdge, otherEdge, ratio);
+        return (point != null) ? point : SegmentTriangle.NONE;
+    }
+
+    /**
+     * Computes the part of a segment inside a triangle, both in the same plane, with robust side tests in the plane
+     * of the two given axes. The result gives the two ends of that part, each with its position on the segment and
+     * its location on the triangle: an end is an end of the segment inside the triangle, or a point where the
+     * segment crosses an edge or passes through a corner. Whether a point is on an edge or a corner, or is an end
+     * of the segment, is exact. Only the ratios of the points inside the segment are approximations.
+     *
+     * @param  s0    start of the segment.
+     * @param  s1    end of the segment.
+     * @param  a     first corner of the triangle.
+     * @param  b     second corner of the triangle.
+     * @param  c     third corner of the triangle.
+     * @param  axes  indices of the two coordinates of the plane, for example from {@link Maths#projectionAxes(double[])}.
+     * @return the ends of the part of the segment inside the triangle by increasing ratio: none if the segment
+     *         does not touch the triangle, one if it touches it at a single point, two otherwise.
+     *         Their types are {@code INTERIOR}, {@code EDGE} or {@code VERTEX}.
+     */
+    public static List<SegmentTriangle> segmentInTriangle2D(double[] s0, double[] s1, double[] a, double[] b, double[] c, int[] axes) {
+        final int u = axes[0], v = axes[1];
+        final double[][] corners = {a, b, c};
+        if (side(a, b, c, u, v) == 0) {
+            return List.of();                       // a degenerate triangle has no interior.
+        }
+        final List<SegmentTriangle> points = new ArrayList<>(4);
+        final SegmentTriangle start = locate2D(s0, corners, u, v, 0);
+        if (start != SegmentTriangle.NONE) {
+            points.add(start);
+        }
+        final boolean isPoint = (s0[u] == s1[u] && s0[v] == s1[v]);
+        if (!isPoint) {
+            final SegmentTriangle end = locate2D(s1, corners, u, v, 1);
+            if (end != SegmentTriangle.NONE) {
+                points.add(end);
+            }
+            /*
+             * The corners on the line of the segment, between its ends.
+             * The ends of the segment have been located above.
+             */
+            final double dx = s1[u] - s0[u], dy = s1[v] - s0[v];
+            final double[] sides = new double[3];
+            for (int j = 0; j < 3; j++) {
+                final double[] p = corners[j];
+                sides[j] = side(s0, s1, p, u, v);
+                if (sides[j] == 0 && !(p[u] == s0[u] && p[v] == s0[v]) && !(p[u] == s1[u] && p[v] == s1[v])) {
+                    final double t = ((p[u] - s0[u]) * dx + (p[v] - s0[v]) * dy) / (dx*dx + dy*dy);
+                    if (t > 0 && t < 1) {
+                        points.add(new SegmentTriangle(SegmentTriangle.Type.VERTEX, j, t));
+                    }
+                }
+            }
+            /*
+             * The edges crossed between their corners, by the segment between its ends.
+             */
+            for (int i = 0; i < 3; i++) {
+                final int j = (i + 1) % 3;
+                if (opposite(sides[i], sides[j])) {
+                    final double d0 = side(corners[i], corners[j], s0, u, v);
+                    final double d1 = side(corners[i], corners[j], s1, u, v);
+                    if (opposite(d0, d1)) {
+                        points.add(new SegmentTriangle(SegmentTriangle.Type.EDGE, i, ratio(d0, d1)));
+                    }
+                }
+            }
+        }
+        switch (points.size()) {
+            case 0:  return List.of();
+            case 1:  return List.of(points.get(0));
+            default: {
+                //the triangle is convex, so the part inside it goes from the first point to the last one.
+                points.sort(Comparator.comparingDouble(SegmentTriangle::ratio));
+                return List.of(points.get(0), points.get(points.size() - 1));
+            }
+        }
+    }
+
+    /**
+     * Computes the intersection of two triangles in three dimensions. A corner of a triangle within the tolerance
+     * of the plane of the other triangle is on that plane, as decided by {@link Maths#planeSide Maths.planeSide(…)}.
+     * If the three corners of a triangle are on the plane of the other one, the triangles are
+     * {@linkplain TriangleTriangle.Type#COPLANAR coplanar} and shall be intersected in that plane,
+     * for example with {@link #segmentInTriangle2D segmentInTriangle2D(…)}.
+     *
+     * <p>Otherwise the intersection is on the line where the two planes meet, and each of its ends is a point
+     * where an edge of a triangle touches the other triangle, as given by {@link #segmentTriangle segmentTriangle(…)}.
+     * An edge in the plane of the other triangle is intersected with it in that plane.</p>
+     *
+     * @param  a0  first corner of the first triangle.
+     * @param  a1  second corner of the first triangle.
+     * @param  a2  third corner of the first triangle.
+     * @param  b0  first corner of the second triangle.
+     * @param  b1  second corner of the second triangle.
+     * @param  b2  third corner of the second triangle.
+     * @param  tolerance  largest distance to the plane of a triangle for considering a point on it.
+     * @return the intersection of the two triangles.
+     */
+    public static TriangleTriangle triangleTriangle(double[] a0, double[] a1, double[] a2,
+                                                    double[] b0, double[] b1, double[] b2, double tolerance)
+    {
+        final double[][] first = {a0, a1, a2}, second = {b0, b1, b2};
+        final int[] sidesOfSecond = sides(first, second, tolerance);
+        final int[] sidesOfFirst  = sides(second, first, tolerance);
+        if (allOnSameSide(sidesOfSecond) || allOnSameSide(sidesOfFirst)) {
+            return TriangleTriangle.DISJOINT;
+        }
+        if (Arrays.equals(sidesOfSecond, new int[3]) || Arrays.equals(sidesOfFirst, new int[3])) {
+            return TriangleTriangle.COPLANAR;
+        }
+        final List<TriangleEdgeContact> contacts = new ArrayList<>();
+        addEdgeContacts(first, second, true, tolerance, contacts);
+        addEdgeContacts(second, first, false, tolerance, contacts);
+        if (contacts.isEmpty()) {
+            return TriangleTriangle.DISJOINT;
+        }
+        /*
+         * All points are on the line where the two planes meet.
+         * The ends of the intersection are the two extreme points along that line.
+         */
+        final double[] direction = Vectors.cross(normal(a0, a1, a2), normal(b0, b1, b2));
+        TriangleEdgeContact start = null, end = null;
+        double min = Double.POSITIVE_INFINITY, max = Double.NEGATIVE_INFINITY;
+        for (final TriangleEdgeContact contact : contacts) {
+            final double t = Vectors.dot(contact.position(), direction);
+            if (t < min) {min = t; start = contact;}
+            if (t > max) {max = t; end   = contact;}
+        }
+        if (start == end || locations(start).equals(locations(end)) || Arrays.equals(start.position(), end.position())) {
+            return new TriangleTriangle(TriangleTriangle.Type.POINT, start, start);
+        }
+        return new TriangleTriangle(TriangleTriangle.Type.SEGMENT, start, end);
+    }
+
+    /**
+     * Returns the sides of the corners of a triangle relative to the plane of another triangle.
+     */
+    private static int[] sides(final double[][] plane, final double[][] triangle, final double tolerance) {
+        final int[] sides = new int[3];
+        for (int i = 0; i < 3; i++) {
+            sides[i] = Maths.planeSide(plane[0], plane[1], plane[2], triangle[i], tolerance);
+        }
+        return sides;
+    }
+
+    /**
+     * Returns whether the three sides are all strictly positive or all strictly negative.
+     */
+    private static boolean allOnSameSide(final int[] sides) {
+        return sameSide(sides[0], sides[1]) && sameSide(sides[1], sides[2]);
+    }
+
+    /**
+     * Adds the points where the edges of a triangle touch another triangle.
+     *
+     * @param edges        the triangle whose edges are tested.
+     * @param other        the triangle touched by the edges.
+     * @param edgeOfFirst  whether the triangle of the edges is the first one given to {@code triangleTriangle(…)}.
+     * @param tolerance    largest distance to the plane of a triangle for considering a point on it.
+     * @param contacts     where to add the points.
+     */
+    private static void addEdgeContacts(final double[][] edges, final double[][] other, final boolean edgeOfFirst,
+                                        final double tolerance, final List<TriangleEdgeContact> contacts)
+    {
+        for (int i = 0; i < 3; i++) {
+            final double[] p = edges[i], q = edges[(i + 1) % 3];
+            final SegmentTriangle contact = segmentTriangle(p, q, other[0], other[1], other[2], tolerance);
+            switch (contact.type()) {
+                case NONE: break;
+                case COPLANAR: {
+                    final int[] axes = Maths.projectionAxes(normal(other[0], other[1], other[2]));
+                    for (final SegmentTriangle point : segmentInTriangle2D(p, q, other[0], other[1], other[2], axes)) {
+                        contacts.add(new TriangleEdgeContact(edgeOfFirst, i, point, interpolate(p, q, point.ratio())));
+                    }
+                    break;
+                }
+                default: {
+                    contacts.add(new TriangleEdgeContact(edgeOfFirst, i, contact, interpolate(p, q, contact.ratio())));
+                    break;
+                }
+            }
+        }
+    }
+
+    /**
+     * Returns the three first coordinates of the point at the given ratio between two points,
+     * exactly one of them at ratio 0 or 1.
+     */
+    private static double[] interpolate(final double[] p, final double[] q, final double ratio) {
+        if (ratio == 0) return Arrays.copyOf(p, 3);
+        if (ratio == 1) return Arrays.copyOf(q, 3);
+        final double[] point = new double[3];
+        for (int k = 0; k < 3; k++) {
+            point[k] = p[k] + ratio * (q[k] - p[k]);
+        }
+        return point;
+    }
+
+    /**
+     * Where a point is on a triangle: inside, or on the edge or the corner of the given index.
+     */
+    private record Location(SegmentTriangle.Type type, int index) {
+    }
+
+    /**
+     * Returns where the point of the given contact is on the first triangle and on the second triangle.
+     * Two contacts found from different edges are the same point if they are at the same locations.
+     */
+    private static List<Location> locations(final TriangleEdgeContact contact) {
+        final double ratio = contact.contact().ratio();
+        final int edge = contact.edge();
+        final Location onEdge;
+        if      (ratio == 0) onEdge = new Location(SegmentTriangle.Type.VERTEX, edge);
+        else if (ratio == 1) onEdge = new Location(SegmentTriangle.Type.VERTEX, (edge + 1) % 3);
+        else                 onEdge = new Location(SegmentTriangle.Type.EDGE, edge);
+        final Location onOther = new Location(contact.contact().type(), contact.contact().feature());
+        return contact.edgeOfFirst() ? List.of(onEdge, onOther) : List.of(onOther, onEdge);
+    }
+
     /**
      * Intersection operations on BSpline curves and surfaces.
      */
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/EarClipping.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/EarClipping.java
index 00ed3a038d..d5ace07d12 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/EarClipping.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/EarClipping.java
@@ -30,7 +30,7 @@ import org.apache.sis.geometries.surface.Polygon;
 import org.apache.sis.maths.Array;
 import org.apache.sis.maths.Maths;
 import org.apache.sis.maths.NDArrays;
-import org.apache.sis.maths.Vector;
+import org.apache.sis.maths.ReadOnly;
 import org.apache.sis.util.ArraysExt;
 
 
@@ -82,15 +82,16 @@ public class EarClipping {
     private DataPoints source;
 
     /**
-     * Positions of the polygon, with their coordinates in the order used by the triangulation.
-     * The triangulation works on the two first coordinates, in the plane where the polygon has its largest area.
+     * Positions of the vertices, with their coordinates in the order used by the triangulation.
+     * The triangulation works on the two first coordinates. For a polygon, those positions are
+     * the ones of {@link #source} in the plane where the polygon has its largest area.
      */
-    private Vector<?>[] working;
+    private ReadOnly.Vector<?>[] working;
 
     /**
-     * Indices in {@link #source} of the vertices of the ring joining the holes to the exterior ring, without
-     * repeated consecutive positions and without the closing position. The ring has the orientation of the
-     * exterior ring.
+     * Indices in {@link #working} of the vertices of the ring to triangulate, without repeated consecutive
+     * positions and without the closing position. For a polygon, this is the ring joining the holes to the
+     * exterior ring, which has the orientation of the exterior ring.
      */
     private int[] ring;
 
@@ -112,7 +113,7 @@ public class EarClipping {
     private Angle[] angles;
 
     /**
-     * Indices in {@link #source} of the three vertices of each triangle found so far.
+     * Indices in {@link #working} of the three vertices of each triangle found so far.
      */
     private final List<int[]> triangles = new ArrayList<>();
 
@@ -127,13 +128,39 @@ public class EarClipping {
     public Triangulation triangulateIndexed(Polygon geometry) throws OperationException {
         source = geometry.getDataPoints();
         final int[] axes = workingAxes(geometry.getExteriorRing().getDataPoints().getAttributeArray(DataPointsType.ATT_POSITION));
-        working = new Vector<?>[source.size()];
+        working = new ReadOnly.Vector<?>[source.size()];
         Arrays.setAll(working, (i) -> source.getPosition(i).swizzle(axes));
         triangles.clear();
         run(HoleToExterior.ring(geometry));
         return result();
     }
 
+    /**
+     * Triangulates a simple ring given by the indices of its vertices in an array of positions.
+     * The triangulation works on the two first coordinates of the positions: a ring in three dimensions
+     * shall be projected first, for example with {@link ReadOnly.Vector#swizzle(int...)} and the axes given by
+     * {@link Maths#projectionAxes(double[])}. Consecutive vertices at the same position are a single vertex.
+     *
+     * @param positions  positions of the vertices, of which the ring may use only some.
+     * @param indices    indices in {@code positions} of the vertices of the ring, in order. The ring can be closed,
+     *                   with its last vertex at the location of the first one, or not.
+     * @return indices in {@code positions} of the three corners of each triangle, one triangle after the other.
+     *         The triangles have the orientation of the ring.
+     * @throws OperationException if the ring cannot be triangulated, for example because it intersects itself.
+     */
+    public int[] triangulate(final ReadOnly.Vector<?>[] positions, int[] indices) throws OperationException {
+        working = positions;
+        triangles.clear();
+        final int n = indices.length;
+        if (n != 0 && !positions[indices[0]].equals(positions[indices[n - 1]])) {
+            //the triangulation expects a closed ring.
+            indices = Arrays.copyOf(indices, n + 1);
+            indices[n] = indices[0];
+        }
+        run(indices);
+        return triangles.stream().flatMapToInt(Arrays::stream).toArray();
+    }
+
     /**
      * Triangulates the given polygon into an indexed mesh holding all the attributes of the polygon.
      *
@@ -152,7 +179,7 @@ public class EarClipping {
 
     /**
      * Returns the order of the coordinates to use for triangulating a polygon with the given positions,
-     * to be given to {@link Vector#swizzle(int...)}. The triangulation works on the two first coordinates
+     * to be given to {@link ReadOnly.Vector#swizzle(int...)}. The triangulation works on the two first coordinates
      * of that order. With three or more dimensions, the three first axes are in the {@linkplain Maths#projectionAxes
      * projection order} of the {@linkplain Maths#calculatePolygonNormal(Array) normal of the polygon}: the axis along
      * which the normal is the largest goes after the two axes of the projection plane, so that a vertical polygon is
@@ -189,7 +216,7 @@ public class EarClipping {
      * triangle with its two neighbors contains no other vertex. Cutting it removes the vertex from the
      * ring, which stays a valid polygon, until only one triangle is left.
      *
-     * @param joined  indices in {@link #source} of the ring positions, the last one at the location of the first one.
+     * @param joined  indices in {@link #working} of the ring positions, the last one at the location of the first one.
      * @throws OperationException if the vertices left without ear still enclose an area.
      */
     private void run(final int[] joined) throws OperationException {
@@ -239,19 +266,19 @@ public class EarClipping {
      * @param start  a vertex which is not cut yet.
      */
     private boolean isDegenerate(final int start) {
-        final Vector<?> origin = position(start);
+        final ReadOnly.Vector<?> origin = position(start);
         final double ox = origin.get(0), oy = origin.get(1);
         double area = 0;            // twice the signed area.
         int vertex = start;
         do {
-            final Vector<?> a = position(vertex), b = position(next[vertex]);
+            final ReadOnly.Vector<?> a = position(vertex), b = position(next[vertex]);
             area += (a.get(0) - ox) * (b.get(1) - oy) - (b.get(0) - ox) * (a.get(1) - oy);
             vertex = next[vertex];
         } while (vertex != start);
         double xmin = Double.POSITIVE_INFINITY, xmax = Double.NEGATIVE_INFINITY;
         double ymin = Double.POSITIVE_INFINITY, ymax = Double.NEGATIVE_INFINITY;
         for (final int index : ring) {
-            final Vector<?> p = working[index];
+            final ReadOnly.Vector<?> p = working[index];
             xmin = Math.min(xmin, p.get(0)); xmax = Math.max(xmax, p.get(0));
             ymin = Math.min(ymin, p.get(1)); ymax = Math.max(ymax, p.get(1));
         }
@@ -262,7 +289,7 @@ public class EarClipping {
     /**
      * Returns the working position of the given vertex of the ring.
      */
-    private Vector<?> position(final int vertex) {
+    private ReadOnly.Vector<?> position(final int vertex) {
         return working[ring[vertex]];
     }
 
@@ -283,11 +310,11 @@ public class EarClipping {
      * so a copy located on a corner of the triangle does not prevent the ear.
      */
     private boolean isEar(final int vertex) {
-        final Vector<?> t1 = position(previous[vertex]);
-        final Vector<?> t2 = position(vertex);
-        final Vector<?> t3 = position(next[vertex]);
+        final ReadOnly.Vector<?> t1 = position(previous[vertex]);
+        final ReadOnly.Vector<?> t2 = position(vertex);
+        final ReadOnly.Vector<?> t3 = position(next[vertex]);
         for (int i = next[next[vertex]]; i != previous[vertex]; i = next[i]) {
-            final Vector<?> p = position(i);
+            final ReadOnly.Vector<?> p = position(i);
             if (!isConvex(i) && !p.equals(t1) && !p.equals(t2) && !p.equals(t3) && Maths.inTriangle(t1, t2, t3, p)) {
                 return false;
             }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java
index 185505bc55..799a9bed0e 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java
@@ -167,6 +167,34 @@ public final class Maths {
         return (normal[drop] < 0) ? new int[] {v, u, drop} : new int[] {u, v, drop};
     }
 
+    /**
+     * Returns the side of a point relative to the plane of a triangle, with a tolerance. The point is on the plane
+     * if its distance to the plane is not greater than the tolerance. Otherwise its side is given by the robust
+     * {@link ShewchukPredicates#orient3d orient3d} predicate, so that the side is right even very close to the plane
+     * when the tolerance is zero. Only the three first coordinates are used.
+     *
+     * @param a first corner of the triangle
+     * @param b second corner of the triangle
+     * @param c third corner of the triangle
+     * @param p point to test
+     * @param tolerance largest distance to the plane for considering the point on it, zero for an exact test
+     * @return +1 if the point is on the side toward which the normal (b−a)×(c−a) points, −1 if it is on the
+     *         other side, 0 if it is on the plane. A degenerate triangle has no plane: every point is on it.
+     */
+    public static int planeSide(double[] a, double[] b, double[] c, double[] p, double tolerance) {
+        final double ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2];
+        final double vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2];
+        final double nx = uy*vz - uz*vy, ny = uz*vx - ux*vz, nz = ux*vy - uy*vx;
+        //the distance to the plane multiplied by the length of the normal.
+        final double distance = nx * (p[0] - a[0]) + ny * (p[1] - a[1]) + nz * (p[2] - a[2]);
+        if (Math.abs(distance) <= tolerance * Math.sqrt(nx*nx + ny*ny + nz*nz)) {
+            return 0;
+        }
+        //orient3d is positive below the plane, on the side opposite to the normal.
+        return (int) -Math.signum(ShewchukPredicates.orient3d(a[0], a[1], a[2], b[0], b[1], b[2],
+                                                              c[0], c[1], c[2], p[0], p[1], p[2]));
+    }
+
     public static double[] subtract(float[] A, float[] B){
         return new double[] {
             A[0] - (double) B[0],
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/Contains3DTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/Contains3DTest.java
new file mode 100644
index 0000000000..9e703703ab
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/Contains3DTest.java
@@ -0,0 +1,182 @@
+/*
+ * 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.operation;
+
+import java.util.Arrays;
+import java.util.List;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.geometries.Geometries;
+import org.apache.sis.geometries.GeometryCollection;
+import org.apache.sis.geometries.GeometryFactory;
+import org.apache.sis.geometries.curve.LinearRing;
+import org.apache.sis.geometries.solid.Polyhedron;
+import org.apache.sis.geometries.solid.polyhedron.Icosahedron;
+import org.apache.sis.geometries.surface.Polygon;
+import org.apache.sis.maths.Array;
+import org.apache.sis.maths.Maths;
+import org.apache.sis.maths.NDArrays;
+import org.apache.sis.maths.Vectors;
+import org.apache.sis.referencing.CommonCRS;
+
+// Test dependencies
+import static org.junit.jupiter.api.Assertions.*;
+import org.junit.jupiter.api.Test;
+
+
+/**
+ * Tests the three-dimensional and planar tests of {@link Contains}: winding number of a surface,
+ * polyhedron containing a point, and ring containing a point in any plane.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public class Contains3DTest {
+
+    private static final double EPS = 1E-9;
+
+    private static final CoordinateReferenceSystem CRS3D = Geometries.getUndefinedCRS(3);
+
+    /**
+     * Indices of the corners of the twelve triangles of a cube, oriented outward. The corner of index
+     * <var>x</var> + 2<var>y</var> + 4<var>z</var> is at the minimum (0) or maximum (1) of each axis.
+     */
+    private static final int[] CUBE = {
+        0,2,3, 0,3,1,       // z = 0
+        4,5,7, 4,7,6,       // z = 1
+        0,1,5, 0,5,4,       // y = 0
+        2,6,7, 2,7,3,       // y = 1
+        0,4,6, 0,6,2,       // x = 0
+        1,3,7, 1,7,5        // x = 1
+    };
+
+    /**
+     * Returns the coordinates of the eight corners of the cube between the given minimum and maximum on all axes.
+     */
+    private static double[] corners(final double min, final double max) {
+        final double[] xyz = new double[8 * 3];
+        for (int i = 0; i < 8; i++) {
+            xyz[i*3  ] = ((i & 1) == 0) ? min : max;
+            xyz[i*3+1] = ((i & 2) == 0) ? min : max;
+            xyz[i*3+2] = ((i & 4) == 0) ? min : max;
+        }
+        return xyz;
+    }
+
+    /**
+     * Returns the triangles of the cube, reversed so that they are oriented inward, with indices shifted by the given offset.
+     */
+    private static int[] inward(final int offset) {
+        final int[] triangles = new int[CUBE.length];
+        for (int i = 0; i < CUBE.length; i += 3) {
+            triangles[i  ] = CUBE[i  ] + offset;
+            triangles[i+1] = CUBE[i+2] + offset;
+            triangles[i+2] = CUBE[i+1] + offset;
+        }
+        return triangles;
+    }
+
+    /**
+     * The winding number is 1 inside a closed surface oriented outward, −1 if oriented inward, 0 outside,
+     * and ½ on a face.
+     */
+    @Test
+    public void testWindingNumber() {
+        final double[] xyz = corners(0, 1);
+        assertEquals( 1,   Contains.windingNumber(xyz, CUBE,      new double[] {0.5, 0.5, 0.5}), EPS);
+        assertEquals( 1,   Contains.windingNumber(xyz, CUBE,      new double[] {0.9, 0.1, 0.3}), EPS);
+        assertEquals( 0,   Contains.windingNumber(xyz, CUBE,      new double[] {2.0, 0.5, 0.5}), EPS);
+        assertEquals( 0,   Contains.windingNumber(xyz, CUBE,      new double[] {-3,  7,   0.2}), EPS);
+        assertEquals(-1,   Contains.windingNumber(xyz, inward(0), new double[] {0.5, 0.5, 0.5}), EPS);
+        assertEquals( 0.5, Contains.windingNumber(xyz, CUBE,      new double[] {0.3, 0.6, 0.0}), EPS, "On a face.");
+    }
+
+    /**
+     * The winding numbers of an outer surface and of the inward surface of its void add,
+     * giving 0 inside the void.
+     */
+    @Test
+    public void testWindingNumberWithVoid() {
+        final double[] outer = corners(0, 4), inner = corners(1, 3);
+        final double[] xyz = Arrays.copyOf(outer, outer.length + inner.length);
+        System.arraycopy(inner, 0, xyz, outer.length, inner.length);
+        final int[] triangles = Arrays.copyOf(CUBE, CUBE.length * 2);
+        System.arraycopy(inward(8), 0, triangles, CUBE.length, CUBE.length);
+        assertEquals(1, Contains.windingNumber(xyz, triangles, new double[] {0.5, 0.5, 0.5}), EPS, "In the solid.");
+        assertEquals(0, Contains.windingNumber(xyz, triangles, new double[] {2,   2,   2  }), EPS, "In the void.");
+        assertEquals(0, Contains.windingNumber(xyz, triangles, new double[] {5,   2,   2  }), EPS, "Outside.");
+    }
+
+    /**
+     * A polyhedron contains the points inside its exterior shell and outside its interior shells.
+     * The interior shell is oriented as the exterior one, which shall not matter.
+     */
+    @Test
+    public void testPolyhedron() {
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        final Polyhedron cube = f.createPolyhedron(cube(0, 4), List.of());
+        assertTrue (Contains.contains(cube, f.createPoint(CRS3D, 2, 2, 2)));
+        assertTrue (Contains.contains(cube, f.createPoint(CRS3D, 0.1, 3.9, 0.2)));
+        assertFalse(Contains.contains(cube, f.createPoint(CRS3D, 5, 2, 2)));
+
+        final Polyhedron hollow = f.createPolyhedron(cube(0, 4), List.of(cube(1, 3)));
+        assertTrue (Contains.contains(hollow, f.createPoint(CRS3D, 0.5, 2, 2)), "In the solid.");
+        assertFalse(Contains.contains(hollow, f.createPoint(CRS3D, 2, 2, 2)),   "In the void.");
+        assertFalse(Contains.contains(hollow, f.createPoint(CRS3D, 5, 2, 2)),   "Outside.");
+
+        final Polyhedron icosahedron = new Icosahedron();
+        assertTrue (Contains.contains(icosahedron, f.createPoint(icosahedron.getCoordinateReferenceSystem(), 0.1, -0.2, 0.3)));
+        assertFalse(Contains.contains(icosahedron, f.createPoint(icosahedron.getCoordinateReferenceSystem(), 0.9, 0.9, 0)));
+
+        assertThrows(OperationException.class, () -> Contains.contains(cube, f.createPoint(CommonCRS.WGS84.geographic3D(), 2, 2, 2)));
+    }
+
+    /**
+     * A ring in a vertical plane contains a point when tested in the plane of its normal.
+     */
+    @Test
+    public void testRingInPlane() {
+        final Array ring = NDArrays.of(3, new double[] {0,2,0, 4,2,0, 4,2,4, 0,2,4, 0,2,0});
+        final int[] axes = Maths.projectionAxes(Maths.calculatePolygonNormal(ring));
+        assertTrue (Contains.contains(ring, Vectors.createDouble(3).set(new double[] {1, 2, 3}), axes));
+        assertFalse(Contains.contains(ring, Vectors.createDouble(3).set(new double[] {5, 2, 3}), axes));
+        assertFalse(Contains.contains(ring, Vectors.createDouble(3).set(new double[] {1, 2, 5}), axes));
+    }
+
+    /**
+     * Returns the six faces of the cube between the given minimum and maximum on all axes, oriented outward.
+     */
+    private static GeometryCollection<Polygon> cube(final double min, final double max) {
+        final double[] xyz = corners(min, max);
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        final Polygon[] faces = new Polygon[6];
+        for (int i = 0; i < 6; i++) {
+            //the two triangles of a face are (a,b,c) and (a,c,d), so the ring of the face is a, b, c, d.
+            final int a = CUBE[i*6], b = CUBE[i*6+1], c = CUBE[i*6+2], d = CUBE[i*6+5];
+            final double[] coordinates = new double[5 * 3];
+            int k = 0;
+            for (final int corner : new int[] {a, b, c, d, a}) {
+                System.arraycopy(xyz, corner * 3, coordinates, k, 3);
+                k += 3;
+            }
+            faces[i] = f.createPolygon(ring(coordinates), List.of());
+        }
+        return f.createMultiPolygon(CRS3D, faces);
+    }
+
+    private static LinearRing ring(final double... coordinates) {
+        return GeometryFactory.DEFAULT.createLinearRing(GeometryFactory.DEFAULT.createDataPoints(NDArrays.of(CRS3D, coordinates)));
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/Intersection3DTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/Intersection3DTest.java
new file mode 100644
index 0000000000..3c241dfac0
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/Intersection3DTest.java
@@ -0,0 +1,264 @@
+/*
+ * 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.operation;
+
+import java.util.List;
+import org.apache.sis.geometries.operation.Intersection.SegmentSegment;
+import org.apache.sis.geometries.operation.Intersection.SegmentTriangle;
+import org.apache.sis.geometries.operation.Intersection.TriangleTriangle;
+import org.apache.sis.maths.Maths;
+import org.apache.sis.maths.Vectors;
+
+// Test dependencies
+import static org.junit.jupiter.api.Assertions.*;
+import org.junit.jupiter.api.Test;
+
+
+/**
+ * Tests the intersections of segments and triangles of {@link Intersection}.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public class Intersection3DTest {
+
+    private static final double EPS = 1E-12;
+
+    /**
+     * The axes of the (x,y) plane.
+     */
+    private static final int[] XY = {0, 1};
+
+    /**
+     * The triangle used by most tests, in the (x,y) plane, counterclockwise seen from above.
+     */
+    private static final double[] A = {0, 0, 0}, B = {4, 0, 0}, C = {0, 4, 0};
+
+    private static double[] p(final double... coordinates) {
+        return coordinates;
+    }
+
+    /**
+     * Segments crossing, touching at an end, parallel, or apart.
+     */
+    @Test
+    public void testSegmentSegment() {
+        SegmentSegment r = Intersection.segmentSegment2D(p(0,0), p(2,2), p(0,2), p(2,0), XY);
+        assertEquals(SegmentSegment.Type.POINT, r.type());
+        assertEquals(0.5, r.startOnFirst(),  EPS);
+        assertEquals(0.5, r.startOnSecond(), EPS);
+
+        r = Intersection.segmentSegment2D(p(0,0), p(1,0), p(1,0), p(1,1), XY);
+        assertEquals(SegmentSegment.Type.POINT, r.type(), "Touching at their ends.");
+        assertEquals(1, r.startOnFirst(),  "The end of a segment shall be exact.");
+        assertEquals(0, r.startOnSecond(), "The end of a segment shall be exact.");
+
+        r = Intersection.segmentSegment2D(p(0,0), p(2,0), p(1,0), p(1,1), XY);
+        assertEquals(SegmentSegment.Type.POINT, r.type(), "An end on the other segment.");
+        assertEquals(0.5, r.startOnFirst(), EPS);
+        assertEquals(0,   r.startOnSecond());
+
+        assertSame(SegmentSegment.NONE, Intersection.segmentSegment2D(p(0,0), p(1,0), p(0,1), p(1,1), XY), "Parallel.");
+        assertSame(SegmentSegment.NONE, Intersection.segmentSegment2D(p(0,0), p(1,0), p(2,-1), p(2,1), XY), "Lines crossing apart.");
+
+        //segments in the vertical plane y = 5, in its (x,z) axes.
+        r = Intersection.segmentSegment2D(p(0,5,0), p(2,5,2), p(0,5,2), p(2,5,0), new int[] {0, 2});
+        assertEquals(SegmentSegment.Type.POINT, r.type());
+        assertEquals(0.5, r.startOnFirst(), EPS);
+    }
+
+    /**
+     * Segments on the same line, overlapping, touching at an end, or apart.
+     */
+    @Test
+    public void testCollinearSegments() {
+        SegmentSegment r = Intersection.segmentSegment2D(p(0,0), p(4,0), p(3,0), p(1,0), XY);
+        assertEquals(SegmentSegment.Type.OVERLAP, r.type());
+        assertEquals(0.25, r.startOnFirst(), EPS);
+        assertEquals(0.75, r.endOnFirst(),   EPS);
+        assertEquals(1, r.startOnSecond(), "The point at (1,0) is the end of the second segment.");
+        assertEquals(0, r.endOnSecond(),   "The point at (3,0) is the start of the second segment.");
+
+        r = Intersection.segmentSegment2D(p(4,0), p(0,0), p(1,0), p(3,0), XY);
+        assertEquals(SegmentSegment.Type.OVERLAP, r.type(), "First segment in the opposite direction.");
+        assertEquals(0.25, r.startOnFirst(), EPS);
+        assertEquals(0.75, r.endOnFirst(),   EPS);
+        assertEquals(1, r.startOnSecond());
+        assertEquals(0, r.endOnSecond());
+
+        r = Intersection.segmentSegment2D(p(0,0), p(1,1), p(1,1), p(2,2), XY);
+        assertEquals(SegmentSegment.Type.POINT, r.type(), "Touching at their ends.");
+        assertEquals(1, r.startOnFirst());
+        assertEquals(0, r.startOnSecond());
+
+        assertSame(SegmentSegment.NONE, Intersection.segmentSegment2D(p(0,0), p(1,0), p(2,0), p(3,0), XY));
+    }
+
+    /**
+     * A segment crossing the plane of the triangle inside it, on an edge, on a corner, or outside.
+     */
+    @Test
+    public void testSegmentTriangle() {
+        assertContact(SegmentTriangle.Type.INTERIOR, -1, 0.5,  p(1,1,-1), p(1,1,1));
+        assertContact(SegmentTriangle.Type.EDGE,      0, 0.5,  p(2,0,-1), p(2,0,1));
+        assertContact(SegmentTriangle.Type.EDGE,      1, 0.5,  p(2,2,-1), p(2,2,1));
+        assertContact(SegmentTriangle.Type.EDGE,      2, 0.5,  p(0,2,-1), p(0,2,1));
+        assertContact(SegmentTriangle.Type.VERTEX,    1, 0.5,  p(4,0,-1), p(4,0,1));
+        assertContact(SegmentTriangle.Type.VERTEX,    0, 0.25, p(0,0,-1), p(0,0,3));
+        assertSame(SegmentTriangle.NONE, Intersection.segmentTriangle(p(5,5,-1), p(5,5,1), A, B, C, 0), "Outside.");
+        assertSame(SegmentTriangle.NONE, Intersection.segmentTriangle(p(1,1, 1), p(1,1,2), A, B, C, 0), "Above.");
+    }
+
+    /**
+     * A segment with an end on the plane of the triangle, or in that plane.
+     */
+    @Test
+    public void testSegmentEndOnTriangle() {
+        assertContact(SegmentTriangle.Type.INTERIOR, -1, 0, p(1,1,0), p(1,1,5));
+        assertContact(SegmentTriangle.Type.EDGE,      0, 1, p(2,0,3), p(2,0,0));
+        assertContact(SegmentTriangle.Type.VERTEX,    2, 0, p(0,4,0), p(1,1,1));
+        assertSame(SegmentTriangle.NONE,     Intersection.segmentTriangle(p(5,5,0), p(5,5,1), A, B, C, 0), "End on the plane, outside.");
+        assertSame(SegmentTriangle.COPLANAR, Intersection.segmentTriangle(p(1,1,0), p(3,0,0), A, B, C, 0));
+        //an end near the plane is on it only within the tolerance.
+        assertSame(SegmentTriangle.NONE, Intersection.segmentTriangle(p(1,1,1E-12), p(1,1,5), A, B, C, 0));
+        final SegmentTriangle r = Intersection.segmentTriangle(p(1,1,1E-12), p(1,1,5), A, B, C, 1E-9);
+        assertEquals(SegmentTriangle.Type.INTERIOR, r.type());
+        assertEquals(0, r.ratio());
+    }
+
+    /**
+     * A segment crossing a triangle in an oblique plane, with coordinates which are not integers.
+     */
+    @Test
+    public void testSegmentObliqueTriangle() {
+        final double[] o = {0.1, 0.2, 0.3}, u = {1/3., 2/3., 2/3.}, v = {2/3., 1/3., -2/3.};
+        final double[] n = Vectors.cross(u, v);
+        final double[] a = o, b = Vectors.add(o, Vectors.scale(u, 4)), c = Vectors.add(o, Vectors.scale(v, 4));
+        final double[] inside  = Vectors.add(o, Vectors.add(u, v));
+        final double[] outside = Vectors.add(o, Vectors.scale(Vectors.add(u, v), 3));
+        SegmentTriangle r = Intersection.segmentTriangle(Vectors.add(inside, Vectors.scale(n, -1)), Vectors.add(inside, Vectors.scale(n, 3)), a, b, c, 0);
+        assertEquals(SegmentTriangle.Type.INTERIOR, r.type());
+        assertEquals(0.25, r.ratio(), EPS);
+        r = Intersection.segmentTriangle(Vectors.add(outside, Vectors.scale(n, -1)), Vectors.add(outside, Vectors.scale(n, 1)), a, b, c, 0);
+        assertEquals(SegmentTriangle.Type.NONE, r.type());
+    }
+
+    /**
+     * Segments in the plane of the triangle: inside, crossing, through a corner, along an edge, or outside.
+     */
+    @Test
+    public void testSegmentInTriangle() {
+        List<SegmentTriangle> r = Intersection.segmentInTriangle2D(p(1,1,0), p(2,1,0), A, B, C, XY);
+        assertPoints(r, SegmentTriangle.Type.INTERIOR, -1, 0, SegmentTriangle.Type.INTERIOR, -1, 1);
+
+        r = Intersection.segmentInTriangle2D(p(-1,1,0), p(5,1,0), A, B, C, XY);
+        assertPoints(r, SegmentTriangle.Type.EDGE, 2, 1/6., SegmentTriangle.Type.EDGE, 1, 2/3.);
+
+        r = Intersection.segmentInTriangle2D(p(1,1,0), p(5,1,0), A, B, C, XY);
+        assertPoints(r, SegmentTriangle.Type.INTERIOR, -1, 0, SegmentTriangle.Type.EDGE, 1, 0.5);
+
+        r = Intersection.segmentInTriangle2D(p(-1,-1,0), p(2,2,0), A, B, C, XY);
+        assertPoints(r, SegmentTriangle.Type.VERTEX, 0, 1/3., SegmentTriangle.Type.EDGE, 1, 1);
+
+        r = Intersection.segmentInTriangle2D(p(-1,0,0), p(2,0,0), A, B, C, XY);
+        assertPoints(r, SegmentTriangle.Type.VERTEX, 0, 1/3., SegmentTriangle.Type.EDGE, 0, 1);
+
+        r = Intersection.segmentInTriangle2D(p(4,0,0), p(5,-1,0), A, B, C, XY);
+        assertEquals(1, r.size(), "Touching at a corner only.");
+        assertEquals(SegmentTriangle.Type.VERTEX, r.get(0).type());
+        assertEquals(1, r.get(0).feature());
+        assertEquals(0, r.get(0).ratio());
+
+        assertTrue(Intersection.segmentInTriangle2D(p(5,5,0), p(6,6,0), A, B, C, XY).isEmpty());
+
+        //a triangle in the vertical plane y = 2, projected on the plane of its normal.
+        final double[] a = {0,2,0}, b = {4,2,0}, c = {0,2,4};
+        final int[] axes = Maths.projectionAxes(Maths.calculateNormal(a, b, c));
+        r = Intersection.segmentInTriangle2D(p(1,2,1), p(1,2,5), a, b, c, axes);
+        assertPoints(r, SegmentTriangle.Type.INTERIOR, -1, 0, SegmentTriangle.Type.EDGE, 1, 0.5);
+    }
+
+    /**
+     * Triangles crossing, touching at a point or along an edge, coplanar or apart.
+     */
+    @Test
+    public void testTriangleTriangle() {
+        //a vertical triangle crossing the interior of the horizontal one.
+        TriangleTriangle r = Intersection.triangleTriangle(A, B, C, p(1,1,-1), p(1,1,1), p(2.5,1,0), 0);
+        assertEquals(TriangleTriangle.Type.SEGMENT, r.type());
+        assertEnds(r, p(1,1,0), p(2.5,1,0));
+
+        //a vertical triangle crossing an edge of the horizontal one.
+        r = Intersection.triangleTriangle(A, B, C, p(1,-1,-1), p(1,-1,1), p(1,2,0), 0);
+        assertEquals(TriangleTriangle.Type.SEGMENT, r.type());
+        assertEnds(r, p(1,0,0), p(1,2,0));
+
+        //a vertical triangle standing on an edge of the horizontal one.
+        r = Intersection.triangleTriangle(A, B, C, p(1,0,0), p(3,0,0), p(2,0,2), 0);
+        assertEquals(TriangleTriangle.Type.SEGMENT, r.type());
+        assertEnds(r, p(1,0,0), p(3,0,0));
+
+        //a triangle touching the horizontal one by a corner.
+        r = Intersection.triangleTriangle(A, B, C, p(1,1,0), p(2,1,1), p(1,2,1), 0);
+        assertEquals(TriangleTriangle.Type.POINT, r.type());
+        assertSame(r.start(), r.end());
+        assertArrayEquals(p(1,1,0), r.start().position());
+
+        assertSame(TriangleTriangle.COPLANAR, Intersection.triangleTriangle(A, B, C, p(1,1,0), p(5,1,0), p(1,5,0), 0));
+        assertSame(TriangleTriangle.DISJOINT, Intersection.triangleTriangle(A, B, C, p(10,10,1), p(11,10,1), p(10,11,2), 0), "Apart.");
+        assertSame(TriangleTriangle.DISJOINT, Intersection.triangleTriangle(A, B, C, p(5,5,-1), p(5,5,1), p(6,6,0), 0),
+                   "Crossing the plane outside of the triangle.");
+    }
+
+    /**
+     * Verifies the result of the intersection of a segment with the triangle {@link #A}, {@link #B}, {@link #C}.
+     */
+    private static void assertContact(SegmentTriangle.Type type, int feature, double ratio, double[] s0, double[] s1) {
+        final SegmentTriangle r = Intersection.segmentTriangle(s0, s1, A, B, C, 0);
+        assertEquals(type,    r.type());
+        assertEquals(feature, r.feature());
+        assertEquals(ratio,   r.ratio(), EPS);
+    }
+
+    /**
+     * Verifies the two ends of the part of a segment inside a triangle.
+     */
+    private static void assertPoints(final List<SegmentTriangle> points,
+            final SegmentTriangle.Type type1, final int feature1, final double ratio1,
+            final SegmentTriangle.Type type2, final int feature2, final double ratio2)
+    {
+        assertEquals(2, points.size());
+        assertEquals(type1,    points.get(0).type());
+        assertEquals(feature1, points.get(0).feature());
+        assertEquals(ratio1,   points.get(0).ratio(), EPS);
+        assertEquals(type2,    points.get(1).type());
+        assertEquals(feature2, points.get(1).feature());
+        assertEquals(ratio2,   points.get(1).ratio(), EPS);
+    }
+
+    /**
+     * Verifies the ends of the intersection of two triangles, in any order.
+     */
+    private static void assertEnds(final TriangleTriangle r, final double[] p1, final double[] p2) {
+        final double[] start = r.start().position(), end = r.end().position();
+        if (Math.abs(start[0] - p1[0]) + Math.abs(start[1] - p1[1]) + Math.abs(start[2] - p1[2]) < EPS) {
+            assertArrayEquals(p2, end, EPS);
+        } else {
+            assertArrayEquals(p1, end,   EPS);
+            assertArrayEquals(p2, start, EPS);
+        }
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/triangulate/EarClippingTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/triangulate/EarClippingTest.java
index a649a46445..0a1c535918 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/triangulate/EarClippingTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/triangulate/EarClippingTest.java
@@ -28,6 +28,7 @@ import org.apache.sis.geometries.operation.OperationException;
 import org.apache.sis.geometries.surface.Polygon;
 import org.apache.sis.maths.Maths;
 import org.apache.sis.maths.NDArrays;
+import org.apache.sis.maths.ReadOnly;
 import org.apache.sis.maths.Vectors;
 import org.apache.sis.referencing.CommonCRS;
 
@@ -127,6 +128,47 @@ public class EarClippingTest {
         assertThrows(OperationException.class, () -> new EarClipping().triangulateIndexed(bowTie));
     }
 
+    /**
+     * Triangulates a concave ring given by the indices of its vertices in an array of positions, some of them
+     * not in the ring. The ring gives n − 2 triangles covering its area, made of its vertices only and turning
+     * in its direction, whether it is closed or not.
+     */
+    @Test
+    public void testTriangulateRing() {
+        final ReadOnly.Vector<?>[] positions = {
+            vector(9, 9),                   // not in the ring.
+            vector(0, 0), vector(4, 0), vector(4, 4), vector(2, 1), vector(0, 4)
+        };
+        assertRingTriangles(positions, new int[] {1, 2, 3, 4, 5},    true);
+        assertRingTriangles(positions, new int[] {1, 2, 3, 4, 5, 1}, true);
+        assertRingTriangles(positions, new int[] {5, 4, 3, 2, 1},    false);
+
+        final ReadOnly.Vector<?>[] bowTie = {vector(0, 0), vector(2, 2), vector(2, 0), vector(0, 2)};
+        assertThrows(OperationException.class, () -> new EarClipping().triangulate(bowTie, new int[] {0, 1, 2, 3}));
+    }
+
+    /**
+     * Triangulates the given ring of five vertices, of area 10, and verifies the triangles.
+     */
+    private static void assertRingTriangles(final ReadOnly.Vector<?>[] positions, final int[] ring, final boolean counterclockwise) {
+        final int[] triangles = new EarClipping().triangulate(positions, ring);
+        assertEquals((5 - 2) * 3, triangles.length, "Expected n − 2 triangles.");
+        double area = 0;
+        for (int i = 0; i < triangles.length; i += 3) {
+            for (int k = 0; k < 3; k++) {
+                assertNotEquals(0, triangles[i+k], "The position is not a vertex of the ring.");
+            }
+            final double side = Maths.lineSide(positions[triangles[i]], positions[triangles[i+1]], positions[triangles[i+2]]);
+            assertTrue(counterclockwise ? side > 0 : side < 0, "The triangle shall turn in the direction of the ring.");
+            area += Math.abs(side) / 2;
+        }
+        assertEquals(10, area, 1E-12);
+    }
+
+    private static ReadOnly.Vector<?> vector(final double x, final double y) {
+        return Vectors.createDouble(2).set(new double[] {x, y});
+    }
+
     /**
      * Triangulates the given polygon and verifies that each triangle turns in the direction of the exterior ring,
      * which is that its normal has the direction of the normal of the ring. Also verifies the area and the number
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/MathsTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/MathsTest.java
index 140dde08a1..d997e4f5c0 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/MathsTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/MathsTest.java
@@ -217,6 +217,34 @@ public class MathsTest {
         assertThrows(IllegalArgumentException.class, () -> Maths.projectionAxes(new double[] {0, 1}));
     }
 
+    /**
+     * Tests {@link Maths#planeSide(double[], double[], double[], double[], double)} on a horizontal triangle,
+     * with and without tolerance, and on a triangle in an oblique plane with coordinates which are not integers.
+     */
+    @Test
+    public void testPlaneSide() {
+        final double[] a = {0, 0, 0}, b = {1, 0, 0}, c = {0, 1, 0};
+        assertEquals( 1, Maths.planeSide(a, b, c, new double[] {0.3, 0.3,  1}, 0), "Above, toward the normal.");
+        assertEquals(-1, Maths.planeSide(a, b, c, new double[] {0.3, 0.3, -1}, 0), "Below.");
+        assertEquals(-1, Maths.planeSide(a, c, b, new double[] {0.3, 0.3,  1}, 0), "Reversed triangle.");
+        assertEquals( 0, Maths.planeSide(a, b, c, new double[] {5,   5,    0}, 0), "On the plane, outside of the triangle.");
+        assertEquals( 1, Maths.planeSide(a, b, c, new double[] {0.3, 0.3, 1E-12}, 0));
+        assertEquals( 0, Maths.planeSide(a, b, c, new double[] {0.3, 0.3, 1E-12}, 1E-9), "Within the tolerance.");
+        assertEquals( 1, Maths.planeSide(a, b, c, new double[] {0.3, 0.3, 2E-9},  1E-9), "Beyond the tolerance.");
+        assertEquals( 0, Maths.planeSide(a, b, new double[] {2, 0, 0}, new double[] {0, 0, 1}, 0), "Degenerate triangle.");
+        /*
+         * A triangle made of two orthonormal vectors u and v of an oblique plane, whose normal is u × v.
+         */
+        final double[] o = {0.1, 0.2, 0.3}, u = {1/3., 2/3., 2/3.}, v = {2/3., 1/3., -2/3.};
+        final double[] n = Vectors.cross(u, v);
+        final double[] ou = Vectors.add(o, u), ov = Vectors.add(o, v);
+        final double[] inPlane = Vectors.add(o, Vectors.add(Vectors.scale(u, 0.4), Vectors.scale(v, 0.3)));
+        assertEquals( 1, Maths.planeSide(o, ou, ov, Vectors.add(inPlane, Vectors.scale(n,  1E-3)), 0));
+        assertEquals(-1, Maths.planeSide(o, ou, ov, Vectors.add(inPlane, Vectors.scale(n, -1E-3)), 0));
+        assertEquals( 0, Maths.planeSide(o, ou, ov, Vectors.add(inPlane, Vectors.scale(n,  1E-3)), 1E-2));
+        assertEquals( 0, Maths.planeSide(o, ou, ov, inPlane, 1E-12), "Rounding errors within the tolerance.");
+    }
+
     /**
      * Tests that {@link Maths#projectionAxes(double[])} keeps the orientation: a triangle whose normal has the
      * direction given to the method is counterclockwise once projected, and the reversed triangle clockwise.


(sis) 06/13: feat(Geometry): simplify EarClipping

Posted by js...@apache.org.
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

commit 7f9bdfd15da62d06d5a086dd2fd44225dcccd336
Author: jsorel <jo...@geomatys.com>
AuthorDate: Fri Oct 16 15:57:43 2026 +0200

    feat(Geometry): simplify EarClipping
---
 .../internal/shared/CurveLinearizer.java           |  12 +-
 .../geometries/internal/shared/DefaultPrism.java   |  31 +-
 .../operation/triangulate/EarClipping.java         | 320 ++++++------------
 .../operation/triangulate/HoleToExterior.java      | 357 +++++++--------------
 .../operation/triangulate/HoleToExteriorTest.java  |  29 ++
 5 files changed, 266 insertions(+), 483 deletions(-)

diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/CurveLinearizer.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/CurveLinearizer.java
index 3ef811015a..5947bd65fa 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/CurveLinearizer.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/CurveLinearizer.java
@@ -26,7 +26,6 @@ import org.opengis.geometry.Envelope;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.geometries.Curve;
 import org.apache.sis.geometries.DataPoints;
-import org.apache.sis.geometries.DataPointsType;
 import org.apache.sis.geometries.Geometries;
 import org.apache.sis.geometries.GeometryFactory;
 import org.apache.sis.geometries.Point;
@@ -482,13 +481,8 @@ public final class CurveLinearizer {
         if (result.size() == n) {
             return points;
         }
-        final DataPointsType type = points.getType();
-        final DataPoints copy = GeometryFactory.DEFAULT.createDataPoints(type, result.size());
-        for (int i = 0; i < result.size(); i++) {
-            for (final String name : type.getAttributeNames()) {
-                copy.setAttribute(i, name, result.get(i).getAttribute(name));
-            }
-        }
-        return copy;
+        //copy the points, so that the polyline does not depend on the given ones.
+        final DataPoints view = ConcatenatedDataPoints.of(result.toArray(Point[]::new));
+        return view.subset(0, view.size());
     }
 }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultPrism.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultPrism.java
index fdda173d48..faf8a6315f 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultPrism.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultPrism.java
@@ -45,7 +45,6 @@ import org.apache.sis.maths.Array;
 import org.apache.sis.maths.NDArrays;
 import org.apache.sis.maths.SampleSystem;
 import org.apache.sis.maths.Vector;
-import org.apache.sis.maths.Vectors;
 import org.apache.sis.measure.NumberRange;
 import org.apache.sis.referencing.CRS;
 import org.apache.sis.util.Utilities;
@@ -403,30 +402,24 @@ public non-sealed class DefaultPrism extends AbstractGeometry implements Prism {
          * @param  values   extrusion value of each copied position.
          */
         private DataPoints copy(final DataPoints source, final int[] indices, final double[] values) {
-            final DataPointsType type = source.getType();
-            final SampleSystem system = SampleSystem.of(crs);
+            final DataPoints copy = source.subset(indices);
             final Map<String,Array> attributes = new LinkedHashMap<>();
-            for (final String name : type.getAttributeNames()) {
-                final boolean isPosition = DataPointsType.ATT_POSITION.equals(name);
-                attributes.put(name, NDArrays.of(isPosition ? system : type.getAttributeSystem(name),
-                                                 type.getAttributeType(name), indices.length));
+            for (final String name : copy.getType().getAttributeNames()) {
+                attributes.put(name, copy.getAttributeArray(name));
             }
-            final Array positions = attributes.get(DataPointsType.ATT_POSITION);
-            final Vector<?> position = Vectors.create(system, type.getAttributeType(DataPointsType.ATT_POSITION));
-            final int dimension = source.getDimension();
+            //the positions are in the reference system of the extrusion, which may have one more dimension.
+            final Array positions = NDArrays.of(SampleSystem.of(crs),
+                    copy.getType().getAttributeType(DataPointsType.ATT_POSITION), indices.length);
             for (int i = 0; i < indices.length; i++) {
-                final Vector<?> p = source.getPosition(indices[i]);
-                for (int d = 0; d < dimension; d++) {
-                    position.set(d, p.get(d));
+                Vector<?> position = copy.getPosition(i);
+                if (axis < position.getDimension()) {
+                    position.set(axis, values[i]);
+                } else {
+                    position = position.extend(values[i]);
                 }
-                position.set(axis, values[i]);
                 positions.set(i, position);
-                for (final Map.Entry<String,Array> entry : attributes.entrySet()) {
-                    if (entry.getValue() != positions) {
-                        entry.getValue().set(i, source.getAttribute(indices[i], entry.getKey()));
-                    }
-                }
             }
+            attributes.put(DataPointsType.ATT_POSITION, positions);
             return factory.createDataPoints(attributes);
         }
     }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/EarClipping.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/EarClipping.java
index 5f85efcbbd..41c77e90a8 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/EarClipping.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/EarClipping.java
@@ -18,12 +18,12 @@ package org.apache.sis.geometries.operation.triangulate;
 
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.IntStream;
 import org.apache.sis.geometries.DataPoints;
 import org.apache.sis.geometries.DataPointsType;
-import org.apache.sis.geometries.GeometryFactory;
 import org.apache.sis.geometries.mesh.MeshPrimitive;
 import org.apache.sis.geometries.surface.Polygon;
 import org.apache.sis.maths.Array;
@@ -41,7 +41,7 @@ import org.apache.sis.util.ArraysExt;
  *
  * <p>The holes of the polygon are first joined to its exterior ring by {@link HoleToExterior},
  * then the ring is triangulated. The triangulation works on the indices of the positions of
- * the ring: the triangles reference the vertices instead of copying their positions, so that
+ * the polygon: the triangles reference the vertices instead of copying their positions, so that
  * all the attributes of the vertices are kept.</p>
  *
  * @author Johann Sorel
@@ -57,19 +57,6 @@ public class EarClipping {
     public record Triangulation(DataPoints vertices, int[] triangles) {
     }
 
-    /**
-     * Values of all the attributes of a vertex, for finding the vertices which are identical.
-     */
-    private record Key(double[] values) {
-        @Override public boolean equals(final Object other) {
-            return (other instanceof Key k) && Arrays.equals(values, k.values);
-        }
-
-        @Override public int hashCode() {
-            return Arrays.hashCode(values);
-        }
-    }
-
     /**
      * Type of the angle made by the ring at a vertex, computed when first needed.
      */
@@ -80,48 +67,38 @@ public class EarClipping {
     }
 
     /**
-     * Positions of the ring to triangulate, the last one at the same position as the first one.
+     * Positions of the polygon to triangulate, those of the exterior ring followed by those of the holes.
      */
     private DataPoints source;
 
     /**
-     * Positions of the ring, with their coordinates in the order used by the triangulation.
+     * Positions of the polygon, with their coordinates in the order used by the triangulation.
      * The triangulation works on the two first coordinates, in the plane where the polygon has its largest area.
      */
     private Vector<?>[] working;
 
     /**
-     * Indices of the three vertices of each triangle found so far.
+     * Indices in {@link #source} of the vertices of the ring joining the holes to the exterior ring, without
+     * repeated consecutive positions and without the closing position. The ring is clockwise in the plane
+     * of the triangulation.
      */
-    private final List<int[]> triangles = new ArrayList<>();
+    private int[] ring;
 
-    /** number of coordinates, may be inferior to coords length. */
-    private int nbCoords;
-    /** indices of the vertices to triangulate. */
-    private int[] coords;
     /**
-     * The type of angle made by the ring at each vertex to triangulate,
-     * stored to avoid computing it each time.
+     * For each vertex of the {@linkplain #ring}, the previous and next vertices which are not cut yet.
+     * Cutting an ear unlinks its vertex from the ring.
      */
-    private Angle[] coordType;
+    private int[] previous, next;
 
-    //current segment analyzed
-    private int indexPrevious;
-    private int index;
-    private int indexNext;
-    private Vector<?> t1; //previous point
-    private Vector<?> t2; //point at index
-    private Vector<?> t3; //next point
+    /**
+     * The type of angle made by the ring at each vertex, stored to avoid computing it each time.
+     */
+    private Angle[] angles;
 
-    private void reset(){
-        //reset values
-        source = null;
-        working = null;
-        triangles.clear();
-        nbCoords = 0;
-        coords = null;
-        coordType = null;
-    }
+    /**
+     * Indices in {@link #source} of the three vertices of each triangle found so far.
+     */
+    private final List<int[]> triangles = new ArrayList<>();
 
     /**
      * Triangulates the given polygon.
@@ -130,14 +107,12 @@ public class EarClipping {
      * @return the vertices of the triangles and their indices.
      */
     public Triangulation triangulateIndexed(Polygon geometry) {
-        reset();
-        source = HoleToExterior.apply(geometry).getExteriorRing().getDataPoints();
-        final int[] axes = workingAxes(source.getAttributeArray(DataPointsType.ATT_POSITION));
+        source = geometry.getDataPoints();
+        final int[] axes = workingAxes(geometry.getExteriorRing().getDataPoints().getAttributeArray(DataPointsType.ATT_POSITION));
         working = new Vector<?>[source.size()];
-        for (int i = 0; i < working.length; i++) {
-            working[i] = source.getPosition(i).swizzle(axes);
-        }
-        run();
+        Arrays.setAll(working, (i) -> source.getPosition(i).swizzle(axes));
+        triangles.clear();
+        run(HoleToExterior.ring(geometry));
         return result();
     }
 
@@ -158,207 +133,116 @@ public class EarClipping {
     /**
      * Returns the order of the coordinates to use for triangulating a polygon with the given positions,
      * to be given to {@link Vector#swizzle(int...)}. The triangulation works on the two first coordinates
-     * of that order. The axis along which the normal of the polygon is the largest, computed with Newell's
-     * method, is moved after the two axes of the projection plane, so that a vertical polygon is not
-     * degenerate. If the polygon can be triangulated in its (x,y) plane, the order is unchanged.
+     * of that order. The axis along which the {@linkplain Maths#calculatePolygonNormal(Array) normal of the
+     * polygon} is the largest is moved after the two axes of the projection plane, so that a vertical polygon
+     * is not degenerate. If the polygon can be triangulated in its (x,y) plane, the order is unchanged.
      */
     static int[] workingAxes(final Array positions) {
         final int dim = positions.getDimension();
-        final int[] axes = new int[dim];
-        for (int i = 0; i < dim; i++) {
-            axes[i] = i;
-        }
-        if (dim < 3) {
-            return axes;
-        }
-        final double[] c = positions.toArrayDouble();
-        final int n = c.length / dim;
-        double nx = 0, ny = 0, nz = 0;
-        for (int i = 0; i < n; i++) {
-            final int a = i * dim, b = ((i + 1) % n) * dim;
-            nx += (c[a+1] - c[b+1]) * (c[a+2] + c[b+2]);
-            ny += (c[a+2] - c[b+2]) * (c[a]   + c[b]);
-            nz += (c[a]   - c[b])   * (c[a+1] + c[b+1]);
+        final int[] axes = ArraysExt.range(0, dim);
+        if (dim >= 3) {
+            final double[] normal = Maths.calculatePolygonNormal(positions);
+            final double nx = Math.abs(normal[0]), ny = Math.abs(normal[1]), nz = Math.abs(normal[2]);
+            final int drop = (nz >= nx && nz >= ny) ? 2 : (nx >= ny) ? 0 : 1;
+            //shift the following axes of the plane to the place of the dropped axis, which goes after them.
+            System.arraycopy(axes, drop + 1, axes, drop, 2 - drop);
+            axes[2] = drop;
         }
-        nx = Math.abs(nx); ny = Math.abs(ny); nz = Math.abs(nz);
-        if (nz >= nx && nz >= ny) {
-            return axes;
-        }
-        final int drop = (nx >= ny) ? 0 : 1;
-        int k = 0;
-        for (int i = 0; i < 3; i++) {
-            if (i != drop) axes[k++] = i;
-        }
-        axes[k] = drop;
         return axes;
     }
 
     /**
      * Builds the result from the triangles found. Only the vertices used by a triangle are kept, in the
-     * order of the ring, and the vertices having the same values for all attributes are merged. Those are
-     * the last position of the ring, which repeats the first one, and the positions repeated by the cuts
-     * joining the holes.
+     * order of their first use. The positions repeated by the cuts joining the holes have the same index,
+     * so they are a single vertex.
      */
     private Triangulation result() {
-        final int count = working.length;
-        final boolean[] used = new boolean[count];
+        final Map<Integer,Integer> vertices = new LinkedHashMap<>();      // index in the source → index in the result.
+        final int[] indices = new int[triangles.size() * 3];
+        int k = 0;
         for (final int[] triangle : triangles) {
             for (final int vertex : triangle) {
-                used[vertex] = true;
-            }
-        }
-        final DataPointsType type = source.getType();
-        final List<String> names = type.getAttributeNames();
-        final Map<Key,Integer> distinct = new HashMap<>();
-        final List<Integer> kept = new ArrayList<>();
-        final int[] remap = new int[count];
-        for (int i = 0; i < count; i++) {
-            if (used[i]) {
-                double[] values = new double[0];
-                for (final String name : names) {
-                    final double[] value = source.getAttribute(i, name).toArrayDouble();
-                    values = Arrays.copyOf(values, values.length + value.length);
-                    System.arraycopy(value, 0, values, values.length - value.length, value.length);
-                }
-                final Integer previous = distinct.putIfAbsent(new Key(values), kept.size());
-                if (previous != null) {
-                    remap[i] = previous;
-                } else {
-                    remap[i] = kept.size();
-                    kept.add(i);
-                }
-            }
-        }
-        final DataPoints vertices = GeometryFactory.DEFAULT.createDataPoints(type, kept.size());
-        for (int i = 0; i < kept.size(); i++) {
-            for (final String name : names) {
-                vertices.setAttribute(i, name, source.getAttribute(kept.get(i), name));
+                indices[k++] = vertices.computeIfAbsent(vertex, (key) -> vertices.size());
             }
         }
-        final int[] indices = new int[triangles.size() * 3];
-        for (int i = 0, k = 0; i < triangles.size(); i++) {
-            for (final int vertex : triangles.get(i)) {
-                indices[k++] = remap[vertex];
-            }
-        }
-        return new Triangulation(vertices, indices);
+        return new Triangulation(source.subset(vertices.keySet().stream().mapToInt(Integer::intValue).toArray()), indices);
     }
 
     /**
-     * Triangulates the ring.
+     * Triangulates the given ring by cutting its ears one after the other. An ear is a convex vertex whose
+     * triangle with its two neighbors contains no other vertex. Cutting it removes the vertex from the
+     * ring, which stays a valid polygon, until only one triangle is left.
+     *
+     * @param joined  indices in {@link #source} of the ring positions, the last one at the location of the first one.
      */
-    private void run() {
-        if (working.length == 0) {
-            return;
-        }
-        final List<Integer> border = new ArrayList<>(working.length);
-        for (int i = 0; i < working.length; i++) {
-            border.add(i);
-        }
-
-        //remove any neighor points overlaping
-        Vector<?> t = working[border.get(0)];
-        for(int i=1,n=border.size();i<n;i++){
-            final Vector<?> candidate = working[border.get(i)];
-            if(candidate.equals(t)){
-                border.remove(i);
-                i--;
-                n--;
-            }else{
-                t = candidate;
+    private void run(final int[] joined) {
+        //remove the repeated consecutive positions, and the closing position which repeats the first one.
+        ring = IntStream.range(0, joined.length - 1).filter((i) -> !working[joined[i]].equals(working[joined[i + 1]]))
+                        .map((i) -> joined[i]).toArray();
+        if (!Maths.isClockWise(Arrays.stream(joined).mapToObj((i) -> working[i]).toList())) {
+            ArraysExt.reverse(ring);
+        }
+        final int n = ring.length;
+        previous = new int[n];
+        next     = new int[n];
+        for (int i = 0; i < n; i++) {
+            previous[i] = (i + n - 1) % n;
+            next[i]     = (i + 1) % n;
+        }
+        angles = new Angle[n];
+        Arrays.fill(angles, Angle.UNKNOWN);
+
+        //walk around the ring, until a whole turn finds no ear, which should not happen if the geometry is correct.
+        int vertex = 0;
+        for (int remaining = n, attempts = 0; remaining >= 3 && attempts < remaining; vertex = next[vertex]) {
+            if (isConvex(vertex) && isEar(vertex)) {
+                triangles.add(new int[] {ring[previous[vertex]], ring[vertex], ring[next[vertex]]});
+                //unlink the vertex, the angles at its neighbors may change.
+                next[previous[vertex]] = next[vertex];
+                previous[next[vertex]] = previous[vertex];
+                angles[previous[vertex]] = Angle.UNKNOWN;
+                angles[next[vertex]]     = Angle.UNKNOWN;
+                remaining--;
+                attempts = 0;
+            } else {
+                attempts++;
             }
         }
+    }
 
-        nbCoords = border.size();
-        coordType = new Angle[nbCoords];
-        Arrays.fill(coordType, Angle.UNKNOWN);
-        coords = border.stream().mapToInt(Integer::intValue).toArray();
-
-        //flip coordinates if not clockwise
-        if(!Maths.isClockWise(Arrays.stream(coords).mapToObj((i) -> working[i]).toList())){
-            ArraysExt.reverse(coords);
-        }
-
-        //now cut ears progressively
-        int nbcut = 0;
-        while(nbCoords > 3){
-            nbcut = 0;
-            for(index=0;index<nbCoords-2 && nbCoords>3;){
-                indexPrevious = (index==0) ? nbCoords-2 : index-1 ;
-                indexNext = index+1;
-
-                t1 = working[coords[indexPrevious]];
-                t2 = working[coords[index]];
-                t3 = working[coords[indexNext]];
-
-                if(isConvex(index) && isEar()){
-                    //reset angle type, we remove a point so it might change
-                    coordType[indexPrevious]=Angle.UNKNOWN;
-                    coordType[indexNext]=Angle.UNKNOWN;
-
-                    triangles.add(new int[]{coords[indexPrevious], coords[index], coords[indexNext]});
-                    Maths.removeWithin(coords, index);
-                    Maths.removeWithin(coordType, index);
-                    nbCoords--;
-                    nbcut++;
-                    //we do not increment since we have remove the point
-                }else{
-                    index++;
-                }
-            }
-
-            if(nbcut == 0){
-                //this should not happen if the geometry is correct
-                //System.out.println("Triangulation failed. no ear to cut.");
-                return;
-            }
-        }
+    /**
+     * Returns the working position of the given vertex of the ring.
+     */
+    private Vector<?> position(final int vertex) {
+        return working[ring[vertex]];
     }
 
     /**
-     * @return true if the ring is convex at the vertex of given index
+     * @return true if the ring is convex at the given vertex
      */
-    private boolean isConvex(int idx){
-        if(coordType[idx]==Angle.UNKNOWN){
-            //calculate angle type
-            final Vector<?> s1 = working[coords[(idx==0) ? (nbCoords-2) : (idx-1)]];
-            final Vector<?> s2 = working[coords[idx]];
-            final Vector<?> s3 = working[coords[idx+1]];
-            final double side = Maths.lineSide(s1, s3, s2);
-            coordType[idx] = side>0 ? Angle.CONVEX : Angle.CONCAVE;
+    private boolean isConvex(final int vertex) {
+        if (angles[vertex] == Angle.UNKNOWN) {
+            final boolean convex = Maths.isConvex(position(previous[vertex]), position(vertex), position(next[vertex]), true);
+            angles[vertex] = convex ? Angle.CONVEX : Angle.CONCAVE;
         }
-        return coordType[idx] == Angle.CONVEX;
+        return angles[vertex] == Angle.CONVEX;
     }
 
     /**
-     * Check this segment triangle is an ear.
-     * Does not contain any other point.
-     * @return true if no other vertex is inside the triangle
+     * Returns whether the triangle made by the given vertex and its two neighbors contains no other vertex
+     * of the ring. Only the concave vertices need to be tested. A cut joining a hole repeats its end points,
+     * so a copy located on a corner of the triangle does not prevent the ear.
      */
-    private boolean isEar() {
-        final double[] a = new double[]{t1.get(0), t1.get(1)};
-        final double[] b = new double[]{t2.get(0), t2.get(1)};
-        final double[] c = new double[]{t3.get(0), t3.get(1)};
-
-        for(int i=0; i<nbCoords-1; i++){
-            //test only concave points
-            if (!isConvex(i)){
-                //check it's not one of the current segment
-                if(i!=index && i!=indexNext && i!=indexPrevious){
-                    final Vector<?> v = working[coords[i]];
-                    final double[] p = new double[]{v.get(0),v.get(1)};
-                    //a hole bridge duplicates its end points, a copy located on a triangle vertex does not block the ear
-                    if (Arrays.equals(p, a) || Arrays.equals(p, b) || Arrays.equals(p, c)) {
-                        continue;
-                    }
-                    //check if it's in the segment triangle
-                    if(Maths.inTriangle(a, b, c, p)){
-                        return false;
-                    }
-                }
+    private boolean isEar(final int vertex) {
+        final Vector<?> t1 = position(previous[vertex]);
+        final Vector<?> t2 = position(vertex);
+        final Vector<?> t3 = position(next[vertex]);
+        for (int i = next[next[vertex]]; i != previous[vertex]; i = next[i]) {
+            final Vector<?> p = position(i);
+            if (!isConvex(i) && !p.equals(t1) && !p.equals(t2) && !p.equals(t3) && Maths.inTriangle(t1, t2, t3, p)) {
+                return false;
             }
         }
-
         return true;
     }
 }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/HoleToExterior.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/HoleToExterior.java
index 4ee173c257..91b2b915bd 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/HoleToExterior.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/HoleToExterior.java
@@ -21,6 +21,7 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
+import java.util.stream.IntStream;
 import org.apache.sis.geometries.DataPoints;
 import org.apache.sis.geometries.DataPointsType;
 import org.apache.sis.geometries.GeometryFactory;
@@ -38,11 +39,9 @@ import org.apache.sis.maths.Vector;
  * rightmost one to the leftmost one. Each hole is joined by a cut going from a vertex of the ring to the
  * rightmost position of the hole, around the hole, and back to the same vertex of the ring. The vertex
  * of the ring is one which can be reached without crossing any edge, found as described by David Eberly
- * in <cite>Triangulation by Ear Clipping</cite>. The two sides of a cut are made of the same positions,
- * so the resulting ring touches itself along the cut, and no position is added.</p>
- *
- * <p>If no such vertex is found, which may happen with an invalid polygon, the cut starts at a new
- * position on the nearest segment of the ring, whose attributes are interpolated along that segment.</p>
+ * in <cite>Triangulation by Ear Clipping</cite>. If there is none, which may happen with an invalid polygon,
+ * the cut starts from the nearest end of the segment of the ring nearest to the hole. The two sides of a cut
+ * are made of the same positions, so the resulting ring touches itself along the cut, and no position is added.</p>
  *
  * <p>The positions are compared in the coordinate plane where the polygon has its largest area,
  * so that a vertical polygon is not degenerate. This is the first stage of {@link EarClipping}.</p>
@@ -52,144 +51,123 @@ import org.apache.sis.maths.Vector;
 public final class HoleToExterior {
 
     /**
-     * Positions of the polygon, of the exterior ring followed by those of the holes.
-     * The vertex of index <var>i</var> is the position <var>i</var> of this {@code DataPoints}
-     * if <var>i</var> is smaller than its size, or a vertex added for a cut otherwise.
+     * The polygon whose holes are joined to the exterior ring.
      */
-    private final DataPoints source;
+    private final Polygon polygon;
 
     /**
-     * Positions of all the vertices, with their coordinates in the order used for comparing them.
+     * Positions of the polygon, the exterior ring first, with their coordinates in the order used for comparing them.
      */
-    private final List<Vector<?>> working = new ArrayList<>();
+    private final Vector<?>[] working;
 
     /**
-     * For each vertex added to start a cut, the indices of the positions of the polygon it is interpolated from.
-     * The vertex at index <var>i</var> in this list has the index {@code source.size() + i}.
+     * Whether the exterior ring is clockwise. The joined ring has the same orientation.
      */
-    private final List<int[]> addedIndices = new ArrayList<>();
+    private final boolean clockwise;
 
     /**
-     * For each vertex added to start a cut, the weights of the positions of the polygon it is interpolated from.
+     * Prepares the joining of the holes of the given polygon.
      */
-    private final List<double[]> addedWeights = new ArrayList<>();
+    private HoleToExterior(final Polygon polygon) {
+        this.polygon = polygon;
+        final DataPoints exterior  = polygon.getExteriorRing().getDataPoints();
+        final DataPoints positions = polygon.getDataPoints();
+        final int[] axes = EarClipping.workingAxes(exterior.getAttributeArray(DataPointsType.ATT_POSITION));
+        working = new Vector<?>[positions.size()];
+        Arrays.setAll(working, (i) -> positions.getPosition(i).swizzle(axes));
+        clockwise = Maths.isClockWise(Arrays.asList(working).subList(0, exterior.size()));
+    }
 
     /**
-     * Prepares the joining of the holes of the given polygon.
+     * Returns the exterior ring of the given polygon with its holes joined to it, as the indices of its positions
+     * in {@code polygon.getDataPoints()}, where the positions of the exterior ring are followed by those of the holes.
+     * The positions at both ends of the cuts are repeated, but no position is added. The ring is closed: its last
+     * index is the one of the last position of the exterior ring, which is at the same location as the first one.
+     *
+     * @param  polygon  the polygon whose holes are to be joined to the exterior ring.
+     * @return indices of the positions of the joined ring, or an empty array if the exterior ring is empty.
      */
-    private HoleToExterior(final Polygon polygon) {
-        source = polygon.getDataPoints();
-        final int[] axes = EarClipping.workingAxes(
-                polygon.getExteriorRing().getDataPoints().getAttributeArray(DataPointsType.ATT_POSITION));
-        for (int i = 0, n = source.size(); i < n; i++) {
-            working.add(source.getPosition(i).swizzle(axes));
+    public static int[] ring(final Polygon polygon) {
+        if (polygon.getExteriorRing().getDataPoints().isEmpty()) {
+            return new int[0];
         }
+        return new HoleToExterior(polygon).join();
     }
 
     /**
-     * Returns a polygon without hole covering the same area as the given polygon.
-     * The exterior ring of the returned polygon is the exterior ring of the given polygon with the holes
-     * joined to it. Its positions are those of the given polygon, with all their attributes, some of them
-     * repeated at both ends of the cuts. Positions are added only for the cuts which could not start
-     * from a vertex.
+     * Returns a polygon without hole covering the same area as the given polygon. The exterior ring of the
+     * returned polygon is the {@linkplain #ring(Polygon) joined ring}, made of the positions of the given
+     * polygon with all their attributes.
      *
      * @param  polygon  the polygon whose holes are to be joined to the exterior ring.
      * @return the given polygon if it has no hole, or a new polygon without hole otherwise.
      */
     public static Polygon apply(final Polygon polygon) {
-        if (polygon.getExteriorRing().getDataPoints().isEmpty()) {
+        if (polygon.getExteriorRing().getDataPoints().isEmpty()
+                || polygon.getInteriorRings().stream().allMatch((hole) -> hole.getDataPoints().isEmpty())) {
             return polygon;
         }
-        for (final LinearRing hole : polygon.getInteriorRings()) {
-            if (!hole.getDataPoints().isEmpty()) {
-                return new HoleToExterior(polygon).join(polygon);
-            }
-        }
-        return polygon;
+        final GeometryFactory factory = GeometryFactory.DEFAULT;
+        return factory.createPolygon(factory.createLinearRing(polygon.getDataPoints().subset(ring(polygon))), null);
     }
 
     /**
-     * Joins the holes of the given polygon, which is the polygon given to the constructor.
+     * Joins the holes of the polygon to its exterior ring.
+     *
+     * @return indices of the positions of the joined ring.
      */
-    private Polygon join(final Polygon polygon) {
+    private int[] join() {
         //the positions of the polygon are those of the exterior ring followed by those of the holes.
         int start = polygon.getExteriorRing().getDataPoints().size();
         final List<Integer> border = range(0, start);
         final List<List<Integer>> holes = new ArrayList<>();
         for (final LinearRing hole : polygon.getInteriorRings()) {
             final int size = hole.getDataPoints().size();
-            if (size != 0) {
+            if (size > 1) {
                 holes.add(range(start, size));
             }
             start += size;
         }
 
-        //the holes shall turn in the opposite direction of the exterior ring, so that the joined ring has a single orientation.
-        final double orientation = Math.signum(area(border));
+        //the holes shall turn in the opposite direction of the exterior ring, so that the joined ring has a single
+        //orientation. Each hole is reordered to start and end at its vertex of maximum x, where its cut starts.
         for (final List<Integer> loop : holes) {
-            if (Math.signum(area(loop)) == orientation) {
+            if (Maths.isClockWise(positions(loop)) == clockwise) {
                 Collections.reverse(loop);
             }
+            loop.remove(loop.size() - 1);
+            Collections.rotate(loop, -loop.indexOf(Collections.max(loop, Comparator.comparingDouble(this::x))));
+            loop.add(loop.get(0));
         }
 
-        //sort the holes by decreasing maximum x value: the region on the right of a hole is then
+        //join the holes by decreasing maximum x: the region on the right of a hole is then
         //bounded by the exterior ring and by the holes already joined to it.
-        holes.sort(Comparator.comparingDouble((List<Integer> h) ->
-                h.stream().mapToDouble(this::x).max().orElse(Double.NaN)).reversed());
-
-        //attach holes to the main geometry
+        holes.sort(Comparator.comparingDouble((List<Integer> loop) -> x(loop.get(0))).reversed());
         for (final List<Integer> loop : holes) {
-            //remove the duplicated end point of the hole, the cut comes back to the start point instead.
-            loop.remove(loop.size()-1);
-            if (loop.isEmpty()) {
-                continue;
-            }
-            //the cut starts from the hole position of maximum x.
-            int index = 0;
-            for (int k = 1; k < loop.size(); k++) {
-                if (x(loop.get(k)) > x(loop.get(index))) {
-                    index = k;
-                }
-            }
-            final int rightmost = loop.get(index);
-            final List<Integer> cut = new ArrayList<>(loop.size() + 2);
-            cut.addAll(loop.subList(index, loop.size()));
-            cut.addAll(loop.subList(0, index + 1));
-
-            final int visible = visibleVertex(border, rightmost, orientation);
-            if (visible >= 0) {
-                //the cut goes from a position of the ring to the hole and back, no position is added.
-                cut.add(border.get(visible));
-                border.addAll(visible + 1, cut);
-            } else {
-                //no visible position, which may happen with an invalid polygon. The cut starts on the nearest
-                //segment of the ring, at a new position whose coordinates and attributes are interpolated.
-                final Vector<?> m = working.get(rightmost);
-                final double[] buffer1 = new double[2];
-                final double[] buffer2 = new double[2];
-                final double[] ratio = new double[2];
-                final int[] offset = new int[2];
-                Distance.nearest(positions(border), buffer1, List.of(m, m), buffer2, ratio, offset, 0.000000001);
-                final int bridge = interpolate(border.get(offset[0]), border.get(offset[0] + 1), ratio[0]);
-                cut.add(0, bridge);
-                cut.add(bridge);
-                border.addAll(offset[0] + 1, cut);
-            }
+            //the cut goes from a vertex of the ring around the hole, and back to that vertex.
+            final int vertex = cutStart(border, loop.get(0));
+            loop.add(border.get(vertex));
+            border.addAll(vertex + 1, loop);
         }
+        return border.stream().mapToInt(Integer::intValue).toArray();
+    }
 
-        //build the ring, with the attributes of the positions of the polygon or interpolated for the added ones.
-        final DataPointsType type = source.getType();
-        final DataPoints ring = GeometryFactory.DEFAULT.createDataPoints(type, border.size());
-        for (int i = 0; i < border.size(); i++) {
-            final int vertex = border.get(i);
-            final int added = vertex - source.size();
-            for (final String name : type.getAttributeNames()) {
-                ring.setAttribute(i, name, (added < 0) ? source.getAttribute(vertex, name)
-                        : source.interpolateAttribute(name, addedIndices.get(added), addedWeights.get(added)));
-            }
+    /**
+     * Returns the position in the ring of the vertex where the cut toward the given hole vertex starts.
+     * It is a vertex visible from the hole vertex if there is one or, which may happen with an invalid
+     * polygon, the nearest end of the segment of the ring nearest to the hole vertex.
+     *
+     * @param  border  the ring, the last position being the same vertex as the first one.
+     * @param  hole    the hole vertex of maximum x.
+     * @return position in {@code border} of the vertex where the cut starts.
+     */
+    private int cutStart(final List<Integer> border, final int hole) {
+        final int visible = visibleVertex(border, hole);
+        if (visible >= 0) {
+            return visible;
         }
-        final GeometryFactory factory = GeometryFactory.DEFAULT;
-        return factory.createPolygon(factory.createLinearRing(ring), null);
+        final Distance.SegmentPoint nearest = Distance.nearestSegment(positions(border), working[hole]);
+        return occurrence(border, nearest.segment() + (nearest.ratio() > 0.5 ? 1 : 0), hole);
     }
 
     /**
@@ -201,112 +179,87 @@ public final class HoleToExterior {
      * of the hole vertex, the point reached and that end. Then the reflex vertex closest in angle to the ray
      * is visible.
      *
-     * @param  border       the ring, the last position being the same vertex as the first one.
-     * @param  hole         the hole vertex of maximum x.
-     * @param  orientation  1 if the ring is counterclockwise, -1 if it is clockwise.
+     * @param  border  the ring, the last position being the same vertex as the first one.
+     * @param  hole    the hole vertex of maximum x.
      * @return position in {@code border} of a visible vertex, or -1.
      */
-    private int visibleVertex(final List<Integer> border, final int hole, final double orientation) {
+    private int visibleVertex(final List<Integer> border, final int hole) {
         final double mx = x(hole), my = y(hole);
         double ix = Double.POSITIVE_INFINITY;
         int hit = -1;               // position of the vertex reached by the ray, if any.
         int edge = -1;              // position of the start of the segment reached by the ray, if any.
         for (int i = 1; i < border.size(); i++) {
             final int a = border.get(i-1), b = border.get(i);
-            final double ay = y(a), by = y(b);
-            for (final int k : new int[] {i-1, i}) {
-                final int v = border.get(k);
-                if (y(v) == my && x(v) >= mx && x(v) < ix) {
-                    ix = x(v); hit = k; edge = -1;
-                }
-            }
-            if ((ay < my && by > my) || (ay > my && by < my)) {
-                final double x = x(a) + (my - ay) * (x(b) - x(a)) / (by - ay);
+            if (y(b) == my && x(b) >= mx && x(b) < ix) {
+                ix = x(b); hit = i; edge = -1;
+            } else if ((y(a) < my && y(b) > my) || (y(a) > my && y(b) < my)) {
+                final double x = x(a) + (my - y(a)) * (x(b) - x(a)) / (y(b) - y(a));
                 if (x >= mx && x < ix) {
                     ix = x; hit = -1; edge = i-1;
                 }
             }
         }
         if (hit >= 0) {
-            return occurrence(border, hit, hole, orientation);
+            return occurrence(border, hit, hole);
         }
         if (edge < 0) {
             return -1;
         }
-        int best = (x(border.get(edge + 1)) > x(border.get(edge))) ? edge + 1 : edge;
-        final int end = border.get(best);
-        final double[] m = {mx, my}, reached = {ix, my}, p = {x(end), y(end)};
-        double bestAngle = angle(hole, end);
-        double bestDistance = distance(hole, end);
-        for (int k = 0; k < border.size() - 1; k++) {
-            final int v = border.get(k);
-            if ((x(v) == p[0] && y(v) == p[1]) || !isReflex(border, k, orientation)
-                    || !Maths.inTriangle(m, reached, p, new double[] {x(v), y(v)})) {
-                continue;
-            }
-            final double angle = angle(hole, v);
-            final double distance = distance(hole, v);
-            if (angle < bestAngle || (angle == bestAngle && distance < bestDistance)) {
-                best = k;
-                bestAngle = angle;
-                bestDistance = distance;
-            }
-        }
-        return occurrence(border, best, hole, orientation);
+        final int end = (x(border.get(edge + 1)) > x(border.get(edge))) ? edge + 1 : edge;
+        final Vector<?> m = working[hole], p = working[border.get(end)];
+        final Vector<?> reached = m.copy();
+        reached.set(0, ix);
+        final Comparator<Integer> closestToRay = Comparator.comparingDouble((Integer k) -> angle(hole, border.get(k)))
+                .thenComparingDouble((k) -> Distance.distanceSquare(m, working[border.get(k)]));
+        final int visible = IntStream.range(0, border.size() - 1).boxed().filter((k) -> {
+            final Vector<?> v = working[border.get(k)];
+            return !v.equals(p) && isReflex(border, k) && Maths.inTriangle(m, reached, p, v);
+        }).min(closestToRay).orElse(end);
+        return occurrence(border, visible, hole);
     }
 
     /**
      * Returns the position in the ring of the vertex at the given position, or of another occurrence of it
      * such as those added by previous cuts, from which the segment toward the hole vertex enters the polygon.
      */
-    private int occurrence(final List<Integer> border, int position, final int hole, final double orientation) {
+    private int occurrence(final List<Integer> border, int position, final int hole) {
         if (position == border.size() - 1) {
             position = 0;           // the last position is the same vertex as the first one.
         }
-        final int vertex = border.get(position);
-        if (inCone(border, position, hole, orientation)) {
-            return position;
-        }
-        for (int k = 0; k < border.size() - 1; k++) {
-            final int v = border.get(k);
-            if (x(v) == x(vertex) && y(v) == y(vertex) && inCone(border, k, hole, orientation)) {
-                return k;
+        if (!inCone(border, position, hole)) {
+            final Vector<?> vertex = working[border.get(position)];
+            for (int k = 0; k < border.size() - 1; k++) {
+                if (working[border.get(k)].equals(vertex) && inCone(border, k, hole)) {
+                    return k;
+                }
             }
         }
         return position;
     }
 
     /**
-     * Returns whether the segment from the vertex at the given position of the ring toward the given point
-     * starts inside the polygon, which is between the two segments of the ring at that vertex.
-     * This is the {@code InCone} test of Joseph O'Rourke in <cite>Computational Geometry in C</cite>.
+     * Returns whether the segment from the vertex at the given position of the ring toward the given vertex
+     * starts inside the polygon.
      */
-    private boolean inCone(final List<Integer> border, final int position, final int point, final double orientation) {
-        final int previous = border.get(position == 0 ? border.size() - 2 : position - 1);
-        final int vertex   = border.get(position);
-        final int next     = border.get(position + 1);
-        if (orientation * cross(vertex, next, previous) >= 0) {
-            return orientation * cross(vertex, point, previous) > 0
-                && orientation * cross(point, vertex, next) > 0;
-        }
-        return !(orientation * cross(vertex, point, next) >= 0
-              && orientation * cross(point, vertex, previous) >= 0);
+    private boolean inCone(final List<Integer> border, final int position, final int point) {
+        return Maths.inCone(working[previous(border, position)], working[border.get(position)],
+                            working[border.get(position + 1)], working[point], clockwise);
     }
 
     /**
-     * Returns whether the ring turns in the opposite direction of its orientation at the given position.
+     * Returns whether the ring turns toward its outside at the given position,
+     * which is a convex vertex for the ring traversed in the opposite direction.
      */
-    private boolean isReflex(final List<Integer> border, final int position, final double orientation) {
-        final int previous = border.get(position == 0 ? border.size() - 2 : position - 1);
-        return orientation * cross(previous, border.get(position), border.get(position + 1)) < 0;
+    private boolean isReflex(final List<Integer> border, final int position) {
+        return Maths.isConvex(working[previous(border, position)], working[border.get(position)],
+                              working[border.get(position + 1)], !clockwise);
     }
 
     /**
-     * Returns the cross product of the vectors from <var>p</var> to <var>q</var> and from <var>p</var> to <var>r</var>,
-     * positive if <var>r</var> is on the left of the line from <var>p</var> to <var>q</var>.
+     * Returns the vertex before the given position of the ring, the last position being the same vertex as the first one.
      */
-    private double cross(final int p, final int q, final int r) {
-        return (x(q) - x(p)) * (y(r) - y(p)) - (y(q) - y(p)) * (x(r) - x(p));
+    private static int previous(final List<Integer> border, final int position) {
+        return border.get((position == 0) ? border.size() - 2 : position - 1);
     }
 
     /**
@@ -316,105 +269,35 @@ public final class HoleToExterior {
         return Math.abs(Math.atan2(y(to) - y(from), x(to) - x(from)));
     }
 
-    /**
-     * Returns the squared distance between the two given vertices.
-     */
-    private double distance(final int from, final int to) {
-        final double dx = x(to) - x(from), dy = y(to) - y(from);
-        return dx*dx + dy*dy;
-    }
-
-    /**
-     * Returns twice the signed area of the given ring, positive if it is counterclockwise.
-     */
-    private double area(final List<Integer> ring) {
-        double area = 0;
-        for (int i = 0, n = ring.size(); i < n; i++) {
-            final int a = ring.get(i), b = ring.get((i + 1) % n);
-            area += x(a) * y(b) - x(b) * y(a);
-        }
-        return area;
-    }
-
     /**
      * Returns the first working coordinate of the given vertex.
      */
     private double x(final int vertex) {
-        return working.get(vertex).get(0);
+        return working[vertex].get(0);
     }
 
     /**
      * Returns the second working coordinate of the given vertex.
      */
     private double y(final int vertex) {
-        return working.get(vertex).get(1);
-    }
-
-    /**
-     * Returns the indices from {@code start} inclusive to {@code start + count} exclusive, in a mutable list.
-     */
-    private static List<Integer> range(final int start, final int count) {
-        final List<Integer> indices = new ArrayList<>(count);
-        for (int i = 0; i < count; i++) {
-            indices.add(start + i);
-        }
-        return indices;
+        return working[vertex].get(1);
     }
 
     /**
      * Returns the working positions of the given vertices.
      */
     private List<Vector<?>> positions(final List<Integer> vertices) {
-        final List<Vector<?>> positions = new ArrayList<>(vertices.size());
-        for (final int vertex : vertices) {
-            positions.add(working.get(vertex));
-        }
-        return positions;
+        return vertices.stream().<Vector<?>>map((vertex) -> working[vertex]).toList();
     }
 
     /**
-     * Adds a vertex on the segment joining the two given vertices, and returns its index.
-     * Its working position is interpolated linearly, and its attributes will be interpolated
-     * from the positions of the polygon the two vertices are made of.
-     *
-     * @param  start  index of the vertex at ratio 0.
-     * @param  end    index of the vertex at ratio 1.
-     * @param  ratio  position of the new vertex between the two vertices, from 0 to 1.
-     * @return index of the new vertex.
+     * Returns the indices from {@code start} inclusive to {@code start + count} exclusive, in a mutable list.
      */
-    private int interpolate(final int start, final int end, final double ratio) {
-        final Vector<?> p0 = working.get(start);
-        final Vector<?> p1 = working.get(end);
-        final Vector<?> position = p0.copy();
-        for (int d = 0; d < position.getDimension(); d++) {
-            position.set(d, p0.get(d) + ratio * (p1.get(d) - p0.get(d)));
+    private static List<Integer> range(final int start, final int count) {
+        final List<Integer> indices = new ArrayList<>(count);
+        for (int i = 0; i < count; i++) {
+            indices.add(start + i);
         }
-        final int[] i0 = sourceIndices(start), i1 = sourceIndices(end);
-        final double[] w0 = sourceWeights(start), w1 = sourceWeights(end);
-        final int[] indices = Arrays.copyOf(i0, i0.length + i1.length);
-        System.arraycopy(i1, 0, indices, i0.length, i1.length);
-        final double[] weights = new double[indices.length];
-        for (int i = 0; i < w0.length; i++) weights[i] = (1 - ratio) * w0[i];
-        for (int i = 0; i < w1.length; i++) weights[i0.length + i] = ratio * w1[i];
-        working.add(position);
-        addedIndices.add(indices);
-        addedWeights.add(weights);
-        return working.size() - 1;
-    }
-
-    /**
-     * Returns the indices of the positions of the polygon the given vertex is interpolated from.
-     */
-    private int[] sourceIndices(final int vertex) {
-        final int added = vertex - source.size();
-        return (added < 0) ? new int[] {vertex} : addedIndices.get(added);
-    }
-
-    /**
-     * Returns the weights of the positions of the polygon the given vertex is interpolated from.
-     */
-    private double[] sourceWeights(final int vertex) {
-        final int added = vertex - source.size();
-        return (added < 0) ? new double[] {1} : addedWeights.get(added);
+        return indices;
     }
 }
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/triangulate/HoleToExteriorTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/triangulate/HoleToExteriorTest.java
index 1b8d9802e5..0240a699d2 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/triangulate/HoleToExteriorTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/triangulate/HoleToExteriorTest.java
@@ -133,6 +133,35 @@ public class HoleToExteriorTest {
         assertAttribute(ring);
     }
 
+    /**
+     * The joined ring is given as indices of the positions of the polygon, those of the exterior ring first.
+     * It ends with the closing position of the exterior ring.
+     */
+    @Test
+    public void testRingIndices() {
+        final CoordinateReferenceSystem crs = CommonCRS.WGS84.normalizedGeographic();
+        final Polygon polygon = GeometryFactory.DEFAULT.createPolygon(ring(crs, 0,0, 10,0, 10,10, 0,10, 0,0),
+                List.of(ring(crs, 2,3, 2,5, 4,5, 4,3, 2,3)));
+        //the cut goes from (10,0) to the hole position (4,5) of index 7, around the hole, and back.
+        assertArrayEquals(new int[] {0, 1, 7, 8, 5, 6, 7, 1, 2, 3, 4}, HoleToExterior.ring(polygon));
+        assertArrayEquals(new int[] {0, 1, 2, 3}, HoleToExterior.ring(
+                GeometryFactory.DEFAULT.createPolygon(ring(crs, 0,0, 1,0, 1,1, 0,0), null)), "Without hole");
+    }
+
+    /**
+     * A hole outside of the exterior ring makes an invalid polygon, where no vertex is visible from the hole.
+     * The cut shall start from the nearest end of the nearest segment of the exterior ring, without adding position.
+     */
+    @Test
+    public void testHoleOutside() {
+        final CoordinateReferenceSystem crs = CommonCRS.WGS84.normalizedGeographic();
+        final Polygon polygon = GeometryFactory.DEFAULT.createPolygon(ring(crs, 0,0, 4,0, 4,4, 0,4, 0,0),
+                List.of(ring(crs, 6,1, 6,3, 8,3, 8,1, 6,1)));
+        //the hole position (8,3) of index 7 is nearest to the segment from (4,0) to (4,4), at (4,3).
+        assertArrayEquals(new int[] {0, 1, 2, 7, 8, 5, 6, 7, 2, 3, 4}, HoleToExterior.ring(polygon));
+        assertNoAddedPosition(polygon, HoleToExterior.apply(polygon).getExteriorRing().getDataPoints());
+    }
+
     /**
      * A reflex vertex of the exterior ring hides the end of the segment reached by the ray from the hole,
      * so the cut shall go to that reflex vertex.


(sis) 12/13: feat(Geometry): base topology implementation

Posted by js...@apache.org.
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

commit 30ea2c5c0f9ed573fa8f58159e2be0854cffeb75
Author: jsorel <jo...@geomatys.com>
AuthorDate: Mon Oct 19 16:01:47 2026 +0200

    feat(Geometry): base topology implementation
---
 .../internal/shared/topology/ComplexBuilder.java   |  678 ++++++++
 .../internal/shared/topology/ComplexEditor.java    | 1091 +++++++++++++
 .../internal/shared/topology/ComplexGeometry.java  |  475 ++++++
 .../internal/shared/topology/ComplexValidator.java |  415 +++++
 .../shared/topology/DefaultExpression.java         |  278 ++++
 .../internal/shared/topology/IndexedComplex.java   | 1637 ++++++++++++++++++++
 .../internal/shared/topology/IndexedEdge.java      |  124 ++
 .../internal/shared/topology/IndexedFace.java      |  129 ++
 .../internal/shared/topology/IndexedNode.java      |  105 ++
 .../internal/shared/topology/IndexedSolid.java     |  115 ++
 .../internal/shared/topology/IndexedTopo.java      |  150 ++
 .../internal/shared/topology/NodePoints.java       |  385 +++++
 .../internal/shared/topology/package-info.java     |   37 +
 .../shared/topology/ComplexBuilderTest.java        |  517 +++++++
 .../shared/topology/ComplexEditorTest.java         |  323 ++++
 .../shared/topology/DefaultExpressionTest.java     |  157 ++
 .../internal/shared/topology/NodePointsTest.java   |  152 ++
 17 files changed, 6768 insertions(+)

diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/ComplexBuilder.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/ComplexBuilder.java
new file mode 100644
index 0000000000..7ccb6b7d84
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/ComplexBuilder.java
@@ -0,0 +1,678 @@
+/*
+ * 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.internal.shared.topology;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.IntUnaryOperator;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.geometries.DataPoints;
+import org.apache.sis.geometries.DataPointsType;
+import org.apache.sis.geometries.Geometry;
+import org.apache.sis.geometries.GeometryCollection;
+import org.apache.sis.geometries.Point;
+import org.apache.sis.geometries.curve.LineString;
+import org.apache.sis.geometries.curve.LinearRing;
+import org.apache.sis.geometries.internal.shared.ArrayDataPoints;
+import org.apache.sis.geometries.mesh.MeshPrimitive;
+import org.apache.sis.geometries.solid.Polyhedron;
+import org.apache.sis.geometries.surface.Polygon;
+import org.apache.sis.geometries.surface.PolyhedralSurface;
+import org.apache.sis.maths.Array;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.Utilities;
+import static org.apache.sis.geometries.internal.shared.topology.IndexedComplex.index;
+
+
+/**
+ * Builds an {@link IndexedComplex} from geometries. This is the construction of a topological complex
+ * from a geometric collection defined by ISO 19107:2019 section 10.7.2.2.
+ *
+ * <p>The supported geometries are points, line strings, polygons (including triangles), polyhedral surfaces,
+ * polyhedra, meshes of points, lines or triangles, and collections of them. Their primitives are merged
+ * as follows:</p>
+ * <ul>
+ *   <li>Positions having exactly the same coordinates are the same node. There is no tolerance.
+ *       The node keeps the other attributes of the positions, such as normals or colors, taking each attribute
+ *       from the first position having it. See {@link NodePoints}.</li>
+ *   <li>Segments between the same two nodes are the same edge, whether they come from lines or polygons.</li>
+ *   <li>Polygons having the same edges are the same face, possibly with opposite orientations.
+ *       This is how the face between two adjacent polyhedra is shared.</li>
+ * </ul>
+ *
+ * <p>The orientations are normalized: the holes of a face are wound opposite to its exterior ring,
+ * so that the face is on the left of all its edges, and the shells of a polyhedron are oriented
+ * by the sign of the volume they enclose, so that the solid is below all its faces.</p>
+ *
+ * <p>{@link #build()} verifies that the interiors of the primitives are mutually disjoint, as required by
+ * ISO 19107, except for the primitives isolated in a primitive of higher dimension: a node inside a face
+ * or a solid, and an edge inside a solid. A point inside an edge, or a line crossing a face, is rejected
+ * rather than split: splitting is done afterward with {@link ComplexEditor}. A builder which has thrown
+ * an exception should be discarded.</p>
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class ComplexBuilder {
+
+    /**
+     * The reference system of all geometries, or {@code null} if no geometry has been added yet.
+     */
+    private CoordinateReferenceSystem crs;
+
+    /**
+     * Number of coordinates of each position: 2 or 3, or 0 if no geometry has been added yet.
+     */
+    private int dimension;
+
+    /**
+     * Index of the node at each position.
+     */
+    private final Map<Position, Integer> nodes = new HashMap<>();
+
+    /**
+     * Positions and other attributes of the nodes, or {@code null} if no geometry has been added yet.
+     */
+    private NodePoints points;
+
+    /**
+     * Index of the edge between two nodes, keyed by the smallest node index in the high bits.
+     */
+    private final Map<Long, Integer> edges = new HashMap<>();
+
+    /**
+     * Index of the face having a given set of edges.
+     */
+    private final Map<EdgeSet, Integer> faces = new HashMap<>();
+
+    /**
+     * The arrays of the complex being built. See {@link IndexedComplex} for their meaning.
+     */
+    private final Ints edgeNodes   = new Ints(),
+                       faceRings   = new Ints(0),
+                       ringEdges   = new Ints(0),
+                       faceEdges   = new Ints(),
+                       solidShells = new Ints(0),
+                       shellFaces  = new Ints(0),
+                       solidFaces  = new Ints();
+
+    /**
+     * The geometries given to {@link #add(Geometry)}.
+     */
+    private final List<Geometry> sources = new ArrayList<>();
+
+    /**
+     * Creates an empty builder.
+     */
+    public ComplexBuilder() {
+    }
+
+    /**
+     * Adds the primitives of the given geometry. All geometries shall have the same coordinate reference system,
+     * of 2 or 3 dimensions.
+     *
+     * @param  geometry  a point, line string, polygon, polyhedral surface, polyhedron, mesh, or collection of them.
+     * @return this builder.
+     * @throws IllegalArgumentException if the geometry is not supported, has another reference system,
+     *         or has a degenerate line or ring.
+     */
+    public ComplexBuilder add(final Geometry geometry) {
+        ArgumentChecks.ensureNonNull("geometry", geometry);
+        final CoordinateReferenceSystem other = geometry.getCoordinateReferenceSystem();
+        if (other == null) {
+            throw new IllegalArgumentException("The geometry has no coordinate reference system.");
+        }
+        if (crs == null) {
+            final int n = other.getCoordinateSystem().getDimension();
+            if (n != 2 && n != 3) {
+                throw new IllegalArgumentException("Only 2 and 3 dimensional geometries are supported, found " + n + '.');
+            }
+            crs = other;
+            dimension = n;
+            points = new NodePoints(crs);
+        } else if (!Utilities.equalsIgnoreMetadata(crs, other)) {
+            throw new IllegalArgumentException("All geometries shall have the same coordinate reference system.");
+        }
+        addComponent(geometry);
+        sources.add(geometry);
+        return this;
+    }
+
+    /**
+     * Adds the primitives of the given geometry, which may be a component of a geometry given to {@link #add(Geometry)}.
+     */
+    private void addComponent(final Geometry geometry) {
+        // Meshes first, since some of them are also line strings, surfaces or collections.
+        if (geometry instanceof MeshPrimitive mesh) {
+            addMesh(mesh);
+        } else if (geometry instanceof Polyhedron solid) {
+            addPolyhedron(solid);
+        } else if (geometry instanceof Polygon polygon) {
+            addPolygon(polygon);
+        } else if (geometry instanceof PolyhedralSurface<?> surface) {
+            for (int i = 0, n = surface.getNumPatches(); i < n; i++) {
+                addPolygon(surface.getPatchN(i));
+            }
+        } else if (geometry instanceof LineString line) {
+            addLine(line);
+        } else if (geometry instanceof Point point) {
+            addPoint(point);
+        } else if (geometry instanceof GeometryCollection<?> collection) {
+            for (int i = 0, n = collection.getNumGeometries(); i < n; i++) {
+                addComponent(collection.getGeometryN(i));
+            }
+        } else {
+            throw new IllegalArgumentException("Unsupported geometry: " + geometry.getClass().getSimpleName() + '.');
+        }
+    }
+
+    /**
+     * Returns the complex of all primitives added so far.
+     *
+     * @return the complex of the added geometries.
+     * @throws IllegalArgumentException if the interiors of the primitives are not mutually disjoint,
+     *         if a face has no area, or if a shell is not closed.
+     */
+    public IndexedComplex build() {
+        return new IndexedComplex(crs, dimension, (points != null) ? points : new NodePoints(null), edgeNodes.toArray(),
+                faceRings.toArray(), ringEdges.toArray(), faceEdges.toArray(),
+                solidShells.toArray(), shellFaces.toArray(), solidFaces.toArray(), sources);
+    }
+
+    /**
+     * Adds a point as a node. An empty point is ignored.
+     */
+    private void addPoint(final Point point) {
+        if (!point.isEmpty()) {
+            final double[] position = point.getPosition().toArrayDouble();
+            if (position.length != dimension) {
+                throw new IllegalArgumentException("A point has " + position.length + " coordinates, expected " + dimension + '.');
+            }
+            node(point.getDataPoints(), 0, position, 0);
+        }
+    }
+
+    /**
+     * Adds a line string as edges between its consecutive positions. A closed line string gives a cycle of edges.
+     * An empty line string is ignored.
+     */
+    private void addLine(final LineString line) {
+        if (line.isEmpty()) {
+            return;
+        }
+        final DataPoints source = line.getDataPoints();
+        final Array positions = source.getAttributeArray(DataPointsType.ATT_POSITION);
+        ensureDimension(positions);
+        final double[] c = positions.toArrayDouble();
+        final int[] nodes = new int[c.length / dimension];
+        int n = 0;
+        for (int i = 0; i < c.length; i += dimension) {
+            final int node = node(source, i / dimension, c, i);
+            if (n == 0 || nodes[n - 1] != node) {
+                nodes[n++] = node;
+            }
+        }
+        if (n < 2) {
+            throw new IllegalArgumentException("A line has less than 2 distinct positions.");
+        }
+        for (int i = 1; i < n; i++) {
+            edge(nodes[i - 1], nodes[i]);
+        }
+    }
+
+    /**
+     * Adds a polyhedron as a solid. Each shell is reversed if needed, so that the exterior shell encloses
+     * a positive volume and the interior shells a negative one, which puts the solid below all faces.
+     */
+    private void addPolyhedron(final Polyhedron solid) {
+        if (dimension != 3) {
+            throw new IllegalArgumentException("A polyhedron requires 3-dimensional coordinates.");
+        }
+        final List<GeometryCollection<Polygon>> interiors = solid.getInteriorShells();
+        final int[][] shells = new int[1 + interiors.size()][];
+        shells[0] = addShell(solid.getExteriorShell());
+        for (int i = 0; i < interiors.size(); i++) {
+            shells[i + 1] = addShell(interiors.get(i));
+        }
+        for (int s = 0; s < shells.length; s++) {
+            final int[] shell = shells[s];
+            final double volume = signedVolume(shell);
+            if ((s == 0) ? volume < 0 : volume > 0) {
+                for (int i = 0; i < shell.length; i++) {
+                    shell[i] = ~shell[i];
+                }
+            }
+            for (final int ref : shell) {
+                solidFaces.add(ref);
+            }
+            shellFaces.add(solidFaces.size());
+        }
+        solidShells.add(shellFaces.size() - 1);
+    }
+
+    /**
+     * Adds the faces of the given shell.
+     *
+     * @return signed references to the faces, positive for the orientation of the polygons.
+     */
+    private int[] addShell(final GeometryCollection<Polygon> shell) {
+        final int[] refs = new int[shell.getNumGeometries()];
+        for (int i = 0; i < refs.length; i++) {
+            refs[i] = addPolygon(shell.getGeometryN(i));
+        }
+        return refs;
+    }
+
+    /**
+     * Adds a polygon as a face.
+     *
+     * @return signed reference to the face, positive for the orientation of the polygon.
+     */
+    private int addPolygon(final Polygon polygon) {
+        final List<LinearRing> interiors = polygon.getInteriorRings();
+        final int[][] rings = new int[1 + interiors.size()][];
+        rings[0] = ringNodes(polygon.getExteriorRing());
+        for (int i = 0; i < interiors.size(); i++) {
+            rings[i + 1] = ringNodes(interiors.get(i));
+        }
+        return addFace(rings);
+    }
+
+    /**
+     * Adds the points, segments or triangles of the given mesh as nodes, edges or faces.
+     * Segments and triangles having twice the same node are ignored.
+     */
+    private void addMesh(final MeshPrimitive mesh) {
+        final Array positions = mesh.getPositions();
+        ensureDimension(positions);
+        final Map<String, Array> attributes = new LinkedHashMap<>();
+        for (final String name : mesh.getDataPointsType().getAttributeNames()) {
+            final Array values = mesh.getAttribute(name);
+            if (values != null) {
+                attributes.put(name, values);
+            }
+        }
+        final DataPoints source = new ArrayDataPoints(attributes);      // the vertices, in the order of the index.
+        final double[] c = positions.toArrayDouble();
+        final int count = c.length / dimension;
+        final Array index = mesh.getIndex();
+        final int[] indices;
+        if (index != null) {
+            indices = index.toArrayInt();
+        } else {
+            indices = new int[count];
+            for (int i = 0; i < count; i++) {
+                indices[i] = i;
+            }
+        }
+        final int[] vertexNodes = new int[count];
+        Arrays.fill(vertexNodes, -1);
+        final IntUnaryOperator nodeOf = (vertex) -> {
+            if (vertexNodes[vertex] < 0) {
+                vertexNodes[vertex] = node(source, vertex, c, vertex * dimension);
+            }
+            return vertexNodes[vertex];
+        };
+        final int n = indices.length;
+        switch (mesh.getType()) {
+            case POINTS: {
+                for (final int vertex : indices) {
+                    nodeOf.applyAsInt(vertex);
+                }
+                break;
+            }
+            case LINES: {
+                for (int i = 0; i + 1 < n; i += 2) {
+                    addSegment(nodeOf.applyAsInt(indices[i]), nodeOf.applyAsInt(indices[i + 1]));
+                }
+                break;
+            }
+            case LINE_STRIP:
+            case LINE_LOOP: {
+                for (int i = 0; i + 1 < n; i++) {
+                    addSegment(nodeOf.applyAsInt(indices[i]), nodeOf.applyAsInt(indices[i + 1]));
+                }
+                if (mesh.getType() == MeshPrimitive.Type.LINE_LOOP && n > 2) {
+                    addSegment(nodeOf.applyAsInt(indices[n - 1]), nodeOf.applyAsInt(indices[0]));
+                }
+                break;
+            }
+            case TRIANGLES: {
+                for (int i = 0; i + 2 < n; i += 3) {
+                    addTriangle(nodeOf.applyAsInt(indices[i]), nodeOf.applyAsInt(indices[i + 1]), nodeOf.applyAsInt(indices[i + 2]));
+                }
+                break;
+            }
+            case TRIANGLE_STRIP: {
+                // Every other triangle of a strip is swapped for keeping the orientation.
+                for (int i = 0; i + 2 < n; i++) {
+                    final boolean odd = (i & 1) != 0;
+                    addTriangle(nodeOf.applyAsInt(indices[odd ? i + 1 : i]),
+                                nodeOf.applyAsInt(indices[odd ? i : i + 1]),
+                                nodeOf.applyAsInt(indices[i + 2]));
+                }
+                break;
+            }
+            case TRIANGLE_FAN: {
+                for (int i = 1; i + 1 < n; i++) {
+                    addTriangle(nodeOf.applyAsInt(indices[0]), nodeOf.applyAsInt(indices[i]), nodeOf.applyAsInt(indices[i + 1]));
+                }
+                break;
+            }
+            default: {
+                throw new IllegalArgumentException("Unsupported mesh type: " + mesh.getType() + '.');
+            }
+        }
+    }
+
+    /**
+     * Adds an edge between the given nodes, unless they are the same node.
+     */
+    private void addSegment(final int a, final int b) {
+        if (a != b) {
+            edge(a, b);
+        }
+    }
+
+    /**
+     * Adds a triangular face of the given nodes, unless two of them are the same node.
+     */
+    private void addTriangle(final int a, final int b, final int c) {
+        if (a != b && b != c && c != a) {
+            addFace(new int[][] {{a, b, c}});
+        }
+    }
+
+    /**
+     * Verifies that the given positions have the dimension of the reference system.
+     */
+    private void ensureDimension(final Array positions) {
+        final int n = positions.getDimension();
+        if (n != dimension) {
+            throw new IllegalArgumentException("Positions have " + n + " coordinates, expected " + dimension + '.');
+        }
+    }
+
+    /**
+     * Returns the nodes of the given ring, without consecutive duplicates and without repeating the first node.
+     */
+    private int[] ringNodes(final LinearRing ring) {
+        final DataPoints source = ring.getDataPoints();
+        final Array positions = source.getAttributeArray(DataPointsType.ATT_POSITION);
+        ensureDimension(positions);
+        final double[] c = positions.toArrayDouble();
+        final int[] nodes = new int[c.length / dimension];
+        int n = 0;
+        for (int i = 0; i < c.length; i += dimension) {
+            final int node = node(source, i / dimension, c, i);
+            if (n == 0 || nodes[n - 1] != node) {
+                nodes[n++] = node;
+            }
+        }
+        while (n > 1 && nodes[n - 1] == nodes[0]) {
+            n--;
+        }
+        if (n < 3) {
+            throw new IllegalArgumentException("A ring has less than 3 distinct positions.");
+        }
+        return Arrays.copyOf(nodes, n);
+    }
+
+    /**
+     * Returns the node at the position starting at the given offset, creating it if needed.
+     * The other attributes of the source point are copied to the node, unless the node already has them.
+     *
+     * @param  source  the data points from which the coordinates have been read.
+     * @param  index   index of the point in the source.
+     * @param  c       coordinates read from the source.
+     * @param  offset  index of the first coordinate of the point in {@code c}.
+     */
+    private int node(final DataPoints source, final int index, final double[] c, final int offset) {
+        final double[] position = new double[dimension];
+        for (int k = 0; k < dimension; k++) {
+            final double value = c[offset + k];
+            if (!Double.isFinite(value)) {
+                throw new IllegalArgumentException("Coordinates shall be finite.");
+            }
+            position[k] = value + 0.0;          // replaces −0 by +0, so that both are the same node.
+        }
+        final Position key = new Position(position);
+        Integer node = nodes.get(key);
+        if (node == null) {
+            node = points.add(position);
+            nodes.put(key, node);
+        }
+        points.merge(node, source, index);
+        return node;
+    }
+
+    /**
+     * Returns the edge from node {@code a} to node {@code b}, creating it if needed.
+     *
+     * @return signed reference to the edge, negative if the edge goes from {@code b} to {@code a}.
+     */
+    private int edge(final int a, final int b) {
+        final long key = (a < b) ? ((long) a << 32) | b : ((long) b << 32) | a;
+        Integer edge = edges.get(key);
+        if (edge == null) {
+            edge = edgeNodes.size() / 2;
+            edges.put(key, edge);
+            edgeNodes.add(a);
+            edgeNodes.add(b);
+            return edge;
+        }
+        return (edgeNodes.get(2 * edge) == a) ? edge : ~edge;
+    }
+
+    /**
+     * Adds a face of the given rings of nodes, the exterior ring first, or returns the existing face
+     * having the same edges. The holes are reversed if needed for being wound opposite to the exterior ring.
+     *
+     * @return signed reference to the face, positive for the orientation of the exterior ring.
+     */
+    private int addFace(final int[][] rings) {
+        final double[] normal = normal(rings[0]);
+        for (int r = 1; r < rings.length; r++) {
+            final double[] n = normal(rings[r]);
+            if (n[0]*normal[0] + n[1]*normal[1] + n[2]*normal[2] > 0) {
+                final int[] ring = rings[r];
+                for (int i = 0, j = ring.length - 1; i < j; i++, j--) {
+                    final int t = ring[i]; ring[i] = ring[j]; ring[j] = t;
+                }
+            }
+        }
+        final int[][] refs = new int[rings.length][];
+        int count = 0;
+        for (int r = 0; r < rings.length; r++) {
+            final int[] ring = rings[r];
+            refs[r] = new int[ring.length];
+            for (int i = 0; i < ring.length; i++) {
+                refs[r][i] = edge(ring[i], ring[(i + 1) % ring.length]);
+            }
+            count += ring.length;
+        }
+        final int[] sorted = new int[count];
+        count = 0;
+        for (final int[] ring : refs) {
+            for (final int ref : ring) {
+                sorted[count++] = index(ref);
+            }
+        }
+        Arrays.sort(sorted);
+        final EdgeSet key = new EdgeSet(sorted);
+        final Integer existing = faces.get(key);
+        if (existing != null) {
+            return sameOrientation(existing, refs[0][0]) ? existing : ~existing;
+        }
+        final int face = faceRings.size() - 1;
+        for (final int[] ring : refs) {
+            for (final int ref : ring) {
+                faceEdges.add(ref);
+            }
+            ringEdges.add(faceEdges.size());
+        }
+        faceRings.add(ringEdges.size() - 1);
+        faces.put(key, face);
+        return face;
+    }
+
+    /**
+     * Returns whether the given face uses the edge of the given signed reference in the same direction.
+     */
+    private boolean sameOrientation(final int face, final int edgeRef) {
+        final int edge = index(edgeRef);
+        for (int i = ringEdges.get(faceRings.get(face)), end = ringEdges.get(faceRings.get(face + 1)); i < end; i++) {
+            final int ref = faceEdges.get(i);
+            if (index(ref) == edge) {
+                return (ref < 0) == (edgeRef < 0);
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Returns the coordinate of the given node on the given axis, 0 for the third axis in two dimensions.
+     */
+    private double coordinate(final int node, final int axis) {
+        return (axis < dimension) ? points.coordinates[node * dimension + axis] : 0;
+    }
+
+    /**
+     * Returns the normal of the given ring of nodes with Newell's method,
+     * pointing toward an observer who sees the ring counterclockwise.
+     */
+    private double[] normal(final int[] ring) {
+        final double[] normal = new double[3];
+        for (int i = 0; i < ring.length; i++) {
+            final int a = ring[i], b = ring[(i + 1) % ring.length];
+            final double ax = coordinate(a, 0), ay = coordinate(a, 1), az = coordinate(a, 2);
+            final double bx = coordinate(b, 0), by = coordinate(b, 1), bz = coordinate(b, 2);
+            normal[0] += (ay - by) * (az + bz);
+            normal[1] += (az - bz) * (ax + bx);
+            normal[2] += (ax - bx) * (ay + by);
+        }
+        return normal;
+    }
+
+    /**
+     * Returns the volume enclosed by the given faces, positive if they are oriented outward.
+     * The volume is the sum of the tetrahedra joining a reference node to the triangles
+     * of a fan over each ring, which is exact for planar rings, convex or not.
+     */
+    private double signedVolume(final int[] shell) {
+        final int origin = startNode(faceEdges.get(ringEdges.get(faceRings.get(index(shell[0])))));
+        double volume = 0;
+        for (final int ref : shell) {
+            final int face = index(ref);
+            double sum = 0;
+            for (int r = faceRings.get(face); r < faceRings.get(face + 1); r++) {
+                final int lo = ringEdges.get(r), hi = ringEdges.get(r + 1);
+                final int first = startNode(faceEdges.get(lo));
+                for (int i = lo + 1; i + 1 < hi; i++) {
+                    sum += determinant(origin, first, startNode(faceEdges.get(i)), startNode(faceEdges.get(i + 1)));
+                }
+            }
+            volume += (ref < 0) ? -sum : sum;
+        }
+        return volume / 6;
+    }
+
+    /**
+     * Returns the node where the given oriented edge starts.
+     */
+    private int startNode(final int edgeRef) {
+        return edgeNodes.get(2 * index(edgeRef) + (edgeRef < 0 ? 1 : 0));
+    }
+
+    /**
+     * Returns the determinant of the vectors from node {@code o} to nodes {@code a}, {@code b} and {@code c},
+     * which is six times the signed volume of the tetrahedron.
+     */
+    private double determinant(final int o, final int a, final int b, final int c) {
+        final double ox = coordinate(o, 0), oy = coordinate(o, 1), oz = coordinate(o, 2);
+        final double ax = coordinate(a, 0) - ox, ay = coordinate(a, 1) - oy, az = coordinate(a, 2) - oz;
+        final double bx = coordinate(b, 0) - ox, by = coordinate(b, 1) - oy, bz = coordinate(b, 2) - oz;
+        final double cx = coordinate(c, 0) - ox, cy = coordinate(c, 1) - oy, cz = coordinate(c, 2) - oz;
+        return ax * (by*cz - bz*cy) + ay * (bz*cx - bx*cz) + az * (bx*cy - by*cx);
+    }
+
+    /**
+     * Coordinates of a node, compared by value.
+     */
+    private record Position(double[] coordinates) {
+        @Override
+        public boolean equals(final Object obj) {
+            return (obj instanceof Position other) && Arrays.equals(coordinates, other.coordinates);
+        }
+
+        @Override
+        public int hashCode() {
+            return Arrays.hashCode(coordinates);
+        }
+    }
+
+    /**
+     * Sorted indices of the edges of a face, compared by value.
+     */
+    private record EdgeSet(int[] edges) {
+        @Override
+        public boolean equals(final Object obj) {
+            return (obj instanceof EdgeSet other) && Arrays.equals(edges, other.edges);
+        }
+
+        @Override
+        public int hashCode() {
+            return Arrays.hashCode(edges);
+        }
+    }
+
+    /**
+     * A growable array of integers.
+     */
+    private static final class Ints {
+        private int[] values = new int[16];
+        private int size;
+
+        Ints(final int... initial) {
+            for (final int value : initial) {
+                add(value);
+            }
+        }
+
+        void add(final int value) {
+            if (size == values.length) {
+                values = Arrays.copyOf(values, size * 2);
+            }
+            values[size++] = value;
+        }
+
+        int get(final int i) {
+            return values[i];
+        }
+
+        int size() {
+            return size;
+        }
+
+        int[] toArray() {
+            return Arrays.copyOf(values, size);
+        }
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/ComplexEditor.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/ComplexEditor.java
new file mode 100644
index 0000000000..460b9ded29
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/ComplexEditor.java
@@ -0,0 +1,1091 @@
+/*
+ * 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.internal.shared.topology;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.BitSet;
+import java.util.List;
+import org.apache.sis.geometries.Point;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.Utilities;
+import static org.apache.sis.geometries.internal.shared.topology.IndexedComplex.index;
+import static org.apache.sis.geometries.internal.shared.topology.IndexedComplex.faceContainer;
+import static org.apache.sis.geometries.internal.shared.topology.IndexedComplex.solidContainer;
+
+
+/**
+ * Edits an {@link IndexedComplex} while keeping the interiors of its primitives disjoint.
+ * The operations are modeled after the topology editing functions of ISO/IEC 13249-3 (SQL/MM Spatial),
+ * generalized to three dimensions:
+ *
+ * <table class="sis">
+ *   <caption>Editing operations</caption>
+ *   <tr><th>Method</th>                                 <th>SQL/MM equivalent</th></tr>
+ *   <tr><td>{@link #addIsolatedNode(double...)}, {@link #addIsolatedNode(Point)}</td> <td>{@code ST_AddIsoNode}</td></tr>
+ *   <tr><td>{@link #moveIsolatedNode(int, double...)}</td> <td>{@code ST_MoveIsoNode}</td></tr>
+ *   <tr><td>{@link #removeIsolatedNode(int)}</td>       <td>{@code ST_RemIsoNode}</td></tr>
+ *   <tr><td>{@link #addEdge(int, int)}</td>             <td>{@code ST_AddIsoEdge}, for any nodes</td></tr>
+ *   <tr><td>{@link #removeEdge(int)}</td>               <td>{@code ST_RemIsoEdge}</td></tr>
+ *   <tr><td>{@link #splitEdge(int, double...)}</td>     <td>{@code ST_ModEdgeSplit}</td></tr>
+ *   <tr><td>{@link #healEdges(int, int)}</td>           <td>{@code ST_ModEdgeHeal}</td></tr>
+ *   <tr><td>{@link #splitFace(int, int...)}</td>        <td>{@code ST_AddEdgeModFace}, for a path of edges</td></tr>
+ *   <tr><td>{@link #mergeFaces(int)}</td>               <td>{@code ST_RemEdgeModFace}</td></tr>
+ *   <tr><td>{@link #addFace(int[]...)}, {@link #removeFace(int)}</td>   <td>none, faces are implicit in SQL/MM</td></tr>
+ *   <tr><td>{@link #addSolid(int[]...)}, {@link #removeSolid(int)}</td> <td>none, SQL/MM is planar</td></tr>
+ * </table>
+ *
+ * <p>Primitives are designated by their index in the complex, and oriented primitives by signed references:
+ * {@code i} for the positive orientation of primitive <var>i</var>, {@code ~i} for its negative orientation.
+ * Each operation verifies its arguments and the geometry before modifying the complex, so that an operation
+ * which throws an exception leaves the complex unchanged. The geometric verifications use the tolerance of
+ * the complex, and compare the new primitives with all the primitives of the complex, without spatial index.</p>
+ *
+ * <p>Removed primitives keep their index, which is not reused. There is no verification that faces are planar:
+ * splitting and merging faces only changes the topology, and the realization of a face is a polygon through
+ * its nodes.</p>
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class ComplexEditor {
+
+    /**
+     * The complex to edit.
+     */
+    private final IndexedComplex complex;
+
+    /**
+     * The geometric predicates on the complex.
+     */
+    private final ComplexGeometry geometry;
+
+    /**
+     * Creates an editor of the given complex.
+     *
+     * @param  complex  the complex to edit, which shall not be empty.
+     * @throws IllegalArgumentException if the complex is empty, since it has no coordinate reference system.
+     */
+    public ComplexEditor(final IndexedComplex complex) {
+        ArgumentChecks.ensureNonNull("complex", complex);
+        if (complex.dimension == 0) {
+            throw new IllegalArgumentException("An empty complex has no coordinate reference system.");
+        }
+        this.complex = complex;
+        geometry = new ComplexGeometry(complex);
+    }
+
+    /**
+     * Returns the complex edited by this editor.
+     *
+     * @return the edited complex.
+     */
+    public IndexedComplex getComplex() {
+        return complex;
+    }
+
+    // ──────────────────────────────────────────────────────────────────────────────────────────
+    //  Nodes
+    // ──────────────────────────────────────────────────────────────────────────────────────────
+
+    /**
+     * Adds a node without edge. The node is isolated in the face or solid which contains it, if any.
+     *
+     * @param  coordinates  the coordinates of the node, in the reference system of the complex.
+     * @return index of the new node.
+     * @throws IllegalArgumentException if the position is on a node or inside an edge.
+     */
+    public int addIsolatedNode(final double... coordinates) {
+        final double[] p = toXYZ(coordinates);
+        checkPosition(p, -1);
+        final int container = containerOf(p);
+        final int node = complex.newNode(Arrays.copyOf(p, complex.dimension));
+        complex.setNodeContainer(node, container);
+        return node;
+    }
+
+    /**
+     * Adds a node without edge at the position of the given point, with the other attributes of the point.
+     * The node is isolated in the face or solid which contains it, if any.
+     *
+     * @param  point  the point to add, in the reference system of the complex.
+     * @return index of the new node.
+     * @throws IllegalArgumentException if the point has another reference system, is empty,
+     *         or if its position is on a node or inside an edge.
+     */
+    public int addIsolatedNode(final Point point) {
+        ArgumentChecks.ensureNonNull("point", point);
+        if (point.isEmpty()) {
+            throw failure("The point is empty.");
+        }
+        if (!Utilities.equalsIgnoreMetadata(complex.crs, point.getCoordinateReferenceSystem())) {
+            throw failure("The point is not in the reference system of the complex.");
+        }
+        final int node = addIsolatedNode(point.getPosition().toArrayDouble());
+        complex.points.merge(node, point.getDataPoints(), 0);
+        return node;
+    }
+
+    /**
+     * Moves a node without edge. The face or solid in which the node is isolated is updated.
+     * The other attributes of the node are unchanged.
+     *
+     * @param  node         index of the node to move.
+     * @param  coordinates  the new coordinates of the node, in the reference system of the complex.
+     * @throws IllegalArgumentException if the node is the end of an edge, or if the new position
+     *         is on another node or inside an edge.
+     */
+    public void moveIsolatedNode(final int node, final double... coordinates) {
+        checkNode(node);
+        if (complex.nodeCo[node].length != 0) {
+            throw failure("The node " + geometry.describeNode(node) + " is the end of an edge.");
+        }
+        final double[] p = toXYZ(coordinates);
+        checkPosition(p, node);
+        final int container = containerOf(p);
+        complex.moveNode(node, Arrays.copyOf(p, complex.dimension));
+        complex.setNodeContainer(node, container);
+    }
+
+    /**
+     * Removes a node without edge.
+     *
+     * @param  node  index of the node to remove.
+     * @throws IllegalArgumentException if the node is the end of an edge.
+     */
+    public void removeIsolatedNode(final int node) {
+        checkNode(node);
+        if (complex.nodeCo[node].length != 0) {
+            throw failure("The node " + geometry.describeNode(node) + " is the end of an edge.");
+        }
+        complex.removeNode(node);
+    }
+
+    // ──────────────────────────────────────────────────────────────────────────────────────────
+    //  Edges
+    // ──────────────────────────────────────────────────────────────────────────────────────────
+
+    /**
+     * Adds an edge between two nodes, on the boundary of no face. In three dimensions,
+     * the edge is isolated in the solid which contains it, if any. An edge inside a face
+     * shall be added by {@link #splitFace(int, int...)} instead.
+     *
+     * @param  start  index of the node where the edge starts.
+     * @param  end    index of the node where the edge ends.
+     * @return index of the new edge.
+     * @throws IllegalArgumentException if there is already an edge between the nodes, or if the segment
+     *         between them passes through a node, crosses an edge, or meets the interior of a face.
+     */
+    public int addEdge(final int start, final int end) {
+        checkNode(start);
+        checkNode(end);
+        if (start == end) {
+            throw failure("An edge needs two distinct nodes.");
+        }
+        checkNoEdge(start, end);
+        checkSegment(start, end, -1);
+        final double[] a = geometry.point(start), b = geometry.point(end);
+        final double[] middle = {(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2};
+        int container = -1;
+        for (int s = 0; s < complex.solidCount; s++) {
+            if (!complex.removedSolids.get(s) && geometry.insideSolid(complex.solidShells[s], middle)) {
+                container = s;
+                break;
+            }
+        }
+        final int edge = complex.newEdge(start, end);
+        complex.setEdgeContainer(edge, container);
+        return edge;
+    }
+
+    /**
+     * Removes an edge on the boundary of no face. The nodes at the ends of the edge are kept.
+     *
+     * @param  edge  index of the edge to remove.
+     * @throws IllegalArgumentException if the edge is on the boundary of a face.
+     */
+    public void removeEdge(final int edge) {
+        checkEdge(edge);
+        if (complex.edgeCo[edge].length != 0) {
+            throw failure("The " + geometry.describeEdge(edge) + " is on the boundary of a face.");
+        }
+        complex.removeEdge(edge);
+    }
+
+    /**
+     * Splits an edge by a new node. The edge keeps its start and ends at the new node, and a new edge goes
+     * from the new node to the old end of the edge. Each face around the edge is bounded by both edges.
+     * The attributes of the new node other than its position are interpolated between the ends of the edge,
+     * as specified by {@link NodePoints#getAttributeInterpolation(String)}.
+     *
+     * @param  edge         index of the edge to split.
+     * @param  coordinates  the coordinates of the new node, which shall be inside the edge within the tolerance.
+     * @return index of the new node. The index of the new edge is {@link IndexedComplex#getEdgeCount()} − 1.
+     * @throws IllegalArgumentException if the position is not inside the edge.
+     */
+    public int splitEdge(final int edge, final double... coordinates) {
+        checkEdge(edge);
+        final double[] p = toXYZ(coordinates);
+        final int a = complex.edgeNodes[2*edge], b = complex.edgeNodes[2*edge + 1];
+        if (!geometry.insideSegment(p, geometry.point(a), geometry.point(b))) {
+            throw failure("The position is not inside the " + geometry.describeEdge(edge) + '.');
+        }
+        final int[] faces = distinct(complex.edgeCo[edge]);
+        final int container = complex.edgeContainers[edge];
+        final double[] pa = geometry.point(a), pb = geometry.point(b);
+        double along = 0, length2 = 0;
+        for (int k = 0; k < 3; k++) {
+            along   += (p[k]  - pa[k]) * (pb[k] - pa[k]);
+            length2 += (pb[k] - pa[k]) * (pb[k] - pa[k]);
+        }
+        final int node  = complex.newNode(Arrays.copyOf(p, complex.dimension));
+        complex.points.interpolate(node, a, b, along / length2);
+        final int added = complex.newEdge(node, b);
+        complex.setEdgeNodes(edge, a, node);
+        complex.setEdgeContainer(added, container);
+        if (container >= 0) {
+            complex.setNodeContainer(node, solidContainer(container));
+        }
+        /*
+         * A face going along the edge now goes along both edges, in the same direction.
+         */
+        final int[][][] rings = new int[faces.length][][];
+        for (int i = 0; i < faces.length; i++) {
+            final int[][] old = complex.faceRings[faces[i]];
+            rings[i] = new int[old.length][];
+            for (int r = 0; r < old.length; r++) {
+                final int[] ring = new int[old[r].length * 2];
+                int n = 0;
+                for (final int ref : old[r]) {
+                    if (ref == edge) {
+                        ring[n++] = edge;
+                        ring[n++] = added;
+                    } else if (ref == ~edge) {
+                        ring[n++] = ~added;
+                        ring[n++] = ~edge;
+                    } else {
+                        ring[n++] = ref;
+                    }
+                }
+                rings[i][r] = Arrays.copyOf(ring, n);
+            }
+        }
+        complex.setFaceRings(faces, rings);
+        return node;
+    }
+
+    /**
+     * Merges two edges sharing a node at which no other edge ends, and removes that node.
+     * The first edge is kept and extended to the other end of the second edge, which is removed.
+     * The shared node shall be on the segment between the two other ends, so the geometry does not change.
+     *
+     * @param  first   index of the edge to keep.
+     * @param  second  index of the edge to remove.
+     * @throws IllegalArgumentException if the edges do not share a node, if other edges end at that node,
+     *         if the shared node is not between the other ends, or if the edges are not on the same faces.
+     */
+    public void healEdges(final int first, final int second) {
+        checkEdge(first);
+        checkEdge(second);
+        if (first == second) {
+            throw failure("The edges shall be distinct.");
+        }
+        final int a0 = complex.edgeNodes[2*first],  a1 = complex.edgeNodes[2*first  + 1];
+        final int b0 = complex.edgeNodes[2*second], b1 = complex.edgeNodes[2*second + 1];
+        final int shared;
+        if (a1 == b0 || a1 == b1) {
+            shared = a1;
+        } else if (a0 == b0 || a0 == b1) {
+            shared = a0;
+        } else {
+            throw failure("The edges do not share a node.");
+        }
+        final int near = (a0 == shared) ? a1 : a0;
+        final int far  = (b0 == shared) ? b1 : b0;
+        if (near == far) {
+            throw failure("The edges are between the same nodes.");
+        }
+        if (complex.nodeCo[shared].length != 2) {
+            throw failure("Other edges end at the node " + geometry.describeNode(shared) + '.');
+        }
+        if (complex.edgeContainers[first] != complex.edgeContainers[second]) {
+            throw failure("The edges are isolated in different solids.");
+        }
+        if (!Arrays.equals(distinct(complex.edgeCo[first]), distinct(complex.edgeCo[second]))) {
+            throw failure("The edges are not on the boundary of the same faces.");
+        }
+        if (!geometry.insideSegment(geometry.point(shared), geometry.point(near), geometry.point(far))) {
+            throw failure("The node " + geometry.describeNode(shared) + " is not between the other ends of the edges.");
+        }
+        final int[] faces = distinct(complex.edgeCo[second]);
+        final int[][][] rings = new int[faces.length][][];
+        for (int i = 0; i < faces.length; i++) {
+            final int[][] old = complex.faceRings[faces[i]];
+            rings[i] = new int[old.length][];
+            for (int r = 0; r < old.length; r++) {
+                rings[i][r] = IndexedComplex.remove(IndexedComplex.remove(old[r], second), ~second);
+            }
+        }
+        complex.setEdgeNodes(first, (a0 == shared) ? far : a0, (a1 == shared) ? far : a1);
+        complex.setFaceRings(faces, rings);
+        complex.removeEdge(second);
+        complex.removeNode(shared);
+    }
+
+    // ──────────────────────────────────────────────────────────────────────────────────────────
+    //  Faces
+    // ──────────────────────────────────────────────────────────────────────────────────────────
+
+    /**
+     * Splits a face by a path of new edges, and returns the new face. The path is one of the following:
+     *
+     * <ul>
+     *   <li>An open path from a node to another node of the same ring of the face, passing through
+     *       nodes isolated in the face. The face is split in two along the path. If the ring is a hole,
+     *       the new face is the part between the hole and the path.</li>
+     *   <li>A closed path through nodes isolated in the face, the last node being the first one.
+     *       The new face is the inside of the path, which becomes a hole of the face.</li>
+     * </ul>
+     *
+     * The nodes of the path are no longer isolated. The holes and the isolated nodes of the face which are
+     * inside the new face are moved to the new face. Each solid using the face also uses the new face,
+     * with the same orientation.
+     *
+     * @param  face  index of the face to split.
+     * @param  path  indices of the nodes of the path, at least two for an open path and four for a closed path.
+     * @return index of the new face. The new edges are the last ones of the complex, in the order of the path.
+     * @throws IllegalArgumentException if the path is invalid, goes outside the face, crosses itself,
+     *         or crosses any other primitive.
+     */
+    public int splitFace(final int face, final int... path) {
+        checkFace(face);
+        final int k = path.length - 1;
+        if (k < 1) {
+            throw failure("A path needs at least two nodes.");
+        }
+        for (final int node : path) {
+            checkNode(node);
+        }
+        final boolean closed = (path[0] == path[k]);
+        if (closed && k < 3) {
+            throw failure("A closed path needs at least three distinct nodes.");
+        }
+        for (int i = 0; i < k; i++) {
+            for (int j = i + 1; j <= k; j++) {
+                if (path[i] == path[j] && !(closed && i == 0 && j == k)) {
+                    throw failure("The path passes twice through the node " + geometry.describeNode(path[i]) + '.');
+                }
+            }
+        }
+        final int code = faceContainer(face);
+        for (int i = closed ? 0 : 1; i < k; i++) {
+            if (complex.nodeContainers[path[i]] != code || complex.nodeCo[path[i]].length != 0) {
+                throw failure("The node " + geometry.describeNode(path[i]) + " is not a node without edge inside the face.");
+            }
+        }
+        /*
+         * An open path starts and ends on the same ring of the face.
+         */
+        final int[][] rings = complex.faceRings[face];
+        int ringIndex = -1, i0 = -1, j0 = -1;
+        if (!closed) {
+            for (int r = 0; r < rings.length; r++) {
+                final int[] nodes = complex.nodes(rings[r]);
+                int i = -1, j = -1;
+                for (int p = 0; p < nodes.length; p++) {
+                    if (nodes[p] == path[0] || nodes[p] == path[k]) {
+                        if ((nodes[p] == path[0] ? i : j) >= 0) {
+                            throw failure("The ring passes twice through the node " + geometry.describeNode(nodes[p]) + '.');
+                        }
+                        if (nodes[p] == path[0]) i = p; else j = p;
+                    }
+                }
+                if (i >= 0 || j >= 0) {
+                    if (i < 0 || j < 0 || ringIndex >= 0) {
+                        throw failure("The ends of the path shall be on the same ring of the face.");
+                    }
+                    ringIndex = r;
+                    i0 = i;
+                    j0 = j;
+                }
+            }
+            if (ringIndex < 0) {
+                throw failure("The ends of the path are not on the boundary of the face.");
+            }
+            if (k == 1) {
+                checkNoEdge(path[0], path[1]);
+            }
+        }
+        /*
+         * Each segment of the path is inside the face, crosses no other primitive, and no other segment.
+         */
+        final ComplexGeometry.Plane plane = geometry.plane(face);
+        for (int t = 1; t <= k; t++) {
+            checkSegment(path[t - 1], path[t], face);
+            final double[] a = geometry.point(path[t - 1]), b = geometry.point(path[t]);
+            final double[] middle = {(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2};
+            if (!geometry.insideFace(plane, middle, true)) {
+                throw failure("The path goes outside the face.");
+            }
+        }
+        for (int t = 1; t <= k; t++) {
+            for (int u = t + 2; u <= k; u++) {
+                if (closed && t == 1 && u == k) continue;       // adjacent through the first node.
+                if (geometry.cross(geometry.point(path[t - 1]), geometry.point(path[t]),
+                                   geometry.point(path[u - 1]), geometry.point(path[u])))
+                {
+                    throw failure("The path crosses itself.");
+                }
+            }
+        }
+        /*
+         * Create the edges of the path, then the rings of both faces.
+         */
+        final int[] chord = new int[k];
+        final int[] reversed = new int[k];
+        for (int t = 0; t < k; t++) {
+            chord[t] = complex.newEdge(path[t], path[t + 1]);
+        }
+        for (int t = 0; t < k; t++) {
+            reversed[t] = ~chord[k - 1 - t];
+        }
+        for (int t = closed ? 0 : 1; t < k; t++) {
+            complex.setNodeContainer(path[t], -1);
+        }
+        final List<int[]> kept  = new ArrayList<>();        // rings of the face, exterior first.
+        final List<int[]> holes = new ArrayList<>();        // holes to move to the new face if inside it.
+        final int[] exterior;                               // exterior ring of the new face.
+        if (closed) {
+            final boolean ccw = geometry.counterclockwise(plane, Arrays.copyOf(path, k));
+            exterior = ccw ? chord : reversed;
+            kept.add(rings[0]);
+            kept.add(ccw ? reversed : chord);
+            for (int r = 1; r < rings.length; r++) {
+                holes.add(rings[r]);
+            }
+        } else {
+            final int[] ring  = rings[ringIndex];
+            final int[] part1 = concat(ring, i0, j0, reversed);     // along the ring to the end of the path, then back.
+            final int[] part2 = concat(ring, j0, i0, chord);        // along the ring to the start of the path, then along it.
+            if (ringIndex == 0) {
+                kept.add(part1);
+                exterior = part2;
+                for (int r = 1; r < rings.length; r++) {
+                    holes.add(rings[r]);
+                }
+            } else {
+                // One part goes around the region between the hole and the path, the other part is the new hole.
+                final boolean ccw = geometry.counterclockwise(plane, complex.nodes(part1));
+                exterior = ccw ? part1 : part2;
+                kept.add(rings[0]);
+                for (int r = 1; r < rings.length; r++) {
+                    if (r == ringIndex) {
+                        kept.add(ccw ? part2 : part1);
+                    } else {
+                        holes.add(rings[r]);
+                    }
+                }
+            }
+        }
+        final List<int[]> moved = new ArrayList<>();
+        moved.add(exterior);
+        final int[][] exteriorNodes = {complex.nodes(exterior)};
+        for (final int[] hole : holes) {
+            if (geometry.insideRings(plane, exteriorNodes, geometry.point(complex.startNode(hole[0])))) {
+                moved.add(hole);
+            } else {
+                kept.add(hole);
+            }
+        }
+        complex.setFaceRings(new int[] {face}, new int[][][] {kept.toArray(int[][]::new)});
+        final int created = complex.newFace(moved.toArray(int[][]::new));
+        /*
+         * The solids using the face also use the new face, in the same shell and with the same orientation.
+         */
+        for (final int ref : complex.faceCo[face].clone()) {
+            final int solid = index(ref);
+            final int use = (ref < 0) ? ~face : face;
+            final int[][] shells = complex.solidShells[solid].clone();
+            for (int h = 0; h < shells.length; h++) {
+                if (contains(shells[h], use)) {
+                    shells[h] = IndexedComplex.append(shells[h], (ref < 0) ? ~created : created);
+                }
+            }
+            complex.setShells(solid, shells);
+        }
+        /*
+         * The isolated nodes inside the new face are moved to it.
+         */
+        final int[][] createdNodes = new int[moved.size()][];
+        for (int r = 0; r < createdNodes.length; r++) {
+            createdNodes[r] = complex.nodes(moved.get(r));
+        }
+        for (final int node : complex.faceIsolated[face].clone()) {
+            if (geometry.insideRings(plane, createdNodes, geometry.point(node))) {
+                complex.setNodeContainer(node, faceContainer(created));
+            }
+        }
+        return created;
+    }
+
+    /**
+     * Merges the two faces on each side of an edge, and removes the edge. The first face around the edge
+     * is kept and extended to the second face, which is removed. The holes and isolated nodes of the second
+     * face are moved to the first face. If an end of the edge is not the end of any other edge, it becomes
+     * a node isolated in the face.
+     *
+     * @param  edge  index of the edge between the faces to merge.
+     * @throws IllegalArgumentException if the edge does not separate exactly two faces of consistent
+     *         orientations, if the faces are not on the boundaries of the same solids, or if the faces
+     *         share another edge.
+     */
+    public void mergeFaces(final int edge) {
+        checkEdge(edge);
+        final int[] co = complex.edgeCo[edge];
+        if (co.length != 2) {
+            throw failure("The " + geometry.describeEdge(edge) + " does not separate exactly two faces.");
+        }
+        final int f = index(co[0]), g = index(co[1]);
+        if (f == g) {
+            throw failure("The " + geometry.describeEdge(edge) + " has the same face on both sides.");
+        }
+        if ((co[0] < 0) == (co[1] < 0)) {
+            throw failure("The faces on each side of the " + geometry.describeEdge(edge) + " have opposite orientations.");
+        }
+        final int[] solidsOfF = complex.faceCo[f].clone();
+        final int[] solidsOfG = complex.faceCo[g].clone();
+        Arrays.sort(solidsOfF);
+        Arrays.sort(solidsOfG);
+        if (!Arrays.equals(solidsOfF, solidsOfG)) {
+            throw failure("The faces are not on the boundary of the same solids.");
+        }
+        final int[][] rf = complex.faceRings[f];
+        final int[][] rg = complex.faceRings[g];
+        final int[] pf = locate(rf, edge);
+        final int[] pg = locate(rg, edge);
+        final int[] merged = splice(rf[pf[0]], pf[1], rg[pg[0]], pg[1]);
+        final BitSet used = new BitSet();
+        for (final int ref : merged) {
+            if (used.get(index(ref))) {
+                throw failure("The faces share other edges than the " + geometry.describeEdge(edge) + '.');
+            }
+            used.set(index(ref));
+        }
+        /*
+         * The merged ring is the exterior ring if both rings were exterior rings.
+         * Otherwise one face is in a hole of the other face, and the merged ring is that hole made smaller.
+         */
+        final List<int[]> rings = new ArrayList<>();
+        if (pf[0] == 0 && pg[0] == 0) {
+            rings.add(merged);
+        } else if (pf[0] == 0) {
+            rings.add(rg[0]);
+            rings.add(merged);
+        } else if (pg[0] == 0) {
+            rings.add(rf[0]);
+            rings.add(merged);
+        } else {
+            throw failure("The " + geometry.describeEdge(edge) + " is on holes of both faces.");
+        }
+        for (int r = 1; r < rf.length; r++) {
+            if (r != pf[0]) rings.add(rf[r]);
+        }
+        for (int r = 1; r < rg.length; r++) {
+            if (r != pg[0]) rings.add(rg[r]);
+        }
+        /*
+         * Remove the second face from the solids, move its isolated nodes, then replace it by the first face.
+         */
+        for (final int ref : complex.faceCo[g].clone()) {
+            final int solid = index(ref);
+            final int[][] shells = complex.solidShells[solid].clone();
+            for (int h = 0; h < shells.length; h++) {
+                shells[h] = IndexedComplex.remove(shells[h], (ref < 0) ? ~g : g);
+            }
+            complex.setShells(solid, shells);
+        }
+        for (final int node : complex.faceIsolated[g].clone()) {
+            complex.setNodeContainer(node, faceContainer(f));
+        }
+        complex.removeFace(g);
+        complex.setFaceRings(new int[] {f}, new int[][][] {rings.toArray(int[][]::new)});
+        final int a = complex.edgeNodes[2*edge], b = complex.edgeNodes[2*edge + 1];
+        complex.removeEdge(edge);
+        for (final int node : new int[] {a, b}) {
+            if (complex.nodeCo[node].length == 0) {
+                complex.setNodeContainer(node, faceContainer(f));
+            }
+        }
+    }
+
+    /**
+     * Adds a face bounded by existing edges. The holes are reversed if needed for being wound
+     * opposite to the exterior ring. The nodes inside the face become isolated in it.
+     *
+     * @param  rings  the rings of the face, the exterior ring first, as signed references to edges in traversal order.
+     * @return index of the new face.
+     * @throws IllegalArgumentException if a ring is not closed, if an edge is used twice, if a face has the same edges,
+     *         if an edge crosses the face or is inside it, or if the face is inside a solid.
+     */
+    public int addFace(final int[]... rings) {
+        if (rings.length == 0) {
+            throw failure("A face needs an exterior ring.");
+        }
+        final int[][] refs  = new int[rings.length][];
+        final int[][] nodes = new int[rings.length][];
+        final BitSet used = new BitSet();
+        for (int r = 0; r < rings.length; r++) {
+            refs[r] = rings[r].clone();
+            final int m = refs[r].length;
+            if (m < 3) {
+                throw failure("A ring needs at least three edges.");
+            }
+            for (final int ref : refs[r]) {
+                final int e = index(ref);
+                checkEdge(e);
+                if (used.get(e)) {
+                    throw failure("The " + geometry.describeEdge(e) + " is used twice.");
+                }
+                used.set(e);
+            }
+            for (int i = 0; i < m; i++) {
+                if (complex.endNode(refs[r][i]) != complex.startNode(refs[r][(i + 1) % m])) {
+                    throw failure("A ring is not closed after the " + geometry.describeEdge(index(refs[r][i])) + '.');
+                }
+            }
+            nodes[r] = complex.nodes(refs[r]);
+        }
+        final ComplexGeometry.Plane plane = geometry.plane(nodes);
+        if (plane == null) {
+            throw failure("The face has no area.");
+        }
+        for (int r = 1; r < refs.length; r++) {
+            if (geometry.counterclockwise(plane, nodes[r])) {
+                final int[] ring = refs[r];
+                final int m = ring.length;
+                final int[] reversed = new int[m];
+                for (int i = 0; i < m; i++) {
+                    reversed[i] = ~ring[m - 1 - i];
+                }
+                refs[r]  = reversed;
+                nodes[r] = complex.nodes(reversed);         // also updates the plane, which shares this array.
+            }
+        }
+        for (final int ref : complex.edgeCo[index(refs[0][0])]) {
+            if (sameEdges(index(ref), used)) {
+                throw failure("A face with the same edges already exists.");
+            }
+        }
+        /*
+         * No other edge in the face or across it, and no part of the face inside a solid.
+         */
+        for (int e = 0; e < complex.edgeCount; e++) {
+            if (!complex.removedEdges.get(e) && !used.get(e) &&
+                    geometry.meetsFace(plane, geometry.point(complex.edgeNodes[2*e]), geometry.point(complex.edgeNodes[2*e + 1])))
+            {
+                throw failure("The " + geometry.describeEdge(e) + " crosses the face or is inside it.");
+            }
+        }
+        final BitSet corners = new BitSet();
+        for (final int[] ring : nodes) {
+            for (final int node : ring) corners.set(node);
+        }
+        final List<Integer> inside = new ArrayList<>();
+        for (int n = 0; n < complex.nodeCount; n++) {
+            if (!complex.removedNodes.get(n) && !corners.get(n) && geometry.insideFace(plane, geometry.point(n), true)) {
+                if (complex.nodeContainers[n] >= 0) {
+                    throw failure("The node " + geometry.describeNode(n) + " is already inside a face or a solid.");
+                }
+                inside.add(n);
+            }
+        }
+        final double[] p = geometry.interiorPoint(plane);
+        for (int s = 0; s < complex.solidCount; s++) {
+            if (!complex.removedSolids.get(s) && geometry.insideSolid(complex.solidShells[s], p)) {
+                throw failure("The face is inside the solid " + s + '.');
+            }
+        }
+        final int face = complex.newFace(refs);
+        for (final int node : inside) {
+            complex.setNodeContainer(node, faceContainer(face));
+        }
+        return face;
+    }
+
+    /**
+     * Removes a face on the boundary of no solid. The edges of the face are kept,
+     * and the nodes isolated in the face are no longer isolated.
+     *
+     * @param  face  index of the face to remove.
+     * @throws IllegalArgumentException if the face is on the boundary of a solid.
+     */
+    public void removeFace(final int face) {
+        checkFace(face);
+        if (complex.faceCo[face].length != 0) {
+            throw failure("The " + geometry.describeFace(face) + " is on the boundary of a solid.");
+        }
+        complex.removeFace(face);
+    }
+
+    // ──────────────────────────────────────────────────────────────────────────────────────────
+    //  Solids
+    // ──────────────────────────────────────────────────────────────────────────────────────────
+
+    /**
+     * Adds a solid bounded by existing faces. Each shell is reversed if needed, so that the exterior shell
+     * encloses a positive volume and the interior shells a negative one, which puts the solid below all faces.
+     * The nodes and edges inside the solid become isolated in it.
+     *
+     * @param  shells  the shells of the solid, the exterior shell first, as signed references to faces.
+     * @return index of the new solid.
+     * @throws IllegalArgumentException if a face is used twice, if a shell is not closed, if a face would have
+     *         two solids on the same side, if another face is inside the solid, or if the solid is inside another one.
+     */
+    public int addSolid(final int[]... shells) {
+        if (complex.dimension != 3) {
+            throw failure("A solid requires 3-dimensional coordinates.");
+        }
+        if (shells.length == 0) {
+            throw failure("A solid needs an exterior shell.");
+        }
+        final int[][] refs = new int[shells.length][];
+        final BitSet used = new BitSet();
+        for (int h = 0; h < shells.length; h++) {
+            refs[h] = shells[h].clone();
+            if (refs[h].length == 0) {
+                throw failure("A shell needs faces.");
+            }
+            for (final int ref : refs[h]) {
+                final int f = index(ref);
+                checkFace(f);
+                if (used.get(f)) {
+                    throw failure("The " + geometry.describeFace(f) + " is used twice.");
+                }
+                used.set(f);
+            }
+            final double volume = geometry.signedVolume(refs[h]);
+            if ((h == 0) ? volume < 0 : volume > 0) {
+                for (int i = 0; i < refs[h].length; i++) {
+                    refs[h][i] = ~refs[h][i];
+                }
+            }
+        }
+        final int[] uses = new int[complex.edgeCount];
+        final int future = complex.solidCount;
+        for (final int[] shell : refs) {
+            final String problem = ComplexValidator.closed(complex, shell, uses);
+            if (problem != null) {
+                throw failure("A shell is " + problem + '.');
+            }
+            for (final int ref : shell) {
+                final String side = ComplexValidator.sameSide(IndexedComplex.append(complex.faceCo[index(ref)], (ref < 0) ? ~future : future));
+                if (side != null) {
+                    throw failure("The solids " + side + " the " + geometry.describeFace(index(ref)) + '.');
+                }
+            }
+        }
+        /*
+         * No other face inside the new solid, and no face of the new solid inside another solid.
+         */
+        final double[] box = geometry.box(refs);
+        final int[] triangles = geometry.triangles(refs);
+        for (int f = 0; f < complex.faceCount; f++) {
+            if (!complex.removedFaces.get(f) && !used.get(f)
+                    && geometry.insideSolid(box, triangles, geometry.interiorPoint(geometry.plane(f))))
+            {
+                throw failure("The " + geometry.describeFace(f) + " is inside the solid.");
+            }
+        }
+        for (int s = 0; s < complex.solidCount; s++) {
+            if (complex.removedSolids.get(s)) continue;
+            final BitSet boundary = new BitSet();
+            for (final int f : complex.faces(s)) boundary.set(f);
+            final int[][] other = complex.solidShells[s];
+            final double[] otherBox = geometry.box(other);
+            final int[] otherTriangles = geometry.triangles(other);
+            for (int f = used.nextSetBit(0); f >= 0; f = used.nextSetBit(f + 1)) {
+                if (!boundary.get(f) && geometry.insideSolid(otherBox, otherTriangles, geometry.interiorPoint(geometry.plane(f)))) {
+                    throw failure("The solid is inside the solid " + s + '.');
+                }
+            }
+        }
+        /*
+         * The nodes and edges inside the new solid. A node isolated in a face is on that face, which is not inside.
+         */
+        final BitSet corners = new BitSet();
+        for (int f = used.nextSetBit(0); f >= 0; f = used.nextSetBit(f + 1)) {
+            for (final int[] ring : complex.faceRings[f]) {
+                for (final int node : complex.nodes(ring)) corners.set(node);
+            }
+        }
+        final List<Integer> insideNodes = new ArrayList<>();
+        for (int n = 0; n < complex.nodeCount; n++) {
+            final int c = complex.nodeContainers[n];
+            if (complex.removedNodes.get(n) || corners.get(n) || (c >= 0 && (c & 1) == 0)) continue;
+            if (geometry.insideSolid(box, triangles, geometry.point(n))) {
+                if (c >= 0) {
+                    throw failure("The node " + geometry.describeNode(n) + " is already inside a solid.");
+                }
+                insideNodes.add(n);
+            }
+        }
+        final List<Integer> insideEdges = new ArrayList<>();
+        for (int e = 0; e < complex.edgeCount; e++) {
+            if (complex.removedEdges.get(e) || complex.edgeCo[e].length != 0) continue;
+            final double[] a = geometry.point(complex.edgeNodes[2*e]);
+            final double[] b = geometry.point(complex.edgeNodes[2*e + 1]);
+            if (geometry.insideSolid(box, triangles, new double[] {(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2})) {
+                if (complex.edgeContainers[e] >= 0) {
+                    throw failure("The " + geometry.describeEdge(e) + " is already inside a solid.");
+                }
+                insideEdges.add(e);
+            }
+        }
+        final int solid = complex.newSolid(refs);
+        for (final int node : insideNodes) {
+            complex.setNodeContainer(node, solidContainer(solid));
+        }
+        for (final int edge : insideEdges) {
+            complex.setEdgeContainer(edge, solid);
+        }
+        return solid;
+    }
+
+    /**
+     * Removes a solid. The faces of the solid are kept,
+     * and the nodes and edges isolated in the solid are no longer isolated.
+     *
+     * @param  solid  index of the solid to remove.
+     */
+    public void removeSolid(final int solid) {
+        checkSolid(solid);
+        complex.removeSolid(solid);
+    }
+
+    // ──────────────────────────────────────────────────────────────────────────────────────────
+    //  Verifications and helper methods
+    // ──────────────────────────────────────────────────────────────────────────────────────────
+
+    /**
+     * Returns an exception for an invalid edit.
+     */
+    private static IllegalArgumentException failure(final String message) {
+        return new IllegalArgumentException(message);
+    }
+
+    private void checkNode(final int node) {
+        if (node < 0 || node >= complex.nodeCount || complex.removedNodes.get(node)) {
+            throw failure("No node at index " + node + '.');
+        }
+    }
+
+    private void checkEdge(final int edge) {
+        if (edge < 0 || edge >= complex.edgeCount || complex.removedEdges.get(edge)) {
+            throw failure("No edge at index " + edge + '.');
+        }
+    }
+
+    private void checkFace(final int face) {
+        if (face < 0 || face >= complex.faceCount || complex.removedFaces.get(face)) {
+            throw failure("No face at index " + face + '.');
+        }
+    }
+
+    private void checkSolid(final int solid) {
+        if (solid < 0 || solid >= complex.solidCount || complex.removedSolids.get(solid)) {
+            throw failure("No solid at index " + solid + '.');
+        }
+    }
+
+    /**
+     * Verifies the given coordinates and returns them in three dimensions, with −0 replaced by +0.
+     */
+    private double[] toXYZ(final double[] coordinates) {
+        if (coordinates.length != complex.dimension) {
+            throw failure("Expected " + complex.dimension + " coordinates, got " + coordinates.length + '.');
+        }
+        final double[] p = new double[3];
+        for (int k = 0; k < coordinates.length; k++) {
+            if (!Double.isFinite(coordinates[k])) {
+                throw failure("Coordinates shall be finite.");
+            }
+            p[k] = coordinates[k] + 0.0;
+        }
+        return p;
+    }
+
+    /**
+     * Verifies that the given position is neither on a node nor inside an edge.
+     *
+     * @param ignored  a node to ignore, or −1 if none.
+     */
+    private void checkPosition(final double[] p, final int ignored) {
+        for (int n = 0; n < complex.nodeCount; n++) {
+            if (n != ignored && !complex.removedNodes.get(n) && geometry.coincide(p, geometry.point(n))) {
+                throw failure("The position is on the node " + geometry.describeNode(n) + '.');
+            }
+        }
+        for (int e = 0; e < complex.edgeCount; e++) {
+            if (!complex.removedEdges.get(e) && geometry.insideSegment(p,
+                    geometry.point(complex.edgeNodes[2*e]), geometry.point(complex.edgeNodes[2*e + 1])))
+            {
+                throw failure("The position is inside the " + geometry.describeEdge(e) + '.');
+            }
+        }
+    }
+
+    /**
+     * Returns the container code of a position which is neither on a node nor inside an edge:
+     * the face or solid containing the position, or −1 if none.
+     */
+    private int containerOf(final double[] p) {
+        for (int f = 0; f < complex.faceCount; f++) {
+            if (!complex.removedFaces.get(f) && geometry.insideFace(geometry.plane(f), p, true)) {
+                return faceContainer(f);
+            }
+        }
+        for (int s = 0; s < complex.solidCount; s++) {
+            if (!complex.removedSolids.get(s) && geometry.insideSolid(complex.solidShells[s], p)) {
+                return solidContainer(s);
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * Verifies that there is no edge between the given nodes.
+     */
+    private void checkNoEdge(final int a, final int b) {
+        for (final int ref : complex.nodeCo[a]) {
+            final int e = index(ref);
+            final int other = (ref < 0) ? complex.edgeNodes[2*e + 1] : complex.edgeNodes[2*e];
+            if (other == b) {
+                throw failure("There is already an edge between the nodes.");
+            }
+        }
+    }
+
+    /**
+     * Verifies that the segment between two nodes passes through no other node, crosses no edge,
+     * and does not meet the interior of any face other than the given one.
+     *
+     * @param ignored  a face to ignore, or −1 if none.
+     */
+    private void checkSegment(final int a, final int b, final int ignored) {
+        final double[] pa = geometry.point(a), pb = geometry.point(b);
+        for (int n = 0; n < complex.nodeCount; n++) {
+            if (n != a && n != b && !complex.removedNodes.get(n) && geometry.insideSegment(geometry.point(n), pa, pb)) {
+                throw failure("The node " + geometry.describeNode(n) + " is on the segment.");
+            }
+        }
+        for (int e = 0; e < complex.edgeCount; e++) {
+            if (complex.removedEdges.get(e)) continue;
+            final int e0 = complex.edgeNodes[2*e], e1 = complex.edgeNodes[2*e + 1];
+            if (e0 != a && e0 != b && e1 != a && e1 != b && geometry.cross(pa, pb, geometry.point(e0), geometry.point(e1))) {
+                throw failure("The segment crosses the " + geometry.describeEdge(e) + '.');
+            }
+        }
+        for (int f = 0; f < complex.faceCount; f++) {
+            if (f != ignored && !complex.removedFaces.get(f) && geometry.meetsFace(geometry.plane(f), pa, pb)) {
+                throw failure("The segment crosses the " + geometry.describeFace(f) + " or is inside it.");
+            }
+        }
+    }
+
+    /**
+     * Returns the indices of the primitives designated by the given signed references, sorted and without duplicates.
+     */
+    private static int[] distinct(final int[] refs) {
+        final int[] indices = new int[refs.length];
+        for (int i = 0; i < refs.length; i++) {
+            indices[i] = index(refs[i]);
+        }
+        Arrays.sort(indices);
+        int n = 0;
+        for (int i = 0; i < indices.length; i++) {
+            if (n == 0 || indices[n - 1] != indices[i]) {
+                indices[n++] = indices[i];
+            }
+        }
+        return Arrays.copyOf(indices, n);
+    }
+
+    /**
+     * Returns whether the given array contains the given value.
+     */
+    private static boolean contains(final int[] array, final int value) {
+        for (final int v : array) {
+            if (v == value) return true;
+        }
+        return false;
+    }
+
+    /**
+     * Returns whether the given face has exactly the given edges.
+     */
+    private boolean sameEdges(final int face, final BitSet edges) {
+        int count = 0;
+        for (final int[] ring : complex.faceRings[face]) {
+            for (final int ref : ring) {
+                if (!edges.get(index(ref))) return false;
+                count++;
+            }
+        }
+        return count == edges.cardinality();
+    }
+
+    /**
+     * Returns the ring index and the position in that ring of the given edge in the given rings.
+     */
+    private int[] locate(final int[][] rings, final int edge) {
+        int[] found = null;
+        for (int r = 0; r < rings.length; r++) {
+            for (int p = 0; p < rings[r].length; p++) {
+                if (index(rings[r][p]) == edge) {
+                    if (found != null) {
+                        throw failure("A face goes twice along the " + geometry.describeEdge(edge) + '.');
+                    }
+                    found = new int[] {r, p};
+                }
+            }
+        }
+        return found;
+    }
+
+    /**
+     * Returns the elements of the given ring from position {@code from} inclusive to position {@code to}
+     * exclusive, in cyclic order, followed by the given tail.
+     */
+    private static int[] concat(final int[] ring, final int from, final int to, final int[] tail) {
+        final int count = Math.floorMod(to - from, ring.length);
+        final int[] result = new int[count + tail.length];
+        for (int i = 0; i < count; i++) {
+            result[i] = ring[(from + i) % ring.length];
+        }
+        System.arraycopy(tail, 0, result, count, tail.length);
+        return result;
+    }
+
+    /**
+     * Joins two rings traversing an edge in opposite directions, without that edge. Each ring is taken
+     * from the element after the edge, in cyclic order, so the first ring goes from one end of the edge
+     * to the other, and the second ring goes back.
+     */
+    private static int[] splice(final int[] first, final int p1, final int[] second, final int p2) {
+        final int[] a = concat(first,  (p1 + 1) % first.length,  p1, new int[0]);
+        return concat(second, (p2 + 1) % second.length, p2, a);
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/ComplexGeometry.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/ComplexGeometry.java
new file mode 100644
index 0000000000..cf8df9b37c
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/ComplexGeometry.java
@@ -0,0 +1,475 @@
+/*
+ * 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.internal.shared.topology;
+
+import java.util.Arrays;
+import org.apache.sis.geometries.operation.Contains;
+import org.apache.sis.maths.Maths;
+import static org.apache.sis.geometries.internal.shared.topology.IndexedComplex.index;
+
+
+/**
+ * Geometric predicates on the current state of an {@link IndexedComplex}, with the tolerance of the complex.
+ * They are used for verifying that the interiors of the primitives are disjoint, when building or editing.
+ * All coordinates are in three dimensions, with z = 0 for a complex in two dimensions.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+final class ComplexGeometry {
+
+    /**
+     * The complex on which the predicates are evaluated.
+     */
+    private final IndexedComplex complex;
+
+    /**
+     * Largest distance between two positions considered the same.
+     */
+    final double tolerance;
+
+    /**
+     * Creates the predicates for the given complex.
+     */
+    ComplexGeometry(final IndexedComplex complex) {
+        this.complex   = complex;
+        this.tolerance = complex.tolerance;
+    }
+
+    /**
+     * The plane of a face, or of rings of nodes, with the axes of a projection which keeps the orientation.
+     */
+    static final class Plane {
+        /** Nodes of each ring, the exterior ring first, in traversal order. */
+        final int[][] rings;
+
+        /** Unit normal, toward which the exterior ring is seen counterclockwise. */
+        final double[] normal;
+
+        /** Axes of the projection plane, then the axis left out. */
+        final int[] axes;
+
+        /** A point of the plane. */
+        final double[] origin;
+
+        Plane(final int[][] rings, final double[] normal, final int[] axes, final double[] origin) {
+            this.rings  = rings;
+            this.normal = normal;
+            this.axes   = axes;
+            this.origin = origin;
+        }
+    }
+
+    /**
+     * Returns the coordinates of the given node in three dimensions.
+     */
+    double[] point(final int node) {
+        return complex.position(node);
+    }
+
+    /**
+     * Returns the plane of the given face.
+     *
+     * @throws IllegalArgumentException if the face has no area.
+     */
+    Plane plane(final int face) {
+        final int[][] refs = complex.faceRings[face];
+        final int[][] rings = new int[refs.length][];
+        for (int r = 0; r < refs.length; r++) {
+            rings[r] = complex.nodes(refs[r]);
+        }
+        final Plane plane = plane(rings);
+        if (plane == null) {
+            throw new IllegalArgumentException("The " + describeFace(face) + " has no area.");
+        }
+        return plane;
+    }
+
+    /**
+     * Returns the plane of the given rings of nodes, the exterior ring first,
+     * or {@code null} if the exterior ring has no area.
+     */
+    Plane plane(final int[][] rings) {
+        final double[] n = newell(rings[0]);
+        final double length = Math.sqrt(n[0]*n[0] + n[1]*n[1] + n[2]*n[2]);
+        if (!(length > 0)) {
+            return null;
+        }
+        return new Plane(rings, new double[] {n[0] / length, n[1] / length, n[2] / length},
+                         Maths.projectionAxes(n), point(rings[0][0]));
+    }
+
+    /**
+     * Returns the normal of the given ring of nodes with Newell's method, pointing toward an observer
+     * who sees the ring counterclockwise. Its length is twice the area of the ring.
+     */
+    double[] newell(final int[] ring) {
+        final double[] xyz = complex.xyz;
+        final double[] normal = new double[3];
+        for (int i = 0; i < ring.length; i++) {
+            final int a = ring[i] * 3, b = ring[(i + 1) % ring.length] * 3;
+            normal[0] += (xyz[a+1] - xyz[b+1]) * (xyz[a+2] + xyz[b+2]);
+            normal[1] += (xyz[a+2] - xyz[b+2]) * (xyz[a]   + xyz[b]);
+            normal[2] += (xyz[a]   - xyz[b])   * (xyz[a+1] + xyz[b+1]);
+        }
+        return normal;
+    }
+
+    /**
+     * Returns whether the given ring of nodes is seen counterclockwise from the side toward which the normal
+     * of the given plane points.
+     */
+    boolean counterclockwise(final Plane plane, final int[] ring) {
+        final double[] n = newell(ring);
+        return n[0] * plane.normal[0] + n[1] * plane.normal[1] + n[2] * plane.normal[2] > 0;
+    }
+
+    /**
+     * Returns the signed distance from the given plane to the given point.
+     */
+    double distance(final Plane plane, final double[] p) {
+        final double[] n = plane.normal, o = plane.origin;
+        return n[0] * (p[0] - o[0]) + n[1] * (p[1] - o[1]) + n[2] * (p[2] - o[2]);
+    }
+
+    /**
+     * Returns whether the given point is inside the face of the given plane, excluding its boundary.
+     *
+     * @param checkPlane  whether to verify that the point is in the plane. Otherwise the point is projected.
+     */
+    boolean insideFace(final Plane plane, final double[] p, final boolean checkPlane) {
+        if (checkPlane && Math.abs(distance(plane, p)) > tolerance) {
+            return false;
+        }
+        return insideRings(plane, plane.rings, p);
+    }
+
+    /**
+     * Returns whether the given point, projected on the given plane, is inside the given rings of nodes
+     * with the even-odd rule, excluding the boundaries of the rings.
+     */
+    boolean insideRings(final Plane plane, final int[][] rings, final double[] p) {
+        final double[] xyz = complex.xyz;
+        final int u = plane.axes[0], v = plane.axes[1];
+        final double x = p[u], y = p[v];
+        boolean inside = false;
+        for (final int[] ring : rings) {
+            for (int i = 0; i < ring.length; i++) {
+                final int a = ring[i] * 3, b = ring[(i + 1) % ring.length] * 3;
+                final double ax = xyz[a+u], ay = xyz[a+v], bx = xyz[b+u], by = xyz[b+v];
+                if (nearSegment(x, y, ax, ay, bx, by)) {
+                    return false;                       // on a boundary.
+                }
+                if ((ay > y) != (by > y) && x < ax + (y - ay) * (bx - ax) / (by - ay)) {
+                    inside = !inside;
+                }
+            }
+        }
+        return inside;
+    }
+
+    /**
+     * Returns whether the point (x,y) is at a distance not greater than the tolerance from the given segment.
+     */
+    private boolean nearSegment(final double x, final double y, final double ax, final double ay, final double bx, final double by) {
+        final double dx = bx - ax, dy = by - ay;
+        final double length2 = dx*dx + dy*dy;
+        double t = (length2 > 0) ? ((x - ax)*dx + (y - ay)*dy) / length2 : 0;
+        t = Math.max(0, Math.min(1, t));
+        final double ex = ax + t*dx - x, ey = ay + t*dy - y;
+        return ex*ex + ey*ey <= tolerance * tolerance;
+    }
+
+    /**
+     * Returns whether two points are at a distance not greater than the tolerance.
+     */
+    boolean coincide(final double[] p, final double[] q) {
+        final double dx = p[0] - q[0], dy = p[1] - q[1], dz = p[2] - q[2];
+        return dx*dx + dy*dy + dz*dz <= tolerance * tolerance;
+    }
+
+    /**
+     * Returns whether the given point is on the segment from {@code a} to {@code b}, excluding its ends.
+     */
+    boolean insideSegment(final double[] p, final double[] a, final double[] b) {
+        final double dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
+        final double length2 = dx*dx + dy*dy + dz*dz;
+        if (length2 == 0) {
+            return false;
+        }
+        final double t = ((p[0] - a[0])*dx + (p[1] - a[1])*dy + (p[2] - a[2])*dz) / length2;
+        final double length = Math.sqrt(length2);
+        if (t * length <= tolerance || (1 - t) * length <= tolerance) {
+            return false;
+        }
+        final double ex = a[0] + t*dx - p[0];
+        final double ey = a[1] + t*dy - p[1];
+        final double ez = a[2] + t*dz - p[2];
+        return ex*ex + ey*ey + ez*ez <= tolerance * tolerance;
+    }
+
+    /**
+     * Returns whether the segments from {@code a0} to {@code a1} and from {@code b0} to {@code b1} cross at a point
+     * inside both of them. Parallel segments are considered not crossing: if they overlap, an end of one is inside
+     * the other.
+     */
+    boolean cross(final double[] a0, final double[] a1, final double[] b0, final double[] b1) {
+        final double[] d1 = new double[3], d2 = new double[3], r = new double[3];
+        for (int k = 0; k < 3; k++) {
+            d1[k] = a1[k] - a0[k];
+            d2[k] = b1[k] - b0[k];
+            r[k]  = a0[k] - b0[k];
+        }
+        final double a = dot(d1, d1), e = dot(d2, d2), b = dot(d1, d2), c = dot(d1, r), f = dot(d2, r);
+        final double denominator = a*e - b*b;
+        if (!(denominator > 1E-12 * a * e)) {
+            return false;
+        }
+        // Parameters of the closest points on each line.
+        final double s = (b*f - c*e) / denominator;
+        final double t = (a*f - b*c) / denominator;
+        final double la = Math.sqrt(a), le = Math.sqrt(e);
+        if (s * la <= tolerance || (1 - s) * la <= tolerance || t * le <= tolerance || (1 - t) * le <= tolerance) {
+            return false;
+        }
+        double distance2 = 0;
+        for (int k = 0; k < 3; k++) {
+            final double d = r[k] + s * d1[k] - t * d2[k];
+            distance2 += d * d;
+        }
+        return distance2 <= tolerance * tolerance;
+    }
+
+    private static double dot(final double[] a, final double[] b) {
+        return a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
+    }
+
+    /**
+     * Returns whether the segment from {@code a} to {@code b}, excluding its ends, meets the interior of the face
+     * of the given plane. A segment in the plane of the face is tested by its middle, since it does not cross
+     * the boundary of the face if the edges have been verified. A segment crossing the plane is tested
+     * by its crossing point.
+     */
+    boolean meetsFace(final Plane plane, final double[] a, final double[] b) {
+        final double da = distance(plane, a);
+        final double db = distance(plane, b);
+        final double t;
+        if (Math.abs(da) <= tolerance && Math.abs(db) <= tolerance) {
+            t = 0.5;
+        } else if ((da > tolerance && db < -tolerance) || (da < -tolerance && db > tolerance)) {
+            t = da / (da - db);
+        } else {
+            return false;               // touches the plane at an end at most.
+        }
+        final double[] p = new double[3];
+        for (int k = 0; k < 3; k++) {
+            p[k] = a[k] + t * (b[k] - a[k]);
+        }
+        return insideFace(plane, p, false);
+    }
+
+    /**
+     * Returns a point inside the face of the given plane. The point is on a line of the projection plane passing
+     * through the middle of the largest gap between the nodes, in the middle of the widest interval of that line
+     * inside the face. The point is then lifted to the plane.
+     */
+    double[] interiorPoint(final Plane plane) {
+        final double[] xyz = complex.xyz;
+        final int u = plane.axes[0], v = plane.axes[1], w = plane.axes[2];
+        int count = 0;
+        for (final int[] ring : plane.rings) {
+            count += ring.length;
+        }
+        final double[] ys = new double[count];
+        count = 0;
+        for (final int[] ring : plane.rings) {
+            for (final int node : ring) {
+                ys[count++] = xyz[node * 3 + v];
+            }
+        }
+        Arrays.sort(ys);
+        double y = ys[0], gap = -1;
+        for (int i = 1; i < count; i++) {
+            if (ys[i] - ys[i - 1] > gap) {
+                gap = ys[i] - ys[i - 1];
+                y = (ys[i] + ys[i - 1]) / 2;
+            }
+        }
+        final double[] xs = new double[count];
+        int n = 0;
+        for (final int[] ring : plane.rings) {
+            for (int i = 0; i < ring.length; i++) {
+                final int a = ring[i] * 3, b = ring[(i + 1) % ring.length] * 3;
+                final double ay = xyz[a+v], by = xyz[b+v];
+                if ((ay > y) != (by > y)) {
+                    xs[n++] = xyz[a+u] + (y - ay) * (xyz[b+u] - xyz[a+u]) / (by - ay);
+                }
+            }
+        }
+        Arrays.sort(xs, 0, n);
+        double x = (n != 0) ? xs[0] : plane.origin[u], width = -1;
+        for (int i = 0; i + 1 < n; i += 2) {
+            if (xs[i + 1] - xs[i] > width) {
+                width = xs[i + 1] - xs[i];
+                x = (xs[i] + xs[i + 1]) / 2;
+            }
+        }
+        final double[] normal = plane.normal, o = plane.origin;
+        final double[] p = new double[3];
+        p[u] = x;
+        p[v] = y;
+        p[w] = o[w] - (normal[u] * (x - o[u]) + normal[v] * (y - o[v])) / normal[w];
+        return p;
+    }
+
+    /**
+     * Returns the faces of the given shells as triangles, three node indices per triangle, oriented as the shells
+     * use the faces. Each ring is split in a fan of triangles from its first node, which gives the right winding
+     * number for planar rings, convex or not.
+     */
+    int[] triangles(final int[][] shells) {
+        int count = 0;
+        for (final int[] shell : shells) {
+            for (final int ref : shell) {
+                for (final int[] ring : complex.faceRings[index(ref)]) {
+                    count += ring.length - 2;
+                }
+            }
+        }
+        final int[] triangles = new int[count * 3];
+        int t = 0;
+        for (final int[] shell : shells) {
+            for (final int ref : shell) {
+                final boolean reverse = ref < 0;
+                for (final int[] refs : complex.faceRings[index(ref)]) {
+                    final int[] ring = complex.nodes(refs);
+                    for (int k = 1; k + 1 < ring.length; k++) {
+                        triangles[t++] = ring[0];
+                        triangles[t++] = ring[reverse ? k + 1 : k];
+                        triangles[t++] = ring[reverse ? k : k + 1];
+                    }
+                }
+            }
+        }
+        return triangles;
+    }
+
+    /**
+     * Returns the bounding box of the given shells, as the minimal then maximal coordinates.
+     */
+    double[] box(final int[][] shells) {
+        final double[] xyz = complex.xyz;
+        final double[] box = {Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY,
+                              Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY};
+        for (final int[] shell : shells) {
+            for (final int ref : shell) {
+                for (final int edge : complex.faceRings[index(ref)][0]) {
+                    final int node = complex.startNode(edge) * 3;
+                    for (int k = 0; k < 3; k++) {
+                        box[k]     = Math.min(box[k],     xyz[node + k]);
+                        box[k + 3] = Math.max(box[k + 3], xyz[node + k]);
+                    }
+                }
+            }
+        }
+        return box;
+    }
+
+    /**
+     * Returns whether the given point is inside the solid of the given box and triangles.
+     * The point shall not be on the boundary of the solid.
+     */
+    boolean insideSolid(final double[] box, final int[] triangles, final double[] p) {
+        return inBox(box, p) && Math.abs(Contains.windingNumber(complex.xyz, triangles, p)) > 0.5;
+    }
+
+    /**
+     * Returns whether the given point is inside the solid of the given shells.
+     * The point shall not be on the boundary of the solid.
+     */
+    boolean insideSolid(final int[][] shells, final double[] p) {
+        return inBox(box(shells), p) && Math.abs(Contains.windingNumber(complex.xyz, triangles(shells), p)) > 0.5;
+    }
+
+    /**
+     * Returns whether the given point is in the given box, within the tolerance.
+     */
+    private boolean inBox(final double[] box, final double[] p) {
+        for (int k = 0; k < 3; k++) {
+            if (p[k] < box[k] - tolerance || p[k] > box[k + 3] + tolerance) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Returns the volume enclosed by the given faces, positive if they are oriented outward.
+     * The volume is the sum of the tetrahedra joining a reference node to the triangles
+     * of a fan over each ring, which is exact for planar rings, convex or not.
+     */
+    double signedVolume(final int[] shell) {
+        final double[] xyz = complex.xyz;
+        final int o = complex.startNode(complex.faceRings[index(shell[0])][0][0]) * 3;
+        double volume = 0;
+        for (final int ref : shell) {
+            double sum = 0;
+            for (final int[] refs : complex.faceRings[index(ref)]) {
+                final int[] ring = complex.nodes(refs);
+                final int a = ring[0] * 3;
+                for (int i = 1; i + 1 < ring.length; i++) {
+                    final int b = ring[i] * 3, c = ring[i + 1] * 3;
+                    final double ax = xyz[a] - xyz[o], ay = xyz[a+1] - xyz[o+1], az = xyz[a+2] - xyz[o+2];
+                    final double bx = xyz[b] - xyz[o], by = xyz[b+1] - xyz[o+1], bz = xyz[b+2] - xyz[o+2];
+                    final double cx = xyz[c] - xyz[o], cy = xyz[c+1] - xyz[o+1], cz = xyz[c+2] - xyz[o+2];
+                    sum += ax * (by*cz - bz*cy) + ay * (bz*cx - bx*cz) + az * (bx*cy - by*cx);
+                }
+            }
+            volume += (ref < 0) ? -sum : sum;
+        }
+        return volume / 6;
+    }
+
+    /**
+     * Returns the coordinates of the given node, for error messages.
+     */
+    String describeNode(final int node) {
+        return Arrays.toString(complex.getCoordinates(node));
+    }
+
+    /**
+     * Returns the ends of the given edge, for error messages.
+     */
+    String describeEdge(final int edge) {
+        return "edge from " + describeNode(complex.edgeNodes[2*edge]) + " to " + describeNode(complex.edgeNodes[2*edge + 1]);
+    }
+
+    /**
+     * Returns the first corners of the given face, for error messages.
+     */
+    String describeFace(final int face) {
+        final int[] ring = complex.nodes(complex.faceRings[face][0]);
+        final var buffer = new StringBuilder("face with corners ");
+        for (int i = 0; i < Math.min(ring.length, 4); i++) {
+            if (i != 0) buffer.append(", ");
+            buffer.append(describeNode(ring[i]));
+        }
+        if (ring.length > 4) {
+            buffer.append(", …");
+        }
+        return buffer.toString();
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/ComplexValidator.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/ComplexValidator.java
new file mode 100644
index 0000000000..533a5abec7
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/ComplexValidator.java
@@ -0,0 +1,415 @@
+/*
+ * 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.internal.shared.topology;
+
+import java.util.Arrays;
+import java.util.BitSet;
+import static org.apache.sis.geometries.internal.shared.topology.IndexedComplex.index;
+import static org.apache.sis.geometries.internal.shared.topology.IndexedComplex.faceContainer;
+import static org.apache.sis.geometries.internal.shared.topology.IndexedComplex.solidContainer;
+
+
+/**
+ * Verifies that the primitives of a newly built {@link IndexedComplex} have mutually disjoint interiors,
+ * as required by ISO 19107 for building a topological complex from geometries, and finds the primitives
+ * isolated in a primitive of higher dimension.
+ *
+ * <p>The verifications are geometric, with the tolerance of the complex:</p>
+ * <ul>
+ *   <li>no node lies inside an edge,</li>
+ *   <li>no edge crosses another edge, or the interior of a face,</li>
+ *   <li>each shell of a solid is closed and consistently oriented,</li>
+ *   <li>no two solids are on the same side of a face, and no face other than those of its boundary
+ *       lies inside a solid, so no solid lies inside another one.</li>
+ * </ul>
+ *
+ * <p>A node inside a face or a solid, and an edge inside a solid, are isolated in that primitive,
+ * as allowed by ISO 19107. An edge inside a face is not allowed, since it would be a dangling edge
+ * on the boundary of the face.</p>
+ *
+ * <p>Two faces whose interiors overlap always have an edge of one crossing the other, or an edge
+ * of one inside the other, so faces are not compared directly.</p>
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+final class ComplexValidator {
+
+    /**
+     * Container code of each node, as stored by {@link IndexedComplex#nodeContainers}.
+     */
+    final int[] nodeContainers;
+
+    /**
+     * Solid in which each edge is isolated, or −1 if none.
+     */
+    final int[] edgeContainers;
+
+    /**
+     * The complex to verify.
+     */
+    private final IndexedComplex complex;
+
+    /**
+     * The geometric predicates.
+     */
+    private final ComplexGeometry geometry;
+
+    /**
+     * Plane of each face.
+     */
+    private final ComplexGeometry.Plane[] planes;
+
+    /**
+     * Prepares the verification of the given complex, which shall have no removed primitive.
+     *
+     * @throws IllegalArgumentException if a face has no area.
+     */
+    ComplexValidator(final IndexedComplex complex) {
+        this.complex = complex;
+        geometry = new ComplexGeometry(complex);
+        nodeContainers = new int[complex.nodeCount];
+        edgeContainers = new int[complex.edgeCount];
+        Arrays.fill(nodeContainers, -1);
+        Arrays.fill(edgeContainers, -1);
+        planes = new ComplexGeometry.Plane[complex.faceCount];
+        for (int f = 0; f < planes.length; f++) {
+            planes[f] = geometry.plane(f);
+        }
+    }
+
+    /**
+     * Runs all verifications.
+     *
+     * @throws IllegalArgumentException if the interiors of two primitives intersect, or a shell is not closed.
+     */
+    void validate() {
+        checkIntersections();
+        checkShells();
+        checkSolids();
+    }
+
+    /**
+     * Compares the nodes, edges and faces whose bounding boxes overlap. The boxes are sorted on their
+     * minimal x value, then swept so that only boxes overlapping along x are compared.
+     */
+    private void checkIntersections() {
+        final double tolerance = geometry.tolerance;
+        final int nodeCount = complex.nodeCount;
+        final int edgeEnd   = nodeCount + complex.edgeCount;
+        final int count     = edgeEnd + complex.faceCount;
+        final double[] boxes = new double[count * 6];
+        for (int i = 0; i < nodeCount; i++) {
+            box(boxes, i, new int[] {i});
+        }
+        for (int e = 0; e < complex.edgeCount; e++) {
+            box(boxes, nodeCount + e, new int[] {complex.edgeNodes[2*e], complex.edgeNodes[2*e + 1]});
+        }
+        for (int f = 0; f < planes.length; f++) {
+            box(boxes, edgeEnd + f, planes[f].rings[0]);
+        }
+        final Integer[] order = new Integer[count];
+        for (int i = 0; i < count; i++) {
+            order[i] = i;
+        }
+        Arrays.sort(order, (i, j) -> Double.compare(boxes[i * 6], boxes[j * 6]));
+        final int[] active = new int[count];
+        int activeCount = 0;
+        for (final int i : order) {
+            final double min = boxes[i * 6] - tolerance;
+            int kept = 0;
+            for (int a = 0; a < activeCount; a++) {
+                final int j = active[a];
+                if (boxes[j * 6 + 3] >= min) {
+                    active[kept++] = j;
+                    if (overlaps(boxes, i, j, tolerance)) {
+                        compare(Math.min(i, j), Math.max(i, j), nodeCount, edgeEnd);
+                    }
+                }
+            }
+            activeCount = kept;
+            active[activeCount++] = i;
+        }
+    }
+
+    /**
+     * Stores in {@code boxes} the bounding box of the given nodes, as the minimal then maximal coordinates.
+     */
+    private void box(final double[] boxes, final int item, final int[] nodes) {
+        final double[] xyz = complex.xyz;
+        final int b = item * 6;
+        for (int k = 0; k < 3; k++) {
+            boxes[b + k]     = Double.POSITIVE_INFINITY;
+            boxes[b + 3 + k] = Double.NEGATIVE_INFINITY;
+        }
+        for (final int node : nodes) {
+            for (int k = 0; k < 3; k++) {
+                final double value = xyz[node * 3 + k];
+                boxes[b + k]     = Math.min(boxes[b + k], value);
+                boxes[b + 3 + k] = Math.max(boxes[b + 3 + k], value);
+            }
+        }
+    }
+
+    /**
+     * Returns whether the boxes of the two given items overlap, within the tolerance.
+     */
+    private static boolean overlaps(final double[] boxes, final int i, final int j, final double tolerance) {
+        for (int k = 0; k < 3; k++) {
+            if (boxes[i * 6 + k] > boxes[j * 6 + 3 + k] + tolerance ||
+                boxes[j * 6 + k] > boxes[i * 6 + 3 + k] + tolerance)
+            {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Compares two items whose boxes overlap. Items are numbered nodes first, then edges, then faces,
+     * so the first item is never of a higher dimension than the second one.
+     */
+    private void compare(final int i, final int j, final int nodeCount, final int edgeEnd) {
+        if (j < nodeCount) {
+            return;                                         // two nodes are distinct by construction.
+        }
+        if (i < nodeCount) {
+            if (j < edgeEnd) {
+                nodeInEdge(i, j - nodeCount);
+            } else {
+                nodeInFace(i, j - edgeEnd);
+            }
+        } else if (i < edgeEnd) {
+            if (j < edgeEnd) {
+                edgeCrossesEdge(i - nodeCount, j - nodeCount);
+            } else {
+                edgeCrossesFace(i - nodeCount, j - edgeEnd);
+            }
+        }
+        // Two faces are covered by the comparisons of their nodes and edges.
+    }
+
+    /**
+     * Verifies that the given node is not inside the given edge.
+     */
+    private void nodeInEdge(final int node, final int edge) {
+        final int a = complex.edgeNodes[2*edge], b = complex.edgeNodes[2*edge + 1];
+        if (node != a && node != b && geometry.insideSegment(geometry.point(node), geometry.point(a), geometry.point(b))) {
+            fail("the node " + geometry.describeNode(node) + " is inside the " + geometry.describeEdge(edge));
+        }
+    }
+
+    /**
+     * Records the given node as isolated in the given face if it is inside that face. The node may be
+     * a point, the end of a line, or the corner of a face in another plane, such as the apex of a pyramid
+     * touching the face. A corner of a face in the same plane cannot be inside the face without an edge
+     * of one face inside or across the other, which is reported by the comparisons of edges and faces.
+     */
+    private void nodeInFace(final int node, final int face) {
+        for (final int[] ring : planes[face].rings) {
+            for (final int corner : ring) {
+                if (corner == node) return;
+            }
+        }
+        if (geometry.insideFace(planes[face], geometry.point(node), true)) {
+            final int previous = nodeContainers[node];
+            if (previous >= 0 && previous != faceContainer(face)) {
+                fail("the node " + geometry.describeNode(node) + " is inside the " + geometry.describeFace(previous >> 1)
+                        + " and inside the " + geometry.describeFace(face));
+            }
+            nodeContainers[node] = faceContainer(face);
+        }
+    }
+
+    /**
+     * Verifies that the given edges do not cross each other. Edges sharing a node are not compared:
+     * if they overlap, the other node of one of them is inside the other edge.
+     */
+    private void edgeCrossesEdge(final int e1, final int e2) {
+        final int a0 = complex.edgeNodes[2*e1], a1 = complex.edgeNodes[2*e1 + 1];
+        final int b0 = complex.edgeNodes[2*e2], b1 = complex.edgeNodes[2*e2 + 1];
+        if (a0 != b0 && a0 != b1 && a1 != b0 && a1 != b1 &&
+                geometry.cross(geometry.point(a0), geometry.point(a1), geometry.point(b0), geometry.point(b1)))
+        {
+            fail("the " + geometry.describeEdge(e1) + " crosses the " + geometry.describeEdge(e2));
+        }
+    }
+
+    /**
+     * Verifies that the given edge does not cross the interior of the given face, unless it is an edge of that face.
+     */
+    private void edgeCrossesFace(final int edge, final int face) {
+        for (final int[] ring : complex.faceRings[face]) {
+            for (final int ref : ring) {
+                if (index(ref) == edge) return;
+            }
+        }
+        final double[] a = geometry.point(complex.edgeNodes[2*edge]);
+        final double[] b = geometry.point(complex.edgeNodes[2*edge + 1]);
+        if (geometry.meetsFace(planes[face], a, b)) {
+            fail("the " + geometry.describeEdge(edge) + " crosses the " + geometry.describeFace(face));
+        }
+    }
+
+    /**
+     * Verifies that each shell is closed and consistently oriented:
+     * every edge is used as many times in each direction by the faces of the shell.
+     */
+    private void checkShells() {
+        final int[] uses = new int[complex.edgeCount];
+        for (int s = 0; s < complex.solidCount; s++) {
+            for (final int[] shell : complex.solidShells[s]) {
+                final String problem = ComplexValidator.closed(complex, shell, uses);
+                if (problem != null) {
+                    fail("a shell of the solid " + s + " is " + problem);
+                }
+            }
+        }
+    }
+
+    /**
+     * Verifies that the given shell is closed and consistently oriented: every edge is used as many times
+     * in each direction by the faces of the shell.
+     *
+     * @param  uses  a work array of one element per edge, which shall contain only zeros.
+     *               It contains only zeros again on return.
+     * @return {@code null} if the shell is valid, or a description of the problem otherwise.
+     */
+    static String closed(final IndexedComplex complex, final int[] shell, final int[] uses) {
+        for (final int faceRef : shell) {
+            for (final int[] ring : complex.faceRings[index(faceRef)]) {
+                for (final int edgeRef : ring) {
+                    uses[index(edgeRef)] += ((edgeRef < 0) == (faceRef < 0)) ? 1 : -1;
+                }
+            }
+        }
+        String problem = null;
+        for (final int faceRef : shell) {
+            for (final int[] ring : complex.faceRings[index(faceRef)]) {
+                for (final int edgeRef : ring) {
+                    final int edge = index(edgeRef);
+                    if (uses[edge] != 0 && problem == null) {
+                        problem = "open or not consistently oriented at the "
+                                + new ComplexGeometry(complex).describeEdge(edge);
+                    }
+                    uses[edge] = 0;
+                }
+            }
+        }
+        return problem;
+    }
+
+    /**
+     * Verifies that no two solids are on the same side of a face, and that no face is inside a solid
+     * other than those having that face on their boundary. Since faces do not cross, a face is entirely
+     * inside or outside a solid, which is tested on one point of the face with the winding number of the solid.
+     * Then records the nodes and edges inside a solid as isolated in that solid.
+     */
+    private void checkSolids() {
+        for (int f = 0; f < complex.faceCount; f++) {
+            final String problem = sameSide(complex.faceCo[f]);
+            if (problem != null) {
+                fail("the solids " + problem + " the " + geometry.describeFace(f));
+            }
+        }
+        final int faceCount = complex.faceCount;
+        final double[][] interiorPoints = new double[faceCount][];
+        for (int s = 0; s < complex.solidCount; s++) {
+            final int[][] shells = complex.solidShells[s];
+            final BitSet boundary = new BitSet(faceCount);
+            final BitSet corners  = new BitSet(complex.nodeCount);
+            for (final int face : complex.faces(s)) {
+                boundary.set(face);
+                for (final int[] ring : planes[face].rings) {
+                    for (final int node : ring) corners.set(node);
+                }
+            }
+            final double[] box = geometry.box(shells);
+            final int[] triangles = geometry.triangles(shells);
+            /*
+             * Faces of other solids, or faces without solid. A point of a face is enough,
+             * since a face which does not cross the boundary of the solid is entirely inside or outside.
+             */
+            for (int f = 0; f < faceCount; f++) {
+                if (boundary.get(f)) continue;
+                if (interiorPoints[f] == null) {
+                    interiorPoints[f] = geometry.interiorPoint(planes[f]);
+                }
+                if (geometry.insideSolid(box, triangles, interiorPoints[f])) {
+                    fail("the " + geometry.describeFace(f) + " is inside the solid " + s);
+                }
+            }
+            /*
+             * Nodes which are not on the boundary of the solid. A node isolated in a face is on that face,
+             * which is not inside the solid as verified above.
+             */
+            for (int n = 0; n < complex.nodeCount; n++) {
+                final int previous = nodeContainers[n];
+                if (corners.get(n) || (previous >= 0 && (previous & 1) == 0)) continue;
+                if (geometry.insideSolid(box, triangles, geometry.point(n))) {
+                    if (previous >= 0) {
+                        fail("the node " + geometry.describeNode(n) + " is inside the solids " + (previous >> 1) + " and " + s);
+                    }
+                    nodeContainers[n] = solidContainer(s);
+                }
+            }
+            /*
+             * Edges which are not on the boundary of a face. An edge which does not cross the boundary
+             * of the solid is entirely inside or outside, so its middle is tested.
+             */
+            for (int e = 0; e < complex.edgeCount; e++) {
+                if (complex.edgeCo[e].length != 0) continue;
+                final double[] a = geometry.point(complex.edgeNodes[2*e]);
+                final double[] b = geometry.point(complex.edgeNodes[2*e + 1]);
+                final double[] middle = {(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2};
+                if (geometry.insideSolid(box, triangles, middle)) {
+                    if (edgeContainers[e] >= 0) {
+                        fail("the " + geometry.describeEdge(e) + " is inside the solids " + edgeContainers[e] + " and " + s);
+                    }
+                    edgeContainers[e] = s;
+                }
+            }
+        }
+    }
+
+    /**
+     * Verifies that at most one solid is below a face, and at most one above.
+     *
+     * @param  co  the signed references to the solids having the face on their boundary.
+     * @return {@code null} if valid, or the solids and the side otherwise.
+     */
+    static String sameSide(final int[] co) {
+        int below = -1, above = -1;
+        for (final int ref : co) {
+            final int solid = index(ref);
+            if (ref >= 0) {
+                if (below >= 0) return below + " and " + solid + " are both below";
+                below = solid;
+            } else {
+                if (above >= 0) return above + " and " + solid + " are both above";
+                above = solid;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Reports primitives whose interiors are not disjoint.
+     */
+    private static void fail(final String message) {
+        throw new IllegalArgumentException("Invalid topological complex: " + message + '.');
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/DefaultExpression.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/DefaultExpression.java
new file mode 100644
index 0000000000..e18eebc2aa
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/DefaultExpression.java
@@ -0,0 +1,278 @@
+/*
+ * 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.internal.shared.topology;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.sis.geometries.Orientable;
+import org.apache.sis.geometries.topology.DirectedTopo;
+import org.apache.sis.geometries.topology.Expression;
+import org.apache.sis.geometries.topology.ExpressionTerm;
+
+
+/**
+ * An expression stored as a map from primitives to integer coefficients.
+ *
+ * <p>The map is in canonical form: each key is the positive orientation of its primitive, the sign of the
+ * orientation being carried by the coefficient, and no coefficient is zero. For example −<var>e</var> is
+ * stored as the coefficient −1 of <var>e</var>. Two expressions are therefore equal if their maps are equal,
+ * whatever the order of their terms. Instances are immutable.</p>
+ *
+ * <p>Any {@link DirectedTopo} can be a variable, provided that its {@code equals} and {@code hashCode}
+ * methods identify the oriented primitive, as the views of {@link IndexedComplex} do.</p>
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class DefaultExpression implements Expression {
+
+    /**
+     * The expression without term.
+     */
+    public static final DefaultExpression ZERO = new DefaultExpression(new LinkedHashMap<>());
+
+    /**
+     * Coefficient of each primitive, in canonical form.
+     */
+    private final Map<DirectedTopo, Integer> coefficients;
+
+    /**
+     * Creates an expression from the given coefficients in canonical form, stored without copy.
+     */
+    private DefaultExpression(final LinkedHashMap<DirectedTopo, Integer> coefficients) {
+        this.coefficients = Collections.unmodifiableMap(coefficients);
+    }
+
+    /**
+     * Returns the expression of a single oriented primitive, of coefficient 1.
+     *
+     * @param  variable  the oriented primitive.
+     * @return the expression of the given oriented primitive.
+     */
+    public static DefaultExpression of(final DirectedTopo variable) {
+        final var map = new LinkedHashMap<DirectedTopo, Integer>();
+        add(map, variable, 1);
+        return new DefaultExpression(map);
+    }
+
+    /**
+     * Returns the given expression as an instance of this class.
+     *
+     * @param  expression  the expression to convert.
+     * @return the given expression if it is already an instance of this class, or a copy of its terms otherwise.
+     */
+    public static DefaultExpression of(final Expression expression) {
+        if (expression instanceof DefaultExpression e) {
+            return e;
+        }
+        final var map = new LinkedHashMap<DirectedTopo, Integer>();
+        addAll(map, expression, 1);
+        return new DefaultExpression(map);
+    }
+
+    /**
+     * Returns the sum of the given oriented primitives, each of coefficient 1.
+     *
+     * @param  variables  the oriented primitives to add.
+     * @return the sum of the given oriented primitives.
+     */
+    public static DefaultExpression sum(final Iterable<? extends DirectedTopo> variables) {
+        final var map = new LinkedHashMap<DirectedTopo, Integer>();
+        for (final DirectedTopo variable : variables) {
+            add(map, variable, 1);
+        }
+        return new DefaultExpression(map);
+    }
+
+    /**
+     * Adds a term to the given map, keeping the canonical form.
+     */
+    private static void add(final Map<DirectedTopo, Integer> map, DirectedTopo variable, int coefficient) {
+        if (coefficient != 0) {
+            if (variable.getOrientationSign() == Orientable.Sign.NEGATIVE) {
+                variable = variable.getNegate();
+                coefficient = Math.negateExact(coefficient);
+            }
+            map.merge(variable, coefficient, (a, b) -> {
+                final int sum = Math.addExact(a, b);
+                return (sum != 0) ? sum : null;             // null removes the term.
+            });
+        }
+    }
+
+    /**
+     * Adds the terms of the given expression multiplied by the given factor to the given map.
+     */
+    private static void addAll(final Map<DirectedTopo, Integer> map, final Expression expression, final int factor) {
+        for (final ExpressionTerm term : expression.getTerms()) {
+            add(map, term.getVariable(), Math.multiplyExact(term.getCoefficient(), factor));
+        }
+    }
+
+    /**
+     * Returns the terms of this expression. The variable of each term is the positive orientation
+     * of its primitive, and the sign of the coefficient gives the orientation.
+     */
+    @Override
+    public Collection<ExpressionTerm> getTerms() {
+        final List<ExpressionTerm> terms = new ArrayList<>(coefficients.size());
+        coefficients.forEach((variable, coefficient) -> terms.add(new Term(coefficient, variable)));
+        return Collections.unmodifiableList(terms);
+    }
+
+    /**
+     * Returns the oriented primitives of this expression, each one with the sign of its coefficient.
+     * A coefficient greater than 1 in absolute value gives a single element.
+     */
+    final List<DirectedTopo> toDirectedList() {
+        final List<DirectedTopo> list = new ArrayList<>(coefficients.size());
+        coefficients.forEach((variable, coefficient) -> list.add((coefficient > 0) ? variable : variable.getNegate()));
+        return list;
+    }
+
+    @Override
+    public boolean isZero() {
+        return coefficients.isEmpty();
+    }
+
+    @Override
+    public DefaultExpression getNegate() {
+        final var map = new LinkedHashMap<DirectedTopo, Integer>();
+        coefficients.forEach((variable, coefficient) -> map.put(variable, Math.negateExact(coefficient)));
+        return new DefaultExpression(map);
+    }
+
+    /**
+     * Returns the sum of the boundaries of the primitives, each multiplied by its coefficient.
+     */
+    @Override
+    public DefaultExpression getBoundary() {
+        final var map = new LinkedHashMap<DirectedTopo, Integer>();
+        coefficients.forEach((variable, coefficient) -> {
+            for (final DirectedTopo element : variable.getBoundary()) {
+                add(map, element, coefficient);
+            }
+        });
+        return new DefaultExpression(map);
+    }
+
+    /**
+     * Returns the sum of the co-boundaries of the primitives, each multiplied by its coefficient.
+     */
+    @Override
+    public DefaultExpression getCoBoundary() {
+        final var map = new LinkedHashMap<DirectedTopo, Integer>();
+        coefficients.forEach((variable, coefficient) -> {
+            for (final DirectedTopo element : variable.getCoBoundary()) {
+                add(map, element, coefficient);
+            }
+        });
+        return new DefaultExpression(map);
+    }
+
+    @Override
+    public DefaultExpression plus(final Expression other) {
+        final var map = new LinkedHashMap<>(coefficients);
+        addAll(map, other, 1);
+        return new DefaultExpression(map);
+    }
+
+    @Override
+    public DefaultExpression minus(final Expression other) {
+        final var map = new LinkedHashMap<>(coefficients);
+        addAll(map, other, -1);
+        return new DefaultExpression(map);
+    }
+
+    /**
+     * Returns {@code true} if the given object is an expression with the same terms, in any order.
+     */
+    @Override
+    public boolean equals(final Object obj) {
+        return (obj instanceof Expression other) && coefficients.equals(of(other).coefficients);
+    }
+
+    @Override
+    public int hashCode() {
+        return coefficients.hashCode();
+    }
+
+    /**
+     * Returns the terms of this expression, such as {@code "+2*Edge[0] -Edge[3]"}, or {@code "0"} if none.
+     */
+    @Override
+    public String toString() {
+        if (coefficients.isEmpty()) {
+            return "0";
+        }
+        final var buffer = new StringBuilder();
+        coefficients.forEach((variable, coefficient) -> {
+            if (buffer.length() != 0) {
+                buffer.append(' ');
+            }
+            buffer.append(coefficient < 0 ? '-' : '+');
+            final int magnitude = Math.abs(coefficient);
+            if (magnitude != 1) {
+                buffer.append(magnitude).append('*');
+            }
+            buffer.append(variable);
+        });
+        return buffer.toString();
+    }
+
+    /**
+     * A term of a {@link DefaultExpression}, which is also an expression of that single term.
+     */
+    private static final class Term implements ExpressionTerm {
+        private final int coefficient;
+        private final DirectedTopo variable;
+
+        Term(final int coefficient, final DirectedTopo variable) {
+            this.coefficient = coefficient;
+            this.variable    = variable;
+        }
+
+        @Override
+        public int getCoefficient() {
+            return coefficient;
+        }
+
+        @Override
+        public DirectedTopo getVariable() {
+            return variable;
+        }
+
+        /**
+         * Returns this term as an expression, on which all operations are delegated.
+         */
+        private DefaultExpression expression() {
+            return of(this);
+        }
+
+        @Override public Expression getNegate()               {return expression().getNegate();}
+        @Override public Expression getBoundary()             {return expression().getBoundary();}
+        @Override public Expression getCoBoundary()           {return expression().getCoBoundary();}
+        @Override public Expression plus(Expression other)    {return expression().plus(other);}
+        @Override public Expression minus(Expression other)   {return expression().minus(other);}
+        @Override public boolean    equals(Object obj)        {return expression().equals(obj);}
+        @Override public int        hashCode()                {return expression().hashCode();}
+        @Override public String     toString()                {return expression().toString();}
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedComplex.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedComplex.java
new file mode 100644
index 0000000000..ead35b9ec4
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedComplex.java
@@ -0,0 +1,1637 @@
+/*
+ * 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.internal.shared.topology;
+
+import java.util.AbstractSet;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.BitSet;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.Set;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.geometries.DataPoints;
+import org.apache.sis.geometries.Geometry;
+import org.apache.sis.geometries.GeometryCollection;
+import org.apache.sis.geometries.GeometryFactory;
+import org.apache.sis.geometries.Point;
+import org.apache.sis.geometries.curve.LineString;
+import org.apache.sis.geometries.curve.LinearRing;
+import org.apache.sis.geometries.operation.ShewchukPredicates;
+import org.apache.sis.geometries.solid.Polyhedron;
+import org.apache.sis.geometries.surface.Polygon;
+import org.apache.sis.geometries.topology.Complex;
+import org.apache.sis.geometries.topology.DirectedEdge;
+import org.apache.sis.geometries.topology.DirectedFace;
+import org.apache.sis.geometries.topology.DirectedNode;
+import org.apache.sis.geometries.topology.DirectedSolid;
+import org.apache.sis.geometries.topology.DirectedTopo;
+import org.apache.sis.geometries.topology.Edge;
+import org.apache.sis.geometries.topology.Face;
+import org.apache.sis.geometries.topology.Node;
+import org.apache.sis.geometries.topology.Primitive;
+import org.apache.sis.geometries.topology.Solid;
+
+
+/**
+ * A maximal topological complex storing its primitives in arrays of indices.
+ * Instances are created by {@link ComplexBuilder} and modified by {@link ComplexEditor}.
+ *
+ * <p>The storage is the following:</p>
+ * <ul>
+ *   <li>A node is a point of {@link NodePoints}: its coordinates and the other attributes of the geometries
+ *       from which it has been built, such as normals or colors. The geometric realizations keep them.</li>
+ *   <li>An edge is its start node and its end node.</li>
+ *   <li>A face is a list of rings, the exterior ring first. A ring is a cycle of signed edge references,
+ *       oriented so that the face is on the left of every edge.</li>
+ *   <li>A solid is a list of shells, the exterior shell first. A shell is a set of signed face references,
+ *       oriented so that the solid is below every face.</li>
+ * </ul>
+ *
+ * <p>A signed reference is the index <var>i</var> of a primitive for its positive orientation, and
+ * {@code ~i} (which is −<var>i</var> − 1) for its negative orientation. Each primitive also stores its
+ * co-boundary, which is kept up to date by the edits. In 3 dimensions, the faces around an edge are sorted
+ * in clockwise order seen from the end of the edge. In 2 dimensions, the edges around a node are sorted
+ * in clockwise order. Both sorts use the {@code orient3d} and {@code orient2d} predicates of
+ * {@link ShewchukPredicates}.</p>
+ *
+ * <p>A node inside a face or a solid, or an edge inside a solid, is isolated in it:
+ * that face or solid is its {@linkplain Primitive#getContainer() container}.</p>
+ *
+ * <p>The primitives returned by this complex are lightweight views created on demand. Two views are
+ * equal if they are of the same type, orientation and index in the same complex. A removed primitive
+ * keeps its index, which is never reused, so that the indices of the other primitives do not change.
+ * The views of a removed primitive shall not be used anymore.</p>
+ *
+ * <p>This class is not thread-safe while it is edited.</p>
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class IndexedComplex implements Complex {
+
+    /**
+     * Shared empty array, never modified.
+     */
+    private static final int[] EMPTY = new int[0];
+
+    /**
+     * Tolerance relative to the largest extent of the complex, for the geometric verifications.
+     */
+    private static final double RELATIVE_TOLERANCE = 1E-10;
+
+    /**
+     * The reference system of the node coordinates, or {@code null} for an empty complex.
+     */
+    final CoordinateReferenceSystem crs;
+
+    /**
+     * Number of coordinates of each node: 2, 3, or 0 for an empty complex.
+     */
+    final int dimension;
+
+    /**
+     * Largest distance between two positions considered the same in the geometric verifications.
+     * It is relative to the extent of the complex when it was built.
+     */
+    final double tolerance;
+
+    /**
+     * The positions and other attributes of the nodes, one point per node index.
+     */
+    final NodePoints points;
+
+    /**
+     * Coordinates of the nodes in three dimensions, with z = 0 for 2-dimensional coordinates.
+     * This is {@link NodePoints#xyz}, updated when the points grow.
+     */
+    double[] xyz;
+
+    /**
+     * Number of nodes, edges, faces and solids, including the removed ones.
+     */
+    int nodeCount, edgeCount, faceCount, solidCount;
+
+    /**
+     * The start node then the end node of each edge.
+     */
+    int[] edgeNodes;
+
+    /**
+     * Rings of each face, the exterior ring first. Each ring is the signed references to its edges, in traversal order.
+     */
+    int[][][] faceRings;
+
+    /**
+     * Shells of each solid, the exterior shell first. Each shell is the signed references to its faces.
+     */
+    int[][][] solidShells;
+
+    /**
+     * Co-boundary of each node, edge and face, as signed references to the primitives of the next higher dimension.
+     */
+    int[][] nodeCo, edgeCo, faceCo;
+
+    /**
+     * Container of each node: −1 if none, {@code 2f} for face <var>f</var>, or {@code 2s + 1} for solid <var>s</var>.
+     */
+    int[] nodeContainers;
+
+    /**
+     * Solid containing each edge, or −1 if none.
+     */
+    int[] edgeContainers;
+
+    /**
+     * Primitives isolated in each face and each solid. Those of faces are nodes.
+     * Those of solids are node <var>n</var> as {@code n}, and edge <var>e</var> as {@code ~e}.
+     */
+    int[][] faceIsolated, solidIsolated;
+
+    /**
+     * The removed nodes, edges, faces and solids.
+     */
+    final BitSet removedNodes, removedEdges, removedFaces, removedSolids;
+
+    /**
+     * The geometries from which this complex has been built, or {@code null} after an edit.
+     */
+    private List<Geometry> sources;
+
+    /**
+     * Creates a complex from the given arrays, and verifies it. The faces and solids are given as offsets
+     * in arrays of references: the rings of face <var>f</var> are from {@code faceRingOffsets[f]} inclusive
+     * to {@code faceRingOffsets[f+1]} exclusive, and the references of ring <var>r</var> are at the positions
+     * from {@code ringEdgeOffsets[r]} inclusive to {@code ringEdgeOffsets[r+1]} exclusive in {@code faceEdgeRefs}.
+     * Likewise for solids.
+     *
+     * @throws IllegalArgumentException if the interiors of the primitives are not disjoint.
+     */
+    IndexedComplex(final CoordinateReferenceSystem crs, final int dimension, final NodePoints points,
+                   final int[] edgeNodes, final int[] faceRingOffsets, final int[] ringEdgeOffsets, final int[] faceEdgeRefs,
+                   final int[] solidShellOffsets, final int[] shellFaceOffsets, final int[] solidFaceRefs,
+                   final List<Geometry> sources)
+    {
+        this.crs       = crs;
+        this.dimension = dimension;
+        this.points    = points;
+        this.edgeNodes = edgeNodes;
+        this.sources   = List.copyOf(sources);
+        nodeCount  = points.size();
+        edgeCount  = edgeNodes.length / 2;
+        faceCount  = faceRingOffsets.length - 1;
+        solidCount = solidShellOffsets.length - 1;
+        removedNodes  = new BitSet();
+        removedEdges  = new BitSet();
+        removedFaces  = new BitSet();
+        removedSolids = new BitSet();
+        xyz = points.xyz;
+        faceRings   = split(faceCount,  faceRingOffsets,   ringEdgeOffsets,  faceEdgeRefs);
+        solidShells = split(solidCount, solidShellOffsets, shellFaceOffsets, solidFaceRefs);
+        /*
+         * The co-boundary of a node has the edges ending at the node (positive) and those starting at it
+         * (negative). The other co-boundaries are the transpose of the boundaries.
+         */
+        int[] lower = new int[edgeCount * 2];
+        int[] upper = new int[edgeCount * 2];
+        for (int e = 0; e < edgeCount; e++) {
+            lower[2*e]   = edgeNodes[2*e];   upper[2*e]   = ~e;
+            lower[2*e+1] = edgeNodes[2*e+1]; upper[2*e+1] = e;
+        }
+        nodeCo = group(nodeCount, lower, upper, lower.length);
+        edgeCo = transpose(edgeCount, faceRings);
+        faceCo = transpose(faceCount, solidShells);
+        if (dimension == 3) {
+            for (int e = 0; e < edgeCount; e++) sortAroundEdge(e);
+        } else if (dimension == 2) {
+            for (int n = 0; n < nodeCount; n++) sortAroundNode(n);
+        }
+        double extent = 0;
+        for (int k = 0; k < 3; k++) {
+            double min = Double.POSITIVE_INFINITY, max = Double.NEGATIVE_INFINITY;
+            for (int i = k; i < nodeCount * 3; i += 3) {
+                min = Math.min(min, xyz[i]);
+                max = Math.max(max, xyz[i]);
+            }
+            if (max > min) {
+                extent = Math.max(extent, max - min);
+            }
+        }
+        tolerance = extent * RELATIVE_TOLERANCE;
+        /*
+         * Verify the complex and find the isolated primitives, then the inverse of their containers.
+         */
+        final var validator = new ComplexValidator(this);
+        validator.validate();
+        nodeContainers = validator.nodeContainers;
+        edgeContainers = validator.edgeContainers;
+        lower = new int[nodeCount + edgeCount];
+        upper = new int[nodeCount + edgeCount];
+        int size = 0;
+        for (int n = 0; n < nodeCount; n++) {
+            final int c = nodeContainers[n];
+            if (c >= 0 && (c & 1) == 0) {
+                lower[size] = c >> 1;
+                upper[size++] = n;
+            }
+        }
+        faceIsolated = group(faceCount, lower, upper, size);
+        size = 0;
+        for (int n = 0; n < nodeCount; n++) {
+            final int c = nodeContainers[n];
+            if (c >= 0 && (c & 1) != 0) {
+                lower[size] = c >> 1;
+                upper[size++] = n;
+            }
+        }
+        for (int e = 0; e < edgeCount; e++) {
+            final int c = edgeContainers[e];
+            if (c >= 0) {
+                lower[size] = c;
+                upper[size++] = ~e;
+            }
+        }
+        solidIsolated = group(solidCount, lower, upper, size);
+    }
+
+    /**
+     * Returns the index of the primitive designated by the given signed reference.
+     */
+    static int index(final int ref) {
+        return (ref < 0) ? ~ref : ref;
+    }
+
+    /**
+     * Returns the container code of a node isolated in the given face.
+     */
+    static int faceContainer(final int face) {
+        return face << 1;
+    }
+
+    /**
+     * Returns the container code of a node isolated in the given solid.
+     */
+    static int solidContainer(final int solid) {
+        return (solid << 1) | 1;
+    }
+
+    /**
+     * Splits packed references into one array per part of each primitive, such as the rings of each face.
+     */
+    private static int[][][] split(final int count, final int[] partOffsets, final int[] refOffsets, final int[] refs) {
+        final int[][][] parts = new int[count][][];
+        for (int p = 0; p < count; p++) {
+            final int first = partOffsets[p], last = partOffsets[p + 1];
+            parts[p] = new int[last - first][];
+            for (int r = first; r < last; r++) {
+                parts[p][r - first] = Arrays.copyOfRange(refs, refOffsets[r], refOffsets[r + 1]);
+            }
+        }
+        return parts;
+    }
+
+    /**
+     * Returns the co-boundaries of the primitives used by the parts of higher primitives,
+     * such as the edges used by the rings of faces.
+     */
+    private static int[][] transpose(final int count, final int[][][] higher) {
+        int size = 0;
+        for (final int[][] parts : higher) {
+            for (final int[] part : parts) size += part.length;
+        }
+        final int[] lower = new int[size];
+        final int[] upper = new int[size];
+        size = 0;
+        for (int p = 0; p < higher.length; p++) {
+            for (final int[] part : higher[p]) {
+                for (final int ref : part) {
+                    lower[size] = index(ref);
+                    upper[size++] = (ref < 0) ? ~p : p;
+                }
+            }
+        }
+        return group(count, lower, upper, size);
+    }
+
+    /**
+     * Groups the {@code upper} values by their {@code lower} index, keeping their order.
+     */
+    private static int[][] group(final int count, final int[] lower, final int[] upper, final int size) {
+        final int[] counts = new int[count];
+        for (int k = 0; k < size; k++) {
+            counts[lower[k]]++;
+        }
+        final int[][] groups = new int[count][];
+        for (int i = 0; i < count; i++) {
+            groups[i] = (counts[i] == 0) ? EMPTY : new int[counts[i]];
+        }
+        Arrays.fill(counts, 0);
+        for (int k = 0; k < size; k++) {
+            final int i = lower[k];
+            groups[i][counts[i]++] = upper[k];
+        }
+        return groups;
+    }
+
+    /**
+     * Returns a copy of the given array with the given value appended.
+     */
+    static int[] append(final int[] array, final int value) {
+        final int[] result = Arrays.copyOf(array, array.length + 1);
+        result[array.length] = value;
+        return result;
+    }
+
+    /**
+     * Returns a copy of the given array without the first occurrence of the given value,
+     * or the given array if it does not contain the value.
+     */
+    static int[] remove(final int[] array, final int value) {
+        for (int i = 0; i < array.length; i++) {
+            if (array[i] == value) {
+                if (array.length == 1) return EMPTY;
+                final int[] result = new int[array.length - 1];
+                System.arraycopy(array, 0, result, 0, i);
+                System.arraycopy(array, i + 1, result, i, array.length - i - 1);
+                return result;
+            }
+        }
+        return array;
+    }
+
+    // ──────────────────────────────────────────────────────────────────────────────────────────
+    //  Ordering of co-boundaries
+    // ──────────────────────────────────────────────────────────────────────────────────────────
+
+    /**
+     * Sorts the faces around the given edge in clockwise order seen from the end of the edge, in 3 dimensions.
+     * The first face stays first, since a circular sequence has no other start.
+     */
+    final void sortAroundEdge(final int e) {
+        final int[] co = edgeCo[e];
+        if (dimension != 3 || co.length < 3) {
+            return;                     // a circular sequence of two elements has a single order.
+        }
+        final double[] a = position(edgeNodes[2*e]);
+        final double[] b = position(edgeNodes[2*e + 1]);
+        final double[][] fins = new double[co.length][];
+        for (int k = 0; k < co.length; k++) {
+            fins[k] = finPoint(co[k], a, b);
+        }
+        sortClockwise(co, fins, new AroundEdge(a, b));
+    }
+
+    /**
+     * Returns a node of the given face which is, in the plane of the face, on the same side of the edge
+     * from {@code a} to {@code b} as the face. The half-plane bounded by the edge line and passing through
+     * that node is the half-plane of the face near the edge. The node farthest from the edge is chosen,
+     * which excludes the nodes on the edge line, and those on the other side when the face is not convex.
+     *
+     * @param  faceRef  signed reference to the face, positive if the face goes from {@code a} to {@code b}.
+     */
+    private double[] finPoint(final int faceRef, final double[] a, final double[] b) {
+        final int face = index(faceRef);
+        /*
+         * A face going from a to b is on the left of the edge seen from above the face,
+         * so the direction from the edge into the face is normal × (b − a).
+         */
+        final double[] n = faceNormal(face);
+        final double dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
+        double wx = n[1]*dz - n[2]*dy;
+        double wy = n[2]*dx - n[0]*dz;
+        double wz = n[0]*dy - n[1]*dx;
+        if (faceRef < 0) {
+            wx = -wx; wy = -wy; wz = -wz;
+        }
+        int found = -1;
+        double farthest = 0;
+        for (final int ref : faceRings[face][0]) {
+            final int p = startNode(ref) * 3;
+            final double offset = wx * (xyz[p] - a[0]) + wy * (xyz[p+1] - a[1]) + wz * (xyz[p+2] - a[2]);
+            if (offset > farthest) {
+                farthest = offset;
+                found = p;
+            }
+        }
+        if (found >= 0) {
+            return Arrays.copyOfRange(xyz, found, found + 3);
+        }
+        return new double[] {a[0] + wx, a[1] + wy, a[2] + wz};       // degenerate face.
+    }
+
+    /**
+     * Sorts the edges around the given node in clockwise order, in 2 dimensions.
+     * The first edge stays first, since a circular sequence has no other start.
+     */
+    final void sortAroundNode(final int n) {
+        final int[] co = nodeCo[n];
+        if (dimension != 2 || co.length < 3) {
+            return;                     // a circular sequence of two elements has a single order.
+        }
+        final double[][] ends = new double[co.length][];
+        for (int k = 0; k < co.length; k++) {
+            // A positive edge ends at this node, so its other end is its start.
+            final int ref = co[k];
+            ends[k] = position(edgeNodes[2 * index(ref) + (ref < 0 ? 1 : 0)]);
+        }
+        sortClockwise(co, ends, new AroundNode(position(n)));
+    }
+
+    /**
+     * Returns the coordinates of the given node in three dimensions.
+     */
+    final double[] position(final int node) {
+        return Arrays.copyOfRange(xyz, node * 3, node * 3 + 3);
+    }
+
+    /**
+     * Sorts references in clockwise order of their directions, keeping the first one first.
+     * Each direction is classified relative to the first one: same direction, in the first half turn
+     * clockwise, opposite direction, or in the second half turn. Two directions in the same half turn
+     * are less than half a turn apart, so their order is given by the turn from one to the other.
+     * The sort is an insertion sort, since there are usually few directions.
+     *
+     * @param refs      the references to sort.
+     * @param points    a point giving the direction of each reference, sorted together with them.
+     * @param rotation  the orientation of the directions.
+     */
+    private static void sortClockwise(final int[] refs, final double[][] points, final Rotation rotation) {
+        final int n = points.length;
+        final int[] half = new int[n];
+        for (int i = 1; i < n; i++) {
+            final double turn = rotation.turn(points[0], points[i]);
+            half[i] = (turn < 0) ? 1 : (turn > 0) ? 3 : rotation.opposite(points[0], points[i]) ? 2 : 0;
+        }
+        for (int i = 2; i < n; i++) {
+            final int h = half[i];
+            final double[] p = points[i];
+            final int ref = refs[i];
+            int j = i;
+            while (j > 1 && (h < half[j-1] || (h == half[j-1] && rotation.turn(p, points[j-1]) < 0))) {
+                half[j]   = half[j-1];
+                points[j] = points[j-1];
+                refs[j]   = refs[j-1];
+                j--;
+            }
+            half[j]   = h;
+            points[j] = p;
+            refs[j]   = ref;
+        }
+    }
+
+    /**
+     * Orientation of directions around a center, each direction given by a point.
+     */
+    private interface Rotation {
+        /**
+         * Returns a negative value if the direction of {@code q} is clockwise from the direction of {@code p}
+         * by less than half a turn, a positive value if counterclockwise by less than half a turn,
+         * or zero if both directions are aligned.
+         */
+        double turn(double[] p, double[] q);
+
+        /**
+         * Returns whether two aligned directions are opposite.
+         */
+        boolean opposite(double[] p, double[] q);
+    }
+
+    /**
+     * Directions around the edge from {@code a} to {@code b}, seen from {@code b}.
+     */
+    private static final class AroundEdge implements Rotation {
+        private final double[] a, b;
+
+        AroundEdge(final double[] a, final double[] b) {
+            this.a = a;
+            this.b = b;
+        }
+
+        /**
+         * The turn from p to q is counterclockwise when q is on the side of the plane (a, b, p)
+         * toward which the normal (b − a) × (p − a) points, where {@code orient3d} is negative.
+         */
+        @Override
+        public double turn(final double[] p, final double[] q) {
+            return -ShewchukPredicates.orient3d(a[0], a[1], a[2], b[0], b[1], b[2],
+                                                p[0], p[1], p[2], q[0], q[1], q[2]);
+        }
+
+        /**
+         * Returns whether the components of p − a and q − a perpendicular to the edge have opposite directions.
+         */
+        @Override
+        public boolean opposite(final double[] p, final double[] q) {
+            final double dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
+            final double ux = p[0] - a[0], uy = p[1] - a[1], uz = p[2] - a[2];
+            final double vx = q[0] - a[0], vy = q[1] - a[1], vz = q[2] - a[2];
+            final double dd = dx*dx + dy*dy + dz*dz;
+            return (ux*vx + uy*vy + uz*vz) * dd - (ux*dx + uy*dy + uz*dz) * (vx*dx + vy*dy + vz*dz) < 0;
+        }
+    }
+
+    /**
+     * Directions around the point {@code c} in two dimensions.
+     */
+    private static final class AroundNode implements Rotation {
+        private final double[] c;
+
+        AroundNode(final double[] c) {
+            this.c = c;
+        }
+
+        /**
+         * The turn from p to q is counterclockwise when c, p, q are in counterclockwise order.
+         */
+        @Override
+        public double turn(final double[] p, final double[] q) {
+            return ShewchukPredicates.orient2d(c[0], c[1], p[0], p[1], q[0], q[1]);
+        }
+
+        @Override
+        public boolean opposite(final double[] p, final double[] q) {
+            return (p[0] - c[0]) * (q[0] - c[0]) + (p[1] - c[1]) * (q[1] - c[1]) < 0;
+        }
+    }
+
+    // ──────────────────────────────────────────────────────────────────────────────────────────
+    //  Low-level modifications, without verification. Used by ComplexEditor.
+    // ──────────────────────────────────────────────────────────────────────────────────────────
+
+    /**
+     * Declares that this complex no longer matches the geometries from which it has been built.
+     */
+    private void edited() {
+        sources = null;
+    }
+
+    /**
+     * Adds a node without edge and without container.
+     *
+     * @param  position  the {@link #dimension} coordinates of the node.
+     * @return index of the new node.
+     */
+    final int newNode(final double[] position) {
+        if (nodeCount == nodeCo.length) {
+            final int capacity = Math.max(8, nodeCount * 2);
+            nodeCo         = Arrays.copyOf(nodeCo, capacity);
+            nodeContainers = Arrays.copyOf(nodeContainers, capacity);
+        }
+        final int n = points.add(position);         // same as nodeCount.
+        xyz = points.xyz;
+        nodeCount++;
+        nodeCo[n] = EMPTY;
+        nodeContainers[n] = -1;
+        edited();
+        return n;
+    }
+
+    /**
+     * Changes the coordinates of a node.
+     */
+    final void moveNode(final int n, final double[] position) {
+        points.setCoordinates(n, position);
+        edited();
+    }
+
+    /**
+     * Adds an edge between the given nodes, without face and without container.
+     *
+     * @return index of the new edge.
+     */
+    final int newEdge(final int start, final int end) {
+        if (edgeCount == edgeCo.length) {
+            final int capacity = Math.max(8, edgeCount * 2);
+            edgeNodes      = Arrays.copyOf(edgeNodes, capacity * 2);
+            edgeCo         = Arrays.copyOf(edgeCo, capacity);
+            edgeContainers = Arrays.copyOf(edgeContainers, capacity);
+        }
+        final int e = edgeCount++;
+        edgeNodes[2*e]     = start;
+        edgeNodes[2*e + 1] = end;
+        edgeCo[e] = EMPTY;
+        edgeContainers[e] = -1;
+        attachEdge(e);
+        edited();
+        return e;
+    }
+
+    /**
+     * Changes the nodes of an edge, updating the co-boundaries of the nodes.
+     */
+    final void setEdgeNodes(final int e, final int start, final int end) {
+        detachEdge(e);
+        edgeNodes[2*e]     = start;
+        edgeNodes[2*e + 1] = end;
+        attachEdge(e);
+        edited();
+    }
+
+    private void attachEdge(final int e) {
+        final int start = edgeNodes[2*e], end = edgeNodes[2*e + 1];
+        nodeCo[start] = append(nodeCo[start], ~e);
+        nodeCo[end]   = append(nodeCo[end], e);
+        sortAroundNode(start);
+        sortAroundNode(end);
+    }
+
+    private void detachEdge(final int e) {
+        final int start = edgeNodes[2*e], end = edgeNodes[2*e + 1];
+        nodeCo[start] = remove(nodeCo[start], ~e);
+        nodeCo[end]   = remove(nodeCo[end], e);
+    }
+
+    /**
+     * Adds a face of the given rings of signed edge references, without solid.
+     *
+     * @return index of the new face.
+     */
+    final int newFace(final int[][] rings) {
+        if (faceCount == faceCo.length) {
+            final int capacity = Math.max(8, faceCount * 2);
+            faceRings    = Arrays.copyOf(faceRings, capacity);
+            faceCo       = Arrays.copyOf(faceCo, capacity);
+            faceIsolated = Arrays.copyOf(faceIsolated, capacity);
+        }
+        final int f = faceCount++;
+        faceRings[f]    = rings;
+        faceCo[f]       = EMPTY;
+        faceIsolated[f] = EMPTY;
+        appendFace(f);
+        sortAroundFace(f);
+        edited();
+        return f;
+    }
+
+    /**
+     * Changes the rings of the given faces, updating the co-boundaries of the edges.
+     * All rings are changed before the faces around the edges are sorted, since the sort uses the rings.
+     *
+     * @param faces  indices of the faces to change.
+     * @param rings  the new rings of each face.
+     */
+    final void setFaceRings(final int[] faces, final int[][][] rings) {
+        for (final int f : faces) {
+            detachFace(f);
+        }
+        for (int i = 0; i < faces.length; i++) {
+            faceRings[faces[i]] = rings[i];
+        }
+        for (final int f : faces) {
+            appendFace(f);
+        }
+        for (final int f : faces) {
+            sortAroundFace(f);
+        }
+        edited();
+    }
+
+    /**
+     * Adds the given face to the co-boundaries of its edges, without sorting them.
+     */
+    private void appendFace(final int f) {
+        for (final int[] ring : faceRings[f]) {
+            for (final int ref : ring) {
+                final int e = index(ref);
+                edgeCo[e] = append(edgeCo[e], (ref < 0) ? ~f : f);
+            }
+        }
+    }
+
+    /**
+     * Sorts the faces around each edge of the given face.
+     */
+    private void sortAroundFace(final int f) {
+        for (final int[] ring : faceRings[f]) {
+            for (final int ref : ring) {
+                sortAroundEdge(index(ref));
+            }
+        }
+    }
+
+    private void detachFace(final int f) {
+        for (final int[] ring : faceRings[f]) {
+            for (final int ref : ring) {
+                final int e = index(ref);
+                edgeCo[e] = remove(edgeCo[e], (ref < 0) ? ~f : f);
+            }
+        }
+    }
+
+    /**
+     * Adds a solid of the given shells of signed face references.
+     *
+     * @return index of the new solid.
+     */
+    final int newSolid(final int[][] shells) {
+        if (solidCount == solidShells.length) {
+            final int capacity = Math.max(8, solidCount * 2);
+            solidShells   = Arrays.copyOf(solidShells, capacity);
+            solidIsolated = Arrays.copyOf(solidIsolated, capacity);
+        }
+        final int s = solidCount++;
+        solidShells[s]   = shells;
+        solidIsolated[s] = EMPTY;
+        attachSolid(s);
+        edited();
+        return s;
+    }
+
+    /**
+     * Changes the shells of a solid, updating the co-boundaries of the faces.
+     */
+    final void setShells(final int s, final int[][] shells) {
+        detachSolid(s);
+        solidShells[s] = shells;
+        attachSolid(s);
+        edited();
+    }
+
+    private void attachSolid(final int s) {
+        for (final int[] shell : solidShells[s]) {
+            for (final int ref : shell) {
+                final int f = index(ref);
+                faceCo[f] = append(faceCo[f], (ref < 0) ? ~s : s);
+            }
+        }
+    }
+
+    private void detachSolid(final int s) {
+        for (final int[] shell : solidShells[s]) {
+            for (final int ref : shell) {
+                final int f = index(ref);
+                faceCo[f] = remove(faceCo[f], (ref < 0) ? ~s : s);
+            }
+        }
+    }
+
+    /**
+     * Sets the face or solid in which a node is isolated, as a container code or −1 for none.
+     */
+    final void setNodeContainer(final int n, final int code) {
+        final int old = nodeContainers[n];
+        if (old != code) {
+            if (old >= 0) {
+                if ((old & 1) == 0) faceIsolated[old >> 1]  = remove(faceIsolated[old >> 1], n);
+                else                solidIsolated[old >> 1] = remove(solidIsolated[old >> 1], n);
+            }
+            nodeContainers[n] = code;
+            if (code >= 0) {
+                if ((code & 1) == 0) faceIsolated[code >> 1]  = append(faceIsolated[code >> 1], n);
+                else                 solidIsolated[code >> 1] = append(solidIsolated[code >> 1], n);
+            }
+        }
+    }
+
+    /**
+     * Sets the solid in which an edge is isolated, or −1 for none.
+     */
+    final void setEdgeContainer(final int e, final int solid) {
+        final int old = edgeContainers[e];
+        if (old != solid) {
+            if (old >= 0) solidIsolated[old] = remove(solidIsolated[old], ~e);
+            edgeContainers[e] = solid;
+            if (solid >= 0) solidIsolated[solid] = append(solidIsolated[solid], ~e);
+        }
+    }
+
+    /**
+     * Removes a node, which shall not be the end of any edge.
+     */
+    final void removeNode(final int n) {
+        setNodeContainer(n, -1);
+        removedNodes.set(n);
+        edited();
+    }
+
+    /**
+     * Removes an edge, which shall not be on the boundary of any face.
+     */
+    final void removeEdge(final int e) {
+        detachEdge(e);
+        setEdgeContainer(e, -1);
+        removedEdges.set(e);
+        edited();
+    }
+
+    /**
+     * Removes a face, which shall not be on the boundary of any solid.
+     * The nodes isolated in the face are no longer isolated.
+     */
+    final void removeFace(final int f) {
+        detachFace(f);
+        for (final int n : faceIsolated[f]) {
+            nodeContainers[n] = -1;
+        }
+        faceIsolated[f] = EMPTY;
+        faceRings[f] = new int[0][];
+        removedFaces.set(f);
+        edited();
+    }
+
+    /**
+     * Removes a solid. The nodes and edges isolated in the solid are no longer isolated.
+     */
+    final void removeSolid(final int s) {
+        detachSolid(s);
+        for (final int v : solidIsolated[s]) {
+            if (v >= 0) nodeContainers[v] = -1;
+            else        edgeContainers[~v] = -1;
+        }
+        solidIsolated[s] = EMPTY;
+        solidShells[s] = new int[0][];
+        removedSolids.set(s);
+        edited();
+    }
+
+    // ──────────────────────────────────────────────────────────────────────────────────────────
+    //  Queries on the storage
+    // ──────────────────────────────────────────────────────────────────────────────────────────
+
+    /**
+     * Returns the normal of the given face computed with Newell's method on its exterior ring.
+     * The face is seen counterclockwise from the side toward which the normal points.
+     * The normal is not normalized: its length is twice the area of the exterior ring.
+     */
+    final double[] faceNormal(final int face) {
+        final double[] normal = new double[3];
+        for (final int ref : faceRings[face][0]) {
+            final int a = startNode(ref) * 3;
+            final int b = endNode(ref) * 3;
+            normal[0] += (xyz[a+1] - xyz[b+1]) * (xyz[a+2] + xyz[b+2]);
+            normal[1] += (xyz[a+2] - xyz[b+2]) * (xyz[a]   + xyz[b]);
+            normal[2] += (xyz[a]   - xyz[b])   * (xyz[a+1] + xyz[b+1]);
+        }
+        return normal;
+    }
+
+    /**
+     * Returns the node where the given oriented edge starts.
+     */
+    final int startNode(final int edgeRef) {
+        return edgeNodes[2 * index(edgeRef) + (edgeRef < 0 ? 1 : 0)];
+    }
+
+    /**
+     * Returns the node where the given oriented edge ends.
+     */
+    final int endNode(final int edgeRef) {
+        return edgeNodes[2 * index(edgeRef) + (edgeRef < 0 ? 0 : 1)];
+    }
+
+    /**
+     * Returns the nodes of the given ring of signed edge references in traversal order, without repeating the first one.
+     */
+    final int[] nodes(final int[] ring) {
+        final int[] nodes = new int[ring.length];
+        for (int i = 0; i < ring.length; i++) {
+            nodes[i] = startNode(ring[i]);
+        }
+        return nodes;
+    }
+
+    /**
+     * Returns the indices of the faces of all shells of the given solid.
+     */
+    final int[] faces(final int solid) {
+        int count = 0;
+        for (final int[] shell : solidShells[solid]) count += shell.length;
+        final int[] faces = new int[count];
+        count = 0;
+        for (final int[] shell : solidShells[solid]) {
+            for (final int ref : shell) faces[count++] = index(ref);
+        }
+        return faces;
+    }
+
+    /**
+     * Returns the number of primitives of the given dimension, including the removed ones.
+     */
+    private int count(final int dimension) {
+        switch (dimension) {
+            case 0:  return nodeCount;
+            case 1:  return edgeCount;
+            case 2:  return faceCount;
+            default: return solidCount;
+        }
+    }
+
+    /**
+     * Returns the removed primitives of the given dimension.
+     */
+    final BitSet removed(final int dimension) {
+        switch (dimension) {
+            case 0:  return removedNodes;
+            case 1:  return removedEdges;
+            case 2:  return removedFaces;
+            default: return removedSolids;
+        }
+    }
+
+    /**
+     * Returns the number of primitives of the given dimension which have not been removed.
+     */
+    private int liveCount(final int dimension) {
+        return count(dimension) - removed(dimension).cardinality();
+    }
+
+    // ──────────────────────────────────────────────────────────────────────────────────────────
+    //  Public API
+    // ──────────────────────────────────────────────────────────────────────────────────────────
+
+    /**
+     * Returns the coordinate reference system of the node coordinates.
+     *
+     * @return the coordinate reference system, or {@code null} if this complex is empty.
+     */
+    public CoordinateReferenceSystem getCoordinateReferenceSystem() {
+        return crs;
+    }
+
+    /**
+     * Returns the number of node indices, including those of removed nodes.
+     *
+     * @return number of node indices.
+     */
+    public int getNodeCount() {
+        return nodeCount;
+    }
+
+    /**
+     * Returns the number of edge indices, including those of removed edges.
+     *
+     * @return number of edge indices.
+     */
+    public int getEdgeCount() {
+        return edgeCount;
+    }
+
+    /**
+     * Returns the number of face indices, including those of removed faces.
+     *
+     * @return number of face indices.
+     */
+    public int getFaceCount() {
+        return faceCount;
+    }
+
+    /**
+     * Returns the number of solid indices, including those of removed solids.
+     *
+     * @return number of solid indices.
+     */
+    public int getSolidCount() {
+        return solidCount;
+    }
+
+    /**
+     * Returns the node at the given index.
+     *
+     * @param  index  index of the node, from 0 inclusive to {@link #getNodeCount()} exclusive.
+     * @return the node at the given index, or {@code null} if it has been removed.
+     */
+    public Node getNode(final int index) {
+        return removedNodes.get(Objects.checkIndex(index, nodeCount)) ? null : new IndexedNode(this, index);
+    }
+
+    /**
+     * Returns the edge at the given index.
+     *
+     * @param  index  index of the edge, from 0 inclusive to {@link #getEdgeCount()} exclusive.
+     * @return the edge at the given index, or {@code null} if it has been removed.
+     */
+    public Edge getEdge(final int index) {
+        return removedEdges.get(Objects.checkIndex(index, edgeCount)) ? null : new IndexedEdge(this, index);
+    }
+
+    /**
+     * Returns the face at the given index.
+     *
+     * @param  index  index of the face, from 0 inclusive to {@link #getFaceCount()} exclusive.
+     * @return the face at the given index, or {@code null} if it has been removed.
+     */
+    public Face getFace(final int index) {
+        return removedFaces.get(Objects.checkIndex(index, faceCount)) ? null : new IndexedFace(this, index);
+    }
+
+    /**
+     * Returns the solid at the given index.
+     *
+     * @param  index  index of the solid, from 0 inclusive to {@link #getSolidCount()} exclusive.
+     * @return the solid at the given index, or {@code null} if it has been removed.
+     */
+    public Solid getSolid(final int index) {
+        return removedSolids.get(Objects.checkIndex(index, solidCount)) ? null : new IndexedSolid(this, index);
+    }
+
+    /**
+     * Returns the coordinates of the node at the given index.
+     *
+     * @param  node  index of the node, from 0 inclusive to {@link #getNodeCount()} exclusive.
+     * @return a copy of the coordinates of the node.
+     */
+    public double[] getCoordinates(final int node) {
+        Objects.checkIndex(node, nodeCount);
+        return Arrays.copyOfRange(points.coordinates, node * dimension, (node + 1) * dimension);
+    }
+
+    /**
+     * Returns the positions and other attributes of the nodes, as one point per node index.
+     * The points of removed nodes are included. The attributes other than positions can be modified
+     * through the returned object. The positions shall be modified with {@link ComplexEditor}.
+     *
+     * @return the points of all nodes.
+     */
+    public NodePoints getNodePoints() {
+        return points;
+    }
+
+    /**
+     * Returns the oriented node designated by the given signed reference.
+     */
+    final DirectedNode directedNode(final int ref) {
+        final Node node = new IndexedNode(this, index(ref));
+        return (ref < 0) ? node.getNegate() : node;
+    }
+
+    /**
+     * Returns the oriented edge designated by the given signed reference.
+     */
+    final DirectedEdge directedEdge(final int ref) {
+        final Edge edge = new IndexedEdge(this, index(ref));
+        return (ref < 0) ? edge.getNegate() : edge;
+    }
+
+    /**
+     * Returns the oriented face designated by the given signed reference.
+     */
+    final DirectedFace directedFace(final int ref) {
+        final Face face = new IndexedFace(this, index(ref));
+        return (ref < 0) ? face.getNegate() : face;
+    }
+
+    /**
+     * Returns the oriented solid designated by the given signed reference.
+     */
+    final DirectedSolid directedSolid(final int ref) {
+        final Solid solid = new IndexedSolid(this, index(ref));
+        return (ref < 0) ? solid.getNegate() : solid;
+    }
+
+    /**
+     * Returns the edges starting or ending at the given node, for the given orientation of that node.
+     */
+    final List<DirectedEdge> nodeCoBoundary(final int node, final boolean negate) {
+        final int[] co = nodeCo[node];
+        final DirectedEdge[] edges = new DirectedEdge[co.length];
+        for (int i = 0; i < co.length; i++) {
+            edges[i] = directedEdge(negate ? ~co[i] : co[i]);
+        }
+        return List.of(edges);
+    }
+
+    /**
+     * Returns the faces around the given edge, for the given orientation of that edge.
+     * The opposite edge is seen from its other end, which reverses the clockwise order.
+     */
+    final List<DirectedFace> edgeCoBoundary(final int edge, final boolean negate) {
+        final int[] co = edgeCo[edge];
+        final DirectedFace[] faces = new DirectedFace[co.length];
+        for (int i = 0; i < co.length; i++) {
+            if (negate) {
+                faces[co.length - 1 - i] = directedFace(~co[i]);
+            } else {
+                faces[i] = directedFace(co[i]);
+            }
+        }
+        return List.of(faces);
+    }
+
+    /**
+     * Returns the solids on each side of the given face, for the given orientation of that face.
+     */
+    final List<DirectedSolid> faceCoBoundary(final int face, final boolean negate) {
+        final int[] co = faceCo[face];
+        final DirectedSolid[] solids = new DirectedSolid[co.length];
+        for (int i = 0; i < co.length; i++) {
+            solids[i] = directedSolid(negate ? ~co[i] : co[i]);
+        }
+        return List.of(solids);
+    }
+
+    /**
+     * Returns the oriented edges of a ring of the given face. The opposite of a ring is traversed in reverse order.
+     */
+    final List<DirectedEdge> ring(final int face, final int ring, final boolean negate) {
+        final int[] refs = faceRings[face][ring];
+        final DirectedEdge[] edges = new DirectedEdge[refs.length];
+        for (int i = 0; i < refs.length; i++) {
+            if (negate) {
+                edges[refs.length - 1 - i] = directedEdge(~refs[i]);
+            } else {
+                edges[i] = directedEdge(refs[i]);
+            }
+        }
+        return List.of(edges);
+    }
+
+    /**
+     * Returns the oriented edges of all rings of the given face, the exterior ring first.
+     */
+    final List<DirectedEdge> faceBoundary(final int face, final boolean negate) {
+        final List<DirectedEdge> edges = new ArrayList<>();
+        for (int r = 0; r < faceRings[face].length; r++) {
+            edges.addAll(ring(face, r, negate));
+        }
+        return Collections.unmodifiableList(edges);
+    }
+
+    /**
+     * Returns the rings of the given face other than the exterior one.
+     */
+    final List<List<DirectedEdge>> interiorRings(final int face) {
+        final List<List<DirectedEdge>> rings = new ArrayList<>();
+        for (int r = 1; r < faceRings[face].length; r++) {
+            rings.add(ring(face, r, false));
+        }
+        return Collections.unmodifiableList(rings);
+    }
+
+    /**
+     * Returns the oriented faces of a shell of the given solid.
+     */
+    final List<DirectedFace> shell(final int solid, final int shell, final boolean negate) {
+        final int[] refs = solidShells[solid][shell];
+        final DirectedFace[] faces = new DirectedFace[refs.length];
+        for (int i = 0; i < refs.length; i++) {
+            faces[i] = directedFace(negate ? ~refs[i] : refs[i]);
+        }
+        return List.of(faces);
+    }
+
+    /**
+     * Returns the oriented faces of all shells of the given solid, the exterior shell first.
+     */
+    final List<DirectedFace> solidBoundary(final int solid, final boolean negate) {
+        final List<DirectedFace> faces = new ArrayList<>();
+        for (int s = 0; s < solidShells[solid].length; s++) {
+            faces.addAll(shell(solid, s, negate));
+        }
+        return Collections.unmodifiableList(faces);
+    }
+
+    /**
+     * Returns the shells of the given solid other than the exterior one.
+     */
+    final List<List<DirectedFace>> interiorShells(final int solid) {
+        final List<List<DirectedFace>> shells = new ArrayList<>();
+        for (int s = 1; s < solidShells[solid].length; s++) {
+            shells.add(shell(solid, s, false));
+        }
+        return Collections.unmodifiableList(shells);
+    }
+
+    /**
+     * Returns the face or solid in which the given primitive is isolated, or {@code null} if none.
+     *
+     * @param  dimension  dimension of the primitive: 0 for a node, 1 for an edge.
+     * @param  index      index of the primitive.
+     */
+    final Primitive container(final int dimension, final int index) {
+        switch (dimension) {
+            case 0: {
+                final int c = nodeContainers[index];
+                if (c < 0) return null;
+                return ((c & 1) == 0) ? new IndexedFace(this, c >> 1) : new IndexedSolid(this, c >> 1);
+            }
+            case 1: {
+                final int c = edgeContainers[index];
+                return (c < 0) ? null : new IndexedSolid(this, c);
+            }
+            default: return null;
+        }
+    }
+
+    /**
+     * Returns the primitives isolated in the given face or solid.
+     *
+     * @param  dimension  dimension of the primitive: 2 for a face, 3 for a solid.
+     * @param  index      index of the primitive.
+     */
+    final Set<Primitive> isolated(final int dimension, final int index) {
+        final int[] values;
+        switch (dimension) {
+            case 2:  values = faceIsolated[index];  break;
+            case 3:  values = solidIsolated[index]; break;
+            default: return Set.of();
+        }
+        final Set<Primitive> isolated = new LinkedHashSet<>();
+        for (final int v : values) {
+            isolated.add((v >= 0) ? new IndexedNode(this, v) : new IndexedEdge(this, ~v));
+        }
+        return Collections.unmodifiableSet(isolated);
+    }
+
+    /**
+     * Returns a copy of the points of the given nodes with all their attributes,
+     * followed by the point of the first node if {@code close} is true.
+     */
+    private DataPoints pointsOf(final int[] nodes, final boolean close) {
+        return points.subset(close ? IndexedComplex.append(nodes, nodes[0]) : nodes);
+    }
+
+    /**
+     * Returns the geometric realization of the given node, with all its attributes.
+     */
+    final Point point(final int node) {
+        return GeometryFactory.DEFAULT.createPoint(pointsOf(new int[] {node}, false));
+    }
+
+    /**
+     * Returns the geometric realization of an edge, as a segment between the given nodes with all their attributes.
+     */
+    final LineString lineString(final int start, final int end) {
+        return GeometryFactory.DEFAULT.createLineString(pointsOf(new int[] {start, end}, false));
+    }
+
+    /**
+     * Returns the geometric realization of the given face, with reversed rings if {@code reverse} is true.
+     * A face of three nodes without hole is realized by a triangle.
+     */
+    final Polygon polygon(final int face, final boolean reverse) {
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        LinearRing exterior = null;
+        int exteriorSize = 0;
+        final List<LinearRing> interiors = new ArrayList<>();
+        for (final int[] refs : faceRings[face]) {
+            final int[] nodes = nodes(refs);
+            if (reverse) {
+                for (int i = 0, j = nodes.length - 1; i < j; i++, j--) {
+                    final int t = nodes[i]; nodes[i] = nodes[j]; nodes[j] = t;
+                }
+            }
+            final LinearRing ring = f.createLinearRing(pointsOf(nodes, true));
+            if (exterior == null) {
+                exterior = ring;
+                exteriorSize = nodes.length;
+            } else {
+                interiors.add(ring);
+            }
+        }
+        if (interiors.isEmpty() && exteriorSize == 3) {
+            return f.createTriangle(exterior);
+        }
+        return f.createPolygon(exterior, interiors);
+    }
+
+    /**
+     * Returns the geometric realization of the given solid, with each face oriented so that the solid is below it.
+     */
+    final Polyhedron polyhedron(final int solid) {
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        GeometryCollection<Polygon> exterior = null;
+        final List<GeometryCollection<Polygon>> interiors = new ArrayList<>();
+        for (final int[] refs : solidShells[solid]) {
+            final Polygon[] polygons = new Polygon[refs.length];
+            for (int i = 0; i < refs.length; i++) {
+                polygons[i] = polygon(index(refs[i]), refs[i] < 0);
+            }
+            final GeometryCollection<Polygon> shell = f.createMultiPolygon(crs, polygons);
+            if (exterior == null) {
+                exterior = shell;
+            } else {
+                interiors.add(shell);
+            }
+        }
+        return f.createPolyhedron(exterior, interiors);
+    }
+
+    /**
+     * Returns {@code true} if this complex has no primitive.
+     */
+    @Override
+    public boolean isEmpty() {
+        return liveCount(0) == 0;
+    }
+
+    /**
+     * Returns the largest dimension of the primitives of this complex, or −1 if it is empty.
+     */
+    @Override
+    public int getDimension() {
+        for (int d = 3; d >= 0; d--) {
+            if (liveCount(d) != 0) return d;
+        }
+        return -1;
+    }
+
+    /**
+     * Returns the sum of the primitives which are on the boundary of no other primitive.
+     */
+    private DefaultExpression topCells() {
+        final List<DirectedTopo> cells = new ArrayList<>();
+        for (int i = 0; i < solidCount; i++) {
+            if (!removedSolids.get(i)) cells.add(new IndexedSolid(this, i));
+        }
+        for (int i = 0; i < faceCount; i++) {
+            if (!removedFaces.get(i) && faceCo[i].length == 0) cells.add(new IndexedFace(this, i));
+        }
+        for (int i = 0; i < edgeCount; i++) {
+            if (!removedEdges.get(i) && edgeCo[i].length == 0) cells.add(new IndexedEdge(this, i));
+        }
+        for (int i = 0; i < nodeCount; i++) {
+            if (!removedNodes.get(i) && nodeCo[i].length == 0) cells.add(new IndexedNode(this, i));
+        }
+        return DefaultExpression.sum(cells);
+    }
+
+    /**
+     * Returns the algebraic boundary of this complex: the boundary of the sum of its primitives which are
+     * on the boundary of no other primitive. Faces shared by two solids, or edges shared by two faces of
+     * consistent orientations, cancel out. Each term is listed once, with the sign of its coefficient.
+     */
+    @Override
+    public List<DirectedTopo> getBoundary() {
+        return topCells().getBoundary().toDirectedList();
+    }
+
+    /**
+     * Returns an empty list: a maximal complex is on the boundary of nothing.
+     */
+    @Override
+    public List<DirectedTopo> getCoBoundary() {
+        return List.of();
+    }
+
+    /**
+     * Returns the primitives of this complex which are neither on its boundary nor on the boundary of it.
+     */
+    @Override
+    public Set<Primitive> getInterior() {
+        final Set<Primitive> boundary = closure(getBoundary());
+        final Set<Primitive> interior = new LinkedHashSet<>();
+        for (final Primitive element : getElements()) {
+            if (!boundary.contains(element)) {
+                interior.add(element);
+            }
+        }
+        return interior;
+    }
+
+    /**
+     * Returns an empty set: every primitive of a maximal complex is in its interior or on its boundary.
+     */
+    @Override
+    public Set<Primitive> getExterior() {
+        return Set.of();
+    }
+
+    /**
+     * Returns all primitives of this complex.
+     */
+    @Override
+    public Set<Primitive> getClosure() {
+        return getElements();
+    }
+
+    /**
+     * Returns the primitives of the given oriented primitives and of their boundaries, recursively.
+     */
+    static Set<Primitive> closure(final Collection<? extends DirectedTopo> start) {
+        final Set<Primitive> closure = new LinkedHashSet<>();
+        final Deque<DirectedTopo> pending = new ArrayDeque<>(start);
+        DirectedTopo next;
+        while ((next = pending.poll()) != null) {
+            if (closure.add(next.getTopo())) {
+                pending.addAll(next.getBoundary());
+            }
+        }
+        return closure;
+    }
+
+    /**
+     * Returns this complex, which is maximal.
+     */
+    @Override
+    public Complex getMaximalComplex() {
+        return this;
+    }
+
+    /**
+     * Returns all primitives of this complex which have not been removed: nodes, then edges, then faces, then solids.
+     */
+    @Override
+    public Set<Primitive> getElements() {
+        return new Elements();
+    }
+
+    /**
+     * Returns the primitive of the given dimension and index.
+     */
+    private Primitive element(final int dimension, final int i) {
+        switch (dimension) {
+            case 0:  return new IndexedNode(this, i);
+            case 1:  return new IndexedEdge(this, i);
+            case 2:  return new IndexedFace(this, i);
+            default: return new IndexedSolid(this, i);
+        }
+    }
+
+    /**
+     * View of all primitives of this complex which have not been removed.
+     */
+    private final class Elements extends AbstractSet<Primitive> {
+        @Override
+        public int size() {
+            return liveCount(0) + liveCount(1) + liveCount(2) + liveCount(3);
+        }
+
+        @Override
+        public boolean contains(final Object obj) {
+            return (obj instanceof IndexedTopo t) && t.complex == IndexedComplex.this
+                    && !t.isNegative() && !removed(t.getDimension()).get(t.index);
+        }
+
+        @Override
+        public Iterator<Primitive> iterator() {
+            return new Iterator<>() {
+                private int kind, index = -1;
+                private Primitive next = advance();
+
+                /** Returns the next primitive which has not been removed, or {@code null} if none. */
+                private Primitive advance() {
+                    while (kind <= 3) {
+                        index = removed(kind).nextClearBit(index + 1);
+                        if (index < count(kind)) {
+                            return element(kind, index);
+                        }
+                        kind++;
+                        index = -1;
+                    }
+                    return null;
+                }
+
+                @Override
+                public boolean hasNext() {
+                    return next != null;
+                }
+
+                @Override
+                public Primitive next() {
+                    final Primitive current = next;
+                    if (current == null) {
+                        throw new NoSuchElementException();
+                    }
+                    next = advance();
+                    return current;
+                }
+            };
+        }
+    }
+
+    /**
+     * Returns {@code true} if every primitive is linked to every other one. Edges link their nodes,
+     * faces link their rings, solids link their shells, and isolated primitives are linked to their container.
+     */
+    @Override
+    public boolean isConnected() {
+        final int[] parent = new int[nodeCount];
+        for (int i = 0; i < nodeCount; i++) {
+            parent[i] = i;
+        }
+        for (int e = 0; e < edgeCount; e++) {
+            if (!removedEdges.get(e)) {
+                union(parent, edgeNodes[2*e], edgeNodes[2*e + 1]);
+                if (edgeContainers[e] >= 0) {
+                    union(parent, edgeNodes[2*e], solidNode(edgeContainers[e]));
+                }
+            }
+        }
+        for (int f = 0; f < faceCount; f++) {
+            if (!removedFaces.get(f)) {
+                for (final int[] ring : faceRings[f]) {
+                    union(parent, faceNode(f), startNode(ring[0]));
+                }
+            }
+        }
+        for (int s = 0; s < solidCount; s++) {
+            if (!removedSolids.get(s)) {
+                for (final int[] shell : solidShells[s]) {
+                    union(parent, solidNode(s), faceNode(index(shell[0])));
+                }
+            }
+        }
+        for (int n = 0; n < nodeCount; n++) {
+            final int c = nodeContainers[n];
+            if (c >= 0) {
+                union(parent, n, ((c & 1) == 0) ? faceNode(c >> 1) : solidNode(c >> 1));
+            }
+        }
+        int roots = 0;
+        for (int i = 0; i < nodeCount; i++) {
+            if (!removedNodes.get(i) && find(parent, i) == i && ++roots > 1) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Returns a node of the given face.
+     */
+    private int faceNode(final int face) {
+        return startNode(faceRings[face][0][0]);
+    }
+
+    /**
+     * Returns a node of the given solid.
+     */
+    private int solidNode(final int solid) {
+        return faceNode(index(solidShells[solid][0][0]));
+    }
+
+    private static int find(final int[] parent, int i) {
+        while (parent[i] != i) {
+            parent[i] = parent[parent[i]];
+            i = parent[i];
+        }
+        return i;
+    }
+
+    private static void union(final int[] parent, final int a, final int b) {
+        parent[find(parent, a)] = find(parent, b);
+    }
+
+    /**
+     * Returns this complex alone: it has no sub-complex other than itself.
+     */
+    @Override
+    public Set<Complex> getSubComplexes() {
+        return Set.of(this);
+    }
+
+    /**
+     * Returns this complex alone: a maximal complex is contained in no other complex.
+     */
+    @Override
+    public Set<Complex> getSuperComplexes() {
+        return Set.of(this);
+    }
+
+    /**
+     * Returns the geometric realization of this complex. Before any edit, this is the geometries from which
+     * this complex has been built: the single geometry given to the builder, or a collection of them.
+     * After an edit, this is a collection of the realizations of the primitives which are on the boundary
+     * of no other primitive. The result is {@code null} if there is no geometry.
+     */
+    @Override
+    public Geometry getGeometry() {
+        List<Geometry> parts = sources;
+        if (parts == null) {
+            parts = new ArrayList<>();
+            for (int i = 0; i < solidCount; i++) {
+                if (!removedSolids.get(i)) parts.add(polyhedron(i));
+            }
+            for (int i = 0; i < faceCount; i++) {
+                if (!removedFaces.get(i) && faceCo[i].length == 0) parts.add(polygon(i, false));
+            }
+            for (int i = 0; i < edgeCount; i++) {
+                if (!removedEdges.get(i) && edgeCo[i].length == 0) parts.add(lineString(edgeNodes[2*i], edgeNodes[2*i + 1]));
+            }
+            for (int i = 0; i < nodeCount; i++) {
+                if (!removedNodes.get(i) && nodeCo[i].length == 0) parts.add(point(i));
+            }
+        }
+        switch (parts.size()) {
+            case 0:  return null;
+            case 1:  return parts.get(0);
+            default: return GeometryFactory.DEFAULT.createGeometryCollection(crs, parts.toArray(Geometry[]::new));
+        }
+    }
+
+    @Override
+    public String toString() {
+        return "IndexedComplex[nodes=" + liveCount(0) + ", edges=" + liveCount(1)
+                + ", faces=" + liveCount(2) + ", solids=" + liveCount(3) + ']';
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedEdge.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedEdge.java
new file mode 100644
index 0000000000..344815994f
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedEdge.java
@@ -0,0 +1,124 @@
+/*
+ * 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.internal.shared.topology;
+
+import java.util.List;
+import org.apache.sis.geometries.Orientable;
+import org.apache.sis.geometries.curve.LineString;
+import org.apache.sis.geometries.topology.DirectedEdge;
+import org.apache.sis.geometries.topology.DirectedFace;
+import org.apache.sis.geometries.topology.DirectedNode;
+import org.apache.sis.geometries.topology.Edge;
+
+
+/**
+ * View of an edge of an {@link IndexedComplex}.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+final class IndexedEdge extends IndexedTopo implements Edge {
+
+    IndexedEdge(final IndexedComplex complex, final int index) {
+        super(complex, index);
+    }
+
+    @Override
+    String typeName() {
+        return "Edge";
+    }
+
+    /**
+     * Returns the start node negatively, then the end node positively.
+     */
+    @Override
+    public List<DirectedNode> getBoundary() {
+        return List.of(complex.directedNode(~complex.edgeNodes[2*index]),
+                       complex.directedNode(complex.edgeNodes[2*index + 1]));
+    }
+
+    @Override
+    public List<DirectedFace> getCoBoundary() {
+        return complex.edgeCoBoundary(index, false);
+    }
+
+    @Override
+    public DirectedEdge getNegate() {
+        return new Negative(complex, index);
+    }
+
+    @Override
+    public LineString getGeometry() {
+        return complex.lineString(complex.edgeNodes[2*index], complex.edgeNodes[2*index + 1]);
+    }
+
+    /**
+     * View of the negative orientation of an edge, which goes from the end node to the start node.
+     */
+    static final class Negative extends IndexedTopo implements DirectedEdge {
+
+        Negative(final IndexedComplex complex, final int index) {
+            super(complex, index);
+        }
+
+        @Override
+        String typeName() {
+            return "Edge";
+        }
+
+        @Override
+        public Orientable.Sign getOrientationSign() {
+            return Orientable.Sign.NEGATIVE;
+        }
+
+        @Override
+        public Edge getTopo() {
+            return new IndexedEdge(complex, index);
+        }
+
+        @Override
+        public DirectedEdge getNegate() {
+            return getTopo();
+        }
+
+        @Override
+        public List<DirectedEdge> getProxy() {
+            return getTopo().getProxy();
+        }
+
+        /**
+         * Returns the end node of the edge negatively, then its start node positively.
+         */
+        @Override
+        public List<DirectedNode> getBoundary() {
+            return List.of(complex.directedNode(~complex.edgeNodes[2*index + 1]),
+                           complex.directedNode(complex.edgeNodes[2*index]));
+        }
+
+        @Override
+        public List<DirectedFace> getCoBoundary() {
+            return complex.edgeCoBoundary(index, true);
+        }
+
+        /**
+         * Returns the segment of the edge, from the end node to the start node.
+         */
+        @Override
+        public LineString getGeometry() {
+            return complex.lineString(complex.edgeNodes[2*index + 1], complex.edgeNodes[2*index]);
+        }
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedFace.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedFace.java
new file mode 100644
index 0000000000..eed290d495
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedFace.java
@@ -0,0 +1,129 @@
+/*
+ * 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.internal.shared.topology;
+
+import java.util.List;
+import org.apache.sis.geometries.Orientable;
+import org.apache.sis.geometries.surface.Polygon;
+import org.apache.sis.geometries.topology.DirectedEdge;
+import org.apache.sis.geometries.topology.DirectedFace;
+import org.apache.sis.geometries.topology.DirectedSolid;
+import org.apache.sis.geometries.topology.Face;
+
+
+/**
+ * View of a face of an {@link IndexedComplex}.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+final class IndexedFace extends IndexedTopo implements Face {
+
+    IndexedFace(final IndexedComplex complex, final int index) {
+        super(complex, index);
+    }
+
+    @Override
+    String typeName() {
+        return "Face";
+    }
+
+    @Override
+    public List<DirectedEdge> getBoundary() {
+        return complex.faceBoundary(index, false);
+    }
+
+    /**
+     * Returns the first ring, which is always the exterior one in an indexed complex.
+     */
+    @Override
+    public List<DirectedEdge> getExteriorRing() {
+        return complex.ring(index, 0, false);
+    }
+
+    @Override
+    public List<List<DirectedEdge>> getInteriorRings() {
+        return complex.interiorRings(index);
+    }
+
+    @Override
+    public List<DirectedSolid> getCoBoundary() {
+        return complex.faceCoBoundary(index, false);
+    }
+
+    @Override
+    public DirectedFace getNegate() {
+        return new Negative(complex, index);
+    }
+
+    @Override
+    public Polygon getGeometry() {
+        return complex.polygon(index, false);
+    }
+
+    /**
+     * View of the negative orientation of a face, whose rings are traversed in reverse order.
+     */
+    static final class Negative extends IndexedTopo implements DirectedFace {
+
+        Negative(final IndexedComplex complex, final int index) {
+            super(complex, index);
+        }
+
+        @Override
+        String typeName() {
+            return "Face";
+        }
+
+        @Override
+        public Orientable.Sign getOrientationSign() {
+            return Orientable.Sign.NEGATIVE;
+        }
+
+        @Override
+        public Face getTopo() {
+            return new IndexedFace(complex, index);
+        }
+
+        @Override
+        public DirectedFace getNegate() {
+            return getTopo();
+        }
+
+        @Override
+        public List<DirectedFace> getProxy() {
+            return getTopo().getProxy();
+        }
+
+        @Override
+        public List<DirectedEdge> getBoundary() {
+            return complex.faceBoundary(index, true);
+        }
+
+        @Override
+        public List<DirectedSolid> getCoBoundary() {
+            return complex.faceCoBoundary(index, true);
+        }
+
+        /**
+         * Returns the polygon of the face with all rings reversed.
+         */
+        @Override
+        public Polygon getGeometry() {
+            return complex.polygon(index, true);
+        }
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedNode.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedNode.java
new file mode 100644
index 0000000000..217dcee4c2
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedNode.java
@@ -0,0 +1,105 @@
+/*
+ * 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.internal.shared.topology;
+
+import java.util.List;
+import org.apache.sis.geometries.Orientable;
+import org.apache.sis.geometries.Point;
+import org.apache.sis.geometries.topology.DirectedEdge;
+import org.apache.sis.geometries.topology.DirectedNode;
+import org.apache.sis.geometries.topology.Node;
+
+
+/**
+ * View of a node of an {@link IndexedComplex}.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+final class IndexedNode extends IndexedTopo implements Node {
+
+    IndexedNode(final IndexedComplex complex, final int index) {
+        super(complex, index);
+    }
+
+    @Override
+    String typeName() {
+        return "Node";
+    }
+
+    @Override
+    public List<DirectedEdge> getCoBoundary() {
+        return complex.nodeCoBoundary(index, false);
+    }
+
+    @Override
+    public DirectedNode getNegate() {
+        return new Negative(complex, index);
+    }
+
+    @Override
+    public Point getGeometry() {
+        return complex.point(index);
+    }
+
+    /**
+     * View of the negative orientation of a node.
+     */
+    static final class Negative extends IndexedTopo implements DirectedNode {
+
+        Negative(final IndexedComplex complex, final int index) {
+            super(complex, index);
+        }
+
+        @Override
+        String typeName() {
+            return "Node";
+        }
+
+        @Override
+        public Orientable.Sign getOrientationSign() {
+            return Orientable.Sign.NEGATIVE;
+        }
+
+        @Override
+        public Node getTopo() {
+            return new IndexedNode(complex, index);
+        }
+
+        @Override
+        public DirectedNode getNegate() {
+            return getTopo();
+        }
+
+        @Override
+        public List<DirectedNode> getProxy() {
+            return getTopo().getProxy();
+        }
+
+        @Override
+        public List<DirectedEdge> getCoBoundary() {
+            return complex.nodeCoBoundary(index, true);
+        }
+
+        /**
+         * Returns the point of the node, since a point has no orientation.
+         */
+        @Override
+        public Point getGeometry() {
+            return complex.point(index);
+        }
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedSolid.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedSolid.java
new file mode 100644
index 0000000000..95d5f5df22
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedSolid.java
@@ -0,0 +1,115 @@
+/*
+ * 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.internal.shared.topology;
+
+import java.util.List;
+import org.apache.sis.geometries.Orientable;
+import org.apache.sis.geometries.solid.Polyhedron;
+import org.apache.sis.geometries.topology.DirectedFace;
+import org.apache.sis.geometries.topology.DirectedSolid;
+import org.apache.sis.geometries.topology.Solid;
+
+
+/**
+ * View of a solid of an {@link IndexedComplex}.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+final class IndexedSolid extends IndexedTopo implements Solid {
+
+    IndexedSolid(final IndexedComplex complex, final int index) {
+        super(complex, index);
+    }
+
+    @Override
+    String typeName() {
+        return "Solid";
+    }
+
+    @Override
+    public List<DirectedFace> getBoundary() {
+        return complex.solidBoundary(index, false);
+    }
+
+    @Override
+    public List<DirectedFace> getExteriorShell() {
+        return complex.shell(index, 0, false);
+    }
+
+    @Override
+    public List<List<DirectedFace>> getInteriorShells() {
+        return complex.interiorShells(index);
+    }
+
+    @Override
+    public DirectedSolid getNegate() {
+        return new Negative(complex, index);
+    }
+
+    @Override
+    public Polyhedron getGeometry() {
+        return complex.polyhedron(index);
+    }
+
+    /**
+     * View of the negative orientation of a solid, which is above all its faces.
+     */
+    static final class Negative extends IndexedTopo implements DirectedSolid {
+
+        Negative(final IndexedComplex complex, final int index) {
+            super(complex, index);
+        }
+
+        @Override
+        String typeName() {
+            return "Solid";
+        }
+
+        @Override
+        public Orientable.Sign getOrientationSign() {
+            return Orientable.Sign.NEGATIVE;
+        }
+
+        @Override
+        public Solid getTopo() {
+            return new IndexedSolid(complex, index);
+        }
+
+        @Override
+        public DirectedSolid getNegate() {
+            return getTopo();
+        }
+
+        @Override
+        public List<DirectedSolid> getProxy() {
+            return getTopo().getProxy();
+        }
+
+        @Override
+        public List<DirectedFace> getBoundary() {
+            return complex.solidBoundary(index, true);
+        }
+
+        /**
+         * Returns the polyhedron of the solid, since a geometric solid has no orientation.
+         */
+        @Override
+        public Polyhedron getGeometry() {
+            return complex.polyhedron(index);
+        }
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedTopo.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedTopo.java
new file mode 100644
index 0000000000..47e91bb4b5
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/IndexedTopo.java
@@ -0,0 +1,150 @@
+/*
+ * 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.internal.shared.topology;
+
+import java.util.LinkedHashSet;
+import java.util.Set;
+import org.apache.sis.geometries.Orientable;
+import org.apache.sis.geometries.topology.Complex;
+import org.apache.sis.geometries.topology.DirectedTopo;
+import org.apache.sis.geometries.topology.Expression;
+import org.apache.sis.geometries.topology.Primitive;
+
+
+/**
+ * Base class of the views of the primitives of an {@link IndexedComplex}, in either orientation.
+ * A view is only an index in a complex, created on demand.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+abstract class IndexedTopo implements DirectedTopo {
+
+    /**
+     * The complex containing the primitive.
+     */
+    final IndexedComplex complex;
+
+    /**
+     * Index of the primitive in the complex.
+     */
+    final int index;
+
+    /**
+     * Creates a view of the primitive at the given index.
+     */
+    IndexedTopo(final IndexedComplex complex, final int index) {
+        this.complex = complex;
+        this.index   = index;
+    }
+
+    /**
+     * Returns the name of the primitive type, for {@link #toString()}.
+     */
+    abstract String typeName();
+
+    /**
+     * Returns {@code true} if this view is the negative orientation of its primitive.
+     */
+    final boolean isNegative() {
+        return getOrientationSign() == Orientable.Sign.NEGATIVE;
+    }
+
+    /**
+     * Returns {@code false}: a primitive of an indexed complex is never empty.
+     */
+    @Override
+    public final boolean isEmpty() {
+        return false;
+    }
+
+    /**
+     * Returns the primitive alone, whatever the orientation of this view.
+     */
+    @Override
+    public final Set<Primitive> getInterior() {
+        return Set.of(getTopo());
+    }
+
+    /**
+     * Returns the primitives of the complex which are not in the closure of this primitive.
+     */
+    @Override
+    public final Set<Primitive> getExterior() {
+        final Set<Primitive> closure = getClosure();
+        final Set<Primitive> exterior = new LinkedHashSet<>();
+        for (final Primitive element : complex.getElements()) {
+            if (!closure.contains(element)) {
+                exterior.add(element);
+            }
+        }
+        return exterior;
+    }
+
+    @Override
+    public final Complex getMaximalComplex() {
+        return complex;
+    }
+
+    @Override
+    public final Set<Complex> getComplexes() {
+        return Set.of(complex);
+    }
+
+    /**
+     * Returns the nodes isolated in a face, or the nodes and edges isolated in a solid.
+     */
+    @Override
+    public final Set<Primitive> getIsolated() {
+        return complex.isolated(getDimension(), index);
+    }
+
+    /**
+     * Returns the face or solid in which a node is isolated, or the solid in which an edge is isolated.
+     */
+    @Override
+    public final Primitive getContainer() {
+        return complex.container(getDimension(), index);
+    }
+
+    @Override
+    public final Expression getExpression() {
+        return DefaultExpression.of(this);
+    }
+
+    /**
+     * Returns {@code true} if the given object is a view of the same type and orientation
+     * on the same primitive of the same complex.
+     */
+    @Override
+    public final boolean equals(final Object obj) {
+        if (obj != null && obj.getClass() == getClass()) {
+            final IndexedTopo other = (IndexedTopo) obj;
+            return other.complex == complex && other.index == index;
+        }
+        return false;
+    }
+
+    @Override
+    public final int hashCode() {
+        return (31 * index + getClass().hashCode()) ^ System.identityHashCode(complex);
+    }
+
+    @Override
+    public final String toString() {
+        return (isNegative() ? "-" : "") + typeName() + '[' + index + ']';
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/NodePoints.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/NodePoints.java
new file mode 100644
index 0000000000..40cf41bb02
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/NodePoints.java
@@ -0,0 +1,385 @@
+/*
+ * 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.internal.shared.topology;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.BitSet;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.geometries.DataPoints;
+import org.apache.sis.geometries.DataPointsType;
+import org.apache.sis.geometries.Point;
+import org.apache.sis.geometries.internal.shared.ArrayDataPoints;
+import org.apache.sis.geometries.internal.shared.IndexedPoint;
+import org.apache.sis.maths.Array;
+import org.apache.sis.maths.DataType;
+import org.apache.sis.maths.NDArrays;
+import org.apache.sis.maths.ReadOnly;
+import org.apache.sis.maths.SampleSystem;
+import org.apache.sis.maths.Vector;
+import org.apache.sis.maths.Vectors;
+
+
+/**
+ * The nodes of an {@link IndexedComplex} as data points: one point per node index, with the positions
+ * and all the other attributes of the geometries from which the nodes have been built.
+ *
+ * <p>The positions are stored as doubles, since the topology is computed on them. Other attributes keep the
+ * sample system and the data type of the first geometry providing them. A node made of several positions,
+ * of the same geometry or of different geometries, takes the value of each attribute from the first of
+ * those positions having that attribute. A node without value for an attribute, for example a node added
+ * by {@link ComplexEditor} at given coordinates, has zero values for that attribute.</p>
+ *
+ * <p>The positions are changed only by {@link ComplexEditor}, since they define the topology.
+ * The other attributes can be changed freely. The points of removed nodes are kept,
+ * so that the index of a point is always the index of its node.</p>
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class NodePoints implements DataPoints, DataPointsType {
+
+    /**
+     * Number of points for which space is allocated at creation time.
+     */
+    private static final int INITIAL_CAPACITY = 16;
+
+    /**
+     * The reference system of the positions, or {@code null} for the nodes of an empty complex.
+     */
+    private final CoordinateReferenceSystem crs;
+
+    /**
+     * Number of coordinates of each position: 2, 3, or 0 for the nodes of an empty complex.
+     */
+    final int dimension;
+
+    /**
+     * Coordinates of the nodes, {@link #dimension} values per node.
+     */
+    double[] coordinates;
+
+    /**
+     * Coordinates of the nodes in three dimensions, with z = 0 for 2-dimensional coordinates.
+     * This is the same array as {@link #coordinates} when the dimension is 3.
+     */
+    double[] xyz;
+
+    /**
+     * Number of points, including those of removed nodes.
+     */
+    private int size;
+
+    /**
+     * Values of the attributes other than positions. The length of the arrays is the capacity, not the size.
+     */
+    private final Map<String, Array> attributes = new LinkedHashMap<>();
+
+    /**
+     * For each attribute other than positions, the nodes having a value for that attribute.
+     */
+    private final Map<String, BitSet> assigned = new HashMap<>();
+
+    /**
+     * Creates an empty set of nodes.
+     *
+     * @param  crs  the reference system of the positions, or {@code null} for an empty complex.
+     */
+    NodePoints(final CoordinateReferenceSystem crs) {
+        this.crs  = crs;
+        dimension = (crs != null) ? crs.getCoordinateSystem().getDimension() : 0;
+        coordinates = new double[INITIAL_CAPACITY * dimension];
+        xyz = (dimension == 3) ? coordinates : new double[INITIAL_CAPACITY * 3];
+    }
+
+    /**
+     * Returns the number of points for which space is allocated.
+     */
+    private int capacity() {
+        return xyz.length / 3;
+    }
+
+    /**
+     * Allocates space for the given number of points.
+     */
+    private void ensureCapacity(final int required) {
+        final int capacity = capacity();
+        if (required > capacity) {
+            final int n = Math.max(required, capacity * 2);
+            coordinates = Arrays.copyOf(coordinates, n * dimension);
+            xyz = (dimension == 3) ? coordinates : Arrays.copyOf(xyz, n * 3);
+            for (final Map.Entry<String, Array> entry : attributes.entrySet()) {
+                final Array old = entry.getValue();
+                final Array array = NDArrays.of(old.getSampleSystem(), old.getDataType(), n);
+                array.set(0, old, 0, size);
+                entry.setValue(array);
+            }
+        }
+    }
+
+    /**
+     * Adds a point at the given position, without value for the other attributes.
+     *
+     * @param  position  the {@link #dimension} coordinates of the point.
+     * @return index of the new point.
+     */
+    final int add(final double[] position) {
+        ensureCapacity(size + 1);
+        final int node = size++;
+        setCoordinates(node, position);
+        return node;
+    }
+
+    /**
+     * Changes the position of a point.
+     */
+    final void setCoordinates(final int node, final double[] position) {
+        System.arraycopy(position, 0, coordinates, node * dimension, dimension);
+        if (dimension != 3) {
+            for (int k = 0; k < 3; k++) {
+                xyz[node * 3 + k] = (k < dimension) ? position[k] : 0;
+            }
+        }
+    }
+
+    /**
+     * Copies to the given node the attributes of a point of another set of data points,
+     * for the attributes which have no value yet for that node. Positions are not copied.
+     * Attributes unknown to this object are added, with zero values for the other nodes.
+     *
+     * @param  node    index of the node.
+     * @param  source  the data points from which to copy the attributes.
+     * @param  index   index of the point in the source.
+     * @throws IllegalArgumentException if an attribute has not the same number of components as before.
+     */
+    final void merge(final int node, final DataPoints source, final int index) {
+        final DataPointsType type = source.getType();
+        for (final String name : type.getAttributeNames()) {
+            if (ATT_POSITION.equals(name)) {
+                continue;
+            }
+            Array array = attributes.get(name);
+            if (array == null) {
+                array = NDArrays.of(type.getAttributeSystem(name), type.getAttributeType(name), capacity());
+                attributes.put(name, array);
+                assigned.put(name, new BitSet());
+            }
+            final BitSet set = assigned.get(name);
+            if (!set.get(node)) {
+                final Vector<?> value = source.getAttribute(index, name);
+                if (value != null) {
+                    if (value.getDimension() != array.getDimension()) {
+                        throw new IllegalArgumentException("The attribute " + name + " has " + value.getDimension()
+                                + " components, while " + array.getDimension() + " were expected.");
+                    }
+                    array.set(node, value);
+                    set.set(node);
+                }
+            }
+        }
+    }
+
+    /**
+     * Sets the attributes of a node by interpolation between two nodes, for the attributes having a value
+     * at both nodes. The interpolation method of each attribute is given by {@link #getAttributeInterpolation(String)}.
+     * Positions are not interpolated.
+     *
+     * @param  node   index of the node to set.
+     * @param  a      index of the first node.
+     * @param  b      index of the second node.
+     * @param  ratio  position between the nodes, from 0 at {@code a} to 1 at {@code b}.
+     */
+    final void interpolate(final int node, final int a, final int b, final double ratio) {
+        for (final Map.Entry<String, Array> entry : attributes.entrySet()) {
+            final String name = entry.getKey();
+            final BitSet set = assigned.get(name);
+            if (set.get(a) && set.get(b)) {
+                entry.getValue().set(node, interpolateAttribute(name, a, b, ratio));
+                set.set(node);
+            }
+        }
+    }
+
+    /**
+     * Returns whether the given node has a value for the given attribute.
+     *
+     * @param  index  index of the node.
+     * @param  name   name of the attribute.
+     * @return whether the node has a value, always true for positions.
+     */
+    public boolean hasAttribute(final int index, final String name) {
+        Objects.checkIndex(index, size);
+        if (ATT_POSITION.equals(name)) {
+            return true;
+        }
+        final BitSet set = assigned.get(name);
+        return (set != null) && set.get(index);
+    }
+
+    /**
+     * Returns the reference system of the positions.
+     */
+    @Override
+    public CoordinateReferenceSystem getCoordinateReferenceSystem() {
+        return crs;
+    }
+
+    /**
+     * Unsupported, since the topology is computed in the reference system of the complex.
+     */
+    @Override
+    public void setCoordinateReferenceSystem(final CoordinateReferenceSystem cs) {
+        throw new UnsupportedOperationException("The reference system of a topological complex cannot be changed.");
+    }
+
+    @Override
+    public int getDimension() {
+        return dimension;
+    }
+
+    @Override
+    public DataPointsType getType() {
+        return this;
+    }
+
+    /**
+     * Returns the number of points, including those of removed nodes.
+     */
+    @Override
+    public int size() {
+        return size;
+    }
+
+    @Override
+    public Point getPoint(final int index) {
+        Objects.checkIndex(index, size);
+        return new IndexedPoint(this, index);
+    }
+
+    @Override
+    public Vector<?> getPosition(final int index) {
+        Objects.checkIndex(index, size);
+        return Vectors.createDouble(crs).set(coordinates, index * dimension);
+    }
+
+    /**
+     * Unsupported, since positions define the topology. Use {@link ComplexEditor} instead.
+     */
+    @Override
+    public void setPosition(final int index, final ReadOnly.Vector<?> value) {
+        throw new UnsupportedOperationException("The nodes of a topological complex are moved by ComplexEditor.");
+    }
+
+    @Override
+    public Vector<?> getAttribute(final int index, final String name) {
+        if (ATT_POSITION.equals(name)) {
+            return getPosition(index);
+        }
+        Objects.checkIndex(index, size);
+        final Array array = attributes.get(name);
+        return (array != null) ? array.get(index) : null;
+    }
+
+    /**
+     * Sets the value of an attribute other than positions.
+     *
+     * @throws UnsupportedOperationException if the attribute is the positions.
+     * @throws IllegalArgumentException if there is no attribute of the given name.
+     */
+    @Override
+    public void setAttribute(final int index, final String name, final ReadOnly.Vector<?> value) {
+        if (ATT_POSITION.equals(name)) {
+            setPosition(index, value);
+        }
+        Objects.checkIndex(index, size);
+        final Array array = attributes.get(name);
+        if (array == null) {
+            throw new IllegalArgumentException("No attribute named " + name + '.');
+        }
+        array.set(index, value);
+        assigned.get(name).set(index);
+    }
+
+    @Override
+    public Array getAttributeArray(final String name) {
+        if (ATT_POSITION.equals(name)) {
+            return NDArrays.of(crs, Arrays.copyOf(coordinates, size * dimension));
+        }
+        final Array array = attributes.get(name);
+        if (array == null) {
+            return null;
+        }
+        final Array copy = NDArrays.of(array.getSampleSystem(), array.getDataType(), size);
+        copy.set(0, array, 0, size);
+        return copy;
+    }
+
+    /**
+     * Returns a copy of the points at the given indices, with all their attributes.
+     */
+    @Override
+    public DataPoints subset(final int[] indices) {
+        final double[] c = new double[indices.length * dimension];
+        for (int i = 0; i < indices.length; i++) {
+            System.arraycopy(coordinates, Objects.checkIndex(indices[i], size) * dimension, c, i * dimension, dimension);
+        }
+        final Map<String, Array> subset = new LinkedHashMap<>();
+        subset.put(ATT_POSITION, NDArrays.of(crs, c));
+        attributes.forEach((name, array) -> subset.put(name, NDArrays.subset(array, indices).copy()));
+        return new ArrayDataPoints(subset);
+    }
+
+    @Override
+    public SampleSystem getAttributeSystem(final String name) {
+        if (ATT_POSITION.equals(name)) {
+            return SampleSystem.of(crs);
+        }
+        final Array array = attributes.get(name);
+        return (array != null) ? array.getSampleSystem() : null;
+    }
+
+    @Override
+    public DataType getAttributeType(final String name) {
+        if (ATT_POSITION.equals(name)) {
+            return DataType.DOUBLE;
+        }
+        final Array array = attributes.get(name);
+        return (array != null) ? array.getDataType() : null;
+    }
+
+    @Override
+    public List<String> getAttributeNames() {
+        final List<String> names = new ArrayList<>(attributes.size() + 1);
+        names.add(ATT_POSITION);
+        names.addAll(attributes.keySet());
+        return names;
+    }
+
+    @Override
+    public int hashCode() {
+        return DataPoints.hashCode(this);
+    }
+
+    @Override
+    public boolean equals(final Object obj) {
+        return DataPoints.equals(this, obj);
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/package-info.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/package-info.java
new file mode 100644
index 0000000000..56e37e2ab5
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/package-info.java
@@ -0,0 +1,37 @@
+/*
+ * 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.
+ */
+
+/**
+ * Implementation of the {@link org.apache.sis.geometries.topology} interfaces with primitives stored by index.
+ *
+ * <ul>
+ *   <li>{@link org.apache.sis.geometries.internal.shared.topology.ComplexBuilder} builds a complex from points,
+ *       lines, polygons, polyhedra and meshes, and verifies that the interiors of its primitives are disjoint,
+ *       except for the nodes and edges isolated in a face or a solid.</li>
+ *   <li>{@link org.apache.sis.geometries.internal.shared.topology.IndexedComplex} stores the nodes, edges, faces
+ *       and solids in arrays, and returns lightweight views implementing the topology interfaces.</li>
+ *   <li>{@link org.apache.sis.geometries.internal.shared.topology.NodePoints} holds the positions and the other
+ *       attributes of the nodes, such as normals or colors, which are kept by the geometric realizations.</li>
+ *   <li>{@link org.apache.sis.geometries.internal.shared.topology.ComplexEditor} adds, splits, merges and removes
+ *       primitives, modeled after the topology editing functions of ISO/IEC 13249-3 (SQL/MM Spatial).</li>
+ *   <li>{@link org.apache.sis.geometries.internal.shared.topology.DefaultExpression} implements the polynomials
+ *       of oriented primitives on which boundaries and co-boundaries are computed.</li>
+ * </ul>
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+package org.apache.sis.geometries.internal.shared.topology;
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/topology/ComplexBuilderTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/topology/ComplexBuilderTest.java
new file mode 100644
index 0000000000..49eac8b8fb
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/topology/ComplexBuilderTest.java
@@ -0,0 +1,517 @@
+/*
+ * 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.internal.shared.topology;
+
+import java.util.List;
+import java.util.Set;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.geometries.DataPointsType;
+import org.apache.sis.geometries.Geometries;
+import org.apache.sis.geometries.Geometry;
+import org.apache.sis.geometries.GeometryFactory;
+import org.apache.sis.geometries.Orientable;
+import org.apache.sis.geometries.Point;
+import org.apache.sis.geometries.curve.LineString;
+import org.apache.sis.geometries.curve.LinearRing;
+import org.apache.sis.geometries.mesh.MeshPrimitive;
+import org.apache.sis.geometries.operation.polyhedron.SolidFixtures;
+import org.apache.sis.geometries.solid.Polyhedron;
+import org.apache.sis.geometries.surface.Polygon;
+import org.apache.sis.geometries.topology.DirectedFace;
+import org.apache.sis.geometries.topology.DirectedSolid;
+import org.apache.sis.geometries.topology.Edge;
+import org.apache.sis.geometries.topology.Face;
+import org.apache.sis.geometries.topology.Node;
+import org.apache.sis.geometries.topology.Primitive;
+import org.apache.sis.geometries.topology.Solid;
+import org.apache.sis.maths.NDArrays;
+
+// Test dependencies
+import static org.junit.jupiter.api.Assertions.*;
+import org.junit.jupiter.api.Test;
+
+
+/**
+ * Tests {@link ComplexBuilder} and the {@link IndexedComplex} it builds.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public class ComplexBuilderTest {
+
+    private static final double EPS = 1E-9;
+
+    private static final CoordinateReferenceSystem CRS2D = Geometries.getUndefinedCRS(2);
+
+    private static final CoordinateReferenceSystem CRS3D = SolidFixtures.CRS;
+
+    /**
+     * Builds the complex of the given geometries.
+     */
+    static IndexedComplex build(final Geometry... geometries) {
+        final ComplexBuilder builder = new ComplexBuilder();
+        for (final Geometry geometry : geometries) {
+            builder.add(geometry);
+        }
+        return builder.build();
+    }
+
+    /**
+     * A box gives 8 nodes, 12 edges, 6 faces and 1 solid. Each edge is used in both directions by its two faces,
+     * the boundary of the boundary of the solid is zero, and the faces enclose the volume of the box.
+     */
+    @Test
+    public void testBox() {
+        final Polyhedron box = SolidFixtures.box(0, 0, 0, 1, 2, 3);
+        final IndexedComplex complex = build(box);
+        assertEquals(8,  complex.getNodeCount());
+        assertEquals(12, complex.getEdgeCount());
+        assertEquals(6,  complex.getFaceCount());
+        assertEquals(1,  complex.getSolidCount());
+        assertEquals(3,  complex.getDimension());
+        assertEquals(8 + 12 + 6 + 1, complex.getElements().size());
+        assertTrue(complex.isConnected());
+        assertTrue(complex.isMaximal());
+        assertSame(box, complex.getGeometry());
+        for (int i = 0; i < complex.getNodeCount(); i++) {
+            assertEquals(3, complex.getNode(i).getCoBoundary().size(), "Edges around a corner.");
+        }
+        for (int i = 0; i < complex.getEdgeCount(); i++) {
+            assertOppositeFaces(complex.getEdge(i));
+        }
+        final Solid solid = complex.getSolid(0);
+        for (int i = 0; i < complex.getFaceCount(); i++) {
+            assertEquals(List.of(solid), complex.getFace(i).getCoBoundary(), "The solid is below each face.");
+        }
+        assertClosed(solid);
+        assertEquals(6, volume(solid), EPS);
+        assertEquals(6, complex.getBoundary().size(), "The boundary of the complex is the boundary of the solid.");
+    }
+
+    /**
+     * A cube with a void gives two shells, connected through the solid.
+     */
+    @Test
+    public void testHollowCube() {
+        final IndexedComplex complex = build(SolidFixtures.hollowCube(4, 1));
+        assertEquals(16, complex.getNodeCount());
+        assertEquals(24, complex.getEdgeCount());
+        assertEquals(12, complex.getFaceCount());
+        assertTrue(complex.isConnected());
+        final Solid solid = complex.getSolid(0);
+        assertEquals(6, solid.getExteriorShell().size());
+        assertEquals(1, solid.getInteriorShells().size());
+        assertClosed(solid);
+        assertEquals(64 - 8, volume(solid), EPS);
+    }
+
+    /**
+     * Shells wound the wrong way are reversed, so that the solid is below all its faces:
+     * an exterior shell oriented inward, and a void oriented like the exterior shell.
+     */
+    @Test
+    public void testOrientation() {
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        final Polyhedron inverted = f.createPolyhedron(SolidFixtures.boxShell(0, 0, 0, 1, 1, 1, false), List.of());
+        Solid solid = build(inverted).getSolid(0);
+        assertClosed(solid);
+        assertEquals(1, volume(solid), EPS);
+
+        final Polyhedron hollow = f.createPolyhedron(SolidFixtures.boxShell(0, 0, 0, 4, 4, 4, true),
+                                             List.of(SolidFixtures.boxShell(1, 1, 1, 3, 3, 3, true)));
+        solid = build(hollow).getSolid(0);
+        assertClosed(solid);
+        assertEquals(64 - 8, volume(solid), EPS);
+    }
+
+    /**
+     * Two cubes sharing a face share that face, used positively by one solid and negatively by the other.
+     * The shared face cancels out in the boundary of the complex.
+     */
+    @Test
+    public void testSharingFace() {
+        final Polyhedron[] pair = SolidFixtures.sharingFace();
+        final IndexedComplex complex = build(pair[0], pair[1]);
+        assertEquals(12, complex.getNodeCount());
+        assertEquals(20, complex.getEdgeCount());
+        assertEquals(11, complex.getFaceCount());
+        assertEquals(2,  complex.getSolidCount());
+        assertTrue(complex.isConnected());
+        int shared = 0;
+        for (int i = 0; i < complex.getFaceCount(); i++) {
+            final List<DirectedSolid> solids = complex.getFace(i).getCoBoundary();
+            if (solids.size() == 2) {
+                shared++;
+                assertNotEquals(solids.get(0).getOrientationSign(), solids.get(1).getOrientationSign());
+            }
+        }
+        assertEquals(1, shared, "Faces between the two solids.");
+        assertEquals(10, complex.getBoundary().size());
+        assertEquals(1, volume(complex.getSolid(0)), EPS);
+        assertEquals(1, volume(complex.getSolid(1)), EPS);
+    }
+
+    /**
+     * Cubes sharing an edge or a vertex are valid complexes. The shared edge has four faces around it.
+     */
+    @Test
+    public void testSharingEdgeOrVertex() {
+        Polyhedron[] pair = SolidFixtures.sharingEdge();
+        IndexedComplex complex = build(pair[0], pair[1]);
+        assertEquals(14, complex.getNodeCount());
+        assertEquals(23, complex.getEdgeCount());
+        assertEquals(12, complex.getFaceCount());
+        int around = 0;
+        for (int i = 0; i < complex.getEdgeCount(); i++) {
+            around = Math.max(around, complex.getEdge(i).getCoBoundary().size());
+        }
+        assertEquals(4, around);
+
+        pair = SolidFixtures.sharingVertex();
+        complex = build(pair[0], pair[1]);
+        assertEquals(15, complex.getNodeCount());
+        assertEquals(24, complex.getEdgeCount());
+        assertTrue(complex.isConnected());
+    }
+
+    /**
+     * The prism with a hole has faces with an interior ring, and a closed boundary.
+     */
+    @Test
+    public void testPrismWithHole() {
+        final IndexedComplex complex = build(SolidFixtures.prismWithHole());
+        final Solid solid = complex.getSolid(0);
+        assertClosed(solid);
+        assertEquals(24, volume(solid), EPS);
+        boolean hole = false;
+        for (int i = 0; i < complex.getFaceCount(); i++) {
+            hole |= !complex.getFace(i).getInteriorRings().isEmpty();
+        }
+        assertTrue(hole, "The lower and upper faces have a hole.");
+    }
+
+    /**
+     * Three triangles around an edge are listed in clockwise order seen from the end of the edge,
+     * whatever their order in the mesh. The opposite edge lists the opposite faces in reverse order.
+     */
+    @Test
+    public void testRadialOrder() {
+        final double cos = Math.cos(Math.toRadians(200)), sin = Math.sin(Math.toRadians(200));
+        final MeshPrimitive mesh = MeshPrimitive.create(MeshPrimitive.Type.TRIANGLES);
+        mesh.setPositions(NDArrays.of(CRS3D, new double[] {
+            0, 0, 0,                // start of the edge
+            0, 0, 1,                // end of the edge
+            1, 0, 0.5,              // fin toward 0°
+            0, 1, 0.5,              // fin toward 90°
+            cos, sin, 0.5           // fin toward 200°
+        }));
+        mesh.setIndex(NDArrays.ofUnsigned(1, new int[] {0,1,2, 0,1,3, 0,1,4}));
+        final IndexedComplex complex = build(mesh);
+        assertEquals(3, complex.getFaceCount());
+        final Edge axis = complex.getEdge(0);
+        assertArrayEquals(new double[] {0, 0, 1}, complex.getCoordinates(1));
+        assertEquals(List.of(complex.getFace(0), complex.getFace(2), complex.getFace(1)),
+                     axis.getCoBoundary(), "Clockwise seen from above: 0°, then 200°, then 90°.");
+        assertEquals(List.of(complex.getFace(1).getNegate(), complex.getFace(2).getNegate(), complex.getFace(0).getNegate()),
+                     axis.getNegate().getCoBoundary());
+    }
+
+    /**
+     * Four triangles around an edge, two by two in the same plane on opposite sides of the edge.
+     */
+    @Test
+    public void testRadialOrderOpposite() {
+        final MeshPrimitive mesh = MeshPrimitive.create(MeshPrimitive.Type.TRIANGLES);
+        mesh.setPositions(NDArrays.of(CRS3D, new double[] {
+            0, 0, 0,   0, 0, 1,     // the edge
+             1, 0, 0.5,             // fin toward 0°
+            -1, 0, 0.5,             // fin toward 180°
+             0, 1, 0.5,             // fin toward 90°
+             0,-1, 0.5              // fin toward 270°
+        }));
+        mesh.setIndex(NDArrays.ofUnsigned(1, new int[] {0,1,2, 0,1,3, 0,1,4, 0,1,5}));
+        final IndexedComplex complex = build(mesh);
+        assertEquals(List.of(complex.getFace(0), complex.getFace(3), complex.getFace(1), complex.getFace(2)),
+                     complex.getEdge(0).getCoBoundary(), "Clockwise seen from above: 0°, 270°, 180°, 90°.");
+    }
+
+    /**
+     * A face which is not convex, with nodes on both sides of the line of an edge, is ordered around
+     * that edge by the side where the face is near the edge.
+     */
+    @Test
+    public void testRadialOrderNonConvex() {
+        final double cos = Math.cos(Math.toRadians(200)), sin = Math.sin(Math.toRadians(200));
+        // In the plane y = 0, on the side x > 0 of the edge, and going around above the edge to x < 0.
+        final Polygon hook  = polygon(CRS3D, 0,0,0, 0,0,1, 1,0,1, 1,0,2, -1,0,2, -1,0,3, 2,0,3, 2,0,0, 0,0,0);
+        final Polygon right = polygon(CRS3D, 0,0,0, 0,0,1, 0,1,0.5, 0,0,0);
+        final Polygon other = polygon(CRS3D, 0,0,0, 0,0,1, cos,sin,0.5, 0,0,0);
+        final IndexedComplex complex = build(GeometryFactory.DEFAULT.createMultiPolygon(CRS3D, hook, right, other));
+        assertEquals(List.of(complex.getFace(0), complex.getFace(2), complex.getFace(1)),
+                     complex.getEdge(0).getCoBoundary(), "Clockwise seen from above: 0°, 200°, 90°.");
+    }
+
+    /**
+     * Points and lines in two dimensions. A point inside a polygon is isolated in the face,
+     * and the ends of lines are shared with the corners of the polygon.
+     */
+    @Test
+    public void testPointsAndLines2D() {
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        final Polygon    square = polygon(CRS2D, 0,0, 4,0, 4,4, 0,4, 0,0);
+        final Point      inner  = f.createPoint(CRS2D, 1, 1);
+        final Point      corner = f.createPoint(CRS2D, 5, 5);
+        final LineString path   = line(CRS2D, 4,0, 6,1, 7,0);
+        final LineString link   = line(CRS2D, 4,4, 5,5);
+        final LineString tail   = line(CRS2D, 0,0, -1,-1);
+        final IndexedComplex complex = build(square, inner, corner, path, link, tail);
+        assertEquals(9, complex.getNodeCount());
+        assertEquals(8, complex.getEdgeCount());
+        assertEquals(1, complex.getFaceCount());
+        assertTrue(complex.isConnected(), "The isolated node is linked to its face.");
+
+        final Face face = complex.getFace(0);
+        final Node isolated = complex.getNode(4);
+        assertArrayEquals(new double[] {1, 1}, complex.getCoordinates(4));
+        assertEquals(face, isolated.getContainer());
+        assertEquals(Set.of(isolated), face.getIsolated());
+        assertTrue(isolated.getCoBoundary().isEmpty());
+        assertNull(complex.getNode(5).getContainer(), "Outside the square.");
+        assertEquals(1, complex.getNode(5).getCoBoundary().size(), "The end of the link.");
+        assertEquals(List.of(complex.getEdge(0).getNegate(), complex.getEdge(7).getNegate(), complex.getEdge(3)),
+                     complex.getNode(0).getCoBoundary(), "Clockwise around the origin: 0°, 225°, 90°.");
+
+        assertFalse(build(square, f.createPoint(CRS2D, 9, 9)).isConnected());
+        assertThrows(IllegalArgumentException.class, () -> build(square, f.createPoint(CRS2D, 2, 0)), "A point inside an edge.");
+        assertThrows(IllegalArgumentException.class, () -> build(square, line(CRS2D, -1,2, 5,2)), "A line crossing the face.");
+        assertThrows(IllegalArgumentException.class, () -> build(square, line(CRS2D, 1,2, 3,2)), "A line inside the face.");
+    }
+
+    /**
+     * Points and lines inside a solid are isolated in the solid, and a point on a face is isolated in the face.
+     */
+    @Test
+    public void testIsolatedInSolid() {
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        final Polyhedron cube    = SolidFixtures.box(0, 0, 0, 4, 4, 4);
+        final Point      center  = f.createPoint(CRS3D, 2, 2, 2);
+        final LineString inside  = line(CRS3D, 1,1,1, 3,3,1);
+        final Point      onFace  = f.createPoint(CRS3D, 2, 2, 4);
+        final LineString antenna = line(CRS3D, 2,2,4, 2,2,6);
+        final IndexedComplex complex = build(cube, center, inside, onFace, antenna);
+        assertEquals(13, complex.getNodeCount());
+        assertEquals(14, complex.getEdgeCount());
+        assertTrue(complex.isConnected());
+
+        final Solid solid = complex.getSolid(0);
+        assertEquals(solid, complex.getNode(8).getContainer());
+        assertEquals(solid, complex.getNode(9).getContainer());
+        assertEquals(solid, complex.getEdge(12).getContainer());
+        assertEquals(Set.of(complex.getNode(8), complex.getNode(9), complex.getNode(10), complex.getEdge(12)),
+                     solid.getIsolated());
+
+        final Primitive top = complex.getNode(11).getContainer();
+        assertInstanceOf(Face.class, top, "The point on the top face.");
+        assertEquals(Set.of(complex.getNode(11)), top.getIsolated());
+        assertNull(complex.getEdge(13).getContainer(), "The antenna is outside the solid.");
+        assertClosed(solid);
+
+        assertThrows(IllegalArgumentException.class, () -> build(cube, line(CRS3D, 2,2,-1, 2,2,5)), "A line through the solid.");
+        assertThrows(IllegalArgumentException.class, () -> build(cube, f.createPoint(CRS3D, 2, 0, 0)), "A point inside an edge.");
+        assertThrows(IllegalArgumentException.class, () -> build(cube, polygon(CRS3D, 1,1,1, 3,1,1, 3,3,1, 1,1,1)),
+                     "A face inside the solid.");
+    }
+
+    /**
+     * Meshes of points and lines give nodes and edges. A loop has no boundary.
+     */
+    @Test
+    public void testMeshPointsAndLines() {
+        final MeshPrimitive loop = MeshPrimitive.create(MeshPrimitive.Type.LINE_LOOP);
+        loop.setPositions(NDArrays.of(CRS2D, new double[] {0,0, 1,0, 1,1, 0,1}));
+        final MeshPrimitive points = MeshPrimitive.create(MeshPrimitive.Type.POINTS);
+        points.setPositions(NDArrays.of(CRS2D, new double[] {0,0, 5,5}));
+        final IndexedComplex complex = build(loop, points);
+        assertEquals(5, complex.getNodeCount());
+        assertEquals(4, complex.getEdgeCount());
+        assertEquals(0, complex.getFaceCount());
+        assertEquals(1, complex.getDimension());
+        assertFalse(complex.isConnected());
+        assertTrue(complex.getBoundary().isEmpty(), "A cycle and a point have no boundary.");
+    }
+
+    /**
+     * Two squares sharing an edge in two dimensions share that edge with opposite orientations.
+     */
+    @Test
+    public void testPolygons2D() {
+        final Polygon a = polygon(CRS2D, 0,0, 1,0, 1,1, 0,1, 0,0);
+        final Polygon b = polygon(CRS2D, 1,0, 2,0, 2,1, 1,1, 1,0);
+        final IndexedComplex complex = build(GeometryFactory.DEFAULT.createMultiPolygon(CRS2D, a, b));
+        assertEquals(6, complex.getNodeCount());
+        assertEquals(7, complex.getEdgeCount());
+        assertEquals(2, complex.getFaceCount());
+        assertEquals(0, complex.getSolidCount());
+        assertEquals(2, complex.getDimension());
+        assertTrue(complex.isConnected());
+        assertEquals(6, complex.getBoundary().size(), "The shared edge cancels out.");
+        int shared = 0;
+        for (int i = 0; i < complex.getEdgeCount(); i++) {
+            final List<DirectedFace> faces = complex.getEdge(i).getCoBoundary();
+            if (faces.size() == 2) {
+                shared++;
+                assertNotEquals(faces.get(0).getOrientationSign(), faces.get(1).getOrientationSign());
+            }
+        }
+        assertEquals(1, shared);
+        assertEquals(5, ((Polygon) complex.getFace(0).getGeometry()).getExteriorRing().getDataPoints().size());
+    }
+
+    /**
+     * The views are equal when they designate the same oriented primitive, and the realizations
+     * of the negative orientations are reversed.
+     */
+    @Test
+    public void testViews() {
+        final IndexedComplex complex = build(SolidFixtures.box(0, 0, 0, 1, 1, 1));
+        final Edge edge = complex.getEdge(0);
+        assertEquals(edge, complex.getEdge(0));
+        assertEquals(edge, edge.getNegate().getNegate());
+        assertNotEquals(edge, edge.getNegate());
+        assertEquals(edge, edge.getNegate().getTopo());
+        assertEquals(List.of(edge, edge.getNegate()), edge.getProxy());
+        assertEquals(edge.getNegate(), edge.asDirectedTopo(Orientable.Sign.NEGATIVE));
+        assertEquals(Set.of(edge), edge.getNegate().getInterior());
+        assertEquals(3, edge.getClosure().size(), "The edge and its two nodes.");
+        assertEquals(complex.getElements().size() - 3, edge.getExterior().size());
+        assertSame(complex, edge.getMaximalComplex());
+
+        final double[] c = positions((LineString) edge.getGeometry());
+        final double[] r = positions((LineString) edge.getNegate().getGeometry());
+        assertArrayEquals(new double[] {c[3], c[4], c[5], c[0], c[1], c[2]}, r);
+        assertEquals(edge.getBoundary().get(1).getTopo(), edge.getNegate().getBoundary().get(0).getTopo(),
+                     "The opposite edge starts where the edge ends.");
+
+        final Face face = complex.getFace(0);
+        assertEquals(face.getExteriorRing().size(), face.getNegate().getBoundary().size());
+        assertEquals(face.getExteriorRing().get(0).getNegate(), face.getNegate().getBoundary().get(3));
+    }
+
+    /**
+     * Geometries whose interiors intersect are rejected.
+     */
+    @Test
+    public void testNotDisjoint() {
+        final Polyhedron[] overlapping = SolidFixtures.overlapping();
+        assertThrows(IllegalArgumentException.class, () -> build(overlapping[0], overlapping[1]), "Crossing faces.");
+
+        final Polyhedron[] offset = SolidFixtures.offsetAlongX();
+        assertThrows(IllegalArgumentException.class, () -> build(offset[0], offset[1]), "Overlapping coplanar faces.");
+
+        assertThrows(IllegalArgumentException.class, () -> build(SolidFixtures.box(0, 0, 0, 4, 4, 4),
+                                                                SolidFixtures.box(1, 1, 1, 2, 2, 2)), "Nested solids.");
+
+        final Polyhedron box = SolidFixtures.box(0, 0, 0, 1, 1, 1);
+        assertThrows(IllegalArgumentException.class, () -> build(box, box), "The same solid twice.");
+
+        final Polygon big   = polygon(CRS2D, 0,0, 2,0, 2,2, 0,2, 0,0);
+        final Polygon lower = polygon(CRS2D, 2,0, 3,0, 3,1, 2,1, 2,0);
+        final Polygon upper = polygon(CRS2D, 2,1, 3,1, 3,2, 2,2, 2,1);
+        assertThrows(IllegalArgumentException.class, () -> build(big, lower, upper), "A node inside an edge.");
+
+        final MeshPrimitive mesh = MeshPrimitive.create(MeshPrimitive.Type.TRIANGLES);
+        mesh.setPositions(NDArrays.of(CRS2D, new double[] {0,0, 2,0, 0,2, 1,1.5, 1,-1, 3,1}));
+        mesh.setIndex(NDArrays.ofUnsigned(1, new int[] {0,1,2, 3,4,5}));
+        assertThrows(IllegalArgumentException.class, () -> build(mesh), "Crossing triangles.");
+
+        final Polygon inside = polygon(CRS2D, 0.5,0.5, 1,0.5, 1,1, 0.5,0.5);
+        assertThrows(IllegalArgumentException.class, () -> build(big, inside), "A face inside another.");
+    }
+
+    /**
+     * Unsupported geometries and mixed reference systems are rejected.
+     */
+    @Test
+    public void testInvalidInput() {
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        assertThrows(IllegalArgumentException.class, () -> new ComplexBuilder().add(
+                f.createCircularString(f.createDataPoints(CRS2D, 0,0, 1,1, 2,0))), "Not a line string.");
+        assertThrows(IllegalArgumentException.class, () -> new ComplexBuilder().add(line(CRS2D, 1,1, 1,1)), "Degenerate line.");
+        final ComplexBuilder mixed = new ComplexBuilder().add(SolidFixtures.box(0, 0, 0, 1, 1, 1));
+        assertThrows(IllegalArgumentException.class, () -> mixed.add(polygon(CRS2D, 0,0, 1,0, 1,1, 0,0)));
+    }
+
+    /**
+     * Verifies that the two faces around the given edge use it in opposite directions.
+     */
+    private static void assertOppositeFaces(final Edge edge) {
+        final List<DirectedFace> faces = edge.getCoBoundary();
+        assertEquals(2, faces.size(), "Faces around an edge.");
+        assertNotEquals(faces.get(0).getOrientationSign(), faces.get(1).getOrientationSign());
+    }
+
+    /**
+     * Verifies that the boundary of the solid is closed and consistently oriented,
+     * which is that the boundary of its boundary is zero.
+     */
+    static void assertClosed(final Solid solid) {
+        assertFalse(solid.getExpression().getBoundary().isZero());
+        assertTrue(solid.getExpression().getBoundary().getBoundary().isZero(), "Boundary of the boundary.");
+    }
+
+    /**
+     * Returns the volume enclosed by the realizations of the oriented faces of the given solid.
+     */
+    static double volume(final Solid solid) {
+        double volume = 0;
+        for (final DirectedFace face : solid.getBoundary()) {
+            final Polygon polygon = (Polygon) face.getGeometry();
+            volume += fan(polygon.getExteriorRing());
+            for (final LinearRing hole : polygon.getInteriorRings()) {
+                volume += fan(hole);
+            }
+        }
+        return volume / 6;
+    }
+
+    /**
+     * Returns six times the signed volume of the cone joining the origin to the given closed ring.
+     */
+    private static double fan(final LinearRing ring) {
+        final double[] c = ring.getDataPoints().getAttributeArray(DataPointsType.ATT_POSITION).toArrayDouble();
+        double sum = 0;
+        for (int i = 3; i + 3 < c.length; i += 3) {
+            sum += c[0] * (c[i+1] * c[i+5] - c[i+2] * c[i+4])
+                 + c[1] * (c[i+2] * c[i+3] - c[i]   * c[i+5])
+                 + c[2] * (c[i]   * c[i+4] - c[i+1] * c[i+3]);
+        }
+        return sum;
+    }
+
+    static double[] positions(final LineString line) {
+        return line.getDataPoints().getAttributeArray(DataPointsType.ATT_POSITION).toArrayDouble();
+    }
+
+    static Polygon polygon(final CoordinateReferenceSystem crs, final double... coordinates) {
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        return f.createPolygon(f.createLinearRing(f.createDataPoints(NDArrays.of(crs, coordinates))), List.of());
+    }
+
+    static LineString line(final CoordinateReferenceSystem crs, final double... coordinates) {
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        return f.createLineString(f.createDataPoints(NDArrays.of(crs, coordinates)));
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/topology/ComplexEditorTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/topology/ComplexEditorTest.java
new file mode 100644
index 0000000000..fe56808f12
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/topology/ComplexEditorTest.java
@@ -0,0 +1,323 @@
+/*
+ * 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.internal.shared.topology;
+
+import java.util.List;
+import java.util.Set;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.geometries.DataPointsType;
+import org.apache.sis.geometries.Geometries;
+import org.apache.sis.geometries.GeometryFactory;
+import org.apache.sis.geometries.Point;
+import org.apache.sis.geometries.curve.LineString;
+import org.apache.sis.geometries.operation.polyhedron.SolidFixtures;
+import org.apache.sis.geometries.surface.Polygon;
+import org.apache.sis.geometries.topology.DirectedFace;
+import org.apache.sis.geometries.topology.Edge;
+import org.apache.sis.geometries.topology.Face;
+import org.apache.sis.geometries.topology.Node;
+import org.apache.sis.geometries.topology.Solid;
+import static org.apache.sis.geometries.internal.shared.topology.ComplexBuilderTest.assertClosed;
+import static org.apache.sis.geometries.internal.shared.topology.ComplexBuilderTest.build;
+import static org.apache.sis.geometries.internal.shared.topology.ComplexBuilderTest.line;
+import static org.apache.sis.geometries.internal.shared.topology.ComplexBuilderTest.polygon;
+import static org.apache.sis.geometries.internal.shared.topology.ComplexBuilderTest.positions;
+import static org.apache.sis.geometries.internal.shared.topology.ComplexBuilderTest.volume;
+
+// Test dependencies
+import static org.junit.jupiter.api.Assertions.*;
+import org.junit.jupiter.api.Test;
+
+
+/**
+ * Tests {@link ComplexEditor}. Most tests start from the square from (0,0) to (4,4), whose nodes 0 to 3
+ * are its corners in counterclockwise order from the origin, and whose edge <var>i</var> goes from node
+ * <var>i</var> to the next node.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public class ComplexEditorTest {
+
+    private static final double EPS = 1E-9;
+
+    private static final CoordinateReferenceSystem CRS2D = Geometries.getUndefinedCRS(2);
+
+    private static final CoordinateReferenceSystem CRS3D = SolidFixtures.CRS;
+
+    /**
+     * Returns the complex of the square from (0,0) to (4,4).
+     */
+    private static IndexedComplex square() {
+        return build(polygon(CRS2D, 0,0, 4,0, 4,4, 0,4, 0,0));
+    }
+
+    /**
+     * Returns the coordinates of the given node.
+     */
+    private static double[] coordinates(final Node node) {
+        return ((Point) node.getGeometry()).getPosition().toArrayDouble();
+    }
+
+    /**
+     * Isolated nodes are added in the face which contains them, moved, and removed.
+     */
+    @Test
+    public void testIsolatedNodes() {
+        final IndexedComplex complex = square();
+        final ComplexEditor editor = new ComplexEditor(complex);
+        final int inside  = editor.addIsolatedNode(1, 1);
+        final int outside = editor.addIsolatedNode(6, 6);
+        assertEquals(complex.getFace(0), complex.getNode(inside).getContainer());
+        assertNull(complex.getNode(outside).getContainer());
+        assertThrows(IllegalArgumentException.class, () -> editor.addIsolatedNode(2, 0), "Inside an edge.");
+        assertThrows(IllegalArgumentException.class, () -> editor.addIsolatedNode(4, 4), "On a node.");
+        assertThrows(IllegalArgumentException.class, () -> editor.addIsolatedNode(1, 2, 3), "Wrong dimension.");
+
+        editor.moveIsolatedNode(inside, 7, 7);
+        assertNull(complex.getNode(inside).getContainer());
+        assertTrue(complex.getFace(0).getIsolated().isEmpty());
+
+        editor.removeIsolatedNode(outside);
+        assertNull(complex.getNode(outside));
+        assertEquals(5 + 4 + 1, complex.getElements().size());
+        assertThrows(IllegalArgumentException.class, () -> editor.removeIsolatedNode(0), "The end of edges.");
+    }
+
+    /**
+     * Edges are added between nodes, unless they cross an edge or are inside a face, and removed.
+     */
+    @Test
+    public void testEdges() {
+        final IndexedComplex complex = square();
+        final ComplexEditor editor = new ComplexEditor(complex);
+        final int a = editor.addIsolatedNode(6, 0);
+        final int b = editor.addIsolatedNode(6, 4);
+        final int edge = editor.addEdge(a, b);
+        assertEquals(5, complex.getEdgeCount());
+        assertEquals(1, complex.getNode(a).getCoBoundary().size());
+        assertThrows(IllegalArgumentException.class, () -> editor.addEdge(a, b), "Already an edge.");
+
+        final int c = editor.addIsolatedNode(5, 2);
+        final int d = editor.addIsolatedNode(7, 2);
+        assertThrows(IllegalArgumentException.class, () -> editor.addEdge(c, d), "Crossing an edge.");
+        final int g = editor.addIsolatedNode(1, 1);
+        final int h = editor.addIsolatedNode(3, 1);
+        assertThrows(IllegalArgumentException.class, () -> editor.addEdge(g, h), "Inside the face.");
+        assertThrows(IllegalArgumentException.class, () -> editor.addEdge(0, 2), "Diagonal of the face.");
+
+        editor.removeEdge(edge);
+        assertNull(complex.getEdge(edge));
+        assertNotNull(complex.getNode(a));
+        assertTrue(complex.getNode(a).getCoBoundary().isEmpty());
+        assertThrows(IllegalArgumentException.class, () -> editor.removeEdge(0), "On the boundary of a face.");
+    }
+
+    /**
+     * Splitting an edge of a face inserts a node in the ring of the face, and healing restores the edge.
+     */
+    @Test
+    public void testSplitAndHealEdge() {
+        final IndexedComplex complex = square();
+        final ComplexEditor editor = new ComplexEditor(complex);
+        final int node = editor.splitEdge(0, 2, 0);
+        final int added = complex.getEdgeCount() - 1;
+        final Face face = complex.getFace(0);
+        assertEquals(5, face.getBoundary().size());
+        assertTrue(face.getExpression().getBoundary().getBoundary().isZero());
+        assertEquals(6, ((Polygon) face.getGeometry()).getExteriorRing().getDataPoints().size());
+        assertEquals(Set.of(complex.getEdge(0), complex.getEdge(added).getNegate()),
+                     Set.copyOf(complex.getNode(node).getCoBoundary()), "Ending at the node, then starting from it.");
+        assertEquals(List.of(face), complex.getEdge(added).getCoBoundary());
+        assertThrows(IllegalArgumentException.class, () -> editor.splitEdge(1, 5, 1), "Not on the edge.");
+        assertThrows(IllegalArgumentException.class, () -> editor.healEdges(0, 1), "Other edges at the shared node.");
+
+        editor.healEdges(0, added);
+        assertNull(complex.getNode(node));
+        assertNull(complex.getEdge(added));
+        assertEquals(4, face.getBoundary().size());
+        assertArrayEquals(new double[] {4, 0}, coordinates(complex.getEdge(0).getBoundary().get(1).getTopo()));
+    }
+
+    /**
+     * Splitting an edge of a solid inserts a node in both faces around the edge, and keeps the solid closed.
+     */
+    @Test
+    public void testSplitEdgeOfSolid() {
+        final IndexedComplex complex = build(SolidFixtures.box(0, 0, 0, 1, 1, 1));
+        final ComplexEditor editor = new ComplexEditor(complex);
+        final double[] c = positions((LineString) complex.getEdge(0).getGeometry());
+        editor.splitEdge(0, (c[0] + c[3]) / 2, (c[1] + c[4]) / 2, (c[2] + c[5]) / 2);
+        final int added = complex.getEdgeCount() - 1;
+        for (final DirectedFace face : complex.getEdge(added).getCoBoundary()) {
+            assertEquals(5, face.getBoundary().size());
+        }
+        final Solid solid = complex.getSolid(0);
+        assertClosed(solid);
+        assertEquals(1, volume(solid), EPS);
+
+        editor.healEdges(0, added);
+        assertClosed(solid);
+        assertEquals(8 + 12 + 6 + 1, complex.getElements().size());
+    }
+
+    /**
+     * Splitting a face by a diagonal gives two faces sharing the diagonal, and merging restores the face.
+     */
+    @Test
+    public void testSplitAndMergeFaces() {
+        final IndexedComplex complex = square();
+        final ComplexEditor editor = new ComplexEditor(complex);
+        final int created = editor.splitFace(0, 0, 2);
+        final int diagonal = complex.getEdgeCount() - 1;
+        assertEquals(3, complex.getFace(0).getBoundary().size());
+        assertEquals(3, complex.getFace(created).getBoundary().size());
+        final List<DirectedFace> faces = complex.getEdge(diagonal).getCoBoundary();
+        assertEquals(2, faces.size());
+        assertNotEquals(faces.get(0).getOrientationSign(), faces.get(1).getOrientationSign());
+        assertEquals(4, complex.getBoundary().size(), "The diagonal cancels out.");
+        assertThrows(IllegalArgumentException.class, () -> editor.splitFace(0, 0, 1), "Already an edge.");
+        assertThrows(IllegalArgumentException.class, () -> editor.splitFace(0, 0, 3), "Not on the face.");
+
+        editor.mergeFaces(diagonal);
+        assertNull(complex.getFace(created));
+        assertNull(complex.getEdge(diagonal));
+        assertEquals(4, complex.getFace(0).getBoundary().size());
+        assertTrue(complex.getFace(0).getExpression().getBoundary().getBoundary().isZero());
+        assertThrows(IllegalArgumentException.class, () -> editor.mergeFaces(0), "A single face around the edge.");
+    }
+
+    /**
+     * Splitting a face by a path through isolated nodes, which are moved to the face containing them.
+     */
+    @Test
+    public void testSplitFaceByPath() {
+        final IndexedComplex complex = square();
+        final ComplexEditor editor = new ComplexEditor(complex);
+        final int middle = editor.addIsolatedNode(2, 1);
+        final int upper  = editor.addIsolatedNode(1, 3);
+        final int lower  = editor.addIsolatedNode(3, 1);
+        final int created = editor.splitFace(0, 0, middle, 2);
+        assertEquals(4, complex.getFace(0).getBoundary().size());
+        assertEquals(4, complex.getFace(created).getBoundary().size());
+        assertNull(complex.getNode(middle).getContainer());
+        assertEquals(2, complex.getNode(middle).getCoBoundary().size());
+        assertEquals(complex.getFace(created), complex.getNode(upper).getContainer());
+        assertEquals(complex.getFace(0), complex.getNode(lower).getContainer());
+        assertThrows(IllegalArgumentException.class, () -> editor.splitFace(0, 1, lower, 3), "Not on the same face.");
+    }
+
+    /**
+     * Splitting a face by a closed path gives a face inside a hole of the face.
+     */
+    @Test
+    public void testSplitFaceByClosedPath() {
+        final IndexedComplex complex = square();
+        final ComplexEditor editor = new ComplexEditor(complex);
+        final int a = editor.addIsolatedNode(1, 1);
+        final int b = editor.addIsolatedNode(3, 1);
+        final int c = editor.addIsolatedNode(3, 3);
+        final int d = editor.addIsolatedNode(1, 3);
+        final int center = editor.addIsolatedNode(2, 2);
+        final int created = editor.splitFace(0, a, b, c, d, a);
+        assertEquals(1, complex.getFace(0).getInteriorRings().size());
+        assertEquals(4, complex.getFace(created).getExteriorRing().size());
+        assertEquals(complex.getFace(created), complex.getNode(center).getContainer());
+        assertEquals(4, complex.getBoundary().size(), "The hole and the inner face cancel out.");
+        assertTrue(complex.isConnected());
+        final int edge = complex.getEdgeCount() - 1;
+        assertThrows(IllegalArgumentException.class, () -> editor.mergeFaces(edge), "The faces share other edges.");
+    }
+
+    /**
+     * Splitting a face of a solid adds the new face to the shell of the solid.
+     */
+    @Test
+    public void testSplitFaceOfSolid() {
+        final IndexedComplex complex = build(SolidFixtures.box(0, 0, 0, 1, 1, 1));
+        final ComplexEditor editor = new ComplexEditor(complex);
+        final int[] corners = complex.nodes(complex.faceRings[5][0]);
+        final int created = editor.splitFace(5, corners[0], corners[2]);
+        final Solid solid = complex.getSolid(0);
+        assertEquals(7, solid.getBoundary().size());
+        assertEquals(List.of(solid), complex.getFace(created).getCoBoundary());
+        assertClosed(solid);
+        assertEquals(1, volume(solid), EPS);
+
+        editor.mergeFaces(complex.getEdgeCount() - 1);
+        assertEquals(6, solid.getBoundary().size());
+        assertClosed(solid);
+        assertEquals(1, volume(solid), EPS);
+    }
+
+    /**
+     * Faces are added on existing edges, with the nodes inside them isolated, and removed.
+     */
+    @Test
+    public void testAddAndRemoveFace() {
+        final IndexedComplex complex = build(line(CRS2D, 0,0, 4,0), line(CRS2D, 4,0, 4,4), line(CRS2D, 4,4, 0,4),
+                line(CRS2D, 0,4, 0,0), GeometryFactory.DEFAULT.createPoint(CRS2D, 1, 1));
+        assertEquals(0, complex.getFaceCount());
+        final ComplexEditor editor = new ComplexEditor(complex);
+        final int face = editor.addFace(new int[] {0, 1, 2, 3});
+        assertEquals(complex.getFace(face), complex.getNode(4).getContainer());
+        assertTrue(complex.getFace(face).getExpression().getBoundary().getBoundary().isZero());
+        assertThrows(IllegalArgumentException.class, () -> editor.addFace(new int[] {0, 1, 2, 3}), "Same edges.");
+        assertThrows(IllegalArgumentException.class, () -> editor.addFace(new int[] {0, 1, 3}), "Not closed.");
+
+        editor.removeFace(face);
+        assertNull(complex.getFace(face));
+        assertNull(complex.getNode(4).getContainer());
+        final Edge edge = complex.getEdge(0);
+        assertNotNull(edge);
+        assertTrue(edge.getCoBoundary().isEmpty());
+    }
+
+    /**
+     * Solids are added on existing faces, with the nodes inside them isolated, and removed.
+     */
+    @Test
+    public void testAddAndRemoveSolid() {
+        final IndexedComplex complex = build(SolidFixtures.boxShell(0, 0, 0, 2, 2, 2, true),
+                                             GeometryFactory.DEFAULT.createPoint(CRS3D, 1, 1, 1));
+        assertEquals(0, complex.getSolidCount());
+        final ComplexEditor editor = new ComplexEditor(complex);
+        int solid = editor.addSolid(new int[] {0, 1, 2, 3, 4, 5});
+        assertEquals(complex.getSolid(solid), complex.getNode(8).getContainer());
+        assertClosed(complex.getSolid(solid));
+        assertEquals(8, volume(complex.getSolid(solid)), EPS);
+
+        editor.removeSolid(solid);
+        assertNull(complex.getNode(8).getContainer());
+        assertThrows(IllegalArgumentException.class, () -> editor.addSolid(new int[] {0, 1, 2, 3, 4}), "Open shell.");
+
+        solid = editor.addSolid(new int[] {~0, ~1, ~2, ~3, ~4, ~5});
+        assertEquals(8, volume(complex.getSolid(solid)), EPS, "The shell shall be reversed.");
+        assertThrows(IllegalArgumentException.class, () -> editor.addSolid(new int[] {0, 1, 2, 3, 4, 5}), "Same side.");
+    }
+
+    /**
+     * The geometry of the complex after an edit is made of the realizations of its primitives.
+     */
+    @Test
+    public void testGeometryAfterEdit() {
+        final IndexedComplex complex = square();
+        new ComplexEditor(complex).addIsolatedNode(6, 6);
+        assertEquals(2, ((org.apache.sis.geometries.GeometryCollection<?>) complex.getGeometry()).getNumGeometries());
+        assertEquals(5, ((Polygon) ((org.apache.sis.geometries.GeometryCollection<?>) complex.getGeometry()).getGeometryN(0))
+                .getExteriorRing().getDataPoints().getAttributeArray(DataPointsType.ATT_POSITION).getLength());
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/topology/DefaultExpressionTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/topology/DefaultExpressionTest.java
new file mode 100644
index 0000000000..b3cc0c4487
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/topology/DefaultExpressionTest.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.internal.shared.topology;
+
+import java.util.List;
+import org.apache.sis.geometries.Geometries;
+import org.apache.sis.geometries.GeometryFactory;
+import org.apache.sis.geometries.topology.DirectedTopo;
+import org.apache.sis.geometries.topology.Edge;
+import org.apache.sis.geometries.topology.Expression;
+import org.apache.sis.geometries.topology.ExpressionTerm;
+import org.apache.sis.geometries.topology.Face;
+import org.apache.sis.geometries.topology.Node;
+import org.apache.sis.maths.NDArrays;
+
+// Test dependencies
+import static org.junit.jupiter.api.Assertions.*;
+import org.junit.jupiter.api.Test;
+
+
+/**
+ * Tests {@link DefaultExpression} on the complex of a unit square, whose nodes 0 to 3 are its corners
+ * in counterclockwise order from the origin, and whose edge <var>i</var> goes from node <var>i</var>
+ * to the next node.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public class DefaultExpressionTest {
+
+    /**
+     * Returns the complex of the unit square.
+     */
+    private static IndexedComplex square() {
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        final double[] ring = {0,0, 1,0, 1,1, 0,1, 0,0};
+        return new ComplexBuilder().add(f.createPolygon(f.createLinearRing(f.createDataPoints(
+                NDArrays.of(Geometries.getUndefinedCRS(2), ring))), List.of())).build();
+    }
+
+    /**
+     * Returns the coefficient of the given primitive in the given expression.
+     */
+    private static int coefficient(final Expression expression, final DirectedTopo variable) {
+        for (final ExpressionTerm term : expression.getTerms()) {
+            if (term.getVariable().equals(variable)) {
+                return term.getCoefficient();
+            }
+        }
+        return 0;
+    }
+
+    /**
+     * Terms of the same primitive are combined, and the orientation is carried by the coefficient.
+     */
+    @Test
+    public void testTerms() {
+        final IndexedComplex complex = square();
+        final Edge e0 = complex.getEdge(0), e1 = complex.getEdge(1);
+        final Expression x = e0.getExpression().plus(e0.getExpression()).minus(e1.getNegate().getExpression());
+        assertEquals(2, x.getTerms().size());
+        assertEquals(2, coefficient(x, e0));
+        assertEquals(1, coefficient(x, e1));
+        assertEquals("+2*Edge[0] +Edge[1]", x.toString());
+        assertEquals(-1, coefficient(e0.getNegate().getExpression(), e0), "Stored on the positive edge.");
+        assertTrue(e0.getExpression().plus(e0.getNegate().getExpression()).isZero());
+        assertEquals("0", DefaultExpression.ZERO.toString());
+    }
+
+    /**
+     * Equality is polynomial equality: the order of the terms does not matter,
+     * and a term is equal to the expression of that single term.
+     */
+    @Test
+    public void testEquals() {
+        final IndexedComplex complex = square();
+        final Expression a = complex.getEdge(0).getExpression();
+        final Expression b = complex.getEdge(1).getNegate().getExpression();
+        assertEquals(a.plus(b), b.plus(a));
+        assertEquals(a.plus(b).hashCode(), b.plus(a).hashCode());
+        assertNotEquals(a.plus(b), a.minus(b));
+        assertEquals(DefaultExpression.ZERO, a.minus(a));
+        assertEquals(a.getNegate(), complex.getEdge(0).getNegate().getExpression());
+
+        final ExpressionTerm term = a.getTerms().iterator().next();
+        assertEquals(a, term);
+        assertEquals(term, a);
+        assertEquals(a.hashCode(), term.hashCode());
+    }
+
+    /**
+     * The boundary of an edge is its end minus its start, the boundary of a node is zero,
+     * and the boundary of the boundary of a face is zero.
+     */
+    @Test
+    public void testBoundary() {
+        final IndexedComplex complex = square();
+        final Edge edge = complex.getEdge(0);
+        final Node start = edge.getBoundary().get(0).getTopo();
+        final Node end   = edge.getBoundary().get(1).getTopo();
+        assertEquals(complex.getNode(0), start);
+        assertEquals(complex.getNode(1), end);
+        assertEquals(end.getExpression().minus(start.getExpression()), edge.getExpression().getBoundary());
+        assertTrue(start.getExpression().getBoundary().isZero());
+
+        final Face face = complex.getFace(0);
+        final Expression boundary = face.getExpression().getBoundary();
+        assertEquals(4, boundary.getTerms().size());
+        for (int i = 0; i < 4; i++) {
+            assertEquals(1, coefficient(boundary, complex.getEdge(i)), "The face is on the left of each edge.");
+        }
+        assertTrue(boundary.getBoundary().isZero());
+        assertEquals(boundary.getNegate(), face.getNegate().getExpression().getBoundary());
+    }
+
+    /**
+     * The co-boundary is the transpose of the boundary.
+     */
+    @Test
+    public void testCoBoundary() {
+        final IndexedComplex complex = square();
+        final Face face = complex.getFace(0);
+        for (int i = 0; i < 4; i++) {
+            assertEquals(face.getExpression(), complex.getEdge(i).getExpression().getCoBoundary());
+        }
+        // Edge 3 ends at node 0 and edge 0 starts at it.
+        assertEquals(complex.getEdge(3).getExpression().minus(complex.getEdge(0).getExpression()),
+                     complex.getNode(0).getExpression().getCoBoundary());
+        assertTrue(face.getExpression().getCoBoundary().isZero(), "No solid above or below the face.");
+    }
+
+    /**
+     * The support of a face is the face, its edges and its nodes. As a set of oriented primitives,
+     * each node appears in both orientations, as the start of an edge and the end of another.
+     */
+    @Test
+    public void testSupport() {
+        final IndexedComplex complex = square();
+        final Expression face = complex.getFace(0).getExpression();
+        assertEquals(1 + 4 + 4, face.getSupport().size());
+        assertEquals(1 + 4 + 8, face.asSet().size());
+        assertTrue(face.getSupport().contains(complex.getNode(2)));
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/topology/NodePointsTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/topology/NodePointsTest.java
new file mode 100644
index 0000000000..8bc63a7a34
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/topology/NodePointsTest.java
@@ -0,0 +1,152 @@
+/*
+ * 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.internal.shared.topology;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.geometries.DataPoints;
+import org.apache.sis.geometries.DataPointsType;
+import org.apache.sis.geometries.Geometries;
+import org.apache.sis.geometries.GeometryFactory;
+import org.apache.sis.geometries.Point;
+import org.apache.sis.geometries.mesh.MeshPrimitive;
+import org.apache.sis.geometries.surface.Polygon;
+import org.apache.sis.maths.Array;
+import org.apache.sis.maths.NDArrays;
+import static org.apache.sis.geometries.internal.shared.topology.ComplexBuilderTest.build;
+import static org.apache.sis.geometries.internal.shared.topology.ComplexBuilderTest.polygon;
+
+// Test dependencies
+import static org.junit.jupiter.api.Assertions.*;
+import org.junit.jupiter.api.Test;
+
+
+/**
+ * Tests {@link NodePoints}: the attributes of the geometries are kept by the nodes of a complex,
+ * by the geometric realizations of the primitives, and by the edits.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public class NodePointsTest {
+
+    private static final double EPS = 1E-12;
+
+    private static final CoordinateReferenceSystem CRS2D = Geometries.getUndefinedCRS(2);
+
+    /**
+     * Name of an attribute of one component, interpolated linearly.
+     */
+    private static final String TEMPERATURE = "TEMPERATURE";
+
+    /**
+     * Returns a triangle mesh of the given positions, with one temperature per vertex.
+     */
+    private static MeshPrimitive mesh(final double[] positions, final double[] temperatures, final int... index) {
+        final MeshPrimitive mesh = MeshPrimitive.create(MeshPrimitive.Type.TRIANGLES);
+        mesh.setPositions(NDArrays.of(CRS2D, positions));
+        mesh.setAttribute(TEMPERATURE, NDArrays.of(1, temperatures));
+        mesh.setIndex(NDArrays.ofUnsigned(1, index));
+        return mesh;
+    }
+
+    /**
+     * Returns a point with a temperature.
+     */
+    private static Point point(final double x, final double y, final double temperature) {
+        final Map<String, Array> attributes = new LinkedHashMap<>();
+        attributes.put(DataPointsType.ATT_POSITION, NDArrays.of(CRS2D, new double[] {x, y}));
+        attributes.put(TEMPERATURE, NDArrays.of(1, new double[] {temperature}));
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        return f.createPoint(f.createDataPoints(attributes));
+    }
+
+    /**
+     * Returns the temperature of the given node.
+     */
+    private static double temperature(final IndexedComplex complex, final int node) {
+        return complex.getNodePoints().getAttribute(node, TEMPERATURE).get(0);
+    }
+
+    /**
+     * The attributes of a mesh are kept by the nodes and by the realizations of the faces,
+     * and interpolated when an edge is split.
+     */
+    @Test
+    public void testMeshAttributes() {
+        // The unit square as two triangles, the temperature being 10x + y.
+        final IndexedComplex complex = build(mesh(new double[] {0,0, 1,0, 1,1, 0,1}, new double[] {0, 10, 11, 1}, 0,1,2, 0,2,3));
+        final NodePoints points = complex.getNodePoints();
+        assertEquals(List.of(DataPointsType.ATT_POSITION, TEMPERATURE), points.getAttributeNames());
+        assertEquals(4, points.size());
+        for (int i = 0; i < 4; i++) {
+            final double[] c = complex.getCoordinates(i);
+            assertEquals(10*c[0] + c[1], temperature(complex, i), EPS);
+        }
+        final DataPoints ring = ((Polygon) complex.getFace(0).getGeometry()).getExteriorRing().getDataPoints();
+        assertTrue(ring.getType().getAttributeNames().contains(TEMPERATURE), "The realization keeps the attributes.");
+        for (int i = 0; i < ring.size(); i++) {
+            final double[] c = ring.getPosition(i).toArrayDouble();
+            assertEquals(10*c[0] + c[1], ring.getAttribute(i, TEMPERATURE).get(0), EPS);
+        }
+        final int node = new ComplexEditor(complex).splitEdge(0, 0.25, 0);
+        assertEquals(2.5, temperature(complex, node), EPS, "Interpolated along the edge.");
+    }
+
+    /**
+     * Positions shared by two meshes take the attributes of the first mesh.
+     */
+    @Test
+    public void testFirstValueKept() {
+        final MeshPrimitive first  = mesh(new double[] {0,0, 1,0, 1,1}, new double[] {1, 2, 3}, 0,1,2);
+        final MeshPrimitive second = mesh(new double[] {0,0, 1,1, 0,1}, new double[] {7, 8, 9}, 0,1,2);
+        final IndexedComplex complex = build(first, second);
+        assertEquals(4, complex.getNodeCount());
+        assertEquals(1, temperature(complex, 0), EPS);
+        assertEquals(3, temperature(complex, 2), EPS);
+        assertEquals(9, temperature(complex, 3), EPS);
+    }
+
+    /**
+     * A node without value for an attribute takes it from a later point at the same position,
+     * or has zero values. Points added by the editor keep their attributes.
+     */
+    @Test
+    public void testMissingAttribute() {
+        final Polygon square = polygon(CRS2D, 0,0, 2,0, 2,2, 0,2, 0,0);
+        final IndexedComplex complex = build(square, point(2, 2, 5));
+        final NodePoints points = complex.getNodePoints();
+        assertEquals(4, complex.getNodeCount());
+        assertTrue(points.hasAttribute(2, TEMPERATURE));
+        assertEquals(5, temperature(complex, 2), EPS);
+        assertFalse(points.hasAttribute(0, TEMPERATURE));
+        assertEquals(0, temperature(complex, 0), EPS, "Missing values are zero.");
+
+        final ComplexEditor editor = new ComplexEditor(complex);
+        final int node = editor.addIsolatedNode(point(1, 1, 7));
+        assertEquals(7, temperature(complex, node), EPS);
+        assertEquals(complex.getFace(0), complex.getNode(node).getContainer());
+        final int split = editor.splitEdge(1, 2, 1);
+        assertFalse(points.hasAttribute(split, TEMPERATURE), "Not interpolated when an end has no value.");
+
+        assertThrows(UnsupportedOperationException.class, () -> points.setPosition(0, points.getPosition(1)));
+        points.setAttribute(0, TEMPERATURE, points.getAttribute(2, TEMPERATURE));
+        assertTrue(points.hasAttribute(0, TEMPERATURE));
+        assertEquals(5, temperature(complex, 0), EPS);
+    }
+}


(sis) 13/13: refactor(Vector): remove SampleSystem property

Posted by js...@apache.org.
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

commit f2a9a868bbfab9a3c2b8cff7d7f9d2ae7f7bcf51
Author: jsorel <jo...@geomatys.com>
AuthorDate: Mon Oct 19 16:55:09 2026 +0200

    refactor(Vector): remove SampleSystem property
---
 .../main/org/apache/sis/geometries/BBox.java       |   6 +-
 .../main/org/apache/sis/geometries/DataPoints.java |   5 +-
 .../main/org/apache/sis/geometries/Geometries.java |   5 +-
 .../org/apache/sis/geometries/GeometryFactory.java |  41 +--
 .../main/org/apache/sis/geometries/HyperPlane.java |   7 +-
 .../main/org/apache/sis/geometries/OBBox.java      |  18 +-
 .../main/org/apache/sis/geometries/Plane.java      |   7 +-
 .../main/org/apache/sis/geometries/Point.java      |   4 +-
 .../apache/sis/geometries/adapter/JTSAdapter.java  |   2 +-
 .../org/apache/sis/geometries/adapter/SIS.java     |  19 +-
 .../org/apache/sis/geometries/adapter/Wrapper.java |  10 +-
 .../main/org/apache/sis/geometries/cs/Bearing.java |  22 ++
 .../org/apache/sis/geometries/index/KdTree.java    |   5 +-
 .../internal/shared/AbstractGeometry.java          |  10 +-
 .../internal/shared/BSplineEvaluator.java          |   2 +-
 .../internal/shared/DefaultArcByCenterPoint.java   |   2 +-
 .../geometries/internal/shared/DefaultBearing.java |  13 +-
 .../geometries/internal/shared/DefaultCapsule.java |  30 +-
 .../internal/shared/DefaultCylinder.java           |  30 +-
 .../internal/shared/DefaultNurbCurve.java          |   4 +-
 .../internal/shared/DefaultNurbSurface.java        |  30 +-
 .../geometries/internal/shared/DefaultPoint.java   |   5 +-
 .../sis/geometries/internal/shared/DefaultRay.java |   9 +-
 .../internal/shared/SinglePositionDataPoints.java  |  21 +-
 .../internal/shared/topology/NodePoints.java       |   2 +-
 .../sis/geometries/operation/Intersection.java     |   2 +-
 .../apache/sis/geometries/operation/Transform.java |   6 +-
 .../simplify/greedyinsert/TINBuilder.java          |  40 +--
 .../operation/simplify/greedyinsert/WTriangle.java |   9 +-
 .../operation/triangulate/delaunay/Delaunay.java   |   2 +-
 .../solid/polyhedron/AbstractPolyhedron.java       |   4 +-
 .../org/apache/sis/geometries/surface/Capsule.java |  30 +-
 .../apache/sis/geometries/surface/Cylinder.java    |  48 +--
 .../apache/sis/geometries/surface/PreparedTIN.java |  20 +-
 .../apache/sis/geometries/surface/Triangle.java    |   4 +-
 .../main/org/apache/sis/maths/AbstractCursor.java  |   1 -
 .../main/org/apache/sis/maths/AbstractVector.java  |  50 +--
 .../main/org/apache/sis/maths/Array.java           |   4 +-
 .../main/org/apache/sis/maths/ArrayFactory.java    |   6 +-
 .../org/apache/sis/maths/ArrayFactoryJava.java     |   4 +-
 .../main/org/apache/sis/maths/NDArray.java         |   2 +-
 .../main/org/apache/sis/maths/NDRange.java         |  80 +++--
 .../main/org/apache/sis/maths/ReadOnly.java        |  33 +-
 .../main/org/apache/sis/maths/Sample.java          |  37 +++
 .../main/org/apache/sis/maths/Vector1D.java        | 294 ++---------------
 .../main/org/apache/sis/maths/Vector2D.java        | 310 ++----------------
 .../main/org/apache/sis/maths/Vector3D.java        | 329 ++-----------------
 .../main/org/apache/sis/maths/Vector4D.java        | 347 ++-------------------
 .../main/org/apache/sis/maths/VectorND.java        |  80 +----
 .../org/apache/sis/maths/VectorUnmodifiable.java   |  12 -
 .../main/org/apache/sis/maths/Vectors.java         | 189 +++++------
 .../org/apache/sis/geometries/cs/BearingTest.java  |  27 +-
 .../internal/shared/ArraySequenceTest.java         |  26 +-
 .../internal/shared/CurveLinearizerTest.java       |   6 +-
 .../sis/geometries/mesh/PrimitiveVisitorTest.java  |  14 +-
 .../org/apache/sis/maths/AbstractVectorTest.java   |   2 +-
 .../test/org/apache/sis/maths/NDRangeTest.java     |  10 +-
 .../sis/referencing/dggs/AbstractDggrsTest.java    |   2 +-
 58 files changed, 542 insertions(+), 1797 deletions(-)

diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/BBox.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/BBox.java
index 9130229dbe..c313f5625f 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/BBox.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/BBox.java
@@ -117,7 +117,7 @@ public final class BBox extends GeneralEnvelope implements Geometry {
      * @return a copy of the lower corner.
      */
     public Vector<?> getLower() {
-        final Vector<?> corner = Vectors.create(getCoordinateReferenceSystem(), DataType.DOUBLE);
+        final Vector<?> corner = Vectors.create(getDimension(), DataType.DOUBLE);
         for (int i = 0, n = getDimension(); i < n; i++) {
             corner.set(i, getLower(i));
         }
@@ -131,7 +131,7 @@ public final class BBox extends GeneralEnvelope implements Geometry {
      * @return a copy of the upper corner.
      */
     public Vector<?> getUpper() {
-        final Vector<?> corner = Vectors.create(getCoordinateReferenceSystem(), DataType.DOUBLE);
+        final Vector<?> corner = Vectors.create(getDimension(), DataType.DOUBLE);
         for (int i = 0, n = getDimension(); i < n; i++) {
             corner.set(i, getUpper(i));
         }
@@ -208,7 +208,7 @@ public final class BBox extends GeneralEnvelope implements Geometry {
             case 3: {
                 final var corners = new Vector<?>[8];
                 for (int i = 0; i < corners.length; i++) {
-                    final Vector<?> corner = Vectors.create(crs, DataType.DOUBLE);
+                    final Vector<?> corner = Vectors.create(3, DataType.DOUBLE);
                     for (int k = 0; k < 3; k++) {
                         corner.set(k, ((i & (1 << k)) != 0) ? getMaximum(k) : getMinimum(k));
                     }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/DataPoints.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/DataPoints.java
index f19652805d..40fa689d35 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/DataPoints.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/DataPoints.java
@@ -290,9 +290,10 @@ public interface DataPoints {
         if (isEmpty()) {
             return null;
         }
-        final Vector<?> start = Vectors.create(getType().getAttributeSystem(name), getType().getAttributeType(name))
+        final SampleSystem system = getType().getAttributeSystem(name);
+        final Vector<?> start = Vectors.create(system.getSize(), getType().getAttributeType(name))
                                        .set(getAttribute(0, name));
-        final NDRange env = new NDRange(start, start);
+        final NDRange env = new NDRange(system, start, start);
         for (int i = 1, n = size(); i < n; i++) {
             env.add(getAttribute(i, name));
         }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/Geometries.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/Geometries.java
index 29de0df002..2f612d91ad 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/Geometries.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/Geometries.java
@@ -268,6 +268,7 @@ public final class Geometries {
         final Vector upper = box.getUpper();
 
         final int dim = lower.getDimension();
+        final SampleSystem system = SampleSystem.of(orUndefinedCRS(box.getCoordinateReferenceSystem(), dim));
 
         if (dim == 2) {
             /*
@@ -289,7 +290,7 @@ public final class Geometries {
                 maxx,maxy
             };
 
-            final Array positions = NDArrays.of(SampleSystem.of(lower.getCoordinateReferenceSystem()), values);
+            final Array positions = NDArrays.of(system, values);
             final Array index = NDArrays.ofUnsigned(1, new int[]{0,1,2,3});
 
             final MeshPrimitive primitive = new MeshPrimitive.TriangleStrip();
@@ -314,7 +315,7 @@ public final class Geometries {
             final Vector3D.Double uuu = new Vector3D.Double(maxx, maxy, maxz);
             final Vector3D.Double luu = new Vector3D.Double(minx, maxy, maxz);
 
-            final Array positions = NDArrays.of(SampleSystem.of(lower.getCoordinateReferenceSystem()), new double[24*3]);
+            final Array positions = NDArrays.of(system, new double[24*3]);
             //back
             positions.set(0, lll);
             positions.set(1, ull);
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/GeometryFactory.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/GeometryFactory.java
index e1c3ed0a21..6d9942180a 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/GeometryFactory.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/GeometryFactory.java
@@ -581,7 +581,7 @@ public final class GeometryFactory {
     /**
      * Creates a rational or polynomial parametric surface represented by control points, b-spline
      * basis functions and possibly weights. For the rational flavour carrying a weight per control
-     * point, see {@link #createNurbSurface(Vector[][], double[][], double[], double[], int)}.
+     * point, see {@link #createNurbSurface(CoordinateReferenceSystem, Vector[][], double[][], double[], double[], int)}.
      *
      * @param  points         positions at the knots of the parameter grid, in row-major order.
      *                        There shall be {@code rows} × {@code columns} of them.
@@ -608,6 +608,7 @@ public final class GeometryFactory {
      * Creates a NURBS surface, i.e. the tensor product of two directions each having its own knot
      * vector, over a grid of weighted control points.
      *
+     * @param  crs            reference system of the control points, or {@code null} for an undefined one.
      * @param  controlPoints  control points as a grid, the first index running along <var>u</var>
      *                        and the second one along <var>v</var>.
      * @param  weights        weight of each control point, in the same layout as the control points.
@@ -615,10 +616,10 @@ public final class GeometryFactory {
      * @param  knotsV         knot values along the <var>v</var> parameter.
      * @param  degree         degree of the basis functions.
      */
-    public NurbSurface createNurbSurface(Vector<?>[][] controlPoints, double[][] weights,
+    public NurbSurface createNurbSurface(CoordinateReferenceSystem crs, Vector<?>[][] controlPoints, double[][] weights,
             double[] knotsU, double[] knotsV, int degree)
     {
-        return new DefaultNurbSurface(controlPoints, weights, knotsU, knotsV, degree);
+        return new DefaultNurbSurface(crs, controlPoints, weights, knotsU, knotsV, degree);
     }
 
     /**
@@ -653,14 +654,14 @@ public final class GeometryFactory {
 
     /**
      * Creates a capsule whose capping spheres are centered on the two given positions.
-     * The reference system of the capsule is the one of those positions.
      *
+     * @param  crs     reference system of the capsule, or {@code null} for an undefined one.
      * @param  bottom  the center of the bottom sphere, not null.
      * @param  top     the center of the top sphere, not null.
      * @return the capsule between the two given positions.
      */
-    public Capsule createCapsule(Vector<?> bottom, Vector<?> top) {
-        return new DefaultCapsule(bottom, top);
+    public Capsule createCapsule(CoordinateReferenceSystem crs, Vector<?> bottom, Vector<?> top) {
+        return new DefaultCapsule(crs, bottom, top);
     }
 
     /**
@@ -678,14 +679,14 @@ public final class GeometryFactory {
 
     /**
      * Creates a cylinder along the axis joining the two given positions.
-     * The reference system of the cylinder is the one of those positions.
      *
+     * @param  crs     reference system of the cylinder, or {@code null} for an undefined one.
      * @param  bottom  the center of the bottom circle, not null.
      * @param  top     the center of the top circle, not null.
      * @return the cylinder between the two given positions.
      */
-    public Cylinder createCylinder(Vector<?> bottom, Vector<?> top) {
-        return new DefaultCylinder(bottom, top);
+    public Cylinder createCylinder(CoordinateReferenceSystem crs, Vector<?> bottom, Vector<?> top) {
+        return new DefaultCylinder(crs, bottom, top);
     }
 
     /**
@@ -823,14 +824,14 @@ public final class GeometryFactory {
     /**
      * Creates a ray starting from the given position.
      * A ray is a line which extends to infinity in one direction only.
-     * The reference system of the ray is the one of the given position.
      *
+     * @param  crs        reference system of the ray, or {@code null} for an undefined one.
      * @param  position   the position the ray starts from, not null.
      * @param  direction  the direction the ray extends toward, not null.
      * @return the ray starting from the given position.
      */
-    public Ray createRay(Vector<?> position, Vector<?> direction) {
-        return new DefaultRay(position, direction);
+    public Ray createRay(CoordinateReferenceSystem crs, Vector<?> position, Vector<?> direction) {
+        return new DefaultRay(crs, position, direction);
     }
 
     /**
@@ -849,17 +850,17 @@ public final class GeometryFactory {
     /**
      * Creates a plane centered on the given position and perpendicular to the given normal.
      * A plane is a sheet with no side, both of its faces belonging to it; for a surface which
-     * divides the space in two, see {@link #createHyperPlane(Vector, Vector)}. The plane is
+     * divides the space in two, see {@link #createHyperPlane(CoordinateReferenceSystem, Vector, Vector)}. The plane is
      * unbounded until {@link Plane#setSizeX(double)} and {@link Plane#setSizeZ(double)} give it
      * finite extents, making it a quad.
-     * The reference system of the plane is the one of the given position.
      *
+     * @param  crs       reference system of the plane, or {@code null} for an undefined one.
      * @param  position  the position the plane is centered on, not null.
      * @param  normal    the direction the plane is perpendicular to, not null.
      * @return the plane centered on the given position.
      */
-    public Plane createPlane(Vector<?> position, Vector<?> normal) {
-        return new Plane(position, normal);
+    public Plane createPlane(CoordinateReferenceSystem crs, Vector<?> position, Vector<?> normal) {
+        return new Plane(crs, position, normal);
     }
 
     /**
@@ -877,16 +878,16 @@ public final class GeometryFactory {
 
     /**
      * Creates a hyperplane passing by the given position and perpendicular to the given normal.
-     * Unlike a {@linkplain #createPlane(Vector, Vector) plane}, a hyperplane divides the geometric
+     * Unlike a {@linkplain #createPlane(CoordinateReferenceSystem, Vector, Vector) plane}, a hyperplane divides the geometric
      * space in two, and therefore has a side.
-     * The reference system of the hyperplane is the one of the given position.
      *
+     * @param  crs       reference system of the hyperplane, or {@code null} for an undefined one.
      * @param  position  a position the hyperplane passes by, not null.
      * @param  normal    the direction the hyperplane is perpendicular to, not null.
      * @return the hyperplane passing by the given position.
      */
-    public HyperPlane createHyperPlane(Vector<?> position, Vector<?> normal) {
-        return new HyperPlane(position, normal);
+    public HyperPlane createHyperPlane(CoordinateReferenceSystem crs, Vector<?> position, Vector<?> normal) {
+        return new HyperPlane(crs, position, normal);
     }
 
     /**
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/HyperPlane.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/HyperPlane.java
index 8fec14a709..60474a9ac6 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/HyperPlane.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/HyperPlane.java
@@ -25,6 +25,7 @@ import org.apache.sis.geometries.internal.shared.AbstractGeometry;
 import org.apache.sis.maths.NDArrays;
 import org.apache.sis.maths.NDRange;
 import org.apache.sis.maths.ReadOnly;
+import org.apache.sis.maths.SampleSystem;
 import org.apache.sis.maths.Vector;
 
 
@@ -46,13 +47,13 @@ public final class HyperPlane extends AbstractGeometry{
 
     /**
      * Creates a hyperplane passing by the given position.
-     * The reference system of the hyperplane is the one of that position.
      *
+     * @param  crs       reference system of the hyperplane, or {@code null} for an undefined one.
      * @param  position  a position the hyperplane passes by, not null.
      * @param  normal    the direction the hyperplane is perpendicular to, not null.
      */
-    public HyperPlane(Vector<?> position, Vector<?> normal) {
-        points = GeometryFactory.DEFAULT.createDataPoints(NDArrays.of(position.getSampleSystem(), position.getDataType(), 1));
+    public HyperPlane(CoordinateReferenceSystem crs, Vector<?> position, Vector<?> normal) {
+        points = GeometryFactory.DEFAULT.createDataPoints(NDArrays.of(SampleSystem.of(Geometries.orUndefinedCRS(crs, position.getDimension())), position.getDataType(), 1));
         points.setPosition(0, position);
         this.normal = normal;
     }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/OBBox.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/OBBox.java
index 337c2ad803..40e5a5b315 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/OBBox.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/OBBox.java
@@ -34,7 +34,8 @@ import org.apache.sis.maths.Vector;
  */
 public final class OBBox extends AbstractGeometry {
 
-    private Vector<?> center;
+    private CoordinateReferenceSystem crs;
+    private final Vector<?> center;
     private final Vector<?> xAxis;
     private final Vector<?> yAxis;
     private final Vector<?> zAxis;
@@ -53,7 +54,8 @@ public final class OBBox extends AbstractGeometry {
         if (crs.getCoordinateSystem().getDimension() != 3) {
             throw new IllegalArgumentException("Oriented BBox only support 3 dimensions.");
         }
-        center = Vectors.createDouble(crs);
+        this.crs = crs;
+        center = Vectors.createDouble(3);
         xAxis = Vectors.createDouble(3);
         yAxis = Vectors.createDouble(3);
         zAxis = Vectors.createDouble(3);
@@ -76,7 +78,7 @@ public final class OBBox extends AbstractGeometry {
 
     @Override
     public CoordinateReferenceSystem getCoordinateReferenceSystem() {
-        return center.getCoordinateReferenceSystem();
+        return crs;
     }
 
     @Override
@@ -84,9 +86,7 @@ public final class OBBox extends AbstractGeometry {
         if (cs.getCoordinateSystem().getDimension() != getCoordinateReferenceSystem().getCoordinateSystem().getDimension()) {
             throw new IllegalArgumentException("New CRS dimension must be the same as previous CRS");
         }
-        Vector v = Vectors.create(cs, center.getDataType());
-        v.set(center);
-        center = v;
+        crs = cs;
     }
 
     @Override
@@ -130,8 +130,8 @@ public final class OBBox extends AbstractGeometry {
         final Vector<?> nxAxis = xAxis.copy().scale(-1);
         final Vector<?> nyAxis = yAxis.copy().scale(-1);
         final Vector<?> nzAxis = zAxis.copy().scale(-1);
-        final Vector<?> start = Vectors.create(getCoordinateReferenceSystem(), DataType.DOUBLE).set(center);
-        final NDRange env = new NDRange(start, start);
+        final Vector<?> start = Vectors.create(getDimension(), DataType.DOUBLE).set(center);
+        final NDRange env = new NDRange(crs, start, start);
         final Vector<?> corner = center.copy();
         env.add(corner.set(center).add( xAxis).add( yAxis).add( zAxis));
         env.add(corner.set(center).add( xAxis).add( yAxis).add(nzAxis));
@@ -184,7 +184,7 @@ public final class OBBox extends AbstractGeometry {
      */
     @Override
     public DataPoints getDataPoints() {
-        return new SinglePositionDataPoints(this::getCenter);
+        return new SinglePositionDataPoints(this::getCoordinateReferenceSystem, this::getCenter);
     }
 
 }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/Plane.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/Plane.java
index 2e6cc3910a..c52bd066b6 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/Plane.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/Plane.java
@@ -25,6 +25,7 @@ import org.apache.sis.geometries.internal.shared.AbstractGeometry;
 import org.apache.sis.maths.NDArrays;
 import org.apache.sis.maths.NDRange;
 import org.apache.sis.maths.ReadOnly;
+import org.apache.sis.maths.SampleSystem;
 import org.apache.sis.maths.Vector;
 
 
@@ -58,13 +59,13 @@ public final class Plane extends AbstractGeometry{
 
     /**
      * Creates a plane centered on the given position.
-     * The reference system of the plane is the one of that position.
      *
+     * @param  crs       reference system of the plane, or {@code null} for an undefined one.
      * @param  position  the position the plane is centered on, not null.
      * @param  normal    the direction the plane is perpendicular to, not null.
      */
-    public Plane(Vector<?> position, Vector<?> normal) {
-        points = GeometryFactory.DEFAULT.createDataPoints(NDArrays.of(position.getSampleSystem(), position.getDataType(), 1));
+    public Plane(CoordinateReferenceSystem crs, Vector<?> position, Vector<?> normal) {
+        points = GeometryFactory.DEFAULT.createDataPoints(NDArrays.of(SampleSystem.of(Geometries.orUndefinedCRS(crs, position.getDimension())), position.getDataType(), 1));
         points.setPosition(0, position);
         this.normal = normal;
     }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/Point.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/Point.java
index 5ae7de3749..55d21e78ea 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/Point.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/Point.java
@@ -260,8 +260,8 @@ public sealed interface Point extends Primitive
 
     @Override
     default Envelope getEnvelope() {
-        final Vector<?> position = Vectors.create(getCoordinateReferenceSystem(), DataType.DOUBLE).set(getPosition());
-        return new NDRange(position, position);
+        final Vector<?> position = Vectors.create(getDimension(), DataType.DOUBLE).set(getPosition());
+        return new NDRange(getCoordinateReferenceSystem(), position, position);
     }
 
     /**
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/adapter/JTSAdapter.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/adapter/JTSAdapter.java
index c11283ebb6..11360b1226 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/adapter/JTSAdapter.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/adapter/JTSAdapter.java
@@ -265,7 +265,7 @@ public final class JTSAdapter {
 
         @Override
         public Vector getPosition(int index) {
-            final Vector v = Vectors.create(crs, DataType.DOUBLE);
+            final Vector v = Vectors.create(crs.getCoordinateSystem().getDimension(), DataType.DOUBLE);
             for (int i = 0; i < dim; i++) {
                 v.set(i, jts.getOrdinate(index, i));
             }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/adapter/SIS.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/adapter/SIS.java
index 1b6488e9bb..5d7800bed9 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/adapter/SIS.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/adapter/SIS.java
@@ -607,10 +607,18 @@ public final class SIS extends org.apache.sis.geometry.wrapper.Geometries<Geomet
         // The ClassCastException that may happen here is part of method contract.
         final Collection<?> source = (components instanceof Collection<?> c) ? c : Arrays.asList((Object[]) components);
         final var tuples = new ArrayList<Vector<?>>(source.size());
+        CoordinateReferenceSystem crs = null;
         for (final Object element : source) {
             final Object component = implementation(element);
-            // The ClassCastException that may happen here is part of method contract.
-            tuples.add((component instanceof Point point) ? point.getPosition() : (Vector<?>) component);
+            if (component instanceof Point point) {
+                if (crs == null) {
+                    crs = point.getCoordinateReferenceSystem();
+                }
+                tuples.add(point.getPosition());
+            } else {
+                // The ClassCastException that may happen here is part of method contract.
+                tuples.add((Vector<?>) component);
+            }
         }
         if (tuples.isEmpty()) {
             /*
@@ -620,8 +628,13 @@ public final class SIS extends org.apache.sis.geometry.wrapper.Geometries<Geomet
              */
             return GeometryFactory.DEFAULT.createDataPoints(NDArrays.of(undefinedSystem(BIDIMENSIONAL), DataType.DOUBLE, 0));
         }
+        /*
+         * Vectors do not carry a reference system. It is taken from the first point if any,
+         * otherwise the positions are assigned an undefined system of their dimension.
+         */
         final Vector<?> first = tuples.get(0);
-        return GeometryFactory.DEFAULT.createDataPoints(NDArrays.of(tuples, first.getSampleSystem(), first.getDataType()));
+        final SampleSystem system = (crs != null) ? SampleSystem.of(crs) : undefinedSystem(first.getDimension());
+        return GeometryFactory.DEFAULT.createDataPoints(NDArrays.of(tuples, system, first.getDataType()));
     }
 
     /**
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/adapter/Wrapper.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/adapter/Wrapper.java
index b73631a02b..40e7da88eb 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/adapter/Wrapper.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/adapter/Wrapper.java
@@ -55,6 +55,7 @@ import org.apache.sis.geometry.wrapper.GeometryType;
 import org.apache.sis.geometry.wrapper.GeometryWrapper;
 import org.apache.sis.maths.Array;
 import org.apache.sis.maths.NDArrays;
+import org.apache.sis.maths.SampleSystem;
 import org.apache.sis.maths.Vectors;
 import org.apache.sis.maths.Vector;
 import org.apache.sis.measure.Quantities;
@@ -215,7 +216,7 @@ public final class Wrapper extends GeometryWrapper {
             // The centroid of an empty geometry is the empty geometry, which is no position.
             return null;
         }
-        return new GeneralDirectPosition(Vectors.asDirectPostion(centroid.getPosition()));
+        return new GeneralDirectPosition(Vectors.asDirectPostion(centroid.getPosition(), centroid.getCoordinateReferenceSystem()));
     }
 
     /**
@@ -306,10 +307,9 @@ add:    for (Geometry next = geometry;;) {
      * @param  lines        where to add the line string, if such object is created.
      * @param  coordinates  positions of the line string to create. This list is cleared by this method.
      */
-    private static void addPolyline(final List<LineString> lines, final List<Vector<?>> coordinates) {
+    private void addPolyline(final List<LineString> lines, final List<Vector<?>> coordinates) {
         if (coordinates.size() >= 2) {
-            final Vector<?> first = coordinates.get(0);
-            final Array positions = NDArrays.of(coordinates, first.getSampleSystem(), first.getDataType());
+            final Array positions = NDArrays.of(coordinates, SampleSystem.of(crs), coordinates.get(0).getDataType());
             lines.add(GeometryFactory.DEFAULT.createLineString(GeometryFactory.DEFAULT.createDataPoints(positions)));
         }
         coordinates.clear();
@@ -324,7 +324,7 @@ add:    for (Geometry next = geometry;;) {
      * @param  coordinates  positions accumulated so far, cleared by this method if they are used.
      * @param  line         the line string to append.
      */
-    private static void addPolyline(final List<LineString> lines, final List<Vector<?>> coordinates, final LineString line) {
+    private void addPolyline(final List<LineString> lines, final List<Vector<?>> coordinates, final LineString line) {
         if (coordinates.isEmpty()) {
             lines.add(line);
         } else {
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/cs/Bearing.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/cs/Bearing.java
index ab484af18c..8a53fe340e 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/cs/Bearing.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/cs/Bearing.java
@@ -21,6 +21,8 @@ import javax.measure.quantity.Angle;
 import static org.opengis.annotation.Specification.ISO_19107;
 import org.opengis.annotation.UML;
 import org.apache.sis.geometries.internal.shared.DefaultBearing;
+import org.apache.sis.maths.Sample;
+import org.apache.sis.maths.SampleSystem;
 import org.apache.sis.maths.Vector;
 
 
@@ -204,6 +206,26 @@ public interface Bearing extends ReferenceDirection {
      */
     static Bearing ofDirection(final Vector<?> direction,
                                final ReferenceDirection reference, final Rotation rotation)
+    {
+        return ofDirection(new Sample(SampleSystem.ofSize(direction.getDimension()), direction), reference, rotation);
+    }
+
+    /**
+     * Creates a bearing in the direction of the given vector, with the given reference and sense.
+     * The sample system of the given sample locates the axes, so that a bearing has the same azimuth
+     * whatever the order of the axes of its coordinate system.
+     *
+     * @param  direction  vector giving the direction of the bearing with its sample system, not null.
+     * @param  reference  direction from which the azimuth is measured, not null.
+     * @param  rotation   sense in which the azimuth is measured, not null.
+     * @return a bearing in the direction of the given vector.
+     * @throws IllegalArgumentException if the vector has a coordinate which is not finite, if its
+     *         length is zero, or if the reference direction refers to the bearing being created.
+     *
+     * @see ISO 19107:2019 - 6.2.22.6
+     */
+    static Bearing ofDirection(final Sample direction,
+                               final ReferenceDirection reference, final Rotation rotation)
     {
         return new DefaultBearing(direction, reference, rotation);
     }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/index/KdTree.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/index/KdTree.java
index 3d71d4fd91..11ebc9d5d5 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/index/KdTree.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/index/KdTree.java
@@ -23,7 +23,6 @@ import java.util.Iterator;
 import java.util.List;
 import java.util.Map.Entry;
 import org.apache.sis.maths.DataType;
-import org.apache.sis.maths.SampleSystem;
 import org.apache.sis.maths.Vector;
 
 
@@ -362,8 +361,8 @@ public final class KdTree<T> {
         }
 
         @Override
-        public SampleSystem getSampleSystem() {
-            return SampleSystem.ofSize(2);
+        public int getDimension() {
+            return 2;
         }
 
         @Override
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/AbstractGeometry.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/AbstractGeometry.java
index 6eb87eb6a2..6e17156c16 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/AbstractGeometry.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/AbstractGeometry.java
@@ -26,6 +26,7 @@ import org.apache.sis.geometry.GeneralEnvelope;
 import org.apache.sis.maths.Array;
 import org.apache.sis.maths.DataType;
 import org.apache.sis.maths.NDRange;
+import org.apache.sis.maths.SampleSystem;
 import org.apache.sis.maths.Vector;
 import org.apache.sis.maths.Vectors;
 
@@ -121,10 +122,11 @@ public abstract non-sealed class AbstractGeometry implements Geometry {
                 position = Vectors.createDouble(dimension).set(position).scale(1 / weight);
             }
             if (envelope == null) {
-                final Vector<?> start = (points != null)
-                        ? Vectors.create(points.getCoordinateReferenceSystem(), DataType.DOUBLE).set(position)
-                        : position.copy(DataType.DOUBLE);
-                envelope = new NDRange(start, start);
+                final Vector<?> start = position.copy(DataType.DOUBLE);
+                envelope = (points != null)
+                        ? new NDRange(points.getCoordinateReferenceSystem(), start, start)
+                        : new NDRange(homogeneous ? SampleSystem.ofSize(start.getDimension())
+                                                  : controlPoints.getSampleSystem(), start, start);
             } else {
                 envelope.add(position);
             }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/BSplineEvaluator.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/BSplineEvaluator.java
index 378313a124..f9c2318c60 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/BSplineEvaluator.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/BSplineEvaluator.java
@@ -311,7 +311,7 @@ final class BSplineEvaluator {
                 sum[c] += w * positions[i][c];
             }
         }
-        final Vector<?> position = Vectors.create(SampleSystem.of(controls.getCoordinateReferenceSystem()), DataType.DOUBLE);
+        final Vector<?> position = Vectors.create(controls.getDimension(), DataType.DOUBLE);
         for (int c = 0; c < dim; c++) {
             position.set(c, sum[c] / W);
         }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultArcByCenterPoint.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultArcByCenterPoint.java
index 7383ef599e..fe4c02686f 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultArcByCenterPoint.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultArcByCenterPoint.java
@@ -125,7 +125,7 @@ public non-sealed class DefaultArcByCenterPoint extends AbstractGeometry impleme
         double t = Math.toRadians(lo);
         position.set(0, cx + rx * Math.cos(t));
         position.set(1, cy + ry * Math.sin(t));
-        final var envelope = new NDRange(position, position);
+        final var envelope = new NDRange(getCoordinateReferenceSystem(), position, position);
         t = Math.toRadians(hi);
         add(envelope, position, cx + rx * Math.cos(t), cy + ry * Math.sin(t));
         // Any sweep of 360° or more contains the four extremes, so the loop stops after one turn.
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultBearing.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultBearing.java
index 33d25c4834..f972691c76 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultBearing.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultBearing.java
@@ -27,6 +27,7 @@ import org.apache.sis.geometries.cs.Bearing;
 import org.apache.sis.geometries.cs.ReferenceDirection;
 import org.apache.sis.geometries.cs.Rotation;
 import org.apache.sis.maths.DataType;
+import org.apache.sis.maths.Sample;
 import org.apache.sis.maths.SampleSystem;
 import org.apache.sis.maths.Vector;
 import org.apache.sis.maths.Vectors;
@@ -162,14 +163,16 @@ public final class DefaultBearing implements Bearing {
      * that vector allows it. Only the direction of the given vector is retained; its length is
      * normalized to one.
      *
-     * @param  direction  vector giving the direction of the bearing, not null.
+     * @param  direction  vector giving the direction of the bearing, with the sample system in which
+     *                    its axes are located, not null.
      * @param  reference  direction from which the azimuth is measured, not null.
      * @param  rotation   sense in which the azimuth is measured, not null.
      * @throws IllegalArgumentException if the vector has a coordinate which is not finite, if its
      *         length is zero, or if the reference direction refers to this bearing.
      */
-    public DefaultBearing(final Vector<?> direction, final ReferenceDirection reference, final Rotation rotation) {
+    public DefaultBearing(final Sample direction, final ReferenceDirection reference, final Rotation rotation) {
         ArgumentChecks.ensureNonNull("direction", direction);
+        ArgumentChecks.ensureNonNull("direction.values", direction.values);
         ArgumentChecks.ensureNonNull("rotation",  rotation);
         this.reference = validate(reference);
         this.rotation  = rotation;
@@ -178,7 +181,7 @@ public final class DefaultBearing implements Bearing {
          * full precision. The vector is not copied then normalized, because a copy keeps the data
          * type of its source and normalizing an integer vector would round every coordinate.
          */
-        final double[] c = direction.toArrayDouble();
+        final double[] c = direction.values.toArrayDouble();
         for (final double v : c) {
             if (!Double.isFinite(v)) {
                 throw new IllegalArgumentException("The direction of a bearing shall have finite "
@@ -194,7 +197,7 @@ public final class DefaultBearing implements Bearing {
             c[i] /= length;
         }
         coordinates = c;
-        final SampleSystem s = direction.getSampleSystem();
+        final SampleSystem s = direction.sampleSystem;
         system = (s != null) ? s : SampleSystem.ofSize(c.length);
         /*
          * Derive the angles. `atan2(east, north)` and not `atan2(north, east)`, because the angle
@@ -373,7 +376,7 @@ public final class DefaultBearing implements Bearing {
      */
     @Override
     public Vector<?> getDirection() {
-        final Vector<?> v = Vectors.create(system, DataType.DOUBLE);
+        final Vector<?> v = Vectors.create(coordinates.length, DataType.DOUBLE);
         for (int i=0; i<coordinates.length; i++) {
             v.set(i, coordinates[i]);
         }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultCapsule.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultCapsule.java
index d33f5c06e2..f3c6d0d750 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultCapsule.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultCapsule.java
@@ -18,11 +18,13 @@ package org.apache.sis.geometries.internal.shared;
 
 import java.util.Objects;
 import javax.measure.Quantity;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.geometries.DataPoints;
+import org.apache.sis.geometries.Geometries;
 import org.apache.sis.geometries.GeometryFactory;
 import org.apache.sis.geometries.surface.Capsule;
 import org.apache.sis.maths.NDArrays;
-import org.apache.sis.maths.ReadOnly;
+import org.apache.sis.maths.SampleSystem;
 import org.apache.sis.maths.Vector;
 
 
@@ -44,13 +46,13 @@ public non-sealed class DefaultCapsule extends AbstractGeometry implements Capsu
 
     /**
      * Creates a capsule whose capping spheres are centered on the two given positions.
-     * The reference system of the capsule is the one of those positions.
      *
+     * @param  crs     reference system of the capsule, or {@code null} for an undefined one.
      * @param  bottom  the center of the bottom sphere, not null.
      * @param  top     the center of the top sphere, not null.
      */
-    public DefaultCapsule(Vector<?> bottom, Vector<?> top) {
-        points = GeometryFactory.DEFAULT.createDataPoints(NDArrays.of(bottom.getSampleSystem(), bottom.getDataType(), 2));
+    public DefaultCapsule(CoordinateReferenceSystem crs, Vector<?> bottom, Vector<?> top) {
+        points = GeometryFactory.DEFAULT.createDataPoints(NDArrays.of(SampleSystem.of(Geometries.orUndefinedCRS(crs, bottom.getDimension())), bottom.getDataType(), 2));
         points.setPosition(0, bottom);
         points.setPosition(1, top);
     }
@@ -75,26 +77,6 @@ public non-sealed class DefaultCapsule extends AbstractGeometry implements Capsu
         return points;
     }
 
-    @Override
-    public ReadOnly.Vector<?> getBottom() {
-        return points.getPosition(0);
-    }
-
-    @Override
-    public void setBottom(ReadOnly.Vector<?> position) {
-        points.setPosition(0, position);
-    }
-
-    @Override
-    public ReadOnly.Vector<?> getTop() {
-        return points.getPosition(1);
-    }
-
-    @Override
-    public void setTop(ReadOnly.Vector<?> position) {
-        points.setPosition(1, position);
-    }
-
     @Override
     public double getRadiusTop() {
         return radiusTop;
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultCylinder.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultCylinder.java
index 46077c3f8f..b108382edb 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultCylinder.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultCylinder.java
@@ -17,11 +17,13 @@
 package org.apache.sis.geometries.internal.shared;
 
 import java.util.Objects;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.geometries.DataPoints;
+import org.apache.sis.geometries.Geometries;
 import org.apache.sis.geometries.GeometryFactory;
 import org.apache.sis.geometries.surface.Cylinder;
 import org.apache.sis.maths.NDArrays;
-import org.apache.sis.maths.ReadOnly;
+import org.apache.sis.maths.SampleSystem;
 import org.apache.sis.maths.Vector;
 
 
@@ -48,13 +50,13 @@ public non-sealed class DefaultCylinder extends AbstractGeometry implements Cyli
 
     /**
      * Creates a cylinder along the axis joining the two given positions.
-     * The reference system of the cylinder is the one of those positions.
      *
+     * @param  crs     reference system of the cylinder, or {@code null} for an undefined one.
      * @param  bottom  the center of the bottom circle, not null.
      * @param  top     the center of the top circle, not null.
      */
-    public DefaultCylinder(Vector<?> bottom, Vector<?> top) {
-        points = GeometryFactory.DEFAULT.createDataPoints(NDArrays.of(bottom.getSampleSystem(), bottom.getDataType(), 2));
+    public DefaultCylinder(CoordinateReferenceSystem crs, Vector<?> bottom, Vector<?> top) {
+        points = GeometryFactory.DEFAULT.createDataPoints(NDArrays.of(SampleSystem.of(Geometries.orUndefinedCRS(crs, bottom.getDimension())), bottom.getDataType(), 2));
         points.setPosition(0, bottom);
         points.setPosition(1, top);
     }
@@ -79,26 +81,6 @@ public non-sealed class DefaultCylinder extends AbstractGeometry implements Cyli
         return points;
     }
 
-    @Override
-    public ReadOnly.Vector<?> getBottom() {
-        return points.getPosition(0);
-    }
-
-    @Override
-    public void setBottom(ReadOnly.Vector<?> position) {
-        points.setPosition(0, position);
-    }
-
-    @Override
-    public ReadOnly.Vector<?> getTop() {
-        return points.getPosition(1);
-    }
-
-    @Override
-    public void setTop(ReadOnly.Vector<?> position) {
-        points.setPosition(1, position);
-    }
-
     @Override
     public double getRadiusTop() {
         return radiusTop;
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultNurbCurve.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultNurbCurve.java
index 073967c85e..535960b9dd 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultNurbCurve.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultNurbCurve.java
@@ -313,8 +313,8 @@ public non-sealed class DefaultNurbCurve extends AbstractGeometry implements Nur
      */
     @Override
     public NDRange getEnvelope() {
-        final Vector<?> start = Vectors.create(getCoordinateReferenceSystem(), DataType.DOUBLE).set(controlPoints[0]);
-        final NDRange bbox = new NDRange(start, start);
+        final Vector<?> start = Vectors.create(controlPoints[0].getDimension(), DataType.DOUBLE).set(controlPoints[0]);
+        final NDRange bbox = new NDRange(getCoordinateReferenceSystem(), start, start);
         for (final Vector<?> p : controlPoints) {
             bbox.add(p);
         }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultNurbSurface.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultNurbSurface.java
index bb0d07ea84..b114bd4c44 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultNurbSurface.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultNurbSurface.java
@@ -33,6 +33,7 @@ import org.apache.sis.maths.Array;
 import org.apache.sis.maths.DataType;
 import org.apache.sis.maths.NDArrays;
 import org.apache.sis.maths.NDRange;
+import org.apache.sis.maths.SampleSystem;
 import org.apache.sis.maths.Vector;
 import org.apache.sis.maths.Vectors;
 
@@ -45,14 +46,19 @@ import org.apache.sis.maths.Vectors;
  */
 public non-sealed class DefaultNurbSurface extends AbstractGeometry implements NurbSurface {
 
+    /**
+     * Reference system of the control points, or {@code null} if unspecified.
+     */
+    private final CoordinateReferenceSystem crs;
     final Vector<?>[][] controlPoints;
     final double[][] weights;
     final double[] knotsU;
     final double[] knotsV;
     final int degree;
 
-    public DefaultNurbSurface(final Vector<?>[][] controlPoints, final double[][] weights,
+    public DefaultNurbSurface(final CoordinateReferenceSystem crs, final Vector<?>[][] controlPoints, final double[][] weights,
             final double[] knotsU, final double[] knotsV, final int degree) {
+        this.crs = crs;
         this.controlPoints = controlPoints;
         this.weights = weights;
         this.knotsU = knotsU;
@@ -234,7 +240,7 @@ public non-sealed class DefaultNurbSurface extends AbstractGeometry implements N
 
             final DataPoints colDp = new ArrayDataPoints(
                 NDArrays.of(List.of(colCtrl),
-                colCtrl[0].getSampleSystem(),
+                SampleSystem.of(getCoordinateReferenceSystem()),
                 colCtrl[0].getDataType()));
             final DefaultNurbCurve[] halves = new DefaultNurbCurve(colDp, colW, knotsU, degree).subdivide(u);
 
@@ -257,8 +263,8 @@ public non-sealed class DefaultNurbSurface extends AbstractGeometry implements N
         }
 
         return new DefaultNurbSurface[]{
-            new DefaultNurbSurface(leftCtrl, leftW, leftKnots, knotsV, degree),
-            new DefaultNurbSurface(rightCtrl, rightW, rightKnots, knotsV, degree)
+            new DefaultNurbSurface(crs, leftCtrl, leftW, leftKnots, knotsV, degree),
+            new DefaultNurbSurface(crs, rightCtrl, rightW, rightKnots, knotsV, degree)
         };
     }
 
@@ -275,7 +281,7 @@ public non-sealed class DefaultNurbSurface extends AbstractGeometry implements N
 
             final DataPoints dp = new ArrayDataPoints(
                 NDArrays.of(List.of(controlPoints[i]),
-                controlPoints[i][0].getSampleSystem(),
+                SampleSystem.of(getCoordinateReferenceSystem()),
                 controlPoints[i][0].getDataType()));
 
             final DefaultNurbCurve[] halves = new DefaultNurbCurve(dp, weights[i], knotsV, degree).subdivide(v);
@@ -295,8 +301,8 @@ public non-sealed class DefaultNurbSurface extends AbstractGeometry implements N
         }
 
         return new DefaultNurbSurface[]{
-            new DefaultNurbSurface(leftCtrl, leftW, knotsU, leftKnots, degree),
-            new DefaultNurbSurface(rightCtrl, rightW, knotsU, rightKnots, degree)
+            new DefaultNurbSurface(crs, leftCtrl, leftW, knotsU, leftKnots, degree),
+            new DefaultNurbSurface(crs, rightCtrl, rightW, knotsU, rightKnots, degree)
         };
     }
 
@@ -306,8 +312,7 @@ public non-sealed class DefaultNurbSurface extends AbstractGeometry implements N
      */
     @Override
     public CoordinateReferenceSystem getCoordinateReferenceSystem() {
-        final Vector<?> first = controlPoints[0][0];
-        return Geometries.orUndefinedCRS(first.getCoordinateReferenceSystem(), first.getDimension());
+        return Geometries.orUndefinedCRS(crs, controlPoints[0][0].getDimension());
     }
 
     /**
@@ -316,8 +321,8 @@ public non-sealed class DefaultNurbSurface extends AbstractGeometry implements N
      * @return the bounding box of this surface
      */
     public NDRange getEnvelope() {
-        final Vector<?> start = Vectors.create(getCoordinateReferenceSystem(), DataType.DOUBLE).set(controlPoints[0][0]);
-        final NDRange bbox = new NDRange(start, start);
+        final Vector<?> start = Vectors.create(controlPoints[0][0].getDimension(), DataType.DOUBLE).set(controlPoints[0][0]);
+        final NDRange bbox = new NDRange(getCoordinateReferenceSystem(), start, start);
         for (final Vector<?>[] row : controlPoints) {
             for (final Vector<?> p : row) {
                 bbox.add(p);
@@ -392,8 +397,7 @@ public non-sealed class DefaultNurbSurface extends AbstractGeometry implements N
         for (final Vector<?>[] row : controlPoints) {
             positions.addAll(Arrays.asList(row));
         }
-        final Vector<?> first = controlPoints[0][0];
-        return NDArrays.of(positions, first.getSampleSystem(), first.getDataType());
+        return NDArrays.of(positions, SampleSystem.of(getCoordinateReferenceSystem()), controlPoints[0][0].getDataType());
     }
 
     @Override
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultPoint.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultPoint.java
index 027515aae8..b4a43fa944 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultPoint.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultPoint.java
@@ -19,6 +19,7 @@ package org.apache.sis.geometries.internal.shared;
 import java.util.Objects;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.geometries.DataPoints;
+import org.apache.sis.geometries.Geometries;
 import org.apache.sis.geometries.Point;
 import org.apache.sis.geometries.DataPointsType;
 import org.apache.sis.maths.DataType;
@@ -44,8 +45,8 @@ public non-sealed class DefaultPoint extends AbstractGeometry implements Point {
         points = new ArrayDataPoints(NDArrays.of(SampleSystem.of(crs), DataType.DOUBLE, 1));
     }
 
-    public DefaultPoint(Vector position) {
-        points = new ArrayDataPoints(NDArrays.of(position.getSampleSystem(), position.getDataType(), 1));
+    public DefaultPoint(CoordinateReferenceSystem crs, Vector<?> position) {
+        points = new ArrayDataPoints(NDArrays.of(SampleSystem.of(Geometries.orUndefinedCRS(crs, position.getDimension())), position.getDataType(), 1));
         points.setPosition(0, position);
     }
 
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultRay.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultRay.java
index ea76a5c8b9..e4759afce5 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultRay.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultRay.java
@@ -17,11 +17,14 @@
 package org.apache.sis.geometries.internal.shared;
 
 import java.util.Objects;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.geometries.DataPoints;
+import org.apache.sis.geometries.Geometries;
 import org.apache.sis.geometries.GeometryFactory;
 import org.apache.sis.geometries.curve.Ray;
 import org.apache.sis.maths.NDArrays;
 import org.apache.sis.maths.ReadOnly;
+import org.apache.sis.maths.SampleSystem;
 import org.apache.sis.maths.Vector;
 
 
@@ -41,13 +44,13 @@ public non-sealed class DefaultRay extends AbstractGeometry implements Ray {
 
     /**
      * Creates a ray starting from the given position.
-     * The reference system of the ray is the one of that position.
      *
+     * @param  crs        reference system of the ray, or {@code null} for an undefined one.
      * @param  position   the position the ray starts from, not null.
      * @param  direction  the direction the ray extends toward, not null.
      */
-    public DefaultRay(Vector<?> position, Vector<?> direction) {
-        points = GeometryFactory.DEFAULT.createDataPoints(NDArrays.of(position.getSampleSystem(), position.getDataType(), 1));
+    public DefaultRay(CoordinateReferenceSystem crs, Vector<?> position, Vector<?> direction) {
+        points = GeometryFactory.DEFAULT.createDataPoints(NDArrays.of(SampleSystem.of(Geometries.orUndefinedCRS(crs, position.getDimension())), position.getDataType(), 1));
         points.setPosition(0, position);
         this.direction = direction;
     }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/SinglePositionDataPoints.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/SinglePositionDataPoints.java
index 3c31372084..c5452103ed 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/SinglePositionDataPoints.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/SinglePositionDataPoints.java
@@ -46,18 +46,26 @@ public final class SinglePositionDataPoints implements DataPoints {
      */
     private final Supplier<Vector<?>> position;
 
+    /**
+     * How to reach the reference system of the geometry this view is built on.
+     * Vectors do not carry a reference system, so it is read from the geometry.
+     */
+    private final Supplier<CoordinateReferenceSystem> crs;
+
     /**
      * Creates a view over the position the given supplier reads.
      *
+     * @param  crs       how to read the reference system of the geometry, not null.
      * @param  position  how to read the position of the geometry, not null.
      */
-    public SinglePositionDataPoints(final Supplier<Vector<?>> position) {
+    public SinglePositionDataPoints(final Supplier<CoordinateReferenceSystem> crs, final Supplier<Vector<?>> position) {
+        this.crs = crs;
         this.position = position;
     }
 
     @Override
     public CoordinateReferenceSystem getCoordinateReferenceSystem() {
-        return position.get().getSampleSystem().getCoordinateReferenceSystem();
+        return crs.get();
     }
 
     @Override
@@ -68,14 +76,13 @@ public final class SinglePositionDataPoints implements DataPoints {
     /**
      * {@inheritDoc}
      *
-     * <p>The type is derived from the position, which carries its own sample system and data
-     * type, so that this {@code DataPoints} describes the single attribute it holds.</p>
+     * <p>The type is derived from the reference system of the geometry and from the data type
+     * of the position, so that this {@code DataPoints} describes the single attribute it holds.</p>
      */
     @Override
     public DataPointsType getType() {
-        final Vector<?> p = position.get();
-        final SampleSystem ss = p.getSampleSystem();
-        final DataType dt = p.getDataType();
+        final SampleSystem ss = SampleSystem.of(crs.get());
+        final DataType dt = position.get().getDataType();
         final DataPointsType.Template type = new DataPointsType.Template();
         type.addOrReplaceAttribute(DataPointsType.ATT_POSITION, ss, dt);
         return type;
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/NodePoints.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/NodePoints.java
index 40cf41bb02..07c5b95991 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/NodePoints.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/topology/NodePoints.java
@@ -277,7 +277,7 @@ public final class NodePoints implements DataPoints, DataPointsType {
     @Override
     public Vector<?> getPosition(final int index) {
         Objects.checkIndex(index, size);
-        return Vectors.createDouble(crs).set(coordinates, index * dimension);
+        return Vectors.createDouble(dimension).set(coordinates, index * dimension);
     }
 
     /**
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Intersection.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Intersection.java
index 44526319ae..ed0f3884f0 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Intersection.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Intersection.java
@@ -109,7 +109,7 @@ public final class Intersection {
             @Override
             protected void visit(MeshPrimitive.Vertex vertex) {
                 if (isVisited(vertex)) return;
-                final Optional<Point> opt = evaluator.evaluate(vertex.getPosition());
+                final Optional<Point> opt = evaluator.evaluate(vertex.getCoordinateReferenceSystem(), vertex.getPosition());
                 if (opt.isEmpty()) return;
                 final Point point = opt.get();
                 final long index = vertex.getIndex();
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Transform.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Transform.java
index 696c50b5a3..6b777dbeac 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Transform.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Transform.java
@@ -141,10 +141,10 @@ public final class Transform {
 
                 //transform normal and tangent with local matrix at each point
                 if (normals != null || tangents != null) {
-                    final Vector<?> pos = Vectors.create(positions.getSampleSystem(), positions.getDataType());
+                    final Vector<?> pos = Vectors.create(positions.getDimension(), positions.getDataType());
 
-                    final Vector<?> nor = (normals == null) ? null : Vectors.create(normals.getSampleSystem(), normals.getDataType());
-                    final Vector<?> tag = (tangents == null) ? null : Vectors.create(tangents.getSampleSystem(), tangents.getDataType());
+                    final Vector<?> nor = (normals == null) ? null : Vectors.create(normals.getDimension(), normals.getDataType());
+                    final Vector<?> tag = (tangents == null) ? null : Vectors.create(tangents.getDimension(), tangents.getDataType());
                     for (long i = 0, n = positions.getLength(); i < n; i++) {
                         positions.get(i, pos);
                         final MatrixSIS matrix = MatrixSIS.castOrCopy(transform.derivative(Vectors.asDirectPostion(pos)));
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/simplify/greedyinsert/TINBuilder.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/simplify/greedyinsert/TINBuilder.java
index b656b2fb26..8d24cb9b9f 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/simplify/greedyinsert/TINBuilder.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/simplify/greedyinsert/TINBuilder.java
@@ -37,8 +37,8 @@ import org.apache.sis.geometries.surface.Triangle;
 import org.apache.sis.maths.Array;
 import org.apache.sis.maths.Maths;
 import org.apache.sis.maths.NDArrays;
+import org.apache.sis.maths.SampleSystem;
 import org.apache.sis.maths.Vector;
-import org.apache.sis.referencing.CRS;
 import org.apache.sis.util.ArgumentChecks;
 
 
@@ -71,6 +71,7 @@ public final class TINBuilder {
      *     | / |
      *  p2 +---+ p3
      *
+     * @param crs coordinate reference system of the dem corners and of the points to insert, not null
      * @param p0 first dem corner
      * @param p1 second dem corner
      * @param p2 third dem corner
@@ -78,15 +79,9 @@ public final class TINBuilder {
      * @param delta minimum distance to dem to include point
      * @throws OperationException if an invalid geometry state occurs
      */
-    public TINBuilder(Vector p0, Vector p1, Vector p2, Vector p3, double delta, BiFunction<Vector,Triangle,Double> errorCalculator) throws OperationException {
-        this.crs = p0.getCoordinateReferenceSystem();
+    public TINBuilder(CoordinateReferenceSystem crs, Vector p0, Vector p1, Vector p2, Vector p3, double delta, BiFunction<Vector,Triangle,Double> errorCalculator) throws OperationException {
         ArgumentChecks.ensureNonNull("crs", crs);
-        if (  !crs.equals(p1.getCoordinateReferenceSystem())
-           || !crs.equals(p2.getCoordinateReferenceSystem())
-           || !crs.equals(p3.getCoordinateReferenceSystem())
-            ) {
-            throw new IllegalArgumentException("All corner points must have the same crs");
-        }
+        this.crs = crs;
 
         this.delta = delta;
         this.errorCalculator = errorCalculator;
@@ -97,8 +92,8 @@ public final class TINBuilder {
         final Edge e31 = new Edge(p3, p1);
         final Edge e32 = new Edge(p3, p2);
 
-        final WTriangle t0 = new WTriangle(e01,e12,e02, errorCalculator);
-        final WTriangle t1 = new WTriangle(e12,e31,e32, errorCalculator);
+        final WTriangle t0 = new WTriangle(crs, e01,e12,e02, errorCalculator);
+        final WTriangle t1 = new WTriangle(crs, e12,e31,e32, errorCalculator);
 
         e01.t1 = t0;
         e02.t0 = t0;
@@ -123,7 +118,7 @@ public final class TINBuilder {
         final List<Triangle> triangles = new ArrayList<>(finished.size());
         for (int i = 0, n = finished.size(); i < n; i++) {
             final WTriangle t = finished.get(i);
-            final Array positions = NDArrays.of(Arrays.asList(t.p0, t.p1, t.p2, t.p0), t.p0.getSampleSystem(), t.p0.getDataType());
+            final Array positions = NDArrays.of(Arrays.asList(t.p0, t.p1, t.p2, t.p0), SampleSystem.of(crs), t.p0.getDataType());
             final DataPoints points = GeometryFactory.DEFAULT.createDataPoints(positions);
             final LinearRing exterior = GeometryFactory.DEFAULT.createLinearRing(points);
             triangles.add(GeometryFactory.DEFAULT.createTriangle(exterior));
@@ -158,9 +153,6 @@ public final class TINBuilder {
         final Set<Point2D.Double> set = new HashSet<>(pts.size());
         final List<Vector> uniques = new ArrayList<>();
         for (Vector t : pts) {
-            if (!CRS.equivalent(crs, t.getCoordinateReferenceSystem())) {
-                throw new OperationException("Points must have the same crs avec corner points");
-            }
             if (set.add(new Point2D.Double(t.get(0), t.get(1)))) {
                 uniques.add(t);
             } else {
@@ -291,9 +283,9 @@ public final class TINBuilder {
         final Edge e1 = new Edge(p1, c);
         final Edge e2 = new Edge(p2, c);
         //create the 3 new triangles
-        final WTriangle t0 = new WTriangle(a0, e1, e0, errorCalculator);
-        final WTriangle t1 = new WTriangle(a1, e2, e1, errorCalculator);
-        final WTriangle t2 = new WTriangle(a2, e0, e2, errorCalculator);
+        final WTriangle t0 = new WTriangle(crs, a0, e1, e0, errorCalculator);
+        final WTriangle t1 = new WTriangle(crs, a1, e2, e1, errorCalculator);
+        final WTriangle t2 = new WTriangle(crs, a2, e0, e2, errorCalculator);
         //assign edge triangles
         a0.change(t, t0);
         a1.change(t, t1);
@@ -366,8 +358,8 @@ public final class TINBuilder {
             if (t0.e2.hasPoint(edge.p0)) a0 = t0.e2;
             if (t0.e2.hasPoint(edge.p1)) a1 = t0.e2;
         }
-        final WTriangle t0_0 = new WTriangle(e0, a0, e2, errorCalculator);
-        final WTriangle t0_1 = new WTriangle(e1, a1, e2, errorCalculator);
+        final WTriangle t0_0 = new WTriangle(crs, e0, a0, e2, errorCalculator);
+        final WTriangle t0_1 = new WTriangle(crs, e1, a1, e2, errorCalculator);
         //affect triangles to edges
         e0.t0 = t0_0; e0.t1 = t1;
         e1.t0 = t0_1; e1.t1 = t1;
@@ -404,8 +396,8 @@ public final class TINBuilder {
                 if (t1.e2.hasPoint(edge.p1)) a1 = t1.e2;
             }
 
-            final WTriangle t1_0 = new WTriangle(e0, a0, e2, errorCalculator);
-            final WTriangle t1_1 = new WTriangle(e1, a1, e2, errorCalculator);
+            final WTriangle t1_0 = new WTriangle(crs, e0, a0, e2, errorCalculator);
+            final WTriangle t1_1 = new WTriangle(crs, e1, a1, e2, errorCalculator);
             //affect triangles to edges
             e0.change(t1, t1_0);
             e1.change(t1, t1_1);
@@ -485,8 +477,8 @@ public final class TINBuilder {
             t0.obsolete();
             t1.obsolete();
             final Edge e1 = new Edge(p0, p2);
-            final WTriangle t2 = new WTriangle(e1, a0, a1, errorCalculator);
-            final WTriangle t3 = new WTriangle(e1, a2, a3, errorCalculator);
+            final WTriangle t2 = new WTriangle(crs, e1, a0, a1, errorCalculator);
+            final WTriangle t3 = new WTriangle(crs, e1, a2, a3, errorCalculator);
             e1.t0 = t2;
             e1.t1 = t3;
             a0.change(t0, t2);
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/simplify/greedyinsert/WTriangle.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/simplify/greedyinsert/WTriangle.java
index b652f1f1a5..4be2826b03 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/simplify/greedyinsert/WTriangle.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/simplify/greedyinsert/WTriangle.java
@@ -45,7 +45,7 @@ public final class WTriangle extends AbstractGeometry implements Triangle {
     private final LinearRing ring = GeometryFactory.DEFAULT.createLinearRing(new DataPoints() {
         @Override
         public CoordinateReferenceSystem getCoordinateReferenceSystem() {
-            return Geometries.orUndefinedCRS(p0.getCoordinateReferenceSystem(), p0.getDimension());
+            return Geometries.orUndefinedCRS(crs, p0.getDimension());
         }
 
         @Override
@@ -146,11 +146,16 @@ public final class WTriangle extends AbstractGeometry implements Triangle {
     public final Vector<?> p1;
     public final Vector<?> p2;
 
+    /**
+     * Reference system of the triangle points, may be null.
+     */
+    private final CoordinateReferenceSystem crs;
     private final List<Candidate> candidates;
     private Candidate max;
     private final BiFunction<Vector,Triangle,Double> errorCalculator;
 
-    public WTriangle(Edge e0, Edge e1, Edge e2, BiFunction<Vector,Triangle,Double> errorCalculator) {
+    public WTriangle(CoordinateReferenceSystem crs, Edge e0, Edge e1, Edge e2, BiFunction<Vector,Triangle,Double> errorCalculator) {
+        this.crs = crs;
         this.errorCalculator = errorCalculator;
 
         candidates = new ArrayList<>();
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/delaunay/Delaunay.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/delaunay/Delaunay.java
index afd2a15a39..eb74236c50 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/delaunay/Delaunay.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/triangulate/delaunay/Delaunay.java
@@ -250,7 +250,7 @@ public final class Delaunay {
         final Envelope envelope = buffer(points.getEnvelope(), 10.0);
         final double envelopeMinX = envelope.getMinimum(0);
         final double envelopeMinY = envelope.getMinimum(1);
-        final Vector<?> A = Vectors.create(positions.getSampleSystem(), positions.getDataType());
+        final Vector<?> A = Vectors.create(positions.getDimension(), positions.getDataType());
         A.set(0, envelopeMinX);
         A.set(1, envelopeMinY);
         final Vector<?> B = A.copy();
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/solid/polyhedron/AbstractPolyhedron.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/solid/polyhedron/AbstractPolyhedron.java
index a519bec852..f5126408d1 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/solid/polyhedron/AbstractPolyhedron.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/solid/polyhedron/AbstractPolyhedron.java
@@ -185,7 +185,7 @@ public abstract sealed class AbstractPolyhedron extends AbstractGeometry impleme
      * @return unit direction vector
      */
     protected static ReadOnly.Vector<?> fromLatLon(double latRad, double lonRad) {
-        return new Vector3D.Double(SampleSystem.cartesian(3)).setFromLatLon(latRad, lonRad);
+        return new Vector3D.Double().setFromLatLon(latRad, lonRad);
     }
 
     /**
@@ -198,7 +198,7 @@ public abstract sealed class AbstractPolyhedron extends AbstractGeometry impleme
      * @return direction vector
      */
     protected static ReadOnly.Vector<?> fromLatLon(double latRad, double lonRad, double radius) {
-        return new Vector3D.Double(SampleSystem.cartesian(3)).setFromLatLon(latRad, lonRad).scale(radius);
+        return new Vector3D.Double().setFromLatLon(latRad, lonRad).scale(radius);
     }
 
     /**
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/surface/Capsule.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/surface/Capsule.java
index 9bdeee5562..c1e3057e88 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/surface/Capsule.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/surface/Capsule.java
@@ -78,26 +78,6 @@ public sealed interface Capsule extends Surface
         return List.of(SurfaceInterpolation.SPHERICAL, SurfaceInterpolation.CONIC);
     }
 
-    /**
-     * @return the center of the bottom sphere
-     */
-    ReadOnly.Vector<?> getBottom();
-
-    /**
-     * @param position new center of the bottom sphere
-     */
-    void setBottom(ReadOnly.Vector<?> position);
-
-    /**
-     * @return the center of the top sphere
-     */
-    ReadOnly.Vector<?> getTop();
-
-    /**
-     * @param position new center of the top sphere
-     */
-    void setTop(ReadOnly.Vector<?> position);
-
     /**
      * @return capsule top sphere radius
      */
@@ -126,9 +106,9 @@ public sealed interface Capsule extends Surface
      * @return the capsule height
      */
     default double getHeight() {
-        final ReadOnly.Vector<?> bottom = getBottom();
-        final Vector<?> axis = Vectors.create(bottom.getSampleSystem(), DataType.DOUBLE);
-        axis.set(getTop());
+        final ReadOnly.Vector<?> bottom = getDataPoints().getPosition(0);
+        final Vector<?> axis = Vectors.create(bottom.getDimension(), DataType.DOUBLE);
+        axis.set(getDataPoints().getPosition(1));
         axis.subtract(bottom);
         return axis.length();
     }
@@ -143,8 +123,8 @@ public sealed interface Capsule extends Surface
      */
     @Override
     default Envelope getEnvelope() {
-        final ReadOnly.Vector<?> bottom = getBottom();
-        final ReadOnly.Vector<?> top = getTop();
+        final ReadOnly.Vector<?> bottom = getDataPoints().getPosition(0);
+        final ReadOnly.Vector<?> top = getDataPoints().getPosition(1);
         final double radiusBottom = getRadiusBottom();
         final double radiusTop = getRadiusTop();
         final int dim = getDataPoints().getDimension();
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/surface/Cylinder.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/surface/Cylinder.java
index 68033cfb57..174319f877 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/surface/Cylinder.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/surface/Cylinder.java
@@ -36,6 +36,7 @@ import org.apache.sis.maths.DataType;
 import org.apache.sis.maths.NDArrays;
 import org.apache.sis.maths.NDRange;
 import org.apache.sis.maths.ReadOnly;
+import org.apache.sis.maths.SampleSystem;
 import org.apache.sis.maths.Vectors;
 import org.apache.sis.maths.Vector;
 import org.apache.sis.measure.Quantities;
@@ -114,26 +115,6 @@ public sealed interface Cylinder extends Surface
         return false;
     }
 
-    /**
-     * @return the center of the bottom circle
-     */
-    ReadOnly.Vector<?> getBottom();
-
-    /**
-     * @param position new center of the bottom circle
-     */
-    void setBottom(ReadOnly.Vector<?> position);
-
-    /**
-     * @return the center of the top circle
-     */
-    ReadOnly.Vector<?> getTop();
-
-    /**
-     * @param position new center of the top circle
-     */
-    void setTop(ReadOnly.Vector<?> position);
-
     /**
      * @return cylinder top circle radius
      */
@@ -160,9 +141,9 @@ public sealed interface Cylinder extends Surface
      * an axis being real values even when the positions they are derived from are integers.
      */
     private Vector<?> getAxis() {
-        final ReadOnly.Vector<?> bottom = getBottom();
-        final Vector<?> axis = Vectors.create(bottom.getSampleSystem(), DataType.DOUBLE);
-        axis.set(getTop());
+        final ReadOnly.Vector<?> bottom = getDataPoints().getPosition(0);
+        final Vector<?> axis = Vectors.create(bottom.getDimension(), DataType.DOUBLE);
+        axis.set(getDataPoints().getPosition(1));
         axis.subtract(bottom);
         return axis;
     }
@@ -198,8 +179,8 @@ public sealed interface Cylinder extends Surface
      */
     @Override
     default Envelope getEnvelope() {
-        final ReadOnly.Vector<?> bottom = getBottom();
-        final ReadOnly.Vector<?> top = getTop();
+        final ReadOnly.Vector<?> bottom = getDataPoints().getPosition(0);
+        final ReadOnly.Vector<?> top = getDataPoints().getPosition(1);
         final double radiusBottom = getRadiusBottom();
         final double radiusTop = getRadiusTop();
         final Vector<?> axis = getAxis();
@@ -280,7 +261,7 @@ public sealed interface Cylinder extends Surface
         for (int i = 1; i < 3; i++) {
             if (Math.abs(axis.get(i)) < Math.abs(axis.get(k))) k = i;
         }
-        final Vector<?> u = Vectors.create(axis.getSampleSystem(), DataType.DOUBLE);
+        final Vector<?> u = Vectors.create(axis.getDimension(), DataType.DOUBLE);
         u.set(k, 1);
         u.subtract(axis.copy().scale(axis.get(k)));
         u.normalize();
@@ -288,11 +269,12 @@ public sealed interface Cylinder extends Surface
         final var circles = new ArrayList<Circle>(2);
         final double radiusBottom = getRadiusBottom();
         final double radiusTop = getRadiusTop();
+        final SampleSystem ss = SampleSystem.of(crs);
         if (radiusBottom > 0) {
-            circles.add(circle(getBottom(), radiusBottom, u, w));
+            circles.add(circle(ss, getDataPoints().getPosition(0), radiusBottom, u, w));
         }
         if (radiusTop > 0) {
-            circles.add(circle(getTop(), radiusTop, u, w.copy().scale(-1)));
+            circles.add(circle(ss, getDataPoints().getPosition(1), radiusTop, u, w.copy().scale(-1)));
         }
         return GeometryFactory.DEFAULT.createMultiCurve(crs, circles.toArray(Circle[]::new));
     }
@@ -302,27 +284,27 @@ public sealed interface Cylinder extends Surface
      * turning from {@code u} toward {@code w}. The circle is made of four quarter arcs, since a
      * single arc of a circle shall stay below a half turn.
      */
-    private static Circle circle(final ReadOnly.Vector<?> center, final double radius,
+    private static Circle circle(SampleSystem system, final ReadOnly.Vector<?> center, final double radius,
                                  final ReadOnly.Vector<?> u, final ReadOnly.Vector<?> w)
     {
         final var points  = new ArrayList<Vector<?>>(5);
         final var centers = new ArrayList<Vector<?>>(4);
         final ReadOnly.Vector<?>[] directions = {u, w, u.copy().scale(-1), w.copy().scale(-1), u};
         for (final ReadOnly.Vector<?> direction : directions) {
-            final Vector<?> p = Vectors.create(center.getSampleSystem(), DataType.DOUBLE);
+            final Vector<?> p = Vectors.create(center.getDimension(), DataType.DOUBLE);
             p.set(center);
             p.add(direction.copy().scale(radius));
             points.add(p);
         }
         for (int i = 0; i < 4; i++) {
-            final Vector<?> c = Vectors.create(center.getSampleSystem(), DataType.DOUBLE);
+            final Vector<?> c = Vectors.create(center.getDimension(), DataType.DOUBLE);
             c.set(center);
             centers.add(c);
         }
         final GeometryFactory factory = GeometryFactory.DEFAULT;
         return factory.createCircle(
-                factory.createDataPoints(NDArrays.of(points, center.getSampleSystem(), DataType.DOUBLE)),
-                NDArrays.of(centers, center.getSampleSystem(), DataType.DOUBLE), null);
+                factory.createDataPoints(NDArrays.of(points, system, DataType.DOUBLE)),
+                NDArrays.of(centers, system, DataType.DOUBLE), null);
     }
 
     @Override
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/surface/PreparedTIN.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/surface/PreparedTIN.java
index 16bf3b2815..632eb2a7b8 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/surface/PreparedTIN.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/surface/PreparedTIN.java
@@ -295,7 +295,7 @@ public sealed interface PreparedTIN extends TIN
         private Evaluator(PreparedTIN tin) {
             this.tin = tin;
             this.tincrs = tin.getCoordinateReferenceSystem();
-            target = Vectors.createDouble(tincrs);
+            target = Vectors.createDouble(tincrs.getCoordinateSystem().getDimension());
             env = new GeneralEnvelope(tincrs);
         }
 
@@ -307,8 +307,24 @@ public sealed interface PreparedTIN extends TIN
             this.epsilon = epsilon;
         }
 
+        /**
+         * Evaluates the TIN at the given position, assumed in the reference system of the TIN.
+         *
+         * @param dp position to evaluate
+         * @return point on the TIN at the given position, or empty if outside of the TIN
+         */
         public Optional<Point> evaluate(Vector<?> dp) throws CannotEvaluateException {
-            final CoordinateReferenceSystem dpCrs = dp.getCoordinateReferenceSystem();
+            return evaluate(null, dp);
+        }
+
+        /**
+         * Evaluates the TIN at the given position, transforming it to the reference system of the TIN if needed.
+         *
+         * @param dpCrs reference system of the position, null if the same as the TIN
+         * @param dp position to evaluate
+         * @return point on the TIN at the given position, or empty if outside of the TIN
+         */
+        public Optional<Point> evaluate(final CoordinateReferenceSystem dpCrs, Vector<?> dp) throws CannotEvaluateException {
             final MathTransform transform;
             if (dpCrs == null) {
                 //do nothing, we expect the same crs as the surface
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/surface/Triangle.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/surface/Triangle.java
index fffbf44c9c..377ed127fe 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/surface/Triangle.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/surface/Triangle.java
@@ -129,8 +129,8 @@ public sealed interface Triangle extends Polygon
     @Override
     default Envelope getEnvelope() {
         final DataPoints exterior = getExteriorRing().getDataPoints();
-        final Vector<?> first = Vectors.create(getCoordinateReferenceSystem(), DataType.DOUBLE).set(exterior.getPosition(0));
-        final NDRange env = new NDRange(first, first);
+        final Vector<?> first = Vectors.create(getDimension(), DataType.DOUBLE).set(exterior.getPosition(0));
+        final NDRange env = new NDRange(getCoordinateReferenceSystem(), first, first);
         env.add(exterior.getPosition(1));
         env.add(exterior.getPosition(2));
         return env;
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/AbstractCursor.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/AbstractCursor.java
index 61851504e5..ee1226a35d 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/AbstractCursor.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/AbstractCursor.java
@@ -27,7 +27,6 @@ abstract class AbstractCursor extends AbstractVector implements Cursor {
     protected long offset = -1;
 
     AbstractCursor(Array array) {
-        super(array.getSampleSystem());
         this.array = array;
     }
 
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/AbstractVector.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/AbstractVector.java
index ed2cc3a17b..792b808a52 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/AbstractVector.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/AbstractVector.java
@@ -17,9 +17,6 @@
 package org.apache.sis.maths;
 
 import java.util.Arrays;
-import org.opengis.referencing.crs.CoordinateReferenceSystem;
-import org.apache.sis.util.ArgumentChecks;
-import org.apache.sis.referencing.CRS;
 
 
 /**
@@ -28,25 +25,7 @@ import org.apache.sis.referencing.CRS;
  */
 abstract class AbstractVector<T extends AbstractVector<T>> implements Vector<T> {
 
-    protected final SampleSystem type;
-
-    public AbstractVector(int dimension) {
-        this.type = SampleSystem.ofSize(dimension);
-    }
-
-    public AbstractVector(SampleSystem type) {
-        ArgumentChecks.ensureNonNull("type", type);
-        this.type = type;
-    }
-
-    public AbstractVector(CoordinateReferenceSystem crs) {
-        ArgumentChecks.ensureNonNull("crs", crs);
-        this.type = SampleSystem.of(crs);
-    }
-
-    @Override
-    public SampleSystem getSampleSystem() {
-        return type;
+    public AbstractVector() {
     }
 
     @Override
@@ -87,10 +66,6 @@ abstract class AbstractVector<T extends AbstractVector<T>> implements Vector<T>
                 }
             }
         }
-        //checking crs is expensive, do it last
-        if (!CRS.equivalent(getCoordinateReferenceSystem(), other.getCoordinateReferenceSystem())) {
-            return false;
-        }
         return true;
     }
 
@@ -100,28 +75,15 @@ abstract class AbstractVector<T extends AbstractVector<T>> implements Vector<T>
     }
 
     /**
-     * Check if given type has the expected number of dimensions.
-     *
-     * @param typetype to verify
-     * @param dimension expected dimension
-     * throws IllegalArgumentException if dimension do not match
-     */
-    protected static void ensureDimension(SampleSystem type, int dimension) {
-        if (type.getSize() != dimension) {
-            throw new IllegalArgumentException("CoordinateReferenceSystem dimension must be " + dimension);
-        }
-    }
-
-    /**
-     * Check if given CRS has the expected number of dimensions.
+     * Check if given vector has the expected number of dimensions.
      *
-     * @param crs Coordinate system to verify
+     * @param vector vector to verify
      * @param dimension expected dimension
      * throws IllegalArgumentException if dimension do not match
      */
-    protected static void ensureDimension(CoordinateReferenceSystem crs, int dimension) {
-        if (crs.getCoordinateSystem().getDimension() != dimension) {
-            throw new IllegalArgumentException("CoordinateReferenceSystem dimension must be " + dimension);
+    protected static void ensureDimension(ReadOnly.Vector<?> vector, int dimension) {
+        if (vector.getDimension() != dimension) {
+            throw new IllegalArgumentException("Vector dimension must be " + dimension);
         }
     }
 }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Array.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Array.java
index afe0c0e19a..f743eaada1 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Array.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Array.java
@@ -73,7 +73,7 @@ public interface Array extends NDArray {
      * @return tuple values, tuple is a copy.
      */
     default Vector<?> get(long index) {
-        Vector<?> tuple = Vectors.create(getSampleSystem(), getDataType());
+        Vector<?> tuple = Vectors.create(getDimension(), getDataType());
         get(index, tuple);
         return tuple;
     }
@@ -298,7 +298,7 @@ public interface Array extends NDArray {
      */
     default Vector<?>[] toArray(long offset, long nbTuple) {
         return stream(false).skip(offset).limit(nbTuple)
-                .map((Vector<?> t) -> Vectors.create(t.getSampleSystem(), t.getDataType()).set(t))
+                .map((Vector<?> t) -> Vectors.create(t.getDimension(), t.getDataType()).set(t))
                 .toArray(Vector<?>[]::new);
     }
 
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/ArrayFactory.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/ArrayFactory.java
index 7a63fbfc36..6a39af818b 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/ArrayFactory.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/ArrayFactory.java
@@ -166,7 +166,7 @@ public interface ArrayFactory {
                 if (ite.hasNext()) {
                     final Object o = ite.next();
                     if (o instanceof Vector<?> t) {
-                        return t.getSampleSystem();
+                        return SampleSystem.ofSize(t.getDimension());
                     } else if (o != null && o.getClass().isArray()) {
                         final int size = java.lang.reflect.Array.getLength(o);
                         return SampleSystem.ofSize(size);
@@ -180,7 +180,7 @@ public interface ArrayFactory {
                     final int size = java.lang.reflect.Array.getLength(values);
                     if (size != 0) {
                         final ReadOnly.Vector tuple = (ReadOnly.Vector) java.lang.reflect.Array.get(values, 0);
-                        return tuple.getSampleSystem();
+                        return SampleSystem.ofSize(tuple.getDimension());
                     }
                 }
             }
@@ -296,7 +296,7 @@ public interface ArrayFactory {
                 if (fill instanceof ReadOnly.Vector<?> t) {
                     f = t.copy();
                 } else if (fill.getClass().isArray()) {
-                    f = Vectors.create(system, target.getDataType());
+                    f = Vectors.create(system.getSize(), target.getDataType());
                     for (int i = 0; i < nbDim; i++) {
                         f.set(i, java.lang.reflect.Array.getDouble(fill, i));
                     }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/ArrayFactoryJava.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/ArrayFactoryJava.java
index ba079a9394..5be91eb7ed 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/ArrayFactoryJava.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/ArrayFactoryJava.java
@@ -1160,7 +1160,7 @@ public final class ArrayFactoryJava implements ArrayFactory {
 
         @Override
         public Vector get(long index) {
-            final Vector buffer = Vectors.create(type, DataType.FLOAT);
+            final Vector buffer = Vectors.create(type.getSize(), DataType.FLOAT);
             return buffer.set(array, Math.toIntExact(index * dimension));
         }
 
@@ -1309,7 +1309,7 @@ public final class ArrayFactoryJava implements ArrayFactory {
 
         @Override
         public Vector get(long index) {
-            final Vector buffer = Vectors.create(type, DataType.DOUBLE);
+            final Vector buffer = Vectors.create(type.getSize(), DataType.DOUBLE);
             return buffer.set(array, Math.toIntExact(index * dimension));
         }
 
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/NDArray.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/NDArray.java
index 43b4ddd3b2..5a62427140 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/NDArray.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/NDArray.java
@@ -102,7 +102,7 @@ public interface NDArray {
      * @return tuple values, tuple is a copy.
      */
     default Vector<?> get(long[] index) {
-        Vector tuple = Vectors.create(getSampleSystem(), getDataType());
+        Vector tuple = Vectors.create(getDimension(), getDataType());
         get(index, tuple);
         return tuple;
     }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/NDRange.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/NDRange.java
index 4b078c5874..a3f54bcad4 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/NDRange.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/NDRange.java
@@ -21,7 +21,6 @@ import org.opengis.coordinate.MismatchedDimensionException;
 import org.opengis.geometry.DirectPosition;
 import org.opengis.geometry.Envelope;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
-import org.apache.sis.referencing.CRS;
 import org.apache.sis.util.ArgumentChecks;
 
 
@@ -38,30 +37,64 @@ import org.apache.sis.util.ArgumentChecks;
  * @author Johann Sorel (Geomatys)
  */
 public final class NDRange implements Envelope {
+
+    /**
+     * Sample system of the bounds, of the same dimension than the bounds.
+     */
+    private final SampleSystem system;
+
     /**
-     * Lower and upper bounds, of the same dimension and sample system.
+     * Lower and upper bounds, of the same dimension.
      */
     private final Vector<?> lower, upper;
 
     /**
-     * Creates a range between the given bounds.
+     * Creates a range between the given bounds, in the given reference system.
+     *
+     * @param  crs    reference system of the bounds, or {@code null} if unspecified.
+     * @param  lower  lower bound in each dimension.
+     * @param  upper  upper bound in each dimension.
+     * @throws MismatchedDimensionException if the two bounds do not have the same dimension.
+     * @throws IllegalArgumentException if a lower bound is greater than the upper bound.
+     */
+    public NDRange(final CoordinateReferenceSystem crs, final ReadOnly.Vector<?> lower, final ReadOnly.Vector<?> upper) {
+        this(system(crs, lower.getDimension()), lower, upper);
+    }
+
+    /**
+     * Creates a range between the given bounds, in an unspecified reference system.
      *
      * @param  lower  lower bound in each dimension.
      * @param  upper  upper bound in each dimension.
      * @throws MismatchedDimensionException if the two bounds do not have the same dimension.
-     * @throws IllegalArgumentException if the two bounds are not in the same reference system,
-     *         or if a lower bound is greater than the upper bound.
+     * @throws IllegalArgumentException if a lower bound is greater than the upper bound.
      */
     public NDRange(final ReadOnly.Vector<?> lower, final ReadOnly.Vector<?> upper) {
+        this(SampleSystem.ofSize(lower.getDimension()), lower, upper);
+    }
+
+    /**
+     * Creates a range between the given bounds, in the given sample system.
+     *
+     * @param  system sample system of the bounds.
+     * @param  lower  lower bound in each dimension.
+     * @param  upper  upper bound in each dimension.
+     * @throws MismatchedDimensionException if the two bounds do not have the same dimension.
+     * @throws IllegalArgumentException if a lower bound is greater than the upper bound.
+     */
+    public NDRange(SampleSystem system, final ReadOnly.Vector<?> lower, final ReadOnly.Vector<?> upper) {
+        ArgumentChecks.ensureNonNull("system", system);
         ArgumentChecks.ensureNonNull("lower", lower);
         ArgumentChecks.ensureNonNull("upper", upper);
+        this.system = system;
         final int dimension = lower.getDimension();
         if (upper.getDimension() != dimension) {
             throw new MismatchedDimensionException("The lower bound has " + dimension
                     + " dimensions but the upper bound has " + upper.getDimension() + '.');
         }
-        if (!CRS.equivalent(lower.getCoordinateReferenceSystem(), upper.getCoordinateReferenceSystem())) {
-            throw new IllegalArgumentException("The lower and upper bounds are in different reference systems.");
+        if (system.getSize() != dimension) {
+            throw new MismatchedDimensionException("The sample system has " + system.getSize()
+                    + " dimensions but the bounds have " + dimension + '.');
         }
         for (int i = 0; i < dimension; i++) {
             if (lower.get(i) > upper.get(i)) {
@@ -89,7 +122,7 @@ public final class NDRange implements Envelope {
      * @param  dataType   data type of the bounds.
      */
     public NDRange(final int dimension, final DataType dataType) {
-        this(Vectors.create(positive(dimension), Objects.requireNonNull(dataType, "dataType")));
+        this(SampleSystem.ofSize(dimension), Vectors.create(positive(dimension), Objects.requireNonNull(dataType, "dataType")));
     }
 
     /**
@@ -108,7 +141,7 @@ public final class NDRange implements Envelope {
      * @param  dataType  data type of the bounds.
      */
     public NDRange(final CoordinateReferenceSystem crs, final DataType dataType) {
-        this(Vectors.create(Objects.requireNonNull(crs, "crs"), Objects.requireNonNull(dataType, "dataType")));
+        this(SampleSystem.of(crs), Vectors.create(crs.getCoordinateSystem().getDimension(), dataType));
     }
 
     /**
@@ -127,7 +160,14 @@ public final class NDRange implements Envelope {
      * @param  dataType  data type of the bounds.
      */
     public NDRange(final SampleSystem system, final DataType dataType) {
-        this(Vectors.create(Objects.requireNonNull(system, "system"), Objects.requireNonNull(dataType, "dataType")));
+        this(system, Vectors.create(system.getSize(), dataType));
+    }
+
+    /**
+     * Returns the sample system of the given reference system, or an undefined one if the CRS is null.
+     */
+    private static SampleSystem system(final CoordinateReferenceSystem crs, final int dimension) {
+        return (crs != null) ? SampleSystem.of(crs) : SampleSystem.ofSize(dimension);
     }
 
     /**
@@ -141,7 +181,8 @@ public final class NDRange implements Envelope {
     /**
      * Creates a range whose two bounds are the given vector, which is used directly for the lower bound.
      */
-    private NDRange(final Vector<?> bound) {
+    private NDRange(SampleSystem system, final Vector<?> bound) {
+        this.system = system;
         lower = bound;
         upper = bound.copy();
     }
@@ -153,9 +194,9 @@ public final class NDRange implements Envelope {
      * @param  envelope  the envelope to copy.
      */
     public NDRange(final Envelope envelope) {
-        final CoordinateReferenceSystem crs = envelope.getCoordinateReferenceSystem();
         final int dimension = envelope.getDimension();
-        lower = (crs != null) ? Vectors.create(crs, DataType.DOUBLE) : Vectors.createDouble(dimension);
+        system = system(envelope.getCoordinateReferenceSystem(), dimension);
+        lower = Vectors.createDouble(dimension);
         upper = lower.copy();
         for (int i = 0; i < dimension; i++) {
             lower.set(i, envelope.getMinimum(i));
@@ -168,7 +209,7 @@ public final class NDRange implements Envelope {
      */
     @Override
     public CoordinateReferenceSystem getCoordinateReferenceSystem() {
-        return lower.getCoordinateReferenceSystem();
+        return system.getCoordinateReferenceSystem();
     }
 
     /**
@@ -202,7 +243,7 @@ public final class NDRange implements Envelope {
      */
     @Override
     public DirectPosition getLowerCorner() {
-        return Vectors.asDirectPostion(lower.copy());
+        return Vectors.asDirectPostion(lower.copy(), getCoordinateReferenceSystem());
     }
 
     /**
@@ -210,7 +251,7 @@ public final class NDRange implements Envelope {
      */
     @Override
     public DirectPosition getUpperCorner() {
-        return Vectors.asDirectPostion(upper.copy());
+        return Vectors.asDirectPostion(upper.copy(), getCoordinateReferenceSystem());
     }
 
     @Override
@@ -421,16 +462,17 @@ public final class NDRange implements Envelope {
     }
 
     /**
-     * Returns whether the given object is a range with the same bounds.
+     * Returns whether the given object is a range with the same bounds and sample system.
      */
     @Override
     public boolean equals(final Object obj) {
-        return (obj instanceof NDRange other) && lower.equals(other.lower) && upper.equals(other.upper);
+        return (obj instanceof NDRange other) && lower.equals(other.lower) && upper.equals(other.upper)
+                && system.equals(other.system);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(lower, upper);
+        return Objects.hash(lower, upper, system);
     }
 
     /**
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/ReadOnly.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/ReadOnly.java
index 9ff8cffef5..47f98acb30 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/ReadOnly.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/ReadOnly.java
@@ -16,8 +16,6 @@
  */
 package org.apache.sis.maths;
 
-import org.opengis.referencing.crs.CoordinateReferenceSystem;
-import org.apache.sis.referencing.CRS;
 import org.apache.sis.referencing.operation.matrix.MatrixSIS;
 
 
@@ -32,24 +30,10 @@ public final class ReadOnly {
 
     public static interface Vector<T extends Vector<T>> {
 
-        /**
-         * @return sample system, never null.
-         */
-        SampleSystem getSampleSystem();
-
         /**
          * @return sample system size.
          */
-        default int getDimension() {
-            return getSampleSystem().getSize();
-        }
-
-        /**
-         * @return sample system CRS, may be null.
-         */
-        default CoordinateReferenceSystem getCoordinateReferenceSystem() {
-            return getSampleSystem().getCoordinateReferenceSystem();
-        }
+        int getDimension();
 
         /**
          * @return data type of primitives in this array
@@ -186,7 +170,7 @@ public final class ReadOnly {
          * @return tuple copy.
          */
         default org.apache.sis.maths.Vector<?> copy() {
-            return Vectors.create(getSampleSystem(), getDataType()).set(this);
+            return Vectors.create(getDimension(), getDataType()).set(this);
         }
 
         /**
@@ -197,7 +181,7 @@ public final class ReadOnly {
          * @return tuple copy.
          */
         default org.apache.sis.maths.Vector<?> copy(DataType dataType) {
-            return Vectors.create(getSampleSystem(), dataType).set(this);
+            return Vectors.create(getDimension(), dataType).set(this);
         }
 
         /**
@@ -221,7 +205,7 @@ public final class ReadOnly {
         }
 
         /**
-         * Tuples are equal when all samples and CRS match.
+         * Tuples are equal when all samples match.
          */
         @Override
         boolean equals(Object candidate);
@@ -232,7 +216,7 @@ public final class ReadOnly {
          * Tuples are a key element in OpenGL and GPU which have variable bits representation of numbers
          * including half-floats, oct-compression, normalisation, quantization, unsigned values and more.
          *
-         * This equality test checks values and CRS but does not verify that data types are identical.
+         * This equality test checks values but does not verify that data types are identical.
          * This behavior allows to compare Tuple following their contract ignoring the storage backend.
          *
          * @param obj second tuple to test against
@@ -265,10 +249,6 @@ public final class ReadOnly {
                     }
                 }
             }
-            //checking crs is expensive, do it last
-            if (!CRS.equivalent(getCoordinateReferenceSystem(), other.getCoordinateReferenceSystem())) {
-                return false;
-            }
             return true;
         }
 
@@ -318,7 +298,6 @@ public final class ReadOnly {
 
         /**
          * Increase size of the tuple by one value.
-         * Vector CRS will be lost.
          *
          * @param d last dimension value
          * @return Vector
@@ -335,7 +314,6 @@ public final class ReadOnly {
 
         /**
          * Decrease size of the tuple by one value.
-         * Vector CRS will be lost.
          *
          * @param size number of dimension to preserve
          * @return Vector
@@ -352,7 +330,6 @@ public final class ReadOnly {
          * Create a new tuple with the values of the given axes, in the given order.
          * Axes can be omitted or repeated, for example {@code swizzle(1, 0)} swaps the
          * two first values and {@code swizzle(0, 0, 0)} repeats the first one.
-         * Vector CRS will be lost.
          *
          * @param axes indices of the values of this tuple to copy, one per dimension of the result
          * @return Vector of dimension {@code axes.length}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Sample.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Sample.java
new file mode 100644
index 0000000000..0ff490e685
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Sample.java
@@ -0,0 +1,37 @@
+/*
+ * 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.maths;
+
+/**
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class Sample {
+
+    public final SampleSystem sampleSystem;
+    public final ReadOnly.Vector values;
+
+    /**
+     * @param sampleSystem sample system of the values
+     * @param values sample values
+     */
+    public Sample(SampleSystem sampleSystem, ReadOnly.Vector values) {
+        this.sampleSystem = sampleSystem;
+        this.values = values;
+    }
+
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vector1D.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vector1D.java
index 5e61ddd380..4e7b7f134a 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vector1D.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vector1D.java
@@ -16,7 +16,6 @@
  */
 package org.apache.sis.maths;
 
-
 /**
  *
  * @author Johann Sorel (Geomatys)
@@ -24,11 +23,6 @@ package org.apache.sis.maths;
 public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T> {
 
     public Vector1D() {
-        super(1);
-    }
-
-    protected Vector1D(SampleSystem type) {
-        super(type);
     }
 
     @Override
@@ -43,16 +37,6 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
         public Byte() {
         }
 
-        public Byte(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 1);
-        }
-
-        public Byte(SampleSystem type, byte x) {
-            this(type);
-            this.x = x;
-        }
-
         public Byte(byte x) {
             this.x = x;
         }
@@ -62,28 +46,10 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
         }
 
         public Byte(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 1);
+            ensureDimension(tuple, 1);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Byte(byte x, SampleSystem type) {
-            super(type);
-            this.x = x;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Byte(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.BYTE;
@@ -172,7 +138,7 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
 
         @Override
         public Byte copy() {
-            return new Byte(x, type);
+            return new Byte(x);
         }
 
         @Override
@@ -189,16 +155,6 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
         public UByte() {
         }
 
-        public UByte(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 1);
-        }
-
-        public UByte(SampleSystem type, byte x) {
-            this(type);
-            this.x = x;
-        }
-
         public UByte(byte x) {
             this.x = x;
         }
@@ -208,28 +164,10 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
         }
 
         public UByte(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 1);
+            ensureDimension(tuple, 1);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private UByte(byte x, SampleSystem type) {
-            super(type);
-            this.x = x;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        UByte(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.UBYTE;
@@ -318,7 +256,7 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
 
         @Override
         public UByte copy() {
-            return new UByte(x, type);
+            return new UByte(x);
         }
 
         @Override
@@ -335,16 +273,6 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
         public Short() {
         }
 
-        public Short(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 1);
-        }
-
-        public Short(SampleSystem type, short x) {
-            this(type);
-            this.x = x;
-        }
-
         public Short(short x) {
             this.x = x;
         }
@@ -354,28 +282,10 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
         }
 
         public Short(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 1);
+            ensureDimension(tuple, 1);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Short(short x, SampleSystem type) {
-            super(type);
-            this.x = x;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Short(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.SHORT;
@@ -464,7 +374,7 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
 
         @Override
         public Short copy() {
-            return new Short(x, type);
+            return new Short(x);
         }
 
         @Override
@@ -481,16 +391,6 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
         public UShort() {
         }
 
-        public UShort(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 1);
-        }
-
-        public UShort(SampleSystem type, short x) {
-            this(type);
-            this.x = x;
-        }
-
         public UShort(short x) {
             this.x = x;
         }
@@ -500,28 +400,10 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
         }
 
         public UShort(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 1);
+            ensureDimension(tuple, 1);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private UShort(short x, SampleSystem type) {
-            super(type);
-            this.x = x;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        UShort(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.USHORT;
@@ -610,7 +492,7 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
 
         @Override
         public UShort copy() {
-            return new UShort(x, type);
+            return new UShort(x);
         }
 
         @Override
@@ -627,16 +509,6 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
         public Int() {
         }
 
-        public Int(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 1);
-        }
-
-        public Int(SampleSystem type, int x) {
-            this(type);
-            this.x = x;
-        }
-
         public Int(int x) {
             this.x = x;
         }
@@ -646,28 +518,10 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
         }
 
         public Int(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 1);
+            ensureDimension(tuple, 1);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Int(int x, SampleSystem type) {
-            super(type);
-            this.x = x;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Int(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.INT;
@@ -756,7 +610,7 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
 
         @Override
         public Int copy() {
-            return new Int(x, type);
+            return new Int(x);
         }
 
         @Override
@@ -773,16 +627,6 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
         public UInt() {
         }
 
-        public UInt(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 1);
-        }
-
-        public UInt(SampleSystem type, int x) {
-            this(type);
-            this.x = x;
-        }
-
         public UInt(int x) {
             this.x = x;
         }
@@ -792,28 +636,10 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
         }
 
         public UInt(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 1);
+            ensureDimension(tuple, 1);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private UInt(int x, SampleSystem type) {
-            super(type);
-            this.x = x;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        UInt(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.UINT;
@@ -902,7 +728,7 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
 
         @Override
         public UInt copy() {
-            return new UInt(x, type);
+            return new UInt(x);
         }
 
         @Override
@@ -919,16 +745,6 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
         public Long() {
         }
 
-        public Long(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 1);
-        }
-
-        public Long(SampleSystem type, long x) {
-            this(type);
-            this.x = x;
-        }
-
         public Long(long x) {
             this.x = x;
         }
@@ -938,28 +754,10 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
         }
 
         public Long(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 1);
+            ensureDimension(tuple, 1);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Long(long x, SampleSystem type) {
-            super(type);
-            this.x = x;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Long(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.LONG;
@@ -1048,7 +846,7 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
 
         @Override
         public Long copy() {
-            return new Long(x, type);
+            return new Long(x);
         }
 
         @Override
@@ -1065,16 +863,6 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
         public Float() {
         }
 
-        public Float(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 1);
-        }
-
-        public Float(SampleSystem type, float x) {
-            this(type);
-            this.x = x;
-        }
-
         public Float(float x) {
             this.x = x;
         }
@@ -1084,28 +872,10 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
         }
 
         public Float(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 1);
+            ensureDimension(tuple, 1);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Float(float x, SampleSystem type) {
-            super(type);
-            this.x = x;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Float(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.FLOAT;
@@ -1235,7 +1005,7 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
 
         @Override
         public Float copy() {
-            return new Float(x, type);
+            return new Float(x);
         }
 
         @Override
@@ -1252,16 +1022,6 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
         public Double() {
         }
 
-        public Double(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 1);
-        }
-
-        public Double(SampleSystem type, double x) {
-            this(type);
-            this.x = x;
-        }
-
         public Double(double x) {
             this.x = x;
         }
@@ -1275,28 +1035,10 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
         }
 
         public Double(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 1);
+            ensureDimension(tuple, 1);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Double(double x, SampleSystem type) {
-            super(type);
-            this.x = x;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Double(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.DOUBLE;
@@ -1426,7 +1168,7 @@ public abstract class Vector1D<T extends Vector1D<T>> extends AbstractVector<T>
 
         @Override
         public Double copy() {
-            return new Double(x, type);
+            return new Double(x);
         }
 
         @Override
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vector2D.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vector2D.java
index 5098e533be..98f209531e 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vector2D.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vector2D.java
@@ -24,11 +24,6 @@ package org.apache.sis.maths;
 public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T> {
 
     public Vector2D() {
-        super(2);
-    }
-
-    protected Vector2D(SampleSystem type) {
-        super(type);
     }
 
     /**
@@ -63,16 +58,6 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
         public Byte() {
         }
 
-        public Byte(SampleSystem type) {
-            super(type);
-        }
-
-        public Byte(SampleSystem type, byte x, byte y) {
-            this(type);
-            this.x = x;
-            this.y = y;
-        }
-
         public Byte(byte x, byte y) {
             this.x = x;
             this.y = y;
@@ -84,29 +69,10 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
         }
 
         public Byte(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 2);
+            ensureDimension(tuple, 2);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Byte(byte x, byte y, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Byte(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.BYTE;
@@ -205,7 +171,7 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
 
         @Override
         public Byte copy() {
-            return new Byte(x, y, type);
+            return new Byte(x, y);
         }
 
         @Override
@@ -223,17 +189,6 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
         public UByte() {
         }
 
-        public UByte(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 2);
-        }
-
-        public UByte(SampleSystem type, byte x, byte y) {
-            this(type);
-            this.x = x;
-            this.y = y;
-        }
-
         public UByte(byte x, byte y) {
             this.x = x;
             this.y = y;
@@ -245,29 +200,10 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
         }
 
         public UByte(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 2);
+            ensureDimension(tuple, 2);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private UByte(byte x, byte y, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        UByte(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.UBYTE;
@@ -366,7 +302,7 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
 
         @Override
         public UByte copy() {
-            return new UByte(x, y, type);
+            return new UByte(x, y);
         }
 
         @Override
@@ -384,17 +320,6 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
         public Short() {
         }
 
-        public Short(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 2);
-        }
-
-        public Short(SampleSystem type, short x, short y) {
-            this(type);
-            this.x = x;
-            this.y = y;
-        }
-
         public Short(short x, short y) {
             this.x = x;
             this.y = y;
@@ -406,29 +331,10 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
         }
 
         public Short(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 2);
+            ensureDimension(tuple, 2);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Short(short x, short y, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Short(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.SHORT;
@@ -527,7 +433,7 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
 
         @Override
         public Short copy() {
-            return new Short(x, y, type);
+            return new Short(x, y);
         }
 
         @Override
@@ -545,17 +451,6 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
         public UShort() {
         }
 
-        public UShort(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 2);
-        }
-
-        public UShort(SampleSystem type, short x, short y) {
-            this(type);
-            this.x = x;
-            this.y = y;
-        }
-
         public UShort(short x, short y) {
             this.x = x;
             this.y = y;
@@ -567,29 +462,10 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
         }
 
         public UShort(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 2);
+            ensureDimension(tuple, 2);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private UShort(short x, short y, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        UShort(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.USHORT;
@@ -688,7 +564,7 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
 
         @Override
         public UShort copy() {
-            return new UShort(x, y, type);
+            return new UShort(x, y);
         }
 
         @Override
@@ -706,17 +582,6 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
         public Int() {
         }
 
-        public Int(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 2);
-        }
-
-        public Int(SampleSystem type, int x, int y) {
-            this(type);
-            this.x = x;
-            this.y = y;
-        }
-
         public Int(int x, int y) {
             this.x = x;
             this.y = y;
@@ -728,29 +593,10 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
         }
 
         public Int(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 2);
+            ensureDimension(tuple, 2);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Int(int x, int y, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Int(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.INT;
@@ -849,7 +695,7 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
 
         @Override
         public Int copy() {
-            return new Int(x, y, type);
+            return new Int(x, y);
         }
 
         @Override
@@ -867,17 +713,6 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
         public UInt() {
         }
 
-        public UInt(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 2);
-        }
-
-        public UInt(SampleSystem type, int x, int y) {
-            this(type);
-            this.x = x;
-            this.y = y;
-        }
-
         public UInt(int x, int y) {
             this.x = x;
             this.y = y;
@@ -889,29 +724,10 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
         }
 
         public UInt(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 2);
+            ensureDimension(tuple, 2);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private UInt(int x, int y, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        UInt(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.UINT;
@@ -1010,7 +826,7 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
 
         @Override
         public UInt copy() {
-            return new UInt(x, y, type);
+            return new UInt(x, y);
         }
 
         @Override
@@ -1028,17 +844,6 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
         public Long() {
         }
 
-        public Long(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 2);
-        }
-
-        public Long(SampleSystem type, long x, long y) {
-            this(type);
-            this.x = x;
-            this.y = y;
-        }
-
         public Long(long x, long y) {
             this.x = x;
             this.y = y;
@@ -1050,29 +855,10 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
         }
 
         public Long(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 2);
+            ensureDimension(tuple, 2);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Long(long x, long y, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Long(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.LONG;
@@ -1171,7 +957,7 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
 
         @Override
         public Long copy() {
-            return new Long(x, y, type);
+            return new Long(x, y);
         }
 
         @Override
@@ -1189,17 +975,6 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
         public Float() {
         }
 
-        public Float(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 2);
-        }
-
-        public Float(SampleSystem type, float x, float y) {
-            this(type);
-            this.x = x;
-            this.y = y;
-        }
-
         public Float(float x, float y) {
             this.x = x;
             this.y = y;
@@ -1211,29 +986,10 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
         }
 
         public Float(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 2);
+            ensureDimension(tuple, 2);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Float(float x, float y, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Float(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.FLOAT;
@@ -1377,7 +1133,7 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
 
         @Override
         public Float copy() {
-            return new Float(x, y, type);
+            return new Float(x, y);
         }
 
         @Override
@@ -1395,17 +1151,6 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
         public Double() {
         }
 
-        public Double(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 2);
-        }
-
-        public Double(SampleSystem type, double x, double y) {
-            this(type);
-            this.x = x;
-            this.y = y;
-        }
-
         public Double(double x, double y) {
             this.x = x;
             this.y = y;
@@ -1422,29 +1167,10 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
         }
 
         public Double(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 2);
+            ensureDimension(tuple, 2);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Double(double x, double y, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Double(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.DOUBLE;
@@ -1588,7 +1314,7 @@ public abstract class Vector2D<T extends Vector2D<T>> extends AbstractVector<T>
 
         @Override
         public Double copy() {
-            return new Double(x, y, type);
+            return new Double(x, y);
         }
 
         @Override
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vector3D.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vector3D.java
index 4cb1aa67e5..901d9851e2 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vector3D.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vector3D.java
@@ -24,11 +24,6 @@ package org.apache.sis.maths;
 public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T> {
 
     public Vector3D() {
-        super(3);
-    }
-
-    protected Vector3D(SampleSystem type) {
-        super(type);
     }
 
     /**
@@ -137,18 +132,6 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
         public Byte() {
         }
 
-        public Byte(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 3);
-        }
-
-        public Byte(SampleSystem type, byte x, byte y, byte z) {
-            this(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-        }
-
         public Byte(byte x, byte y, byte z) {
             this.x = x;
             this.y = y;
@@ -162,30 +145,10 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
         }
 
         public Byte(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 3);
+            ensureDimension(tuple, 3);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Byte(byte x, byte y, byte z, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Byte(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.BYTE;
@@ -294,7 +257,7 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
 
         @Override
         public Byte copy() {
-            return new Byte(x, y, z, type);
+            return new Byte(x, y, z);
         }
 
         @Override
@@ -313,18 +276,6 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
         public UByte() {
         }
 
-        public UByte(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 3);
-        }
-
-        public UByte(SampleSystem type, byte x, byte y, byte z) {
-            this(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-        }
-
         public UByte(byte x, byte y, byte z) {
             this.x = x;
             this.y = y;
@@ -338,30 +289,10 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
         }
 
         public UByte(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 3);
+            ensureDimension(tuple, 3);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private UByte(byte x, byte y, byte z, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        UByte(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.UBYTE;
@@ -470,7 +401,7 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
 
         @Override
         public UByte copy() {
-            return new UByte(x, y, z, type);
+            return new UByte(x, y, z);
         }
 
         @Override
@@ -489,18 +420,6 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
         public Short() {
         }
 
-        public Short(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 3);
-        }
-
-        public Short(SampleSystem type, short x, short y, short z) {
-            this(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-        }
-
         public Short(short x, short y, short z) {
             this.x = x;
             this.y = y;
@@ -514,30 +433,10 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
         }
 
         public Short(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 3);
+            ensureDimension(tuple, 3);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Short(short x, short y, short z, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Short(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.SHORT;
@@ -646,7 +545,7 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
 
         @Override
         public Short copy() {
-            return new Short(x, y, z, type);
+            return new Short(x, y, z);
         }
 
         @Override
@@ -665,18 +564,6 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
         public UShort() {
         }
 
-        public UShort(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 3);
-        }
-
-        public UShort(SampleSystem type, short x, short y, short z) {
-            this(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-        }
-
         public UShort(short x, short y, short z) {
             this.x = x;
             this.y = y;
@@ -690,30 +577,10 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
         }
 
         public UShort(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 3);
+            ensureDimension(tuple, 3);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private UShort(short x, short y, short z, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        UShort(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.USHORT;
@@ -834,7 +701,7 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
 
         @Override
         public UShort copy() {
-            return new UShort(x, y, z, type);
+            return new UShort(x, y, z);
         }
 
         @Override
@@ -853,18 +720,6 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
         public Int() {
         }
 
-        public Int(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 3);
-        }
-
-        public Int(SampleSystem type, int x, int y, int z) {
-            this(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-        }
-
         public Int(int x, int y, int z) {
             this.x = x;
             this.y = y;
@@ -878,30 +733,10 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
         }
 
         public Int(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 3);
+            ensureDimension(tuple, 3);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Int(int x, int y, int z, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Int(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.INT;
@@ -1010,7 +845,7 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
 
         @Override
         public Int copy() {
-            return new Int(x, y, z, type);
+            return new Int(x, y, z);
         }
 
         @Override
@@ -1029,18 +864,6 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
         public UInt() {
         }
 
-        public UInt(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 3);
-        }
-
-        public UInt(SampleSystem type, int x, int y, int z) {
-            this(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-        }
-
         public UInt(int x, int y, int z) {
             this.x = x;
             this.y = y;
@@ -1054,30 +877,10 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
         }
 
         public UInt(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 3);
+            ensureDimension(tuple, 3);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private UInt(int x, int y, int z,  SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        UInt(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.UINT;
@@ -1194,7 +997,7 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
 
         @Override
         public UInt copy() {
-            return new UInt(x, y, z, type);
+            return new UInt(x, y, z);
         }
 
         @Override
@@ -1213,18 +1016,6 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
         public Long() {
         }
 
-        public Long(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 3);
-        }
-
-        public Long(SampleSystem type, long x, long y, long z) {
-            this(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-        }
-
         public Long(long x, long y, long z) {
             this.x = x;
             this.y = y;
@@ -1238,30 +1029,10 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
         }
 
         public Long(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 3);
+            ensureDimension(tuple, 3);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Long(long x, long y, long z, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Long(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.LONG;
@@ -1370,7 +1141,7 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
 
         @Override
         public Long copy() {
-            return new Long(x, y, z, type);
+            return new Long(x, y, z);
         }
 
         @Override
@@ -1389,18 +1160,6 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
         public Float() {
         }
 
-        public Float(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 3);
-        }
-
-        public Float(SampleSystem type, float x, float y, float z) {
-            this(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-        }
-
         public Float(float x, float y, float z) {
             this.x = x;
             this.y = y;
@@ -1414,30 +1173,10 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
         }
 
         public Float(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 3);
+            ensureDimension(tuple, 3);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Float(float x, float y, float z, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Float(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.FLOAT;
@@ -1606,7 +1345,7 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
 
         @Override
         public Float copy() {
-            return new Float(x, y, z, type);
+            return new Float(x, y, z);
         }
 
         @Override
@@ -1625,18 +1364,6 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
         public Double() {
         }
 
-        public Double(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 3);
-        }
-
-        public Double(SampleSystem type, double x, double y, double z) {
-            this(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-        }
-
         public Double(double x, double y, double z) {
             this.x = x;
             this.y = y;
@@ -1656,30 +1383,10 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
         }
 
         public Double(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 3);
+            ensureDimension(tuple, 3);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Double(double x, double y, double z, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Double(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.DOUBLE;
@@ -1852,7 +1559,7 @@ public abstract class Vector3D<T extends Vector3D<T>> extends AbstractVector<T>
 
         @Override
         public Double copy() {
-            return new Double(x, y, z, type);
+            return new Double(x, y, z);
         }
 
         @Override
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vector4D.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vector4D.java
index 9380dfc14b..0ee915d61f 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vector4D.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vector4D.java
@@ -24,11 +24,6 @@ package org.apache.sis.maths;
 public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T> {
 
     public Vector4D() {
-        super(4);
-    }
-
-    protected Vector4D(SampleSystem type) {
-        super(type);
     }
 
     @Override
@@ -46,19 +41,6 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
         public Byte() {
         }
 
-        public Byte(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 4);
-        }
-
-        public Byte(SampleSystem type, byte x, byte y, byte z, byte w) {
-            this(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-            this.w = w;
-        }
-
         public Byte(byte x, byte y, byte z, byte w) {
             this.x = x;
             this.y = y;
@@ -74,31 +56,10 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
         }
 
         public Byte(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 4);
+            ensureDimension(tuple, 4);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Byte(byte x, byte y, byte z,  byte w, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-            this.w = w;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Byte(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.BYTE;
@@ -260,7 +221,7 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
 
         @Override
         public Byte copy() {
-            return new Byte(x, y, z, w, type);
+            return new Byte(x, y, z, w);
         }
 
         @Override
@@ -280,19 +241,6 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
         public UByte() {
         }
 
-        public UByte(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 4);
-        }
-
-        public UByte(SampleSystem type, byte x, byte y, byte z, byte w) {
-            this(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-            this.w = w;
-        }
-
         public UByte(byte x, byte y, byte z, byte w) {
             this.x = x;
             this.y = y;
@@ -308,31 +256,10 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
         }
 
         public UByte(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 4);
+            ensureDimension(tuple, 4);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private UByte(byte x, byte y, byte z, byte w, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-            this.w = w;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        UByte(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.UBYTE;
@@ -451,7 +378,7 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
 
         @Override
         public UByte copy() {
-            return new UByte(x, y, z, w, type);
+            return new UByte(x, y, z, w);
         }
 
         @Override
@@ -471,19 +398,6 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
         public Short() {
         }
 
-        public Short(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 4);
-        }
-
-        public Short(SampleSystem type, short x, short y, short z, short w) {
-            this(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-            this.w = w;
-        }
-
         public Short(short x, short y, short z, short w) {
             this.x = x;
             this.y = y;
@@ -499,31 +413,10 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
         }
 
         public Short(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 4);
+            ensureDimension(tuple, 4);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check crs.
-         */
-        private Short(short x, short y, short z,  short w, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-            this.w = w;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Short(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.SHORT;
@@ -685,7 +578,7 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
 
         @Override
         public Short copy() {
-            return new Short(x, y, z, w, type);
+            return new Short(x, y, z, w);
         }
 
         @Override
@@ -705,19 +598,6 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
         public UShort() {
         }
 
-        public UShort(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 4);
-        }
-
-        public UShort(SampleSystem type, short x, short y, short z, short w) {
-            this(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-            this.w = w;
-        }
-
         public UShort(short x, short y, short z, short w) {
             this.x = x;
             this.y = y;
@@ -733,31 +613,10 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
         }
 
         public UShort(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 4);
+            ensureDimension(tuple, 4);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private UShort(short x, short y, short z, short w, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-            this.w = w;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        UShort(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.USHORT;
@@ -934,7 +793,7 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
 
         @Override
         public UShort copy() {
-            return new UShort(x, y, z, w, type);
+            return new UShort(x, y, z, w);
         }
 
         @Override
@@ -954,19 +813,6 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
         public Int() {
         }
 
-        public Int(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 4);
-        }
-
-        public Int(SampleSystem type, int x, int y, int z, int w) {
-            this(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-            this.w = w;
-        }
-
         public Int(int x, int y, int z, int w) {
             this.x = x;
             this.y = y;
@@ -982,31 +828,10 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
         }
 
         public Int(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 4);
+            ensureDimension(tuple, 4);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Int(int x, int y, int z, int w, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-            this.w = w;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Int(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.INT;
@@ -1168,7 +993,7 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
 
         @Override
         public Int copy() {
-            return new Int(x, y, z, w, type);
+            return new Int(x, y, z, w);
         }
 
         @Override
@@ -1188,19 +1013,6 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
         public UInt() {
         }
 
-        public UInt(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 4);
-        }
-
-        public UInt(SampleSystem type, int x, int y, int z, int w) {
-            this(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-            this.w = w;
-        }
-
         public UInt(int x, int y, int z, int w) {
             this.x = x;
             this.y = y;
@@ -1216,31 +1028,10 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
         }
 
         public UInt(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 4);
+            ensureDimension(tuple, 4);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private UInt(int x, int y, int z, int w, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-            this.w = w;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        UInt(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.UINT;
@@ -1379,7 +1170,7 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
 
         @Override
         public UInt copy() {
-            return new UInt(x, y, z, w, type);
+            return new UInt(x, y, z, w);
         }
 
         @Override
@@ -1399,19 +1190,6 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
         public Long() {
         }
 
-        public Long(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 4);
-        }
-
-        public Long(SampleSystem type, long x, long y, long z, long w) {
-            this(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-            this.w = w;
-        }
-
         public Long(long x, long y, long z, long w) {
             this.x = x;
             this.y = y;
@@ -1427,31 +1205,10 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
         }
 
         public Long(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 4);
+            ensureDimension(tuple, 4);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Long(long x, long y, long z, long w, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-            this.w = w;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Long(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.LONG;
@@ -1570,7 +1327,7 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
 
         @Override
         public Long copy() {
-            return new Long(x, y, z, w, type);
+            return new Long(x, y, z, w);
         }
 
         @Override
@@ -1590,19 +1347,6 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
         public Float() {
         }
 
-        public Float(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 4);
-        }
-
-        public Float(SampleSystem type, float x, float y, float z, float w) {
-            this(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-            this.w = w;
-        }
-
         public Float(float x, float y, float z, float w) {
             this.x = x;
             this.y = y;
@@ -1618,31 +1362,10 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
         }
 
         public Float(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 4);
+            ensureDimension(tuple, 4);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Float(float x, float y, float z,  float w, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-            this.w = w;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Float(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.FLOAT;
@@ -1814,7 +1537,7 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
 
         @Override
         public Float copy() {
-            return new Float(x, y, z, w, type);
+            return new Float(x, y, z, w);
         }
 
         @Override
@@ -1834,19 +1557,6 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
         public Double() {
         }
 
-        public Double(SampleSystem type) {
-            super(type);
-            ensureDimension(type, 4);
-        }
-
-        public Double(SampleSystem type, double x, double y, double z, double w) {
-            this(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-            this.w = w;
-        }
-
         public Double(double x, double y, double z, double w) {
             this.x = x;
             this.y = y;
@@ -1869,31 +1579,10 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
         }
 
         public Double(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
-            ensureDimension(type, 4);
+            ensureDimension(tuple, 4);
             set(tuple);
         }
 
-        /**
-         * Copy constructor which does not check type.
-         */
-        private Double(double x, double y, double z,  double w, SampleSystem type) {
-            super(type);
-            this.x = x;
-            this.y = y;
-            this.z = z;
-            this.w = w;
-        }
-
-        /**
-         * Package private benchmarked performance constructor which does not check type.
-         * @param type must be of the correct size, it is not verified
-         * @param none not used, to differenciate with public constructor
-         */
-        Double(SampleSystem type, boolean none) {
-            super(type);
-        }
-
         @Override
         public DataType getDataType() {
             return DataType.DOUBLE;
@@ -2065,7 +1754,7 @@ public abstract class Vector4D<T extends Vector4D<T>> extends AbstractVector<T>
 
         @Override
         public Double copy() {
-            return new Double(x, y, z, w, type);
+            return new Double(x, y, z, w);
         }
 
         @Override
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/VectorND.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/VectorND.java
index d938d22b87..fb2c7f70d1 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/VectorND.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/VectorND.java
@@ -23,12 +23,7 @@ package org.apache.sis.maths;
  */
 public abstract class VectorND<T extends VectorND<T>> extends AbstractVector<T> {
 
-    public VectorND(int size) {
-        super(size);
-    }
-
-    protected VectorND(SampleSystem type) {
-        super(type);
+    public VectorND() {
     }
 
     public static class Byte extends VectorND<Byte> {
@@ -36,22 +31,14 @@ public abstract class VectorND<T extends VectorND<T>> extends AbstractVector<T>
         public final byte[] values;
 
         public Byte(int size) {
-            super(size);
             this.values = new byte[size];
         }
 
-        public Byte(SampleSystem type) {
-            super(type);
-            this.values = new byte[type.getSize()];
-        }
-
         public Byte(byte[] array) {
-            super(array.length);
             this.values = array.clone();
         }
 
         public Byte(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
             this.values = new byte[tuple.getDimension()];
             set(tuple);
         }
@@ -99,22 +86,14 @@ public abstract class VectorND<T extends VectorND<T>> extends AbstractVector<T>
         public final byte[] values;
 
         public UByte(int size) {
-            super(size);
             this.values = new byte[size];
         }
 
-        public UByte(SampleSystem type) {
-            super(type);
-            this.values = new byte[type.getSize()];
-        }
-
         public UByte(byte[] array) {
-            super(array.length);
             this.values = array.clone();
         }
 
         public UByte(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
             this.values = new byte[tuple.getDimension()];
             set(tuple);
         }
@@ -162,22 +141,14 @@ public abstract class VectorND<T extends VectorND<T>> extends AbstractVector<T>
         public final short[] values;
 
         public Short(int size) {
-            super(size);
             this.values = new short[size];
         }
 
-        public Short(SampleSystem type) {
-            super(type);
-            this.values = new short[type.getSize()];
-        }
-
         public Short(short[] array) {
-            super(array.length);
             this.values = array.clone();
         }
 
         public Short(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
             this.values = new short[tuple.getDimension()];
             set(tuple);
         }
@@ -225,22 +196,14 @@ public abstract class VectorND<T extends VectorND<T>> extends AbstractVector<T>
         public final short[] values;
 
         public UShort(int size) {
-            super(size);
             this.values = new short[size];
         }
 
-        public UShort(SampleSystem type) {
-            super(type);
-            this.values = new short[type.getSize()];
-        }
-
         public UShort(short[] array) {
-            super(array.length);
             this.values = array.clone();
         }
 
         public UShort(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
             this.values = new short[tuple.getDimension()];
             set(tuple);
         }
@@ -288,22 +251,14 @@ public abstract class VectorND<T extends VectorND<T>> extends AbstractVector<T>
         public final int[] values;
 
         public Int(int size) {
-            super(size);
             this.values = new int[size];
         }
 
-        public Int(SampleSystem type) {
-            super(type);
-            this.values = new int[type.getSize()];
-        }
-
         public Int(int[] array) {
-            super(array.length);
             this.values = array.clone();
         }
 
         public Int(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
             this.values = new int[tuple.getDimension()];
             set(tuple);
         }
@@ -351,22 +306,14 @@ public abstract class VectorND<T extends VectorND<T>> extends AbstractVector<T>
         public final int[] values;
 
         public UInt(int size) {
-            super(size);
             this.values = new int[size];
         }
 
-        public UInt(SampleSystem type) {
-            super(type);
-            this.values = new int[type.getSize()];
-        }
-
         public UInt(int[] array) {
-            super(array.length);
             this.values = array.clone();
         }
 
         public UInt(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
             this.values = new int[tuple.getDimension()];
             set(tuple);
         }
@@ -414,22 +361,14 @@ public abstract class VectorND<T extends VectorND<T>> extends AbstractVector<T>
         public final long[] values;
 
         public Long(int size) {
-            super(size);
             this.values = new long[size];
         }
 
-        public Long(SampleSystem type) {
-            super(type);
-            this.values = new long[type.getSize()];
-        }
-
         public Long(long[] array) {
-            super(array.length);
             this.values = array.clone();
         }
 
         public Long(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
             this.values = new long[tuple.getDimension()];
             set(tuple);
         }
@@ -477,22 +416,14 @@ public abstract class VectorND<T extends VectorND<T>> extends AbstractVector<T>
         public final float[] values;
 
         public Float(int size) {
-            super(size);
             this.values = new float[size];
         }
 
-        public Float(SampleSystem type) {
-            super(type);
-            this.values = new float[type.getSize()];
-        }
-
         public Float(float[] array) {
-            super(array.length);
             this.values = array.clone();
         }
 
         public Float(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
             this.values = new float[tuple.getDimension()];
             set(tuple);
         }
@@ -535,28 +466,19 @@ public abstract class VectorND<T extends VectorND<T>> extends AbstractVector<T>
         public final double[] values;
 
         public Double(int size) {
-            super(size);
             this.values = new double[size];
         }
 
-        public Double(SampleSystem type) {
-            super(type);
-            this.values = new double[type.getSize()];
-        }
-
         public Double(double[] array) {
-            super(array.length);
             this.values = array.clone();
         }
 
         public Double(float[] array) {
-            super(array.length);
             this.values = new double[array.length];
             set(array);
         }
 
         public Double(ReadOnly.Vector<?> tuple) {
-            super(tuple.getSampleSystem());
             this.values = new double[tuple.getDimension()];
             set(tuple);
         }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/VectorUnmodifiable.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/VectorUnmodifiable.java
index 83687c6717..7f587d0374 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/VectorUnmodifiable.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/VectorUnmodifiable.java
@@ -17,7 +17,6 @@
 package org.apache.sis.maths;
 
 import org.opengis.geometry.DirectPosition;
-import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.util.ArgumentChecks;
 
 
@@ -31,16 +30,10 @@ final class VectorUnmodifiable extends AbstractVector<VectorUnmodifiable> {
     private final Vector<?> parent;
 
     public VectorUnmodifiable(Vector<?> parent) {
-        super(parent.getSampleSystem());
         ArgumentChecks.ensureNonNull("parent", parent);
         this.parent = parent;
     }
 
-    @Override
-    public SampleSystem getSampleSystem() {
-        return parent.getSampleSystem();
-    }
-
     @Override
     public int getDimension() {
         return parent.getDimension();
@@ -161,9 +154,4 @@ final class VectorUnmodifiable extends AbstractVector<VectorUnmodifiable> {
         return parent.isAll(value);
     }
 
-    @Override
-    public CoordinateReferenceSystem getCoordinateReferenceSystem() {
-        return parent.getCoordinateReferenceSystem();
-    }
-
 }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vectors.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vectors.java
index 1fced5a5e6..a32fe24ccd 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vectors.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Vectors.java
@@ -36,175 +36,126 @@ import org.apache.sis.util.ArgumentChecks;
  */
 public final class Vectors {
 
-    public static Vector<?> create(CoordinateReferenceSystem crs, DataType dataType) {
-        return create(SampleSystem.of(crs), dataType);
-    }
-
-    public static Vector<?> create(SampleSystem type, DataType dataType) {
-        final int size = type.getSize();
+    public static Vector<?> create(int size, DataType dataType) {
         switch (dataType) {
             case BYTE :
                 return switch (size) {
-                    case 1 -> new Vector1D.Byte(type, false);
-                    case 2 -> new Vector2D.Byte(type, false);
-                    case 3 -> new Vector3D.Byte(type, false);
-                    case 4 -> new Vector4D.Byte(type, false);
-                    default -> new VectorND.Byte(type);
+                    case 1 -> new Vector1D.Byte();
+                    case 2 -> new Vector2D.Byte();
+                    case 3 -> new Vector3D.Byte();
+                    case 4 -> new Vector4D.Byte();
+                    default -> new VectorND.Byte(size);
                 };
             case UBYTE :
                 return switch (size) {
-                    case 1 -> new Vector1D.UByte(type, false);
-                    case 2 -> new Vector2D.UByte(type, false);
-                    case 3 -> new Vector3D.UByte(type, false);
-                    case 4 -> new Vector4D.UByte(type, false);
-                    default -> new VectorND.UByte(type);
+                    case 1 -> new Vector1D.UByte();
+                    case 2 -> new Vector2D.UByte();
+                    case 3 -> new Vector3D.UByte();
+                    case 4 -> new Vector4D.UByte();
+                    default -> new VectorND.UByte(size);
                 };
             case SHORT :
                 return switch (size) {
-                    case 1 -> new Vector1D.Short(type, false);
-                    case 2 -> new Vector2D.Short(type, false);
-                    case 3 -> new Vector3D.Short(type, false);
-                    case 4 -> new Vector4D.Short(type, false);
-                    default -> new VectorND.Short(type);
+                    case 1 -> new Vector1D.Short();
+                    case 2 -> new Vector2D.Short();
+                    case 3 -> new Vector3D.Short();
+                    case 4 -> new Vector4D.Short();
+                    default -> new VectorND.Short(size);
                 };
             case USHORT :
                 return switch (size) {
-                    case 1 -> new Vector1D.UShort(type, false);
-                    case 2 -> new Vector2D.UShort(type, false);
-                    case 3 -> new Vector3D.UShort(type, false);
-                    case 4 -> new Vector4D.UShort(type, false);
-                    default -> new VectorND.UShort(type);
+                    case 1 -> new Vector1D.UShort();
+                    case 2 -> new Vector2D.UShort();
+                    case 3 -> new Vector3D.UShort();
+                    case 4 -> new Vector4D.UShort();
+                    default -> new VectorND.UShort(size);
                 };
             case INT :
                 return switch (size) {
-                    case 1 -> new Vector1D.Int(type, false);
-                    case 2 -> new Vector2D.Int(type, false);
-                    case 3 -> new Vector3D.Int(type, false);
-                    case 4 -> new Vector4D.Int(type, false);
-                    default -> new VectorND.Int(type);
+                    case 1 -> new Vector1D.Int();
+                    case 2 -> new Vector2D.Int();
+                    case 3 -> new Vector3D.Int();
+                    case 4 -> new Vector4D.Int();
+                    default -> new VectorND.Int(size);
                 };
             case UINT :
                 return switch (size) {
-                    case 1 -> new Vector1D.UInt(type, false);
-                    case 2 -> new Vector2D.UInt(type, false);
-                    case 3 -> new Vector3D.UInt(type, false);
-                    case 4 -> new Vector4D.UInt(type, false);
-                    default -> new VectorND.UInt(type);
+                    case 1 -> new Vector1D.UInt();
+                    case 2 -> new Vector2D.UInt();
+                    case 3 -> new Vector3D.UInt();
+                    case 4 -> new Vector4D.UInt();
+                    default -> new VectorND.UInt(size);
                 };
             case LONG :
                 return switch (size) {
-                    case 1 -> new Vector1D.Long(type, false);
-                    case 2 -> new Vector2D.Long(type, false);
-                    case 3 -> new Vector3D.Long(type, false);
-                    case 4 -> new Vector4D.Long(type, false);
-                    default -> new VectorND.Long(type);
+                    case 1 -> new Vector1D.Long();
+                    case 2 -> new Vector2D.Long();
+                    case 3 -> new Vector3D.Long();
+                    case 4 -> new Vector4D.Long();
+                    default -> new VectorND.Long(size);
                 };
             case FLOAT :
                 return switch (size) {
-                    case 1 -> new Vector1D.Float(type, false);
-                    case 2 -> new Vector2D.Float(type, false);
-                    case 3 -> new Vector3D.Float(type, false);
-                    case 4 -> new Vector4D.Float(type, false);
-                    default -> new VectorND.Float(type);
+                    case 1 -> new Vector1D.Float();
+                    case 2 -> new Vector2D.Float();
+                    case 3 -> new Vector3D.Float();
+                    case 4 -> new Vector4D.Float();
+                    default -> new VectorND.Float(size);
                 };
             case DOUBLE :
                 return switch (size) {
-                    case 1 -> new Vector1D.Double(type, false);
-                    case 2 -> new Vector2D.Double(type, false);
-                    case 3 -> new Vector3D.Double(type, false);
-                    case 4 -> new Vector4D.Double(type, false);
-                    default -> new VectorND.Double(type);
+                    case 1 -> new Vector1D.Double();
+                    case 2 -> new Vector2D.Double();
+                    case 3 -> new Vector3D.Double();
+                    case 4 -> new Vector4D.Double();
+                    default -> new VectorND.Double(size);
                 };
             default :
                 throw new UnsupportedOperationException("Unexpected data type");
         }
     }
 
-    public static Vector<?> create(int dimension, DataType dataType) {
-        return create(SampleSystem.ofSize(dimension), dataType);
-    }
-
     public static Vector<?> createByte(int dimension) {
-        return create(SampleSystem.ofSize(dimension), DataType.BYTE);
-    }
-
-    public static Vector<?> createByte(CoordinateReferenceSystem crs) {
-        return create(crs, DataType.BYTE);
+        return create(dimension, DataType.BYTE);
     }
 
     public static Vector<?> createUByte(int dimension) {
-        return create(SampleSystem.ofSize(dimension), DataType.UBYTE);
-    }
-
-    public static Vector<?> createUByte(CoordinateReferenceSystem crs) {
-        return create(crs, DataType.UBYTE);
+        return create(dimension, DataType.UBYTE);
     }
 
     public static Vector<?> createShort(int dimension) {
-        return create(SampleSystem.ofSize(dimension), DataType.SHORT);
-    }
-
-    public static Vector<?> createShort(CoordinateReferenceSystem crs) {
-        return create(crs, DataType.SHORT);
+        return create(dimension, DataType.SHORT);
     }
 
     public static Vector<?> createUShort(int dimension) {
-        return create(SampleSystem.ofSize(dimension), DataType.USHORT);
-    }
-
-    public static Vector<?> createUShort(CoordinateReferenceSystem crs) {
-        return create(crs, DataType.USHORT);
+        return create(dimension, DataType.USHORT);
     }
 
     public static Vector<?> createInt(int dimension) {
-        return create(SampleSystem.ofSize(dimension), DataType.INT);
-    }
-
-    public static Vector<?> createInt(CoordinateReferenceSystem crs) {
-        return create(crs, DataType.INT);
+        return create(dimension, DataType.INT);
     }
 
     public static Vector<?> createUInt(int dimension) {
-        return create(SampleSystem.ofSize(dimension), DataType.UINT);
-    }
-
-    public static Vector<?> createUInt(CoordinateReferenceSystem crs) {
-        return create(crs, DataType.UINT);
+        return create(dimension, DataType.UINT);
     }
 
     public static Vector<?> createLong(int dimension) {
-        return create(SampleSystem.ofSize(dimension), DataType.LONG);
-    }
-
-    public static Vector<?> createLong(CoordinateReferenceSystem crs) {
-        return create(crs, DataType.LONG);
+        return create(dimension, DataType.LONG);
     }
 
     public static Vector<?> createFloat(int dimension) {
-        return create(SampleSystem.ofSize(dimension), DataType.FLOAT);
-    }
-
-    public static Vector<?> createFloat(CoordinateReferenceSystem crs) {
-        return create(crs, DataType.FLOAT);
+        return create(dimension, DataType.FLOAT);
     }
 
     public static Vector<?> createDouble(int dimension) {
-        return create(SampleSystem.ofSize(dimension), DataType.DOUBLE);
-    }
-
-    public static Vector<?> createDouble(CoordinateReferenceSystem crs) {
-        return create(crs, DataType.DOUBLE);
+        return create(dimension, DataType.DOUBLE);
     }
 
     public static Vector<?> castOrCopy(DirectPosition pos) {
         if (pos instanceof Vector) {
             return (Vector) pos;
         } else {
-            final CoordinateReferenceSystem crs = pos.getCoordinateReferenceSystem();
-            final SampleSystem type = crs != null ? SampleSystem.of(pos.getCoordinateReferenceSystem()) :
-                   SampleSystem.ofSize(pos.getDimension());
-            final DataType dt = DataType.DOUBLE;
-            final Vector v = create(type, dt);
+            final Vector v = create(pos.getDimension(), DataType.DOUBLE);
             v.set(pos);
             return v;
         }
@@ -214,13 +165,23 @@ public final class Vectors {
         if (pos instanceof Vector) {
             return (Vector) pos;
         } else {
-            CoordinateReferenceSystem crs = pos.getCoordinateReferenceSystem();
-            return crs == null ? new WrapDirectPostion(pos, pos.getDimension()) : new WrapDirectPostion(pos);
+            return new WrapDirectPostion(pos);
         }
     }
 
     public static DirectPosition asDirectPostion(Vector<?> tuple) {
-        return new AsDirectPosition(tuple);
+        return new AsDirectPosition(tuple, null);
+    }
+
+    /**
+     * Create a direct position view of the given tuple, in the given reference system.
+     *
+     * @param tuple not null
+     * @param crs reference system of the position, may be null
+     * @return direct position view of the tuple.
+     */
+    public static DirectPosition asDirectPostion(Vector<?> tuple, CoordinateReferenceSystem crs) {
+        return new AsDirectPosition(tuple, crs);
     }
     /**
      * Create an unmodifiable view of the given tuple.
@@ -236,13 +197,7 @@ public final class Vectors {
 
         private final DirectPosition pos;
 
-        public WrapDirectPostion(DirectPosition pos, int size) {
-            super(size);
-            this.pos = pos;
-        }
-
         public WrapDirectPostion(DirectPosition pos) {
-            super(pos.getCoordinateReferenceSystem());
             this.pos = pos;
         }
 
@@ -275,14 +230,16 @@ public final class Vectors {
     private static class AsDirectPosition implements DirectPosition {
 
         private final Vector tuple;
+        private final CoordinateReferenceSystem crs;
 
-        public AsDirectPosition(Vector tuple) {
+        public AsDirectPosition(Vector tuple, CoordinateReferenceSystem crs) {
             this.tuple = tuple;
+            this.crs = crs;
         }
 
         @Override
         public CoordinateReferenceSystem getCoordinateReferenceSystem() {
-            return tuple.getCoordinateReferenceSystem();
+            return crs;
         }
 
         @Override
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/cs/BearingTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/cs/BearingTest.java
index feaf3c3671..ac8c79fa3b 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/cs/BearingTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/cs/BearingTest.java
@@ -20,6 +20,8 @@ import java.util.Arrays;
 import javax.measure.Quantity;
 import javax.measure.quantity.Angle;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.maths.Sample;
+import org.apache.sis.maths.SampleSystem;
 import org.apache.sis.maths.Vector;
 import org.apache.sis.maths.Vectors;
 import org.apache.sis.measure.Quantities;
@@ -67,14 +69,17 @@ public class BearingTest {
     }
 
     /**
-     * Creates a vector with the given coordinates in the given coordinate reference system.
+     * Creates a direction with the given coordinates in the given coordinate reference system.
      */
-    private static Vector<?> vector(final CoordinateReferenceSystem crs, final double... values) {
-        final Vector<?> v = Vectors.createDouble(crs);
-        for (int i=0; i<values.length; i++) {
-            v.set(i, values[i]);
-        }
-        return v;
+    private static Sample direction(final CoordinateReferenceSystem crs, final double... values) {
+        return new Sample(SampleSystem.of(crs), vector(values));
+    }
+
+    /**
+     * Creates a bearing measured clockwise from true north in the direction of the given sample.
+     */
+    private static Bearing ofDirection(final Sample direction) {
+        return Bearing.ofDirection(direction, FixedDirection.TRUE_NORTH, Rotation.CLOCKWISE);
     }
 
     /**
@@ -375,10 +380,10 @@ public class BearingTest {
          */
         final CoordinateReferenceSystem eastNorth = CommonCRS.WGS84.normalizedGeographic();
         final CoordinateReferenceSystem northEast = CommonCRS.WGS84.geographic();
-        assertEquals(90, azimuth(Bearing.ofDirection(vector(eastNorth, 1, 0))), TOLERANCE, "Toward east.");
-        assertEquals(90, azimuth(Bearing.ofDirection(vector(northEast, 0, 1))), TOLERANCE, "Toward east.");
-        assertEquals( 0, azimuth(Bearing.ofDirection(vector(eastNorth, 0, 1))), TOLERANCE, "Toward north.");
-        assertEquals( 0, azimuth(Bearing.ofDirection(vector(northEast, 1, 0))), TOLERANCE, "Toward north.");
+        assertEquals(90, azimuth(ofDirection(direction(eastNorth, 1, 0))), TOLERANCE, "Toward east.");
+        assertEquals(90, azimuth(ofDirection(direction(northEast, 0, 1))), TOLERANCE, "Toward east.");
+        assertEquals( 0, azimuth(ofDirection(direction(eastNorth, 0, 1))), TOLERANCE, "Toward north.");
+        assertEquals( 0, azimuth(ofDirection(direction(northEast, 1, 0))), TOLERANCE, "Toward north.");
     }
 
     @Test
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/ArraySequenceTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/ArraySequenceTest.java
index d5d7f9cab4..b432b6291b 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/ArraySequenceTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/ArraySequenceTest.java
@@ -21,7 +21,6 @@ import java.util.Map;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.geometries.Point;
 import org.apache.sis.geometries.DataPointsType;
-import org.apache.sis.maths.SampleSystem;
 import org.apache.sis.maths.NDArrays;
 import org.apache.sis.maths.Vector2D;
 import org.apache.sis.maths.Vector3D;
@@ -105,15 +104,14 @@ public class ArraySequenceTest {
         assertEquals(normals, array.getAttribute(DataPointsType.ATT_NORMAL));
         assertEquals(2, array.getAttributeNames().size());
 
-        final SampleSystem ss = SampleSystem.of(crs);
-        assertEquals(new Vector2D.Double(ss, 0, 0), array.getAttribute(0, DataPointsType.ATT_POSITION));
-        assertEquals(new Vector2D.Double(ss, 1, 0), array.getAttribute(1, DataPointsType.ATT_POSITION));
-        assertEquals(new Vector2D.Double(ss, 0, 1), array.getAttribute(2, DataPointsType.ATT_POSITION));
-        assertEquals(new Vector2D.Double(ss, 0, 0), array.getAttribute(3, DataPointsType.ATT_POSITION));
-        assertEquals(new Vector2D.Double(ss, 0, 0), array.getPosition(0));
-        assertEquals(new Vector2D.Double(ss, 1, 0), array.getPosition(1));
-        assertEquals(new Vector2D.Double(ss, 0, 1), array.getPosition(2));
-        assertEquals(new Vector2D.Double(ss, 0, 0), array.getPosition(3));
+        assertEquals(new Vector2D.Double(0, 0), array.getAttribute(0, DataPointsType.ATT_POSITION));
+        assertEquals(new Vector2D.Double(1, 0), array.getAttribute(1, DataPointsType.ATT_POSITION));
+        assertEquals(new Vector2D.Double(0, 1), array.getAttribute(2, DataPointsType.ATT_POSITION));
+        assertEquals(new Vector2D.Double(0, 0), array.getAttribute(3, DataPointsType.ATT_POSITION));
+        assertEquals(new Vector2D.Double(0, 0), array.getPosition(0));
+        assertEquals(new Vector2D.Double(1, 0), array.getPosition(1));
+        assertEquals(new Vector2D.Double(0, 1), array.getPosition(2));
+        assertEquals(new Vector2D.Double(0, 0), array.getPosition(3));
         assertEquals(new Vector3D.Double(1, 0, 0), array.getAttribute(0, DataPointsType.ATT_NORMAL));
         assertEquals(new Vector3D.Double(0, 1, 0), array.getAttribute(1, DataPointsType.ATT_NORMAL));
         assertEquals(new Vector3D.Double(0, 0, 1), array.getAttribute(2, DataPointsType.ATT_NORMAL));
@@ -123,10 +121,10 @@ public class ArraySequenceTest {
         Point pt1 = array.getPoint(1);
         Point pt2 = array.getPoint(2);
         Point pt3 = array.getPoint(3);
-        assertEquals(new Vector2D.Double(ss, 0, 0), pt0.getPosition());
-        assertEquals(new Vector2D.Double(ss, 1, 0), pt1.getPosition());
-        assertEquals(new Vector2D.Double(ss, 0, 1), pt2.getPosition());
-        assertEquals(new Vector2D.Double(ss, 0, 0), pt3.getPosition());
+        assertEquals(new Vector2D.Double(0, 0), pt0.getPosition());
+        assertEquals(new Vector2D.Double(1, 0), pt1.getPosition());
+        assertEquals(new Vector2D.Double(0, 1), pt2.getPosition());
+        assertEquals(new Vector2D.Double(0, 0), pt3.getPosition());
         assertEquals(new Vector3D.Double(1, 0, 0), pt0.getAttribute(DataPointsType.ATT_NORMAL));
         assertEquals(new Vector3D.Double(0, 1, 0), pt1.getAttribute(DataPointsType.ATT_NORMAL));
         assertEquals(new Vector3D.Double(0, 0, 1), pt2.getAttribute(DataPointsType.ATT_NORMAL));
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/CurveLinearizerTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/CurveLinearizerTest.java
index 42eab9b13d..30fccf4de6 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/CurveLinearizerTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/CurveLinearizerTest.java
@@ -259,8 +259,8 @@ public class CurveLinearizerTest extends GeometryTest {
     public void testInvalid() {
         final var arc = F.createArcByCenterPoint(F.createPoint(CRS_2D, 10, 20), 5, null, 0, 90);
         assertThrows(IllegalArgumentException.class, () -> arc.asLine(degrees(-1), null));
-        final Vector<?> origin = Vectors.createDouble(CRS_2D).set(new double[] {0, 0});
-        final Vector<?> direction = Vectors.createDouble(CRS_2D).set(new double[] {1, 0});
-        assertThrows(UnsupportedOperationException.class, () -> F.createRay(origin, direction).asLine(null, null));
+        final Vector<?> origin = Vectors.createDouble(2).set(new double[] {0, 0});
+        final Vector<?> direction = Vectors.createDouble(2).set(new double[] {1, 0});
+        assertThrows(UnsupportedOperationException.class, () -> F.createRay(CRS_2D, origin, direction).asLine(null, null));
     }
 }
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/mesh/PrimitiveVisitorTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/mesh/PrimitiveVisitorTest.java
index 4da049c0c7..618685b35c 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/mesh/PrimitiveVisitorTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/mesh/PrimitiveVisitorTest.java
@@ -25,9 +25,6 @@ import org.apache.sis.geometries.Point;
 import org.apache.sis.geometries.curve.LineString;
 import org.apache.sis.geometries.surface.TIN;
 import org.apache.sis.geometries.surface.Triangle;
-import org.apache.sis.geometries.mesh.MeshPrimitive;
-import org.apache.sis.geometries.mesh.MeshPrimitiveVisitor;
-import org.apache.sis.maths.SampleSystem;
 import org.apache.sis.maths.NDArrays;
 import org.apache.sis.maths.Vector3D;
 
@@ -44,7 +41,6 @@ import org.junit.jupiter.api.Test;
 public class PrimitiveVisitorTest {
 
     private static final CoordinateReferenceSystem CRS3D = Geometries.getUndefinedCRS(3);
-    private static final SampleSystem SS3D = SampleSystem.of(CRS3D);
 
     /**
      * Test visiting Points.
@@ -68,8 +64,8 @@ public class PrimitiveVisitorTest {
 
         //test geometry collection
         assertEquals(2, geometry.getNumGeometries());
-        assertEquals(new Vector3D.Double(SS3D, 0, 1, 2), geometry.getGeometryN(0).getPosition());
-        assertEquals(new Vector3D.Double(SS3D, 3, 4, 5), geometry.getGeometryN(1).getPosition());
+        assertEquals(new Vector3D.Double(0, 1, 2), geometry.getGeometryN(0).getPosition());
+        assertEquals(new Vector3D.Double(3, 4, 5), geometry.getGeometryN(1).getPosition());
     }
 
     /**
@@ -97,9 +93,9 @@ public class PrimitiveVisitorTest {
 
         //test geometry collection
         assertEquals(3, geometry.getNumGeometries());
-        assertEquals(new Vector3D.Double(SS3D, 0, 1, 2), geometry.getGeometryN(0).getPosition());
-        assertEquals(new Vector3D.Double(SS3D, 3, 4, 5), geometry.getGeometryN(1).getPosition());
-        assertEquals(new Vector3D.Double(SS3D, 0, 1, 2), geometry.getGeometryN(2).getPosition());
+        assertEquals(new Vector3D.Double(0, 1, 2), geometry.getGeometryN(0).getPosition());
+        assertEquals(new Vector3D.Double(3, 4, 5), geometry.getGeometryN(1).getPosition());
+        assertEquals(new Vector3D.Double(0, 1, 2), geometry.getGeometryN(2).getPosition());
     }
 
     /**
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/AbstractVectorTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/AbstractVectorTest.java
index c32d3223ba..872fb6fae2 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/AbstractVectorTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/AbstractVectorTest.java
@@ -264,7 +264,7 @@ public abstract class AbstractVectorTest {
         for (final DataType dataType : new DataType[] {DataType.DOUBLE, DataType.FLOAT, DataType.SHORT}) {
             final Vector<?> copy = tuple.copy(dataType);
             assertEquals(dataType, copy.getDataType());
-            assertEquals(tuple.getSampleSystem(), copy.getSampleSystem());
+            assertEquals(tuple.getDimension(), copy.getDimension());
             assertArrayEquals(tuple.toArrayDouble(), copy.toArrayDouble(), TOLERANCE);
             //the copy is independent of the original tuple
             copy.set(0, 100);
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/NDRangeTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/NDRangeTest.java
index 156e6cc146..56be0ad434 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/NDRangeTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/NDRangeTest.java
@@ -37,11 +37,11 @@ public class NDRangeTest {
     private static final CoordinateReferenceSystem CRS = CommonCRS.WGS84.geographic3D();
 
     private static Vector<?> vector(double... values) {
-        return Vectors.create(CRS, DataType.DOUBLE).set(values);
+        return Vectors.create(values.length, DataType.DOUBLE).set(values);
     }
 
     private static NDRange range() {
-        return new NDRange(vector(1, 2, 3), vector(4, 6, 3));
+        return new NDRange(CRS, vector(1, 2, 3), vector(4, 6, 3));
     }
 
     @Test
@@ -108,8 +108,8 @@ public class NDRangeTest {
     public void testInvalid() {
         assertThrows(MismatchedDimensionException.class, () -> new NDRange(vector(1, 2, 3), Vectors.createDouble(2)));
         assertThrows(IllegalArgumentException.class, () -> new NDRange(vector(5, 2, 3), vector(4, 6, 3)));
-        assertThrows(IllegalArgumentException.class, () -> new NDRange(vector(1, 2, 3),
-                Vectors.create(CommonCRS.WGS84.geocentric(), DataType.DOUBLE).set(new double[] {4, 6, 3})));
+        assertThrows(MismatchedDimensionException.class, () -> new NDRange(CommonCRS.WGS84.geographic(),
+                vector(1, 2, 3), vector(4, 6, 3)));
     }
 
     /**
@@ -129,7 +129,7 @@ public class NDRangeTest {
         assertArrayEquals(new double[] {4, 6, 10}, range.getUpper().toArrayDouble());
         // A position inside the range does not change it.
         range.add(vector(2, 3, 5));
-        assertEquals(new NDRange(vector(0, 2, 3), vector(4, 6, 10)), range);
+        assertEquals(new NDRange(CRS, vector(0, 2, 3), vector(4, 6, 10)), range);
         // A NaN coordinate is ignored.
         range.add(vector(Double.NaN, 8, 5));
         assertArrayEquals(new double[] {4, 8, 10}, range.getUpper().toArrayDouble());
diff --git a/incubator/src/org.apache.sis.referencing.dggs/test/org/apache/sis/referencing/dggs/AbstractDggrsTest.java b/incubator/src/org.apache.sis.referencing.dggs/test/org/apache/sis/referencing/dggs/AbstractDggrsTest.java
index 4001aef534..0e50026b91 100644
--- a/incubator/src/org.apache.sis.referencing.dggs/test/org/apache/sis/referencing/dggs/AbstractDggrsTest.java
+++ b/incubator/src/org.apache.sis.referencing.dggs/test/org/apache/sis/referencing/dggs/AbstractDggrsTest.java
@@ -192,7 +192,7 @@ public abstract class AbstractDggrsTest {
                     final Vector corner = ps.getPosition(i);
 
                     calculator.setStartPoint(center);
-                    calculator.setEndPoint(Vectors.asDirectPostion(corner));
+                    calculator.setEndPoint(Vectors.asDirectPostion(corner, ps.getCoordinateReferenceSystem()));
                     double distance = calculator.getGeodesicDistance();
                     calculator.setGeodesicDistance(distance * 0.99);
                     final Vector2D.Double candidateInside = new Vector2D.Double(calculator.getEndPoint().getCoordinates());


(sis) 11/13: feat(Geometry): add draft topology interfaces

Posted by js...@apache.org.
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

commit 3337cb71bae057805ce04f831a34d3e6c10f65c4
Author: jsorel <jo...@geomatys.com>
AuthorDate: Mon Oct 19 11:50:04 2026 +0200

    feat(Geometry): add draft topology interfaces
---
 .../org.apache.sis.geometry/main/module-info.java  |   1 +
 .../org/apache/sis/geometries/package-info.java    |   3 +-
 .../apache/sis/geometries/topology/Complex.java    | 116 ++++++++++++
 .../sis/geometries/topology/ComplexData.java       |  45 +++++
 .../sis/geometries/topology/DirectedEdge.java      | 101 +++++++++++
 .../sis/geometries/topology/DirectedFace.java      |  97 +++++++++++
 .../sis/geometries/topology/DirectedNode.java      |  97 +++++++++++
 .../sis/geometries/topology/DirectedSolid.java     |  98 +++++++++++
 .../sis/geometries/topology/DirectedTopo.java      |  94 ++++++++++
 .../org/apache/sis/geometries/topology/Edge.java   |  87 +++++++++
 .../apache/sis/geometries/topology/Expression.java | 194 +++++++++++++++++++++
 .../sis/geometries/topology/ExpressionTerm.java    |  69 ++++++++
 .../org/apache/sis/geometries/topology/Face.java   | 104 +++++++++++
 .../org/apache/sis/geometries/topology/Node.java   |  75 ++++++++
 .../apache/sis/geometries/topology/Primitive.java  | 140 +++++++++++++++
 .../sis/geometries/topology/PrimitiveData.java     |  34 ++++
 .../org/apache/sis/geometries/topology/Solid.java  |  87 +++++++++
 .../apache/sis/geometries/topology/Topology.java   | 167 ++++++++++++++++++
 .../sis/geometries/topology/TopologyData.java      |  72 ++++++++
 .../sis/geometries/topology/package-info.java      |  65 +++++++
 20 files changed, 1745 insertions(+), 1 deletion(-)

diff --git a/incubator/src/org.apache.sis.geometry/main/module-info.java b/incubator/src/org.apache.sis.geometry/main/module-info.java
index a7a0f82480..2a2df363ce 100644
--- a/incubator/src/org.apache.sis.geometry/main/module-info.java
+++ b/incubator/src/org.apache.sis.geometry/main/module-info.java
@@ -40,6 +40,7 @@ module org.apache.sis.geometry {
     exports org.apache.sis.geometries.solid;
     exports org.apache.sis.geometries.solid.polyhedron;
     exports org.apache.sis.geometries.spherical;
+    exports org.apache.sis.geometries.topology;
     exports org.apache.sis.gml;
     exports org.apache.sis.maths;
     exports org.apache.sis.scene;
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/package-info.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/package-info.java
index 17330bc505..f7f41d86f9 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/package-info.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/package-info.java
@@ -66,7 +66,8 @@
  * Geometry.getMaximalComplex returns a Geometry until this interface exists.
  *
  * <h3>TODO : ISO 19107 Topology (Section 10)</h3>
- * No interface or implementation yet.
+ * {@link org.apache.sis.geometries.topology} declares the interfaces, no implementation yet.
+ * The derived topological relations (Section 10.8) are not declared.
  * This work would be interesting to formalize delaunay classes.
  *
  * <h3>TODO : ISO 19107 Simplex (Section 11.1)</h3>
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Complex.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Complex.java
new file mode 100644
index 0000000000..a90c35fcf1
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Complex.java
@@ -0,0 +1,116 @@
+/*
+ * 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.topology;
+
+import java.util.Set;
+import static org.opengis.annotation.Specification.ISO_19107;
+import org.opengis.annotation.UML;
+import org.apache.sis.geometries.Geometry;
+
+
+/**
+ * A set of topological primitives, organized so that set operations on its elements perform the equivalent
+ * operations on the positions of its geometric realization.
+ *
+ * <p>A complex of dimension <var>n</var> contains primitives of every dimension up to <var>n</var>:
+ * a 2-dimensional complex contains faces, edges and nodes, while a 1-dimensional complex, or graph,
+ * contains only edges and nodes.</p>
+ *
+ * <p>The default construction of a topological complex is from a geometric collection, which then becomes
+ * its geometric realization. Only a collection of geometric primitives with mutually disjoint interiors
+ * gives a complex without error. Java interfaces have no constructor, so this is left to implementations.</p>
+ *
+ * @author Johann Sorel (Geomatys)
+ *
+ * @see ISO 19107:2019 - 10.7.2
+ */
+@UML(identifier="Complex", specification=ISO_19107)
+public interface Complex extends Topology {
+
+    /**
+     * The primitives of this complex. This association makes a complex a set of primitives,
+     * and the set operations of the {@linkplain #getSubComplexes() Contains} association agree with it.
+     *
+     * @return primitives of this complex, at least one.
+     *
+     * @see ISO 19107:2019 - 10.7.2.7
+     */
+    @UML(identifier="element", specification=ISO_19107)
+    Set<Primitive> getElements();
+
+    /**
+     * Returns {@code true} if this complex is not contained in any larger complex.
+     *
+     * <p>The default implementation tests whether this complex is its own
+     * {@linkplain #getMaximalComplex() maximal complex}.</p>
+     *
+     * @return whether this complex is maximal.
+     *
+     * @see ISO 19107:2019 - 10.7.2.4
+     */
+    @UML(identifier="isMaximal", specification=ISO_19107)
+    default boolean isMaximal() {
+        return equals(getMaximalComplex());
+    }
+
+    /**
+     * Returns {@code true} if this complex is topologically connected, which implies that its geometric
+     * realization is connected too. It is the case when every primitive of this complex is linked to every
+     * other one by a sequence of boundary, co-boundary and isolated-in associations whose intermediate
+     * primitives all belong to this complex.
+     *
+     * @return whether this complex is connected.
+     *
+     * @see ISO 19107:2019 - 10.7.2.5
+     */
+    @UML(identifier="isConnected", specification=ISO_19107)
+    boolean isConnected();
+
+    /**
+     * The complexes contained in this complex, as sets of primitives.
+     *
+     * @return complexes contained in this complex.
+     *
+     * @see ISO 19107:2019 - 10.7.2.6
+     */
+    @UML(identifier="subComplex", specification=ISO_19107)
+    Set<Complex> getSubComplexes();
+
+    /**
+     * The complexes containing this complex, as sets of primitives.
+     *
+     * @return complexes containing this complex.
+     *
+     * @see ISO 19107:2019 - 10.7.2.6
+     */
+    @UML(identifier="superComplex", specification=ISO_19107)
+    Set<Complex> getSuperComplexes();
+
+    /**
+     * The geometric complex this complex represents, or {@code null} if none.
+     *
+     * <p>Difference with ISO 19107: the standard returns a geometric complex,
+     * a type which does not exist yet in this module.</p>
+     *
+     * @return geometric realization of this complex, or {@code null} if none.
+     *
+     * @see ISO 19107:2019 - 10.7.2.8
+     */
+    @UML(identifier="geometry", specification=ISO_19107)
+    Geometry getGeometry();
+
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/ComplexData.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/ComplexData.java
new file mode 100644
index 0000000000..e70008e29b
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/ComplexData.java
@@ -0,0 +1,45 @@
+/*
+ * 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.topology;
+
+import java.util.List;
+import static org.opengis.annotation.Specification.ISO_19107;
+import org.opengis.annotation.UML;
+
+
+/**
+ * Data for building a topological {@link Complex} from a set of related primitive data, such that all
+ * the primitive interiors are disjoint and every boundary is represented by other geometries.
+ *
+ * @author Johann Sorel (Geomatys)
+ *
+ * @see ISO 19107:2019 - 10.1.7
+ */
+@UML(identifier="ComplexData", specification=ISO_19107)
+public interface ComplexData extends TopologyData {
+
+    /**
+     * Data of the primitives of this complex.
+     *
+     * @return data of the primitives of this complex.
+     *
+     * @see ISO 19107:2019 - 10.1.7.2
+     */
+    @UML(identifier="primitive", specification=ISO_19107)
+    List<PrimitiveData> getPrimitives();
+
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedEdge.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedEdge.java
new file mode 100644
index 0000000000..9de767a3c9
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedEdge.java
@@ -0,0 +1,101 @@
+/*
+ * 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.topology;
+
+import java.util.List;
+import static org.opengis.annotation.Specification.ISO_19107;
+import org.opengis.annotation.UML;
+
+
+/**
+ * An {@link Edge} with an orientation, used in the terms of an {@link Expression}.
+ * It is the topological equivalent of an oriented {@link org.apache.sis.geometries.Curve},
+ * standing for the edge traversed in one direction or the other.
+ *
+ * <p>In a 2-dimensional planar complex, an oriented edge bounds at most one face, on its left.
+ * In 3 dimensions, or in a non-planar 2-dimensional complex, it may bound several faces.</p>
+ *
+ * @author Johann Sorel (Geomatys)
+ *
+ * @see ISO 19107:2019 - 10.4.2
+ */
+@UML(identifier="DirectedEdge", specification=ISO_19107)
+public interface DirectedEdge extends DirectedTopo {
+
+    /**
+     * Returns 1: an edge is a curve.
+     *
+     * @see ISO 19107:2019 - 10.4.1.5
+     */
+    @UML(identifier="dimension", specification=ISO_19107)
+    @Override
+    default int getDimension() {
+        return 1;
+    }
+
+    /**
+     * The two oriented nodes bounding this oriented edge: the node where it ends is positive,
+     * and the node where it starts is negative.
+     *
+     * @return the two oriented nodes at the ends of this oriented edge.
+     *
+     * @see ISO 19107:2019 - 10.4.1.3
+     */
+    @UML(identifier="boundary", specification=ISO_19107)
+    @Override
+    List<DirectedNode> getBoundary();
+
+    /**
+     * Oriented faces having this oriented edge on their boundary.
+     * See {@link Edge#getCoBoundary()} for the orientation and the order of the faces.
+     *
+     * @return oriented faces around this oriented edge.
+     *
+     * @see ISO 19107:2019 - 10.4.1.2
+     */
+    @UML(identifier="coBoundary", specification=ISO_19107)
+    @Override
+    List<DirectedFace> getCoBoundary();
+
+    /**
+     * The same edge with the opposite orientation.
+     *
+     * @see ISO 19107:2019 - 10.1.4.3
+     */
+    @UML(identifier="negate", specification=ISO_19107)
+    @Override
+    DirectedEdge getNegate();
+
+    /**
+     * The edge of which this object is one of the two orientations.
+     *
+     * @see ISO 19107:2019 - 10.4.1.4
+     */
+    @UML(identifier="topo", specification=ISO_19107)
+    @Override
+    Edge getTopo();
+
+    /**
+     * The two oriented edges standing for the edge, the positive one first.
+     *
+     * @see ISO 19107:2019 - 10.4.1.4
+     */
+    @UML(identifier="proxy", specification=ISO_19107)
+    @Override
+    List<DirectedEdge> getProxy();
+
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedFace.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedFace.java
new file mode 100644
index 0000000000..2359666209
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedFace.java
@@ -0,0 +1,97 @@
+/*
+ * 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.topology;
+
+import java.util.List;
+import static org.opengis.annotation.Specification.ISO_19107;
+import org.opengis.annotation.UML;
+
+
+/**
+ * A {@link Face} with an orientation, used in the terms of an {@link Expression} and to define the boundary
+ * of a {@link Solid}. It is the topological equivalent of an oriented {@link org.apache.sis.geometries.Surface}.
+ *
+ * @author Johann Sorel (Geomatys)
+ *
+ * @see ISO 19107:2019 - 10.5.3
+ */
+@UML(identifier="DirectedFace", specification=ISO_19107)
+public interface DirectedFace extends DirectedTopo {
+
+    /**
+     * Returns 2: a face is a surface.
+     *
+     * @see ISO 19107:2019 - 10.5.2.4
+     */
+    @UML(identifier="dimension", specification=ISO_19107)
+    @Override
+    default int getDimension() {
+        return 2;
+    }
+
+    /**
+     * Oriented edges bounding this oriented face, positive when the face is on their left.
+     * See {@link Face#getBoundary()} for the organization in rings.
+     *
+     * @return oriented edges bounding this oriented face.
+     *
+     * @see ISO 19107:2019 - 10.5.2.1
+     */
+    @UML(identifier="boundary", specification=ISO_19107)
+    @Override
+    List<DirectedEdge> getBoundary();
+
+    /**
+     * Oriented solids having this oriented face on their boundary, at most two.
+     * See {@link Face#getCoBoundary()} for the orientation of the solids.
+     *
+     * @return oriented solids on each side of this oriented face.
+     *
+     * @see ISO 19107:2019 - 10.5.2.2
+     */
+    @UML(identifier="coBoundary", specification=ISO_19107)
+    @Override
+    List<DirectedSolid> getCoBoundary();
+
+    /**
+     * The same face with the opposite orientation.
+     *
+     * @see ISO 19107:2019 - 10.1.4.3
+     */
+    @UML(identifier="negate", specification=ISO_19107)
+    @Override
+    DirectedFace getNegate();
+
+    /**
+     * The face of which this object is one of the two orientations.
+     *
+     * @see ISO 19107:2019 - 10.5.2.3
+     */
+    @UML(identifier="topo", specification=ISO_19107)
+    @Override
+    Face getTopo();
+
+    /**
+     * The two oriented faces standing for the face, the positive one first.
+     *
+     * @see ISO 19107:2019 - 10.5.2.3
+     */
+    @UML(identifier="proxy", specification=ISO_19107)
+    @Override
+    List<DirectedFace> getProxy();
+
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedNode.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedNode.java
new file mode 100644
index 0000000000..a67d87bcd9
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedNode.java
@@ -0,0 +1,97 @@
+/*
+ * 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.topology;
+
+import java.util.List;
+import static org.opengis.annotation.Specification.ISO_19107;
+import org.opengis.annotation.UML;
+
+
+/**
+ * A {@link Node} with an orientation, used in the terms of an {@link Expression}.
+ * The end node of an edge appears positively in the boundary of that edge, and its start node negatively.
+ * There is no separate geometric equivalent, since a point has no orientation.
+ *
+ * @author Johann Sorel (Geomatys)
+ *
+ * @see ISO 19107:2019 - 10.3.3
+ */
+@UML(identifier="DirectedNode", specification=ISO_19107)
+public interface DirectedNode extends DirectedTopo {
+
+    /**
+     * Returns 0: a node is a point.
+     *
+     * @see ISO 19107:2019 - 10.3.2.4
+     */
+    @UML(identifier="dimension", specification=ISO_19107)
+    @Override
+    default int getDimension() {
+        return 0;
+    }
+
+    /**
+     * Returns an empty list: a node has no boundary, which is a zero expression.
+     *
+     * @see ISO 19107:2019 - 10.3.2.3
+     */
+    @UML(identifier="boundary", specification=ISO_19107)
+    @Override
+    default List<? extends DirectedTopo> getBoundary() {
+        return List.of();
+    }
+
+    /**
+     * Oriented edges having this oriented node on their boundary.
+     * See {@link Node#getCoBoundary()} for the orientation of the edges.
+     *
+     * @return oriented edges starting or ending at this node.
+     *
+     * @see ISO 19107:2019 - 10.3.2.1
+     */
+    @UML(identifier="coBoundary", specification=ISO_19107)
+    @Override
+    List<DirectedEdge> getCoBoundary();
+
+    /**
+     * The same node with the opposite orientation.
+     *
+     * @see ISO 19107:2019 - 10.1.4.3
+     */
+    @UML(identifier="negate", specification=ISO_19107)
+    @Override
+    DirectedNode getNegate();
+
+    /**
+     * The node of which this object is one of the two orientations.
+     *
+     * @see ISO 19107:2019 - 10.1.4.6
+     */
+    @UML(identifier="topo", specification=ISO_19107)
+    @Override
+    Node getTopo();
+
+    /**
+     * The two oriented nodes standing for the node, the positive one first.
+     *
+     * @see ISO 19107:2019 - 10.3.2.2
+     */
+    @UML(identifier="proxy", specification=ISO_19107)
+    @Override
+    List<DirectedNode> getProxy();
+
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedSolid.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedSolid.java
new file mode 100644
index 0000000000..d8738bd666
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedSolid.java
@@ -0,0 +1,98 @@
+/*
+ * 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.topology;
+
+import java.util.List;
+import static org.opengis.annotation.Specification.ISO_19107;
+import org.opengis.annotation.UML;
+
+
+/**
+ * A {@link Solid} with an orientation, used in the terms of an {@link Expression}.
+ * There is no separate geometric equivalent, since a geometric solid is not orientable.
+ *
+ * @author Johann Sorel (Geomatys)
+ *
+ * @see ISO 19107:2019 - 10.6.2
+ */
+@UML(identifier="DirectedSolid", specification=ISO_19107)
+public interface DirectedSolid extends DirectedTopo {
+
+    /**
+     * Returns 3: a solid is a volume.
+     *
+     * @see ISO 19107:2019 - 10.1.2.3
+     */
+    @UML(identifier="dimension", specification=ISO_19107)
+    @Override
+    default int getDimension() {
+        return 3;
+    }
+
+    /**
+     * Oriented faces bounding this oriented solid, positive when the solid is below them.
+     * See {@link Solid#getBoundary()} for the organization in shells.
+     *
+     * @return oriented faces bounding this oriented solid.
+     *
+     * @see ISO 19107:2019 - 10.6.1.2
+     */
+    @UML(identifier="boundary", specification=ISO_19107)
+    @Override
+    List<DirectedFace> getBoundary();
+
+    /**
+     * Returns an empty list: a solid has the highest dimension, so it is on the boundary of nothing.
+     *
+     * <p>Difference with ISO 19107: the standard returns {@code NULL}.</p>
+     *
+     * @see ISO 19107:2019 - 10.6.1.3
+     */
+    @UML(identifier="coBoundary", specification=ISO_19107)
+    @Override
+    default List<? extends DirectedTopo> getCoBoundary() {
+        return List.of();
+    }
+
+    /**
+     * The same solid with the opposite orientation.
+     *
+     * @see ISO 19107:2019 - 10.1.4.3
+     */
+    @UML(identifier="negate", specification=ISO_19107)
+    @Override
+    DirectedSolid getNegate();
+
+    /**
+     * The solid of which this object is one of the two orientations.
+     *
+     * @see ISO 19107:2019 - 10.6.1.4
+     */
+    @UML(identifier="topo", specification=ISO_19107)
+    @Override
+    Solid getTopo();
+
+    /**
+     * The two oriented solids standing for the solid, the positive one first.
+     *
+     * @see ISO 19107:2019 - 10.6.1.4
+     */
+    @UML(identifier="proxy", specification=ISO_19107)
+    @Override
+    List<DirectedSolid> getProxy();
+
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedTopo.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedTopo.java
new file mode 100644
index 0000000000..4ca63c44a8
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/DirectedTopo.java
@@ -0,0 +1,94 @@
+/*
+ * 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.topology;
+
+import static org.opengis.annotation.Specification.ISO_19107;
+import org.opengis.annotation.UML;
+import org.apache.sis.geometries.Orientable;
+
+
+/**
+ * A topological primitive with an orientation, the topological equivalent of {@link Orientable}.
+ *
+ * <p>Since a primitive has only two orientations whatever its dimension, each primitive has two oriented
+ * primitives. Each primitive type extends its oriented type, so that a primitive is its own positive
+ * orientation, as in the algebraic <var>x</var> = +<var>x</var>. A {@link Node} is a positive
+ * {@link DirectedNode}, an {@link Edge} a positive {@link DirectedEdge}, and so on.</p>
+ *
+ * <p>Oriented primitives of adjacent dimensions agree on the orientation: if a positive edge is on the
+ * boundary of a face, the positive face is on the co-boundary of that edge.</p>
+ *
+ * <p>Constraints:</p>
+ * <ul>
+ *   <li>The {@linkplain #getBoundary() boundary} is the boundary of the {@linkplain #getTopo() primitive},
+ *       each term multiplied by the {@linkplain #getOrientationSign() orientation}. Likewise for the
+ *       {@linkplain #getCoBoundary() co-boundary}.</li>
+ *   <li>The {@linkplain #getNegate() opposite} has the same primitive and the other orientation.</li>
+ * </ul>
+ *
+ * @author Johann Sorel (Geomatys)
+ *
+ * @see ISO 19107:2019 - 10.1.4
+ */
+@UML(identifier="DirectedTopo", specification=ISO_19107)
+public interface DirectedTopo extends Primitive {
+
+    /**
+     * Whether this oriented primitive agrees with the orientation of its {@linkplain #getTopo() primitive}.
+     * The default is {@link Orientable.Sign#POSITIVE}, which is always the case of a primitive itself.
+     *
+     * @return orientation of this object relative to its primitive.
+     *
+     * @see ISO 19107:2019 - 10.1.4.2
+     */
+    @UML(identifier="orientation", specification=ISO_19107)
+    default Orientable.Sign getOrientationSign() {
+        return Orientable.Sign.POSITIVE;
+    }
+
+    /**
+     * The same primitive with the opposite orientation.
+     *
+     * @return this object with reversed orientation.
+     *
+     * @see ISO 19107:2019 - 10.1.4.3
+     */
+    @UML(identifier="negate", specification=ISO_19107)
+    DirectedTopo getNegate();
+
+    /**
+     * This oriented primitive as an expression of a single term, keeping its orientation.
+     *
+     * @return expression made of this oriented primitive.
+     *
+     * @see ISO 19107:2019 - 10.1.4.4
+     */
+    @UML(identifier="expression", specification=ISO_19107)
+    Expression getExpression();
+
+    /**
+     * The primitive of which this object is one of the two orientations.
+     * A primitive returns itself.
+     *
+     * @return primitive underlying this object.
+     *
+     * @see ISO 19107:2019 - 10.1.4.5
+     */
+    @UML(identifier="topo", specification=ISO_19107)
+    Primitive getTopo();
+
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Edge.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Edge.java
new file mode 100644
index 0000000000..f1d9b3ac43
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Edge.java
@@ -0,0 +1,87 @@
+/*
+ * 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.topology;
+
+import java.util.List;
+import static org.opengis.annotation.Specification.ISO_19107;
+import org.opengis.annotation.UML;
+
+
+/**
+ * The 1-dimensional topological primitive, usually realized by a {@link org.apache.sis.geometries.Curve}.
+ *
+ * @author Johann Sorel (Geomatys)
+ *
+ * @see ISO 19107:2019 - 10.4.1
+ */
+@UML(identifier="Edge", specification=ISO_19107)
+public interface Edge extends DirectedEdge {
+
+    /**
+     * The start and end nodes of this edge, as the expression <var>end</var> − <var>start</var>:
+     * the end node is a positive {@link DirectedNode} and the start node a negative one.
+     * When both nodes are the same, the two terms cancel out in the expression.
+     *
+     * @return the two oriented nodes at the ends of this edge.
+     *
+     * @see ISO 19107:2019 - 10.4.1.3
+     */
+    @UML(identifier="boundary", specification=ISO_19107)
+    @Override
+    List<DirectedNode> getBoundary();
+
+    /**
+     * Oriented faces using this edge on their boundary: positive when the face uses this edge,
+     * negative when it uses its negative orientation.
+     *
+     * <p>The faces are listed in clockwise circular order, as seen from the end of the edge
+     * in a geometric realization of the maximal complex containing this edge.</p>
+     *
+     * <p>In a 2-dimensional planar complex there are at most two faces, exactly two in a full topology:
+     * the positive one on the left of the edge, and the negative one on its right.</p>
+     *
+     * @return oriented faces around this edge, in clockwise order seen from its end.
+     *
+     * @see ISO 19107:2019 - 10.4.1.2
+     */
+    @UML(identifier="coBoundary", specification=ISO_19107)
+    @Override
+    List<DirectedFace> getCoBoundary();
+
+    /**
+     * Returns this edge, which is its own positive orientation.
+     *
+     * @see ISO 19107:2019 - 10.1.4.1
+     */
+    @UML(identifier="topo", specification=ISO_19107)
+    @Override
+    default Edge getTopo() {
+        return this;
+    }
+
+    /**
+     * Returns this edge then its {@linkplain #getNegate() negative orientation}.
+     *
+     * @see ISO 19107:2019 - 10.4.1.4
+     */
+    @UML(identifier="proxy", specification=ISO_19107)
+    @Override
+    default List<DirectedEdge> getProxy() {
+        return List.of(this, getNegate());
+    }
+
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Expression.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Expression.java
new file mode 100644
index 0000000000..f340e46e76
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Expression.java
@@ -0,0 +1,194 @@
+/*
+ * 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.topology;
+
+import java.util.ArrayDeque;
+import java.util.Collection;
+import java.util.Deque;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import static org.opengis.annotation.Specification.ISO_19107;
+import org.opengis.annotation.UML;
+
+
+/**
+ * A multivariate polynomial of degree 1 whose variables are oriented primitives, such as
+ * 2<var>e₁</var> − <var>e₂</var>. It is the basis of computational topology: geometric problems
+ * are translated into algebraic ones, solved there, and the solutions are translated back.
+ *
+ * <p>The order of the terms does not change the value of a polynomial, so an expression is
+ * a set of {@linkplain ExpressionTerm terms}, each one an integer coefficient and an oriented primitive.
+ * Expressions behave as vectors having the oriented primitives as basis vectors.</p>
+ *
+ * @author Johann Sorel (Geomatys)
+ *
+ * @see ISO 19107:2019 - 10.1.8
+ */
+@UML(identifier="Expression", specification=ISO_19107)
+public interface Expression {
+
+    /**
+     * The terms of this expression, in no particular order. Terms having the same underlying primitive
+     * are combined, and terms of coefficient zero are removed, so each primitive appears at most once.
+     *
+     * @return terms of this expression, empty for the zero expression.
+     *
+     * @see ISO 19107:2019 - 10.1.8.1
+     */
+    Collection<ExpressionTerm> getTerms();
+
+    /**
+     * Returns {@code true} if this expression has no term.
+     * This method is not in ISO 19107, which refers to the zero expression in several places.
+     *
+     * @return whether this expression is zero.
+     */
+    default boolean isZero() {
+        return getTerms().isEmpty();
+    }
+
+    /**
+     * This expression with every term reversed: the unary minus of polynomials.
+     *
+     * @return the opposite of this expression.
+     *
+     * @see ISO 19107:2019 - 10.1.8.2
+     */
+    @UML(identifier="negate", specification=ISO_19107)
+    Expression getNegate();
+
+    /**
+     * This expression with each primitive replaced by its boundary, then simplified.
+     * The boundary is made of primitives of the next lower dimension, and is zero
+     * if all primitives of this expression are nodes.
+     *
+     * @return boundary of this expression.
+     *
+     * @see ISO 19107:2019 - 10.1.8.3
+     */
+    @UML(identifier="boundary", specification=ISO_19107)
+    Expression getBoundary();
+
+    /**
+     * This expression with each primitive replaced by its co-boundary, then simplified.
+     * The co-boundary is made of primitives of the next higher dimension, and is zero if the primitives
+     * of this expression have the dimension of their maximal complex.
+     *
+     * @return co-boundary of this expression.
+     *
+     * @see ISO 19107:2019 - 10.1.8.4
+     */
+    @UML(identifier="coBoundary", specification=ISO_19107)
+    Expression getCoBoundary();
+
+    /**
+     * This expression as a set of primitives, for computing geometric operators: the primitives
+     * of the {@linkplain #asSet() oriented primitives set}. The support is a valid {@link Complex},
+     * and is empty for the zero expression.
+     *
+     * <p>The default implementation returns the {@linkplain DirectedTopo#getTopo() primitive}
+     * of each element of {@link #asSet()}.</p>
+     *
+     * @return primitives of this expression and of their boundaries.
+     *
+     * @see ISO 19107:2019 - 10.1.8.5
+     */
+    @UML(identifier="support", specification=ISO_19107)
+    default Set<Primitive> getSupport() {
+        final Set<Primitive> support = new LinkedHashSet<>();
+        for (final DirectedTopo element : asSet()) {
+            support.add(element.getTopo());
+        }
+        return support;
+    }
+
+    /**
+     * This expression as a set of oriented primitives, for computing geometric operators. The set contains
+     * the variable of each term, and every element of their boundaries recursively down to the nodes.
+     * It is empty for the zero expression.
+     *
+     * <p>The default implementation takes each variable with the sign of its coefficient,
+     * then adds the boundaries recursively.</p>
+     *
+     * @return oriented primitives of this expression and of their boundaries.
+     *
+     * @see ISO 19107:2019 - 10.1.8.6
+     */
+    @UML(identifier="asSet", specification=ISO_19107)
+    default Set<DirectedTopo> asSet() {
+        final Set<DirectedTopo> set = new LinkedHashSet<>();
+        final Deque<DirectedTopo> pending = new ArrayDeque<>();
+        for (final ExpressionTerm term : getTerms()) {
+            final DirectedTopo variable = term.getVariable();
+            pending.add(term.getCoefficient() < 0 ? variable.getNegate() : variable);
+        }
+        DirectedTopo next;
+        while ((next = pending.poll()) != null) {
+            if (set.add(next)) {
+                pending.addAll(next.getBoundary());
+            }
+        }
+        return set;
+    }
+
+    /**
+     * The vector sum of this expression and the given one. Terms having the same underlying primitive
+     * are combined by adding their coefficients, and terms of coefficient zero are removed.
+     *
+     * @param  other  the expression to add to this expression.
+     * @return sum of the two expressions.
+     *
+     * @see ISO 19107:2019 - 10.1.8.7
+     */
+    @UML(identifier="plus", specification=ISO_19107)
+    Expression plus(Expression other);
+
+    /**
+     * The vector difference of this expression and the given one. Terms having the same underlying primitive
+     * are combined by subtracting their coefficients, and terms of coefficient zero are removed.
+     *
+     * @param  other  the expression to subtract from this expression.
+     * @return difference of the two expressions.
+     *
+     * @see ISO 19107:2019 - 10.1.8.8
+     */
+    @UML(identifier="minus", specification=ISO_19107)
+    Expression minus(Expression other);
+
+    /**
+     * Returns {@code true} if the given object is an expression equal to this one as a polynomial.
+     * The order of the terms does not matter.
+     *
+     * @param  other  the object to compare with this expression.
+     * @return whether the given object is an equal polynomial.
+     *
+     * @see ISO 19107:2019 - 10.1.8.9
+     */
+    @UML(identifier="equals", specification=ISO_19107)
+    @Override
+    boolean equals(Object other);
+
+    /**
+     * Returns a hash code value consistent with {@link #equals(Object)},
+     * therefore independent of the order of the terms.
+     *
+     * @return hash code value of this expression.
+     */
+    @Override
+    int hashCode();
+
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/ExpressionTerm.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/ExpressionTerm.java
new file mode 100644
index 0000000000..4e31a3a115
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/ExpressionTerm.java
@@ -0,0 +1,69 @@
+/*
+ * 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.topology;
+
+import java.util.Collection;
+import java.util.List;
+import static org.opengis.annotation.Specification.ISO_19107;
+import org.opengis.annotation.UML;
+
+
+/**
+ * A term of an {@link Expression}: an oriented primitive with an integer coefficient, such as
+ * 2<var>e₁</var>. A term is a monomial, so it is also an expression of a single term.
+ * Arithmetic on terms is the usual vector arithmetic.
+ *
+ * @author Johann Sorel (Geomatys)
+ *
+ * @see ISO 19107:2019 - 10.1.9
+ */
+@UML(identifier="ExpressionTerm", specification=ISO_19107)
+public interface ExpressionTerm extends Expression {
+
+    /**
+     * The integer multiplying the {@linkplain #getVariable() variable}. The default is 1.
+     *
+     * @return coefficient of this term.
+     *
+     * @see ISO 19107:2019 - 10.1.9
+     */
+    @UML(identifier="coefficient", specification=ISO_19107)
+    default int getCoefficient() {
+        return 1;
+    }
+
+    /**
+     * The oriented primitive of this term.
+     *
+     * @return variable of this term.
+     *
+     * @see ISO 19107:2019 - 10.1.9
+     */
+    @UML(identifier="variable", specification=ISO_19107)
+    DirectedTopo getVariable();
+
+    /**
+     * Returns this term alone, or nothing if its {@linkplain #getCoefficient() coefficient} is zero.
+     *
+     * @return this term, or an empty list for the zero expression.
+     */
+    @Override
+    default Collection<ExpressionTerm> getTerms() {
+        return (getCoefficient() == 0) ? List.of() : List.of(this);
+    }
+
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Face.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Face.java
new file mode 100644
index 0000000000..b3753e065c
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Face.java
@@ -0,0 +1,104 @@
+/*
+ * 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.topology;
+
+import java.util.List;
+import static org.opengis.annotation.Specification.ISO_19107;
+import org.opengis.annotation.UML;
+
+
+/**
+ * The 2-dimensional topological primitive, usually realized by a {@link org.apache.sis.geometries.Surface}.
+ *
+ * @author Johann Sorel (Geomatys)
+ *
+ * @see ISO 19107:2019 - 10.5.2
+ */
+@UML(identifier="Face", specification=ISO_19107)
+public interface Face extends DirectedFace {
+
+    /**
+     * Oriented edges bounding this face: positive for the edges having this face on their left,
+     * negative for those having it on their right. The edges of every ring are included,
+     * those of the {@linkplain #getExteriorRing() exterior ring} first.
+     *
+     * @return oriented edges bounding this face, at least one.
+     *
+     * @see ISO 19107:2019 - 10.5.2.1
+     */
+    @UML(identifier="boundary", specification=ISO_19107)
+    @Override
+    List<DirectedEdge> getBoundary();
+
+    /**
+     * The ring of the {@linkplain #getBoundary() boundary} which is the exterior of this face,
+     * as oriented edges in traversal order.
+     *
+     * <p>The same restriction applies as for the geometric boundary of a surface: on a closed surface such
+     * as a sphere, no ring can be told to be the exterior one, and this method returns an empty list.</p>
+     *
+     * @return oriented edges of the exterior ring, or an empty list if it cannot be determined.
+     *
+     * @see ISO 19107:2019 - 10.5.2.1
+     */
+    List<DirectedEdge> getExteriorRing();
+
+    /**
+     * The rings of the {@linkplain #getBoundary() boundary} other than the {@linkplain #getExteriorRing() exterior one},
+     * each as oriented edges in traversal order.
+     *
+     * @return oriented edges of every interior ring, empty if none.
+     *
+     * @see ISO 19107:2019 - 10.5.2.1
+     */
+    List<List<DirectedEdge>> getInteriorRings();
+
+    /**
+     * Oriented solids using this face on their boundary: positive when the solid uses this face,
+     * negative when it uses its negative orientation. A face separates at most two solids.
+     *
+     * @return oriented solids on each side of this face, at most two.
+     *
+     * @see ISO 19107:2019 - 10.5.2.2
+     */
+    @UML(identifier="coBoundary", specification=ISO_19107)
+    @Override
+    List<DirectedSolid> getCoBoundary();
+
+    /**
+     * Returns this face, which is its own positive orientation.
+     *
+     * @see ISO 19107:2019 - 10.1.4.1
+     */
+    @UML(identifier="topo", specification=ISO_19107)
+    @Override
+    default Face getTopo() {
+        return this;
+    }
+
+    /**
+     * Returns this face then its {@linkplain #getNegate() negative orientation}.
+     *
+     * @see ISO 19107:2019 - 10.5.2.3
+     */
+    @UML(identifier="proxy", specification=ISO_19107)
+    @Override
+    default List<DirectedFace> getProxy() {
+        return List.of(this, getNegate());
+    }
+
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Node.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Node.java
new file mode 100644
index 0000000000..79b5df2df2
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Node.java
@@ -0,0 +1,75 @@
+/*
+ * 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.topology;
+
+import java.util.List;
+import static org.opengis.annotation.Specification.ISO_19107;
+import org.opengis.annotation.UML;
+
+
+/**
+ * The 0-dimensional topological primitive, usually realized by a {@link org.apache.sis.geometries.Point}.
+ *
+ * <p>A node has no boundary. It may be isolated in a face while being the end of an edge, as long as that
+ * edge is not on the boundary of the face: the realization is a curve hanging in space and ending where it
+ * meets a surface.</p>
+ *
+ * @author Johann Sorel (Geomatys)
+ *
+ * @see ISO 19107:2019 - 10.3.2
+ */
+@UML(identifier="Node", specification=ISO_19107)
+public interface Node extends DirectedNode {
+
+    /**
+     * Oriented edges having this node on their boundary: positive for the edges entering
+     * this node (ending at it), and negative for the edges leaving it (starting at it).
+     *
+     * <p>In a 2-dimensional maximal complex, the edges can be listed as a clockwise circular sequence
+     * around the node in any geometric realization. In a 3-dimensional complex the order is arbitrary.</p>
+     *
+     * @return oriented edges starting or ending at this node.
+     *
+     * @see ISO 19107:2019 - 10.3.2.1
+     */
+    @UML(identifier="coBoundary", specification=ISO_19107)
+    @Override
+    List<DirectedEdge> getCoBoundary();
+
+    /**
+     * Returns this node, which is its own positive orientation.
+     *
+     * @see ISO 19107:2019 - 10.1.4.1
+     */
+    @UML(identifier="topo", specification=ISO_19107)
+    @Override
+    default Node getTopo() {
+        return this;
+    }
+
+    /**
+     * Returns this node then its {@linkplain #getNegate() negative orientation}.
+     *
+     * @see ISO 19107:2019 - 10.3.2.2
+     */
+    @UML(identifier="proxy", specification=ISO_19107)
+    @Override
+    default List<DirectedNode> getProxy() {
+        return List.of(this, getNegate());
+    }
+
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Primitive.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Primitive.java
new file mode 100644
index 0000000000..3c97f815ec
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Primitive.java
@@ -0,0 +1,140 @@
+/*
+ * 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.topology;
+
+import java.util.List;
+import java.util.Set;
+import static org.opengis.annotation.Specification.ISO_19107;
+import org.opengis.annotation.UML;
+import org.apache.sis.geometries.Orientable;
+
+
+/**
+ * A non-decomposed element of a topological {@link Complex}.
+ *
+ * <p>A topological primitive usually stands for a geometric primitive of the same dimension. When a
+ * geometric complex is the realization of a topological complex, the primitives of both are in a
+ * one-to-one correspondence which preserves the dimension.</p>
+ *
+ * <p>Unlike a geometric primitive, which may stand alone, a topological primitive is always part of at
+ * least one complex.</p>
+ *
+ * @author Johann Sorel (Geomatys)
+ *
+ * @see ISO 19107:2019 - 10.1.3
+ */
+@UML(identifier="Primitive", specification=ISO_19107)
+public interface Primitive extends Topology {
+
+    /**
+     * Returns this primitive alone: a primitive is its own interior.
+     *
+     * @return a set containing only this primitive.
+     *
+     * @see ISO 19107:2019 - 10.1.2.6
+     */
+    @UML(identifier="interior", specification=ISO_19107)
+    @Override
+    default Set<Primitive> getInterior() {
+        return Set.of(this);
+    }
+
+    /**
+     * The geometric primitive this primitive represents in its maximal complex, or {@code null} if the
+     * maximal complex describes a logical structure without geometric realization.
+     *
+     * <p>Constraints:</p>
+     * <ul>
+     *   <li>Either every primitive of a maximal complex has a geometry, or none has.</li>
+     *   <li>A geometric primitive is associated with at most one primitive of any given complex,
+     *       but may be associated with primitives of different complexes.</li>
+     * </ul>
+     *
+     * @return geometric realization of this primitive, or {@code null} if none.
+     *
+     * @see ISO 19107:2019 - 10.1.3.2
+     */
+    @UML(identifier="geometry", specification=ISO_19107)
+    org.apache.sis.geometries.Primitive getGeometry();
+
+    /**
+     * The complexes containing this primitive. They are all sub-complexes of the single
+     * maximal complex containing this primitive.
+     *
+     * @return complexes containing this primitive.
+     *
+     * @see ISO 19107:2019 - 10.1.3.3
+     */
+    @UML(identifier="complex", specification=ISO_19107)
+    Set<Complex> getComplexes();
+
+    /**
+     * Primitives isolated in this primitive: lying in its interior without any primitive of intermediate
+     * dimension between them. Examples are a node inside a face and not attached to the boundary of that face,
+     * or an edge inside a solid and not attached to its boundary faces.
+     *
+     * <p>Constraints:</p>
+     * <ul>
+     *   <li>The dimension of every isolated primitive is lower than the dimension of this primitive minus one.</li>
+     * </ul>
+     *
+     * @return primitives isolated in this primitive, empty if none.
+     *
+     * @see ISO 19107:2019 - 10.1.3.4
+     */
+    @UML(identifier="isolated", specification=ISO_19107)
+    Set<Primitive> getIsolated();
+
+    /**
+     * The primitive in which this primitive is isolated, or {@code null} if none.
+     * A primitive without container lies on the boundary of another primitive,
+     * unless it has the highest dimension of its complex.
+     *
+     * @return primitive in which this primitive is isolated, or {@code null} if none.
+     *
+     * @see ISO 19107:2019 - 10.1.3.4
+     */
+    @UML(identifier="container", specification=ISO_19107)
+    Primitive getContainer();
+
+    /**
+     * The two oriented primitives standing for this primitive,
+     * the {@linkplain Orientable.Sign#POSITIVE positive} one first.
+     *
+     * @return the positive then the negative oriented primitive.
+     *
+     * @see ISO 19107:2019 - 10.1.4.5
+     */
+    @UML(identifier="proxy", specification=ISO_19107)
+    List<? extends DirectedTopo> getProxy();
+
+    /**
+     * Returns the oriented primitive standing for this primitive with the given orientation.
+     *
+     * <p>The default implementation returns the first or the second {@linkplain #getProxy() proxy}.</p>
+     *
+     * @param  orientation  orientation of the requested oriented primitive.
+     * @return the oriented primitive with the given orientation.
+     *
+     * @see ISO 19107:2019 - 10.1.3
+     */
+    @UML(identifier="asDirectedTopo", specification=ISO_19107)
+    default DirectedTopo asDirectedTopo(final Orientable.Sign orientation) {
+        return getProxy().get(orientation == Orientable.Sign.POSITIVE ? 0 : 1);
+    }
+
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/PrimitiveData.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/PrimitiveData.java
new file mode 100644
index 0000000000..0a644c2581
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/PrimitiveData.java
@@ -0,0 +1,34 @@
+/*
+ * 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.topology;
+
+import static org.opengis.annotation.Specification.ISO_19107;
+import org.opengis.annotation.UML;
+
+
+/**
+ * Data for building a topological {@link Primitive} from its underlying geometric primitive, such that all
+ * the primitive interiors are disjoint and every primitive boundary is represented by other geometries.
+ * All its information comes from {@link TopologyData}.
+ *
+ * @author Johann Sorel (Geomatys)
+ *
+ * @see ISO 19107:2019 - 10.1.6
+ */
+@UML(identifier="PrimitiveData", specification=ISO_19107)
+public interface PrimitiveData extends TopologyData {
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Solid.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Solid.java
new file mode 100644
index 0000000000..7dd18bde79
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Solid.java
@@ -0,0 +1,87 @@
+/*
+ * 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.topology;
+
+import java.util.List;
+import static org.opengis.annotation.Specification.ISO_19107;
+import org.opengis.annotation.UML;
+
+
+/**
+ * The 3-dimensional topological primitive, usually realized by a {@link org.apache.sis.geometries.Solid}.
+ *
+ * @author Johann Sorel (Geomatys)
+ *
+ * @see ISO 19107:2019 - 10.6.1
+ */
+@UML(identifier="Solid", specification=ISO_19107)
+public interface Solid extends DirectedSolid {
+
+    /**
+     * Oriented faces bounding this solid: positive for the faces having this solid below them,
+     * negative for those having it above them. The faces of every shell are included,
+     * those of the {@linkplain #getExteriorShell() exterior shell} first.
+     *
+     * @return oriented faces bounding this solid, at least one.
+     *
+     * @see ISO 19107:2019 - 10.6.1.2
+     */
+    @UML(identifier="boundary", specification=ISO_19107)
+    @Override
+    List<DirectedFace> getBoundary();
+
+    /**
+     * The shell of the {@linkplain #getBoundary() boundary} which is the exterior of this solid.
+     *
+     * @return oriented faces of the exterior shell.
+     *
+     * @see ISO 19107:2019 - 10.6.1.2
+     */
+    List<DirectedFace> getExteriorShell();
+
+    /**
+     * The shells of the {@linkplain #getBoundary() boundary} bounding the voids of this solid.
+     *
+     * @return oriented faces of every interior shell, empty if none.
+     *
+     * @see ISO 19107:2019 - 10.6.1.2
+     */
+    List<List<DirectedFace>> getInteriorShells();
+
+    /**
+     * Returns this solid, which is its own positive orientation.
+     *
+     * @see ISO 19107:2019 - 10.1.4.1
+     */
+    @UML(identifier="topo", specification=ISO_19107)
+    @Override
+    default Solid getTopo() {
+        return this;
+    }
+
+    /**
+     * Returns this solid then its {@linkplain #getNegate() negative orientation}.
+     *
+     * @see ISO 19107:2019 - 10.6.1.4
+     */
+    @UML(identifier="proxy", specification=ISO_19107)
+    @Override
+    default List<DirectedSolid> getProxy() {
+        return List.of(this, getNegate());
+    }
+
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Topology.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Topology.java
new file mode 100644
index 0000000000..5e6387d4e5
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/Topology.java
@@ -0,0 +1,167 @@
+/*
+ * 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.topology;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import static org.opengis.annotation.Specification.ISO_19107;
+import org.opengis.annotation.UML;
+
+
+/**
+ * Root type of the topological primitives and complexes.
+ *
+ * <p>Topology describes how objects are connected, independently of their coordinates. A topological
+ * complex turns geometric computations, such as containment, adjacency, boundaries or network tracing,
+ * into combinatorial ones. Its primitives usually have a geometric realization, but a complex may also
+ * describe a logical structure which has none.</p>
+ *
+ * <p>This type is not a {@link org.apache.sis.geometries.Geometry}. The topological boundary and the
+ * geometric boundary are two different operations, kept separate to avoid any confusion, even though
+ * they agree on any geometric realization of the topology.</p>
+ *
+ * @author Johann Sorel (Geomatys)
+ *
+ * @see ISO 19107:2019 - 10.1.2
+ */
+@UML(identifier="Topology", specification=ISO_19107)
+public interface Topology {
+
+    /**
+     * Returns {@code true} if this topology contains nothing.
+     *
+     * <p>Constraints:</p>
+     * <ul>
+     *   <li>An empty topology has a {@linkplain #getDimension() dimension} of −1.</li>
+     *   <li>An empty topology is realized only by empty geometries.</li>
+     *   <li>Every other attribute and operation behaves consistently on an empty topology.</li>
+     * </ul>
+     *
+     * <p>An empty topology is meant as a temporary state, for example until a transaction completes.</p>
+     *
+     * @return whether this topology is empty.
+     *
+     * @see ISO 19107:2019 - 10.1.2.2
+     */
+    @UML(identifier="isEmpty", specification=ISO_19107)
+    boolean isEmpty();
+
+    /**
+     * Topological dimension of this object: 0 for nodes, 1 for edges, 2 for faces and 3 for solids.
+     * It depends only on the type of this object, and is −1 for an empty topology.
+     *
+     * @return topological dimension of this object.
+     *
+     * @see ISO 19107:2019 - 10.1.2.3
+     */
+    @UML(identifier="dimension", specification=ISO_19107)
+    int getDimension();
+
+    /**
+     * Oriented primitives of the next lower dimension which bound this object.
+     *
+     * <p>Constraints:</p>
+     * <ul>
+     *   <li>The dimension of the boundary is the dimension of this object minus one.</li>
+     *   <li>The orientations agree with the geometric realization of this object.</li>
+     * </ul>
+     *
+     * <p>This list is usually a valid {@link Expression}, except that a primitive may appear twice
+     * with opposite orientations, as a dangling edge which has the same face on both sides. Those two
+     * terms would cancel out in the expression.</p>
+     *
+     * @return boundary of this object, empty if none.
+     *
+     * @see ISO 19107:2019 - 10.1.2.4
+     */
+    @UML(identifier="boundary", specification=ISO_19107)
+    List<? extends DirectedTopo> getBoundary();
+
+    /**
+     * Oriented primitives of the next higher dimension having this object on their boundary.
+     *
+     * <p>This list is usually a valid {@link Expression}, except for an object lying on the boundary of a
+     * closed object, such as the node at both ends of a closed edge. That object appears twice, with
+     * opposite orientations, and would cancel out in the expression.</p>
+     *
+     * @return co-boundary of this object, empty if none.
+     *
+     * @see ISO 19107:2019 - 10.1.2.5
+     */
+    @UML(identifier="coBoundary", specification=ISO_19107)
+    List<? extends DirectedTopo> getCoBoundary();
+
+    /**
+     * Primitives forming the interior of this object within its {@linkplain #getMaximalComplex() maximal complex}.
+     * A primitive is its own interior. The interior of a complex is made of its elements which are not on its
+     * boundary.
+     *
+     * @return interior of this object.
+     *
+     * @see ISO 19107:2019 - 10.1.2.6
+     */
+    @UML(identifier="interior", specification=ISO_19107)
+    Set<Primitive> getInterior();
+
+    /**
+     * Primitives forming the exterior of this object within its {@linkplain #getMaximalComplex() maximal complex}:
+     * those which are neither in the interior nor on the boundary of this object.
+     *
+     * @return exterior of this object.
+     *
+     * @see ISO 19107:2019 - 10.1.2.7
+     */
+    @UML(identifier="exterior", specification=ISO_19107)
+    Set<Primitive> getExterior();
+
+    /**
+     * Union of the {@linkplain #getInterior() interior} and of the {@linkplain #getBoundary() boundary} of this object.
+     *
+     * <p>The default implementation adds the interior, then the primitives on the boundary,
+     * then those on their boundaries down to the nodes.</p>
+     *
+     * @return closure of this object.
+     *
+     * @see ISO 19107:2019 - 10.1.2.8
+     */
+    @UML(identifier="closure", specification=ISO_19107)
+    default Set<Primitive> getClosure() {
+        final Set<Primitive> closure = new LinkedHashSet<>(getInterior());
+        final Deque<DirectedTopo> pending = new ArrayDeque<>(getBoundary());
+        DirectedTopo next;
+        while ((next = pending.poll()) != null) {
+            if (closure.add(next.getTopo())) {
+                pending.addAll(next.getBoundary());
+            }
+        }
+        return closure;
+    }
+
+    /**
+     * The largest complex containing this object. Every object is contained in exactly one maximal complex.
+     *
+     * @return maximal complex containing this object.
+     *
+     * @see ISO 19107:2019 - 10.1.2.9
+     */
+    @UML(identifier="maximalComplex", specification=ISO_19107)
+    Complex getMaximalComplex();
+
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/TopologyData.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/TopologyData.java
new file mode 100644
index 0000000000..fd33f6a756
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/TopologyData.java
@@ -0,0 +1,72 @@
+/*
+ * 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.topology;
+
+import java.util.List;
+import static org.opengis.annotation.Specification.ISO_19107;
+import org.opengis.annotation.UML;
+import org.opengis.util.GenericName;
+import org.apache.sis.geometries.Geometry;
+
+
+/**
+ * Data for building a topology from its underlying geometry, such that all the geometry interiors
+ * are disjoint and every geometry boundary is represented by other geometries.
+ *
+ * @author Johann Sorel (Geomatys)
+ *
+ * @see ISO 19107:2019 - 10.1.5
+ */
+@UML(identifier="TopologyData", specification=ISO_19107)
+public interface TopologyData {
+
+    /**
+     * Identifier by which other data of the same construction refer to this one.
+     * Its namespace component is unique within its maximal complex and its topological dimension.
+     *
+     * @return identifier of this data.
+     *
+     * @see ISO 19107:2019 - 10.1.5.2
+     */
+    @UML(identifier="id", specification=ISO_19107)
+    GenericName getId();
+
+    /**
+     * Data of the elements on the boundary of this element.
+     *
+     * @return data of the boundary elements, empty if none.
+     *
+     * @see ISO 19107:2019 - 10.1.5.3
+     */
+    @UML(identifier="boundaryData", specification=ISO_19107)
+    List<? extends TopologyData> getBoundaryData();
+
+    /**
+     * Geometry of the interior of this element. Its dimension usually matches the topological dimension,
+     * but not necessarily when the topology describes connections, as in a Poincaré dual graph.
+     *
+     * <p>Difference with ISO 19107: the standard type is {@code GeometryData},
+     * which does not exist in this module.</p>
+     *
+     * @return geometry of the interior of this element.
+     *
+     * @see ISO 19107:2019 - 10.1.5.4
+     */
+    @UML(identifier="geometryData", specification=ISO_19107)
+    Geometry getGeometryData();
+
+}
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/package-info.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/package-info.java
new file mode 100644
index 0000000000..66abfd699d
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/topology/package-info.java
@@ -0,0 +1,65 @@
+/*
+ * 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.
+ */
+
+/**
+ * Topology interfaces of ISO 19107:2019 section 10.
+ *
+ * <p>A topological complex describes how primitives are connected, independently of their coordinates,
+ * which turns geometric computations into combinatorial ones. Each primitive usually has a geometric
+ * realization of the same dimension in the {@link org.apache.sis.geometries} package.</p>
+ *
+ * <table class="sis">
+ *   <caption>Topological types</caption>
+ *   <tr><th>Dimension</th> <th>Primitive</th>                  <th>Oriented primitive</th>                 <th>Usual realization</th></tr>
+ *   <tr><td>0</td>         <td>{@link org.apache.sis.geometries.topology.Node}</td>  <td>{@link org.apache.sis.geometries.topology.DirectedNode}</td>  <td>{@link org.apache.sis.geometries.Point}</td></tr>
+ *   <tr><td>1</td>         <td>{@link org.apache.sis.geometries.topology.Edge}</td>  <td>{@link org.apache.sis.geometries.topology.DirectedEdge}</td>  <td>{@link org.apache.sis.geometries.Curve}</td></tr>
+ *   <tr><td>2</td>         <td>{@link org.apache.sis.geometries.topology.Face}</td>  <td>{@link org.apache.sis.geometries.topology.DirectedFace}</td>  <td>{@link org.apache.sis.geometries.Surface}</td></tr>
+ *   <tr><td>3</td>         <td>{@link org.apache.sis.geometries.topology.Solid}</td> <td>{@link org.apache.sis.geometries.topology.DirectedSolid}</td> <td>{@link org.apache.sis.geometries.Solid}</td></tr>
+ * </table>
+ *
+ * <p>As in the standard, each primitive extends its oriented type, so a primitive is its own positive
+ * orientation. Oriented primitives are the variables of {@link org.apache.sis.geometries.topology.Expression},
+ * the polynomials on which boundaries and co-boundaries are computed.</p>
+ *
+ * <h2>Differences with ISO 19107</h2>
+ * <ul>
+ *   <li>Attributes are getter methods, such as {@code getBoundary()} for {@code boundary}.</li>
+ *   <li>The orientation reuses {@link org.apache.sis.geometries.Orientable.Sign}, and is read by
+ *       {@code getOrientationSign()} as for geometric orientable primitives.</li>
+ *   <li>The two proxies of a primitive are listed with the positive one first.</li>
+ *   <li>The rings of a face and the shells of a solid are read by {@code getExteriorRing()}, {@code getInteriorRings()},
+ *       {@code getExteriorShell()} and {@code getInteriorShells()}, instead of {@code FaceBoundary} and
+ *       {@code SolidBoundary} types.</li>
+ *   <li>The co-boundary of a solid is an empty list instead of {@code NULL}.</li>
+ *   <li>The geometric complex realizing a topological complex, and the {@code GeometryData} of the construction
+ *       data types, are a {@link org.apache.sis.geometries.Geometry}, since neither type exists in this module yet.</li>
+ *   <li>The construction of a complex from a geometric collection is left to implementations,
+ *       since Java interfaces have no constructor.</li>
+ *   <li>{@code Expression} gives access to its terms, and tells whether it is zero.</li>
+ * </ul>
+ *
+ * <h2>Not covered</h2>
+ * <ul>
+ *   <li>The derived topological relations of section 10.8: aggregate boundaries, Boolean, Egenhofer
+ *       and full topological operators.</li>
+ *   <li>The simplicial complexes of section 11.1.</li>
+ *   <li>Editing operations, such as splitting an edge or a face, which the standard does not define.</li>
+ * </ul>
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+package org.apache.sis.geometries.topology;


(sis) 10/13: test(Geometry): add solid tests

Posted by js...@apache.org.
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

commit ca2f8e59f3b305cb7f066da2eebdf4d6191528ff
Author: jsorel <jo...@geomatys.com>
AuthorDate: Mon Oct 19 10:36:10 2026 +0200

    test(Geometry): add solid tests
---
 .../operation/polyhedron/SolidFixtures.java        | 393 +++++++++++++++++++++
 .../operation/polyhedron/SolidFixturesTest.java    | 137 +++++++
 2 files changed, 530 insertions(+)

diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/polyhedron/SolidFixtures.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/polyhedron/SolidFixtures.java
new file mode 100644
index 0000000000..f3ab70ccc6
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/polyhedron/SolidFixtures.java
@@ -0,0 +1,393 @@
+/*
+ * 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.operation.polyhedron;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.geometries.DataPoints;
+import org.apache.sis.geometries.Geometries;
+import org.apache.sis.geometries.GeometryCollection;
+import org.apache.sis.geometries.GeometryFactory;
+import org.apache.sis.geometries.curve.LinearRing;
+import org.apache.sis.geometries.solid.Polyhedron;
+import org.apache.sis.geometries.solid.polyhedron.Icosahedron;
+import org.apache.sis.geometries.surface.Polygon;
+import org.apache.sis.maths.NDArrays;
+import org.apache.sis.maths.Vector;
+import org.apache.sis.measure.NumberRange;
+
+// Test dependencies
+import static org.junit.jupiter.api.Assertions.*;
+
+
+/**
+ * Solids for the tests of the operations on polyhedra, all in the three-dimensional undefined reference system
+ * of {@link Geometries#getUndefinedCRS(int)}. The faces of the exterior shells are oriented outward, which is
+ * counterclockwise seen from outside, and the faces of the interior shells are oriented toward the inside of
+ * their void, as required by {@link org.apache.sis.geometries.Solid}. Each method creates new geometries.
+ *
+ * <p>Pairs of solids are returned as arrays of two polyhedra. They are made of unit cubes touching each other,
+ * except the pairs which overlap.</p>
+ *
+ * <p>This class also provides the assertions on polyhedra. They never format a polyhedron in a message,
+ * because polyhedra have no Well Known Text and their {@code toString()} throws an exception.</p>
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public final class SolidFixtures {
+
+    /**
+     * The reference system of all solids.
+     */
+    public static final CoordinateReferenceSystem CRS = Geometries.getUndefinedCRS(3);
+
+    /**
+     * Corners of each face of a box, oriented outward. The corner of index <var>i</var> is at the
+     * minimum or maximum of each axis depending on the bits of <var>i</var>: 1 for x, 2 for y, 4 for z.
+     */
+    private static final int[][] BOX_FACES = {
+        {0, 4, 6, 2},       // x minimum
+        {1, 3, 7, 5},       // x maximum
+        {0, 1, 5, 4},       // y minimum
+        {2, 6, 7, 3},       // y maximum
+        {0, 2, 3, 1},       // z minimum
+        {4, 5, 7, 6}        // z maximum
+    };
+
+    private SolidFixtures() {
+    }
+
+    /**
+     * Returns the box between the given corners.
+     *
+     * @return a box of volume (x1−x0)(y1−y0)(z1−z0).
+     */
+    public static Polyhedron box(double x0, double y0, double z0, double x1, double y1, double z1) {
+        return GeometryFactory.DEFAULT.createPolyhedron(boxShell(x0, y0, z0, x1, y1, z1, true), List.of());
+    }
+
+    /**
+     * Returns the cube from 0 to {@code size} on all axes, with a cubic void inside it at the given distance
+     * from its faces. The faces of the void are oriented toward the inside of the void.
+     *
+     * @return a solid of volume size³ − (size − 2⋅wall)³.
+     */
+    public static Polyhedron hollowCube(double size, double wall) {
+        final double w = size - wall;
+        return GeometryFactory.DEFAULT.createPolyhedron(boxShell(0, 0, 0, size, size, size, true),
+                List.of(boxShell(wall, wall, wall, w, w, w, false)));
+    }
+
+    /**
+     * Returns the box from (0,0,0) to (1,2,3), rotated around an oblique axis and translated,
+     * so that its coordinates are not integers and its faces are not parallel to the axes.
+     *
+     * @return a box of volume 6.
+     */
+    public static Polyhedron rotatedBox() {
+        final double[] axis = {1/3., 2/3., 2/3.};        // unit vector.
+        final double[] translation = {0.3, -1.7, 2.9};
+        final double[][] corners = corners(0, 0, 0, 1, 2, 3);
+        for (int i = 0; i < corners.length; i++) {
+            final double[] p = rotate(corners[i], axis, 0.7);
+            for (int k = 0; k < 3; k++) {
+                p[k] += translation[k];
+            }
+            corners[i] = p;
+        }
+        return GeometryFactory.DEFAULT.createPolyhedron(shell(corners, true), List.of());
+    }
+
+    /**
+     * Returns the regular icosahedron centered on the origin, with its vertices on the unit sphere.
+     * This polyhedron computes its faces from shared vertices, and does not support {@code getDataPoints()}.
+     *
+     * @return an icosahedron of edge a = 4/√(10 + 2√5) and volume 5(3 + √5)a³/12.
+     */
+    public static Polyhedron icosahedron() {
+        return new Icosahedron();
+    }
+
+    /**
+     * Returns the extrusion from z = 0 to z = 2 of the square from 0 to 4 with a square hole from 1 to 3,
+     * computed by the prism of that polygon.
+     *
+     * @return a solid of volume (16 − 4)⋅2 = 24, whose lower and upper faces have a hole.
+     */
+    public static Polyhedron prismWithHole() {
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        final CoordinateReferenceSystem crs2D = Geometries.getUndefinedCRS(2);
+        final Polygon base = f.createPolygon(ring(crs2D, 0,0, 4,0, 4,4, 0,4, 0,0),
+                                     List.of(ring(crs2D, 1,1, 1,3, 3,3, 3,1, 1,1)));
+        return (Polyhedron) f.createPrism(base, NumberRange.create(0., true, 2., true), null).toExtrusion();
+    }
+
+    /**
+     * Returns two unit cubes sharing a face, the plane x = 1.
+     */
+    public static Polyhedron[] sharingFace() {
+        return new Polyhedron[] {box(0, 0, 0, 1, 1, 1), box(1, 0, 0, 2, 1, 1)};
+    }
+
+    /**
+     * Returns two unit cubes sharing an edge, the segment from (1,1,0) to (1,1,1).
+     */
+    public static Polyhedron[] sharingEdge() {
+        return new Polyhedron[] {box(0, 0, 0, 1, 1, 1), box(1, 1, 0, 2, 2, 1)};
+    }
+
+    /**
+     * Returns two unit cubes sharing a vertex, the point (1,1,1).
+     */
+    public static Polyhedron[] sharingVertex() {
+        return new Polyhedron[] {box(0, 0, 0, 1, 1, 1), box(1, 1, 1, 2, 2, 2)};
+    }
+
+    /**
+     * Returns two cubes of size 2 overlapping in the unit cube from (1,1,1) to (2,2,2).
+     * No face of a cube is in the plane of a face of the other cube.
+     */
+    public static Polyhedron[] overlapping() {
+        return new Polyhedron[] {box(0, 0, 0, 2, 2, 2), box(1, 1, 1, 3, 3, 3)};
+    }
+
+    /**
+     * Returns two cubes of size 2 offset along x, overlapping in the box from (1,0,0) to (2,2,2).
+     * Four faces of each cube are in the planes of four faces of the other cube.
+     */
+    public static Polyhedron[] offsetAlongX() {
+        return new Polyhedron[] {box(0, 0, 0, 2, 2, 2), box(1, 0, 0, 3, 2, 2)};
+    }
+
+    /**
+     * Returns the six faces of the box between the given corners.
+     *
+     * @param outward  whether the faces are oriented toward the outside of the box (exterior shell)
+     *                 or toward its inside (shell of a void).
+     */
+    public static GeometryCollection<Polygon> boxShell(double x0, double y0, double z0,
+                                                       double x1, double y1, double z1, boolean outward)
+    {
+        return shell(corners(x0, y0, z0, x1, y1, z1), outward);
+    }
+
+    /**
+     * Returns the eight corners of the box between the given corners, in the order of {@link #BOX_FACES}.
+     */
+    private static double[][] corners(double x0, double y0, double z0, double x1, double y1, double z1) {
+        final double[][] corners = new double[8][];
+        for (int i = 0; i < 8; i++) {
+            corners[i] = new double[] {
+                ((i & 1) == 0) ? x0 : x1,
+                ((i & 2) == 0) ? y0 : y1,
+                ((i & 4) == 0) ? z0 : z1
+            };
+        }
+        return corners;
+    }
+
+    /**
+     * Returns the six faces of a box of the given corners, in the order of {@link #BOX_FACES}.
+     */
+    private static GeometryCollection<Polygon> shell(final double[][] corners, final boolean outward) {
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        final Polygon[] faces = new Polygon[BOX_FACES.length];
+        for (int i = 0; i < faces.length; i++) {
+            final int[] face = BOX_FACES[i];
+            final double[] coordinates = new double[(face.length + 1) * 3];
+            for (int j = 0; j <= face.length; j++) {
+                final int corner = face[outward ? j % face.length : (face.length - j) % face.length];
+                System.arraycopy(corners[corner], 0, coordinates, j * 3, 3);
+            }
+            faces[i] = f.createPolygon(ring(CRS, coordinates), List.of());
+        }
+        return f.createMultiPolygon(CRS, faces);
+    }
+
+    /**
+     * Returns the given point rotated by the given angle around an axis through the origin,
+     * with the formula of Rodrigues.
+     *
+     * @param axis   unit vector of the axis.
+     * @param angle  angle in radians, counterclockwise seen from the side toward which the axis points.
+     */
+    private static double[] rotate(final double[] p, final double[] axis, final double angle) {
+        final double cos = Math.cos(angle), sin = Math.sin(angle);
+        final double dot = axis[0]*p[0] + axis[1]*p[1] + axis[2]*p[2];
+        final double[] cross = {
+            axis[1]*p[2] - axis[2]*p[1],
+            axis[2]*p[0] - axis[0]*p[2],
+            axis[0]*p[1] - axis[1]*p[0]
+        };
+        final double[] rotated = new double[3];
+        for (int k = 0; k < 3; k++) {
+            rotated[k] = p[k]*cos + cross[k]*sin + axis[k]*dot*(1 - cos);
+        }
+        return rotated;
+    }
+
+    private static LinearRing ring(final CoordinateReferenceSystem crs, final double... coordinates) {
+        return GeometryFactory.DEFAULT.createLinearRing(GeometryFactory.DEFAULT.createDataPoints(NDArrays.of(crs, coordinates)));
+    }
+
+    /**
+     * Verifies the volume of the given polyhedron, with a tolerance relative to the expected value.
+     *
+     * @param expected  the expected volume.
+     * @param solid     the polyhedron to verify.
+     */
+    public static void assertVolume(final double expected, final Polyhedron solid) {
+        assertEquals(expected, solid.getVolume().getValue().doubleValue(), VOLUME_TOLERANCE * Math.max(1, Math.abs(expected)), "Volume.");
+    }
+
+    /**
+     * Verifies that each shell of the given polyhedron is closed and oriented. Every edge of a face, holes included,
+     * shall be shared by exactly one other face going along it in the opposite direction, which makes the shell
+     * closed, without more than two faces on an edge, and with all its faces oriented the same way. The volume
+     * enclosed by the shell shall be positive for the exterior shell, oriented outward, and negative for a void,
+     * whose faces are oriented toward the inside of the void.
+     *
+     * <p>The edges are compared by the exact positions of their ends, so a vertex of a face lying inside an edge
+     * of another face is an error. This verification does not use the classes under test, and its messages do
+     * not format the polyhedron, which has no Well Known Text.</p>
+     *
+     * @param solid  the polyhedron to verify.
+     */
+    public static void assertClosedAndOriented(final Polyhedron solid) {
+        assertClosedAndOriented(solid.getExteriorShell(), "Exterior shell", true);
+        final List<GeometryCollection<Polygon>> voids = solid.getInteriorShells();
+        for (int i = 0; i < voids.size(); i++) {
+            assertClosedAndOriented(voids.get(i), "Interior shell " + i, false);
+        }
+    }
+
+    /**
+     * Verifies that the given shell is closed and oriented.
+     *
+     * @param shell     the shell to verify.
+     * @param name      name of the shell in the error messages.
+     * @param exterior  whether the shell shall enclose a positive volume (exterior shell) or a negative one (void).
+     */
+    private static void assertClosedAndOriented(final GeometryCollection<Polygon> shell, final String name, final boolean exterior) {
+        assertNotEquals(0, shell.getNumGeometries(), () -> name + " has no face.");
+        final Map<Edge,Integer> edges = new HashMap<>();
+        for (int i = 0, n = shell.getNumGeometries(); i < n; i++) {
+            final Polygon face = shell.getGeometryN(i);
+            addEdges(face.getExteriorRing(), edges);
+            for (final LinearRing hole : face.getInteriorRings()) {
+                addEdges(hole, edges);
+            }
+        }
+        for (final Map.Entry<Edge,Integer> entry : edges.entrySet()) {
+            final Edge edge = entry.getKey();
+            final int count = entry.getValue();
+            assertEquals(1, count, () -> name + ": the edge " + edge + " is used " + count + " times in the same direction.");
+            assertEquals(1, edges.getOrDefault(edge.reverse(), 0), () -> name + ": the edge " + edge + " is not used once in the opposite direction.");
+        }
+        final double volume = signedVolume(shell);
+        if (exterior) {
+            assertTrue(volume > 0, () -> name + " shall be oriented outward, but encloses the volume " + volume + '.');
+        } else {
+            assertTrue(volume < 0, () -> name + " shall be oriented toward the inside of the void, but encloses the volume " + volume + '.');
+        }
+    }
+
+    /**
+     * Adds the edges of the given ring, each one counted in its direction. Edges of zero length are ignored.
+     */
+    private static void addEdges(final LinearRing ring, final Map<Edge,Integer> edges) {
+        final DataPoints points = ring.getDataPoints();
+        final int n = points.size();
+        for (int i = 0; i < n; i++) {
+            final Vertex from = vertex(points, i);
+            final Vertex to   = vertex(points, (i + 1) % n);
+            if (!from.equals(to)) {
+                edges.merge(new Edge(from, to), 1, Integer::sum);
+            }
+        }
+    }
+
+    /**
+     * Returns the volume enclosed by the given shell, positive if its faces are oriented outward of that volume.
+     * The rings of each face are split in fans of triangles, and the volumes of the tetrahedra joining a vertex
+     * of the shell to those triangles are added. The holes, oriented opposite to the exterior ring of their face,
+     * subtract their volume.
+     *
+     * @param shell  the shell of a polyhedron.
+     * @return the volume enclosed by the shell, with the sign of its orientation.
+     */
+    public static double signedVolume(final GeometryCollection<Polygon> shell) {
+        double[] origin = null;
+        double volume = 0;
+        for (int i = 0, n = shell.getNumGeometries(); i < n; i++) {
+            final Polygon face = shell.getGeometryN(i);
+            final List<LinearRing> rings = new ArrayList<>();
+            rings.add(face.getExteriorRing());
+            rings.addAll(face.getInteriorRings());
+            for (final LinearRing ring : rings) {
+                final DataPoints points = ring.getDataPoints();
+                if (points.size() < 3) continue;
+                final double[] a = points.getPosition(0).toArrayDouble();
+                if (origin == null) {
+                    origin = a;
+                }
+                for (int j = 1; j + 1 < points.size(); j++) {
+                    final double[] b = points.getPosition(j).toArrayDouble();
+                    final double[] c = points.getPosition(j + 1).toArrayDouble();
+                    final double ax = a[0] - origin[0], ay = a[1] - origin[1], az = a[2] - origin[2];
+                    final double bx = b[0] - origin[0], by = b[1] - origin[1], bz = b[2] - origin[2];
+                    final double cx = c[0] - origin[0], cy = c[1] - origin[1], cz = c[2] - origin[2];
+                    volume += ax * (by*cz - bz*cy) + ay * (bz*cx - bx*cz) + az * (bx*cy - by*cx);
+                }
+            }
+        }
+        return volume / 6;
+    }
+
+    /**
+     * Relative tolerance on the volumes.
+     */
+    private static final double VOLUME_TOLERANCE = 1E-9;
+
+    /**
+     * A vertex compared by its exact position. Negative zeros are replaced by positive zeros,
+     * because the components of a record are compared as by {@link Double#compare(double, double)}.
+     */
+    private record Vertex(double x, double y, double z) {
+    }
+
+    /**
+     * Returns the vertex at the given index of the given positions.
+     */
+    private static Vertex vertex(final DataPoints points, final int index) {
+        final Vector<?> p = points.getPosition(index);
+        return new Vertex(p.get(0) + 0.0, p.get(1) + 0.0, p.get(2) + 0.0);
+    }
+
+    /**
+     * An edge going from a vertex to another one.
+     */
+    private record Edge(Vertex from, Vertex to) {
+        /** Returns the same edge in the opposite direction. */
+        Edge reverse() {
+            return new Edge(to, from);
+        }
+    }
+}
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/polyhedron/SolidFixturesTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/polyhedron/SolidFixturesTest.java
new file mode 100644
index 0000000000..b4d9406aff
--- /dev/null
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/polyhedron/SolidFixturesTest.java
@@ -0,0 +1,137 @@
+/*
+ * 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.operation.polyhedron;
+
+import org.apache.sis.geometries.DataPointsType;
+import org.apache.sis.geometries.GeometryCollection;
+import org.apache.sis.geometries.operation.triangulate.EarClipping;
+import org.apache.sis.geometries.solid.Polyhedron;
+import org.apache.sis.geometries.surface.Polygon;
+
+// Test dependencies
+import static org.junit.jupiter.api.Assertions.*;
+import org.junit.jupiter.api.Test;
+
+
+/**
+ * Verifies the volumes and the orientations of the {@link SolidFixtures}.
+ * The orientation of a shell is verified by the sign of the volume it encloses,
+ * positive if its faces are oriented outward of the volume.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public class SolidFixturesTest {
+
+    private static final double EPS = 1E-9;
+
+    @Test
+    public void testBox() {
+        final Polyhedron box = SolidFixtures.box(0, 0, 0, 2, 3, 4);
+        assertEquals(SolidFixtures.CRS, box.getCoordinateReferenceSystem());
+        assertVolume(24, box);
+        assertTrue(box.getInteriorShells().isEmpty());
+    }
+
+    /**
+     * The faces of the void are oriented toward the inside of the void, so the void encloses a negative volume.
+     */
+    @Test
+    public void testHollowCube() {
+        final Polyhedron hollow = SolidFixtures.hollowCube(4, 1);
+        assertVolume(64 - 8, hollow);
+        assertEquals(1, hollow.getInteriorShells().size());
+        assertEquals(-8, signedVolume(hollow.getInteriorShells().get(0)), EPS);
+    }
+
+    @Test
+    public void testRotatedBox() {
+        final Polyhedron box = SolidFixtures.rotatedBox();
+        assertVolume(6, box);
+        final double x = box.getExteriorShell().getGeometryN(0).getExteriorRing().getDataPoints().getPosition(1).get(0);
+        assertNotEquals(Math.rint(x), x, "The coordinates shall not be integers.");
+    }
+
+    @Test
+    public void testIcosahedron() {
+        final double edge = 4 / Math.sqrt(10 + 2 * Math.sqrt(5));
+        assertVolume(5 * (3 + Math.sqrt(5)) * edge * edge * edge / 12, SolidFixtures.icosahedron());
+    }
+
+    @Test
+    public void testPrismWithHole() {
+        final Polyhedron prism = SolidFixtures.prismWithHole();
+        assertEquals(SolidFixtures.CRS, prism.getCoordinateReferenceSystem());
+        assertVolume((16 - 4) * 2, prism);
+    }
+
+    /**
+     * The pairs of cubes have the expected volumes, and touch where documented.
+     */
+    @Test
+    public void testPairs() {
+        Polyhedron[] pair = SolidFixtures.sharingFace();
+        assertVolume(1, pair[0]);
+        assertVolume(1, pair[1]);
+        assertEquals(pair[0].getEnvelope().getMaximum(0), pair[1].getEnvelope().getMinimum(0));
+
+        pair = SolidFixtures.sharingEdge();
+        assertVolume(1, pair[1]);
+        assertEquals(pair[0].getEnvelope().getMaximum(1), pair[1].getEnvelope().getMinimum(1));
+
+        pair = SolidFixtures.sharingVertex();
+        assertVolume(1, pair[1]);
+        assertEquals(pair[0].getEnvelope().getMaximum(2), pair[1].getEnvelope().getMinimum(2));
+
+        pair = SolidFixtures.overlapping();
+        assertVolume(8, pair[0]);
+        assertVolume(8, pair[1]);
+
+        pair = SolidFixtures.offsetAlongX();
+        assertVolume(8, pair[1]);
+        assertEquals(pair[0].getEnvelope().getMinimum(1), pair[1].getEnvelope().getMinimum(1));
+    }
+
+    /**
+     * Verifies the volume of the given polyhedron, and that its exterior shell is oriented outward.
+     */
+    private static void assertVolume(final double expected, final Polyhedron solid) {
+        assertEquals(expected, solid.getVolume().getValue().doubleValue(), EPS, "Volume.");
+        assertTrue(signedVolume(solid.getExteriorShell()) > 0, "The exterior shell shall be oriented outward.");
+    }
+
+    /**
+     * Returns the volume enclosed by the given shell, positive if its faces are oriented outward of that volume.
+     * Each face is split in triangles, and the volumes of the tetrahedra joining the origin to the triangles
+     * are added.
+     */
+    private static double signedVolume(final GeometryCollection<Polygon> shell) {
+        final EarClipping triangulator = new EarClipping();
+        double volume = 0;
+        for (int i = 0, n = shell.getNumGeometries(); i < n; i++) {
+            final EarClipping.Triangulation face = triangulator.triangulateIndexed(shell.getGeometryN(i));
+            final double[] c = face.vertices().getAttributeArray(DataPointsType.ATT_POSITION).toArrayDouble();
+            final int[] triangles = face.triangles();
+            for (int k = 0; k < triangles.length; k += 3) {
+                final int a = triangles[k] * 3, b = triangles[k+1] * 3, d = triangles[k+2] * 3;
+                volume += c[a]   * (c[b+1] * c[d+2] - c[b+2] * c[d+1])
+                        + c[a+1] * (c[b+2] * c[d]   - c[b]   * c[d+2])
+                        + c[a+2] * (c[b]   * c[d+1] - c[b+1] * c[d]);
+            }
+        }
+        return volume / 6;
+    }
+}


(sis) 04/13: feat(Geometry): fix hashcode error in Vector class, add DataPoints.subset

Posted by js...@apache.org.
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

commit 32c5356683565f3dafa4db617e5ce4ff6a85df76
Author: jsorel <jo...@geomatys.com>
AuthorDate: Fri Oct 16 15:30:51 2026 +0200

    feat(Geometry): fix hashcode error in Vector class, add DataPoints.subset
---
 .../main/org/apache/sis/geometries/DataPoints.java | 43 +++++++++++
 .../internal/shared/ArrayDataPoints.java           | 35 +++++++++
 .../main/org/apache/sis/maths/AbstractVector.java  |  4 +-
 .../main/org/apache/sis/maths/Maths.java           | 87 ++++++++++++++--------
 .../org/apache/sis/geometries/DataPointsTest.java  | 51 ++++++++++++-
 .../internal/shared/ArrayDataPointsTest.java       | 15 ++++
 .../org/apache/sis/maths/AbstractVectorTest.java   |  6 ++
 .../test/org/apache/sis/maths/MathsTest.java       | 49 ++++++++++--
 8 files changed, 251 insertions(+), 39 deletions(-)

diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/DataPoints.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/DataPoints.java
index 43ee21e73f..f19652805d 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/DataPoints.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/DataPoints.java
@@ -29,6 +29,7 @@ import org.apache.sis.maths.ReadOnly;
 import org.apache.sis.maths.SampleSystem;
 import org.apache.sis.maths.Vector;
 import org.apache.sis.maths.Vectors;
+import org.apache.sis.util.ArraysExt;
 
 
 /**
@@ -298,6 +299,48 @@ public interface DataPoints {
         return env;
     }
 
+    /**
+     * Returns a copy of the points at the given indices, with all their attributes. The point at index
+     * <var>i</var> of the returned {@code DataPoints} is a copy of the point at index {@code indices[i]}
+     * of this {@code DataPoints}. An index can be given more than once. The returned {@code DataPoints}
+     * is independent of this one: changing either of them does not change the other one.
+     *
+     * @param  indices  indices of the points to copy, in the order of the returned points.
+     * @return a new {@code DataPoints} of {@code indices.length} points with the attributes of this one.
+     * @throws IndexOutOfBoundsException if an index is negative or not smaller than {@link #size()}.
+     *
+     * @see #subset(int, int)
+     */
+    default DataPoints subset(final int[] indices) {
+        final DataPointsType type = getType();
+        final List<String> names = type.getAttributeNames();
+        final DataPoints subset = GeometryFactory.DEFAULT.createDataPoints(type, indices.length);
+        final int size = size();
+        for (int i = 0; i < indices.length; i++) {
+            final int index = Objects.checkIndex(indices[i], size);
+            for (final String name : names) {
+                subset.setAttribute(i, name, getAttribute(index, name));
+            }
+        }
+        return subset;
+    }
+
+    /**
+     * Returns a copy of the given range of points, with all their attributes. The returned {@code DataPoints}
+     * is independent of this one: changing either of them does not change the other one.
+     *
+     * @param  start   index of the first point to copy.
+     * @param  length  number of points to copy.
+     * @return a new {@code DataPoints} of {@code length} points with the attributes of this one.
+     * @throws IndexOutOfBoundsException if the range is not inside this {@code DataPoints}.
+     *
+     * @see #subset(int[])
+     */
+    default DataPoints subset(final int start, final int length) {
+        Objects.checkFromIndexSize(start, length, size());
+        return subset(ArraysExt.range(start, start + length));
+    }
+
     /**
      * Returns a hash code value for the given {@code DataPoints}, computed from the attributes it declares
      * and from the values it holds for them. Implementations of this interface shall base their
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/ArrayDataPoints.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/ArrayDataPoints.java
index de767cfe89..a3e1a2a570 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/ArrayDataPoints.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/ArrayDataPoints.java
@@ -19,8 +19,10 @@ package org.apache.sis.geometries.internal.shared;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.geometries.DataPoints;
 import org.apache.sis.geometries.Point;
@@ -163,6 +165,39 @@ public final class ArrayDataPoints implements DataPoints, DataPointsType {
         return NDArrays.computeRange(attributes.get(name));
     }
 
+    /**
+     * Returns a copy of the points at the given indices.
+     * This implementation copies a {@linkplain NDArrays#subset(Array, int...) view} of each attribute array,
+     * without creating a vector for each point.
+     */
+    @Override
+    public DataPoints subset(final int[] indices) {
+        final int size = size();
+        for (final int index : indices) {
+            Objects.checkIndex(index, size);
+        }
+        final Map<String,Array> subset = new LinkedHashMap<>();
+        attributes.forEach((name, array) -> subset.put(name, NDArrays.subset(array, indices).copy()));
+        return new ArrayDataPoints(subset);
+    }
+
+    /**
+     * Returns a copy of the given range of points.
+     * This implementation copies the range of each attribute array at once.
+     */
+    @Override
+    public DataPoints subset(final int start, final int length) {
+        Objects.checkFromIndexSize(start, length, size());
+        final Map<String,Array> subset = new LinkedHashMap<>();
+        for (final Map.Entry<String,Array> entry : attributes.entrySet()) {
+            final Array source = entry.getValue();
+            final Array target = NDArrays.of(source.getSampleSystem(), source.getDataType(), length);
+            target.set(0, source, start, length);
+            subset.put(entry.getKey(), target);
+        }
+        return new ArrayDataPoints(subset);
+    }
+
     @Override
     public int hashCode() {
         return DataPoints.hashCode(this);
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/AbstractVector.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/AbstractVector.java
index ef6ec53783..ed2cc3a17b 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/AbstractVector.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/AbstractVector.java
@@ -53,7 +53,9 @@ abstract class AbstractVector<T extends AbstractVector<T>> implements Vector<T>
     public int hashCode() {
         int hash = 31;
         for (int i = 0, n = getDimension(); i < n; i++) {
-            hash += 31 * hash + Double.hashCode(get(i));
+            final double value = get(i);
+            //positive and negative zero are equal, so they shall have the same hash code.
+            hash += 31 * hash + Double.hashCode(value == 0 ? 0 : value);
         }
         return hash;
     }
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java
index fb90d5880b..41948a2609 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java
@@ -111,6 +111,33 @@ public final class Maths {
         return res;
     }
 
+    /**
+     * Calculate normal of a polygon with Newell's method. Unlike the normal of a triangle made of three
+     * points of the polygon, this normal is reliable for any ring, including rings with collinear points
+     * and rings which are not exactly planar. The normal follows the right-hand rule: it points toward an
+     * observer who sees the ring counterclockwise. It is not normalized: for a planar ring, its length is
+     * twice the area of the polygon.
+     *
+     * @param positions positions of the ring, closed or not. Only the three first coordinates are used.
+     * @return polygon normal as (x,y,z) components, zero for a degenerate ring
+     * @throws IllegalArgumentException if the positions have less than 3 coordinates
+     */
+    public static double[] calculatePolygonNormal(Array positions){
+        final int dim = positions.getDimension();
+        if (dim < 3) {
+            throw new IllegalArgumentException("Positions shall have at least 3 coordinates, found " + dim + ".");
+        }
+        final double[] c = positions.toArrayDouble();
+        final double[] normal = new double[3];
+        for (int a = 0; a < c.length; a += dim) {
+            final int b = (a + dim) % c.length;
+            normal[0] += (c[a+1] - c[b+1]) * (c[a+2] + c[b+2]);
+            normal[1] += (c[a+2] - c[b+2]) * (c[a]   + c[b]);
+            normal[2] += (c[a]   - c[b])   * (c[a+1] + c[b+1]);
+        }
+        return normal;
+    }
+
     public static double[] subtract(float[] A, float[] B){
         return new double[] {
             A[0] - (double) B[0],
@@ -754,6 +781,34 @@ public final class Maths {
         return bary[1] >= 0.0 && bary[2] >= 0.0 && (bary[1] + bary[2]) <= 1.0;
     }
 
+    /**
+     * Test if a point is inside given triangle, its edges and corners included. Only X,Y ordinates are used.
+     * Robust variant : the side of the point compared to each edge is computed with
+     * {@link #lineSide(ReadOnly.Vector, ReadOnly.Vector, ReadOnly.Vector)}, without division.
+     * The triangle can be clockwise or counterclockwise. A degenerate triangle, whose three corners
+     * are on a same line, contains the points of that line between its corners.
+     *
+     * @param a first triangle point
+     * @param b second triangle point
+     * @param c third triangle point
+     * @param p point to test
+     * @return true if point is inside triangle
+     */
+    public static boolean inTriangle(ReadOnly.Vector<?> a, ReadOnly.Vector<?> b, ReadOnly.Vector<?> c, ReadOnly.Vector<?> p) {
+        final double d1 = lineSide(a, b, p);
+        final double d2 = lineSide(b, c, p);
+        final double d3 = lineSide(c, a, p);
+        final boolean left  = d1 > 0 || d2 > 0 || d3 > 0;
+        final boolean right = d1 < 0 || d2 < 0 || d3 < 0;
+        if (left || right) {
+            return !(left && right);
+        }
+        //the point is on the lines of the three edges, which happens only if the triangle is degenerate.
+        final double x = p.get(0), y = p.get(1);
+        return x >= Math.min(a.get(0), Math.min(b.get(0), c.get(0))) && x <= Math.max(a.get(0), Math.max(b.get(0), c.get(0)))
+            && y >= Math.min(a.get(1), Math.min(b.get(1), c.get(1))) && y <= Math.max(a.get(1), Math.max(b.get(1), c.get(1)));
+    }
+
     /**
      * Calculate the barycentric value in triangle for given point.
      *
@@ -1278,36 +1333,4 @@ public final class Maths {
                 }
         }
     }
-
-    /**
-     * Remove element at index.
-     * Move elements after index by -1.
-     * Last element is set to null
-     *
-     * @param values original array
-     * @param index removed element index
-     * @return removed object
-     */
-    public static Object removeWithin(Object[] values, int index) {
-        final Object removedValue = values[index];
-        System.arraycopy(values, index+1, values, index, values.length-index-1);
-        values[values.length-1] = null; //remove reference
-        return removedValue;
-    }
-
-    /**
-     * Remove element at index.
-     * Move elements after index by -1.
-     * Last element is set to zero
-     *
-     * @param values original array
-     * @param index removed element index
-     * @return removed value
-     */
-    public static int removeWithin(int[] values, int index) {
-        final int removedValue = values[index];
-        System.arraycopy(values, index+1, values, index, values.length-index-1);
-        values[values.length-1] = 0;
-        return removedValue;
-    }
 }
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/DataPointsTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/DataPointsTest.java
index 6e3b0e219f..f9b170071c 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/DataPointsTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/DataPointsTest.java
@@ -45,10 +45,59 @@ public class DataPointsTest {
      * The returned instance implements only the abstract methods, in order to test the default methods.
      */
     private static DataPoints createSample() {
+        return GeometryTest.withInterpolations(createArraySample(), Map.of());
+    }
+
+    /**
+     * Creates the points of {@link #createSample()}, stored in the arrays of the default implementation.
+     */
+    protected static DataPoints createArraySample() {
         final Map<String,Array> attributes = new LinkedHashMap<>();
         attributes.put(DataPointsType.ATT_POSITION, NDArrays.of(GeometryTest.CRS_2D, new double[] {0,0, 10,5, 4,20}));
         attributes.put("temperature", NDArrays.of(1, new double[] {10, 30, 20}));
-        return GeometryTest.withInterpolations(GeometryFactory.DEFAULT.createDataPoints(attributes), Map.of());
+        return GeometryFactory.DEFAULT.createDataPoints(attributes);
+    }
+
+    /**
+     * Tests {@link DataPoints#subset(int[])} and {@link DataPoints#subset(int, int)}.
+     */
+    @Test
+    public void testSubset() {
+        assertSubsets(createSample());
+    }
+
+    /**
+     * Verifies the copies of points made by the given {@code DataPoints},
+     * which shall hold the points of {@link #createSample()}.
+     */
+    protected static void assertSubsets(final DataPoints points) {
+        final DataPoints subset = points.subset(new int[] {2, 0, 2});
+        assertEquals(3, subset.size());
+        assertEquals(points.getCoordinateReferenceSystem(), subset.getCoordinateReferenceSystem());
+        assertEquals(Set.copyOf(points.getType().getAttributeNames()), Set.copyOf(subset.getType().getAttributeNames()));
+        assertPositionEquals(4, 20, subset.getPosition(0));
+        assertPositionEquals(0,  0, subset.getPosition(1));
+        assertPositionEquals(4, 20, subset.getPosition(2));
+        assertEquals(20, subset.getAttribute(0, "temperature").get(0));
+        assertEquals(10, subset.getAttribute(1, "temperature").get(0));
+
+        final DataPoints range = points.subset(1, 2);
+        assertEquals(2, range.size());
+        assertPositionEquals(10,  5, range.getPosition(0));
+        assertPositionEquals( 4, 20, range.getPosition(1));
+        assertEquals(30, range.getAttribute(0, "temperature").get(0));
+        assertEquals(0, points.subset(3, 0).size());
+
+        //the copies are independent of the copied points.
+        final Vector<?> value = range.getAttribute(0, "temperature");
+        value.set(0, 99);
+        range.setAttribute(0, "temperature", value);
+        assertEquals(30, points.getAttribute(1, "temperature").get(0));
+
+        assertThrows(IndexOutOfBoundsException.class, () -> points.subset(new int[] {3}));
+        assertThrows(IndexOutOfBoundsException.class, () -> points.subset(new int[] {-1}));
+        assertThrows(IndexOutOfBoundsException.class, () -> points.subset(2, 2));
+        assertThrows(IndexOutOfBoundsException.class, () -> points.subset(-1, 1));
     }
 
     /**
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/ArrayDataPointsTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/ArrayDataPointsTest.java
index 5585a056ad..363d32f372 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/ArrayDataPointsTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/ArrayDataPointsTest.java
@@ -16,7 +16,11 @@
  */
 package org.apache.sis.geometries.internal.shared;
 
+import org.apache.sis.geometries.DataPoints;
+
 // Test dependencies
+import static org.junit.jupiter.api.Assertions.*;
+import org.junit.jupiter.api.Test;
 import org.apache.sis.geometries.DataPointsTest;
 
 
@@ -26,4 +30,15 @@ import org.apache.sis.geometries.DataPointsTest;
  * @author Johann Sorel (Geomatys)
  */
 public class ArrayDataPointsTest extends DataPointsTest {
+    /**
+     * Tests the copies of points made from the arrays, which do not use the default methods.
+     */
+    @Test
+    public void testSubsetOfArrays() {
+        final DataPoints points = createArraySample();
+        assertInstanceOf(ArrayDataPoints.class, points);
+        assertSubsets(points);
+        assertInstanceOf(ArrayDataPoints.class, points.subset(new int[] {1}));
+        assertInstanceOf(ArrayDataPoints.class, points.subset(0, 1));
+    }
 }
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/AbstractVectorTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/AbstractVectorTest.java
index 78bbd04e5e..c32d3223ba 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/AbstractVectorTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/AbstractVectorTest.java
@@ -244,6 +244,12 @@ public abstract class AbstractVectorTest {
             assertFalse(copy.equals(tuple));
             tuple.set(0, Double.NaN);
             assertEquals(copy, tuple);
+
+            //test signed zero equality, which shall give the same hash code
+            copy.set(0, 0.0);
+            tuple.set(0, -0.0);
+            assertEquals(copy, tuple);
+            assertEquals(copy.hashCode(), tuple.hashCode());
         }
 
     }
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/MathsTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/MathsTest.java
index ec2b77efe3..2d52192a4b 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/MathsTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/MathsTest.java
@@ -121,6 +121,50 @@ public class MathsTest {
 
     }
 
+    /**
+     * Returns a vector of the given values.
+     */
+    private static Vector<?> vector(final double... values) {
+        return Vectors.createDouble(values.length).set(values);
+    }
+
+    /**
+     * Tests the robust {@link Maths#inTriangle(ReadOnly.Vector, ReadOnly.Vector, ReadOnly.Vector, ReadOnly.Vector)},
+     * which includes the edges and corners of the triangle, whatever its orientation.
+     */
+    @Test
+    public void testInTriangle() {
+        final Vector<?> a = vector(0, 0), b = vector(4, 0), c = vector(0, 4);
+        for (final Vector<?>[] triangle : new Vector<?>[][] {{a, b, c}, {a, c, b}}) {
+            assertTrue (Maths.inTriangle(triangle[0], triangle[1], triangle[2], vector(1, 1)), "Inside");
+            assertTrue (Maths.inTriangle(triangle[0], triangle[1], triangle[2], vector(2, 0)), "On an edge");
+            assertTrue (Maths.inTriangle(triangle[0], triangle[1], triangle[2], vector(4, 0)), "On a corner");
+            assertFalse(Maths.inTriangle(triangle[0], triangle[1], triangle[2], vector(3, 3)), "Outside");
+            assertFalse(Maths.inTriangle(triangle[0], triangle[1], triangle[2], vector(-1, 1)), "Outside");
+        }
+        //a degenerate triangle contains the points of its line between its corners.
+        final Vector<?> d = vector(2, 2), e = vector(4, 4);
+        assertTrue (Maths.inTriangle(a, d, e, vector(1, 1)));
+        assertFalse(Maths.inTriangle(a, d, e, vector(5, 5)));
+        assertFalse(Maths.inTriangle(a, d, e, vector(1, 0)));
+    }
+
+    /**
+     * Tests {@link Maths#calculatePolygonNormal(Array)}, whose length is twice the area of the polygon.
+     */
+    @Test
+    public void testCalculatePolygonNormal() {
+        //a counterclockwise square in the (x,y) plane, closed or not.
+        assertArrayEquals(new double[] {0, 0, 8}, Maths.calculatePolygonNormal(
+                NDArrays.of(3, new double[] {0,0,0, 2,0,0, 2,2,0, 0,2,0, 0,0,0})), EPSILON);
+        assertArrayEquals(new double[] {0, 0, 8}, Maths.calculatePolygonNormal(
+                NDArrays.of(3, new double[] {0,0,0, 2,0,0, 2,2,0, 0,2,0})), EPSILON);
+        //a vertical square in the (x,z) plane, with collinear points.
+        assertArrayEquals(new double[] {0, -8, 0}, Maths.calculatePolygonNormal(
+                NDArrays.of(3, new double[] {0,1,0, 1,1,0, 2,1,0, 2,1,2, 0,1,2, 0,1,0})), EPSILON);
+        assertThrows(IllegalArgumentException.class, () -> Maths.calculatePolygonNormal(NDArrays.of(2, new double[] {0,0, 1,0, 0,1})));
+    }
+
     @Test
     public void testNormalizedByte() {
         assertEquals(-1.0, Maths.normalizedByte(Byte.MIN_VALUE), 0.0);
@@ -230,11 +274,6 @@ public class MathsTest {
         assertEquals(0xFFFFFFFFFFFFFFFFL, Maths.toNormalizedULong(1.0));
     }
 
-    @Test
-    public void testInTriangle() {
-
-    }
-
     /**
      * A known double precisions mathematical problem.
      */


(sis) 05/13: feat(Math): add Maths.isConvex/inCone, Distance.nearest(line,point)

Posted by js...@apache.org.
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

commit 13e5ba92f837a8151f66c19c57a2cdc9934038b9
Author: jsorel <jo...@geomatys.com>
AuthorDate: Fri Oct 16 15:56:14 2026 +0200

    feat(Math): add Maths.isConvex/inCone, Distance.nearest(line,point)
---
 .../apache/sis/geometries/operation/Distance.java  | 42 ++++++++++++++++++
 .../main/org/apache/sis/maths/Maths.java           | 51 ++++++++++++++++++++++
 .../sis/geometries/operation/DistanceTest.java     | 24 ++++++++++
 .../test/org/apache/sis/maths/MathsTest.java       | 33 ++++++++++++++
 4 files changed, 150 insertions(+)

diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Distance.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Distance.java
index 379ede03bc..ba896a5647 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Distance.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Distance.java
@@ -19,6 +19,7 @@ package org.apache.sis.geometries.operation;
 import java.util.List;
 import org.apache.sis.geometries.Point;
 import org.apache.sis.maths.Maths;
+import org.apache.sis.maths.ReadOnly;
 import org.apache.sis.maths.Vectors;
 import org.apache.sis.maths.Vector;
 
@@ -188,6 +189,47 @@ public final class Distance {
         }
     }
 
+    /**
+     * A point on a polyline, given by the segment where it is and its position along that segment.
+     *
+     * @param segment  index of the segment, which goes from the point at that index to the next one.
+     * @param ratio    position along the segment, from 0 at its start to 1 at its end.
+     */
+    public record SegmentPoint(int segment, double ratio) {
+    }
+
+    /**
+     * Finds the point of a polyline nearest to the given point.
+     * Only the two first coordinates of the positions are used.
+     *
+     * @param polyline positions of the polyline, at least two
+     * @param point the point to search the nearest point of the polyline for
+     * @return the nearest point of the polyline, on the first nearest segment if many are at the same distance
+     * @throws IllegalArgumentException if the polyline has less than two positions
+     */
+    public static SegmentPoint nearestSegment(List<? extends ReadOnly.Vector<?>> polyline, ReadOnly.Vector<?> point) {
+        if (polyline.size() < 2) {
+            throw new IllegalArgumentException("A polyline shall have at least two positions.");
+        }
+        final double x = point.get(0), y = point.get(1);
+        SegmentPoint nearest = null;
+        double nearestDistance = Double.POSITIVE_INFINITY;
+        for (int i = 1; i < polyline.size(); i++) {
+            final ReadOnly.Vector<?> a = polyline.get(i-1), b = polyline.get(i);
+            final double ax = a.get(0), ay = a.get(1), bx = b.get(0), by = b.get(1);
+            final double projection = Maths.projectionRatio(ax, ay, bx, by, x, y);
+            final double ratio = Double.isNaN(projection) ? 0 : Maths.clamp(projection, 0, 1);  // NaN if the segment has no length.
+            final double dx = ax + ratio * (bx - ax) - x;
+            final double dy = ay + ratio * (by - ay) - y;
+            final double distance = dx*dx + dy*dy;
+            if (distance < nearestDistance) {
+                nearestDistance = distance;
+                nearest = new SegmentPoint(i - 1, ratio);
+            }
+        }
+        return nearest;
+    }
+
     /**
      * Point to point distance.
      */
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java
index 41948a2609..43b573aaa4 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/Maths.java
@@ -306,6 +306,57 @@ public final class Maths {
         return ShewchukPredicates.orient2d(x1, y1, x2, y2, x, y);
     }
 
+    /**
+     * Test if a ring is convex at a vertex, which is whether the ring turns toward its inside at that vertex:
+     * to the right for a clockwise ring, to the left for a counterclockwise ring. Only X,Y ordinates are used.
+     * A vertex aligned with its two neighbors is not convex. A vertex where the ring turns toward its outside,
+     * called reflex, is convex for the ring traversed in the opposite direction.
+     *
+     * @param previous vertex before the tested vertex in the ring
+     * @param vertex tested vertex
+     * @param next vertex after the tested vertex in the ring
+     * @param clockwise whether the ring is clockwise
+     * @return true if the ring is convex at the vertex
+     */
+    public static boolean isConvex(ReadOnly.Vector<?> previous,
+                                   ReadOnly.Vector<?> vertex,
+                                   ReadOnly.Vector<?> next,
+                                   boolean clockwise) {
+        final double side = lineSide(previous, vertex, next);
+        return clockwise ? side < 0 : side > 0;
+    }
+
+    /**
+     * Test if the segment from a vertex of a ring toward a point starts inside the ring, which is between
+     * the two edges of the ring at that vertex. A segment along one of the edges is not inside.
+     * Only X,Y ordinates are used. This is the {@code InCone} test of Joseph O'Rourke in
+     * <cite>Computational Geometry in C</cite>, which tells whether a segment between two vertices
+     * of a polygon is a diagonal near its ends.
+     *
+     * @param previous vertex before the tested vertex in the ring
+     * @param vertex start of the segment, a vertex of the ring
+     * @param next vertex after the tested vertex in the ring
+     * @param point end of the segment
+     * @param clockwise whether the ring is clockwise
+     * @return true if the segment starts inside the ring
+     */
+    public static boolean inCone(ReadOnly.Vector<?> previous,
+                                 ReadOnly.Vector<?> vertex,
+                                 ReadOnly.Vector<?> next,
+                                 ReadOnly.Vector<?> point,
+                                 boolean clockwise)
+    {
+        final double sign = clockwise ? -1 : 1;
+        if (sign * lineSide(vertex, next, previous) >= 0) {
+            //convex vertex: the segment shall be strictly between the two edges.
+            return sign * lineSide(vertex, point, previous) > 0
+                && sign * lineSide(point, vertex, next) > 0;
+        }
+        //reflex vertex: the segment shall not be in the outside wedge, its edges included.
+        return !(sign * lineSide(vertex, point, next) >= 0
+              && sign * lineSide(point, vertex, previous) >= 0);
+    }
+
     /**
      * Test if point is inside triangle using barycentric weights.
      * Only X,Y ordinates are used.
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/DistanceTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/DistanceTest.java
index c21f4b1b7b..95e26a0690 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/DistanceTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/DistanceTest.java
@@ -16,11 +16,14 @@
  */
 package org.apache.sis.geometries.operation;
 
+import java.util.List;
 import javax.measure.Quantity;
 import org.apache.sis.geometries.Geometry;
 import org.apache.sis.geometries.GeometryFactory;
 import org.apache.sis.geometries.Point;
 import org.apache.sis.maths.SampleSystem;
+import org.apache.sis.maths.Vector;
+import org.apache.sis.maths.Vectors;
 import org.apache.sis.measure.Quantities;
 import org.apache.sis.measure.Units;
 import org.apache.sis.referencing.CommonCRS;
@@ -28,6 +31,7 @@ import org.apache.sis.referencing.CommonCRS;
 // Test dependencies
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import org.junit.jupiter.api.Test;
 
 
@@ -168,4 +172,24 @@ public class DistanceTest extends AbstractD9IMTest {
         return result.getValue().doubleValue();
     }
 
+    /**
+     * Returns a vector of the given values.
+     */
+    private static Vector<?> vector(final double... values) {
+        return Vectors.createDouble(values.length).set(values);
+    }
+
+    /**
+     * Tests {@code Distance.nearestSegment(…)}, which clamps the nearest point inside the segments.
+     */
+    @Test
+    public void testNearestSegment() {
+        final List<Vector<?>> polyline = List.of(vector(0,0), vector(4,0), vector(4,4));
+        assertEquals(new Distance.SegmentPoint(0, 0.25), Distance.nearestSegment(polyline, vector( 1,  2)));
+        assertEquals(new Distance.SegmentPoint(1, 0.75), Distance.nearestSegment(polyline, vector( 5,  3)));
+        assertEquals(new Distance.SegmentPoint(0, 0),    Distance.nearestSegment(polyline, vector(-2, -1)), "Before the start");
+        //a segment without length is a point.
+        assertEquals(new Distance.SegmentPoint(1, 0.5), Distance.nearestSegment(List.of(vector(0,0), vector(0,0), vector(2,0)), vector(1, 1)));
+        assertThrows(IllegalArgumentException.class, () -> Distance.nearestSegment(List.of(vector(0,0)), vector(1, 1)));
+    }
 }
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/MathsTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/MathsTest.java
index 2d52192a4b..ab7d3e59eb 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/MathsTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/MathsTest.java
@@ -149,6 +149,39 @@ public class MathsTest {
         assertFalse(Maths.inTriangle(a, d, e, vector(1, 0)));
     }
 
+    /**
+     * Tests {@link Maths#isConvex(ReadOnly.Vector, ReadOnly.Vector, ReadOnly.Vector, boolean)}.
+     */
+    @Test
+    public void testIsConvex() {
+        //the counterclockwise ring (0,0), (4,0), (4,4) turns left at (4,0).
+        assertTrue (Maths.isConvex(vector(0,0), vector(4,0), vector(4,4), false));
+        assertFalse(Maths.isConvex(vector(0,0), vector(4,0), vector(4,4), true));
+        assertTrue (Maths.isConvex(vector(4,4), vector(4,0), vector(0,0), true), "Same ring traversed clockwise");
+        //aligned vertices are not convex.
+        assertFalse(Maths.isConvex(vector(0,0), vector(2,0), vector(4,0), false));
+        assertFalse(Maths.isConvex(vector(0,0), vector(2,0), vector(4,0), true));
+    }
+
+    /**
+     * Tests {@link Maths#inCone(ReadOnly.Vector, ReadOnly.Vector, ReadOnly.Vector, ReadOnly.Vector, boolean)}
+     * at a convex vertex and at a reflex vertex, in counterclockwise and clockwise rings.
+     */
+    @Test
+    public void testInCone() {
+        //convex vertex (0,0) of the counterclockwise square (0,0), (4,0), (4,4), (0,4).
+        final Vector<?> vertex = vector(0,0), previous = vector(0,4), next = vector(4,0);
+        assertTrue (Maths.inCone(previous, vertex, next, vector( 2,  2), false));
+        assertFalse(Maths.inCone(previous, vertex, next, vector(-1, -1), false));
+        assertFalse(Maths.inCone(previous, vertex, next, vector( 2,  0), false), "Along an edge");
+        assertTrue (Maths.inCone(next, vertex, previous, vector( 2,  2), true), "Same ring traversed clockwise");
+        //reflex vertex (2,1) of the counterclockwise ring (0,0), (4,0), (4,4), (2,1), (0,4), with a notch above it.
+        final Vector<?> reflex = vector(2,1), before = vector(4,4), after = vector(0,4);
+        assertTrue (Maths.inCone(before, reflex, after, vector(2, 0), false));
+        assertFalse(Maths.inCone(before, reflex, after, vector(2, 3), false), "In the notch");
+        assertTrue (Maths.inCone(after, reflex, before, vector(2, 0), true), "Same ring traversed clockwise");
+    }
+
     /**
      * Tests {@link Maths#calculatePolygonNormal(Array)}, whose length is twice the area of the polygon.
      */


(sis) 07/13: refactor(NDRange): move intersection with tolerance on NDRange class

Posted by js...@apache.org.
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

commit 7c618a7feb0f9d548bd48bf2b0381382edec6e4a
Author: jsorel <jo...@geomatys.com>
AuthorDate: Mon Oct 19 09:00:30 2026 +0200

    refactor(NDRange): move intersection with tolerance on NDRange class
---
 .../internal/shared/DefaultNurbCurve.java          |  3 +-
 .../internal/shared/DefaultNurbSurface.java        |  3 +-
 .../sis/geometries/operation/Intersection.java     | 21 ++----------
 .../main/org/apache/sis/maths/NDRange.java         | 34 +++++++++++++++++-
 .../test/org/apache/sis/maths/NDRangeTest.java     | 40 ++++++++++++++++++++++
 5 files changed, 77 insertions(+), 24 deletions(-)

diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultNurbCurve.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultNurbCurve.java
index b63279026e..073967c85e 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultNurbCurve.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultNurbCurve.java
@@ -19,7 +19,6 @@ package org.apache.sis.geometries.internal.shared;
 import java.util.Arrays;
 import java.util.List;
 import javax.measure.Quantity;
-import org.opengis.geometry.Envelope;
 import org.apache.sis.geometries.DataPoints;
 import org.apache.sis.geometries.DataPointsType;
 import org.apache.sis.geometries.Geometries;
@@ -313,7 +312,7 @@ public non-sealed class DefaultNurbCurve extends AbstractGeometry implements Nur
      * @return the bounding box of this curve
      */
     @Override
-    public Envelope getEnvelope() {
+    public NDRange getEnvelope() {
         final Vector<?> start = Vectors.create(getCoordinateReferenceSystem(), DataType.DOUBLE).set(controlPoints[0]);
         final NDRange bbox = new NDRange(start, start);
         for (final Vector<?> p : controlPoints) {
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultNurbSurface.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultNurbSurface.java
index 6f8cc6d840..bb0d07ea84 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultNurbSurface.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultNurbSurface.java
@@ -21,7 +21,6 @@ import java.util.Arrays;
 import java.util.List;
 import javax.measure.Quantity;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
-import org.opengis.geometry.Envelope;
 import org.apache.sis.geometries.Curve;
 import org.apache.sis.geometries.DataPoints;
 import org.apache.sis.geometries.Geometry;
@@ -316,7 +315,7 @@ public non-sealed class DefaultNurbSurface extends AbstractGeometry implements N
      *
      * @return the bounding box of this surface
      */
-    public Envelope getEnvelope() {
+    public NDRange getEnvelope() {
         final Vector<?> start = Vectors.create(getCoordinateReferenceSystem(), DataType.DOUBLE).set(controlPoints[0][0]);
         final NDRange bbox = new NDRange(start, start);
         for (final Vector<?>[] row : controlPoints) {
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Intersection.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Intersection.java
index 652d15c6af..db21ba0719 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Intersection.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/operation/Intersection.java
@@ -22,7 +22,6 @@ import java.util.Iterator;
 import java.util.List;
 import java.util.Optional;
 import java.util.stream.Stream;
-import org.opengis.geometry.Envelope;
 import org.opengis.referencing.operation.TransformException;
 import org.apache.sis.geometries.Geometries;
 import org.apache.sis.geometries.GeometryFactory;
@@ -64,22 +63,6 @@ public final class Intersection {
         return a.copy().scale(s);
     }
 
-    /**
-     * Tests whether two bounding boxes overlap, within the given tolerance.
-     * Boxes separated by less than the tolerance are still considered as overlapping,
-     * which {@link Envelope#intersects Envelope.intersects(…)} cannot express.
-     */
-    private static boolean boxesOverlap(final Envelope box1, final Envelope box2, final double tol) {
-        for (int i = 0, n = box1.getDimension(); i < n; i++) {
-            if (box1.getMaximum(i) + tol < box2.getMinimum(i)
-             || box2.getMaximum(i) + tol < box1.getMinimum(i)) {
-                return false;
-            }
-        }
-        return true;
-    }
-
-
     /**
      * Inherit attributes from TIN.
      * The POSITION attribute is ignored.
@@ -248,7 +231,7 @@ public final class Intersection {
         }
 
         private static void intersectRecursive(final DefaultNurbCurve c1, final DefaultNurbCurve c2, final double tol, final List<CurveIntersectionPoint> results) {
-            if (!boxesOverlap(c1.getEnvelope(), c2.getEnvelope(), tol)) {
+            if (!c1.getEnvelope().intersects(c2.getEnvelope(), tol)) {
                 return;
             }
 
@@ -370,7 +353,7 @@ public final class Intersection {
 
         private static void intersectRecursive(final DefaultNurbSurface s1, final DefaultNurbSurface s2, final double tol,
                 final List<SurfaceIntersectionPoint> results, final int depth) {
-            if (!boxesOverlap(s1.getEnvelope(), s2.getEnvelope(), tol)) {
+            if (!s1.getEnvelope().intersects(s2.getEnvelope(), tol)) {
                 return;
             }
 
diff --git a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/NDRange.java b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/NDRange.java
index 9293d42f9a..4b078c5874 100644
--- a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/NDRange.java
+++ b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/maths/NDRange.java
@@ -378,12 +378,44 @@ public final class NDRange implements Envelope {
         return value >= lower.get(dimension) && value <= upper.get(dimension);
     }
 
+    /**
+     * Returns whether the given envelope intersects this range, within the given tolerance.
+     * Envelopes separated by no more than the tolerance in every dimension are considered as intersecting,
+     * so that envelopes touching on their border intersect even with a tolerance of zero.
+     * A NaN bound in this range or in the envelope makes them disjoint, the range being empty in that dimension.
+     * The envelope is assumed in the reference system of this range, without verification.
+     *
+     * @param  envelope   the envelope to test.
+     * @param  tolerance  the largest distance between the envelope and this range for considering them as intersecting.
+     * @return whether the envelope intersects this range or is separated from it by no more than the tolerance.
+     * @throws MismatchedDimensionException if the envelope does not have the dimension of this range.
+     * @throws IllegalArgumentException if the tolerance is negative or NaN.
+     */
+    public boolean intersects(final Envelope envelope, final double tolerance) throws MismatchedDimensionException {
+        ensureDimensionMatches("envelope", envelope.getDimension());
+        ArgumentChecks.ensurePositive("tolerance", tolerance);
+        for (int i = 0; i < lower.getDimension(); i++) {
+            // Written for being false if a bound is NaN.
+            if (!(envelope.getMinimum(i) <= upper.get(i) + tolerance && lower.get(i) <= envelope.getMaximum(i) + tolerance)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
     /**
      * Verifies that a position has the dimension of this range.
      */
     private void ensureDimensionMatches(final int dimension) {
+        ensureDimensionMatches("position", dimension);
+    }
+
+    /**
+     * Verifies that an object of the given name, for example a position or an envelope, has the dimension of this range.
+     */
+    private void ensureDimensionMatches(final String name, final int dimension) {
         if (dimension != lower.getDimension()) {
-            throw new MismatchedDimensionException("The position has " + dimension
+            throw new MismatchedDimensionException("The " + name + " has " + dimension
                     + " dimensions but the range has " + lower.getDimension() + '.');
         }
     }
diff --git a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/NDRangeTest.java b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/NDRangeTest.java
index 156abbd09b..156e6cc146 100644
--- a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/NDRangeTest.java
+++ b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/maths/NDRangeTest.java
@@ -183,6 +183,46 @@ public class NDRangeTest {
         assertThrows(MismatchedDimensionException.class, () -> range.contains(Vectors.createDouble(2)));
     }
 
+    /**
+     * Ranges intersect if they overlap, touch, or are separated by no more than the tolerance in every dimension.
+     */
+    @Test
+    public void testIntersects() {
+        final NDRange range = range();
+        final NDRange overlapping = new NDRange(vector(3, 5, 3), vector(7, 8, 3));
+        assertTrue(range.intersects(overlapping, 0));
+        assertTrue(overlapping.intersects(range, 0));
+        assertTrue(range.intersects(range(), 0), "A range intersects itself.");
+        assertTrue(range.intersects(new NDRange(vector(2, 3, 3), vector(3, 4, 3)), 0), "A contained range intersects.");
+        assertTrue(range.intersects(new NDRange(vector(4, 6, 3), vector(5, 7, 3)), 0), "Touching ranges intersect.");
+
+        // Separated by 0.5 along the first axis only.
+        final NDRange near = new NDRange(vector(4.5, 2, 3), vector(5, 6, 3));
+        assertFalse(range.intersects(near, 0));
+        assertFalse(range.intersects(near, 0.25));
+        assertTrue (range.intersects(near, 0.5));
+        assertTrue (near.intersects(range, 0.5));
+
+        // Overlapping along two axes is not enough.
+        assertFalse(range.intersects(new NDRange(vector(1, 2, 4), vector(4, 6, 5)), 0));
+
+        // Any envelope implementation is accepted.
+        final GeneralEnvelope envelope = new GeneralEnvelope(CRS);
+        envelope.setEnvelope(0, 0, 0, 1, 2, 3);
+        assertTrue (range.intersects(envelope, 0));
+        envelope.setEnvelope(0, 0, 0, 0.9, 2, 3);
+        assertFalse(range.intersects(envelope, 0));
+
+        // A NaN bound makes the range empty.
+        final NDRange empty = new NDRange(vector(Double.NaN, 2, 3), vector(Double.NaN, 6, 3));
+        assertFalse(range.intersects(empty, 1));
+        assertFalse(empty.intersects(range, 1));
+
+        assertThrows(MismatchedDimensionException.class, () -> range.intersects(new NDRange(2), 0));
+        assertThrows(IllegalArgumentException.class, () -> range.intersects(overlapping, -1));
+        assertThrows(IllegalArgumentException.class, () -> range.intersects(overlapping, Double.NaN));
+    }
+
     /**
      * The constructors without bounds create a range of the given data type, {@code DOUBLE} by default,
      * with all bounds at zero.