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 5c99a3add75cfe8c6ede4b73a1d7ec994ac004db
Author: jsorel <[email protected]>
AuthorDate: Mon Nov 2 16:11:46 2026 +0100

    feat(Geometry): add DefaultLineStringOffset curve
---
 .../org/apache/sis/geometries/GeometryFactory.java |   4 +
 .../internal/shared/ArrayDataPoints.java           |  52 ++-
 .../internal/shared/DefaultOffsetLineString.java   | 471 +++++++++++++++++++++
 .../sis/geometries/internal/shared/Vertex.java     |  17 +
 .../apache/sis/geometries/mesh/MeshPrimitive.java  |  15 +-
 .../geometries/operation/GeometryProcessor.java    |  39 ++
 .../internal/shared/ArrayDataPointsTest.java       |  59 +++
 .../shared/DefaultOffsetLineStringTest.java        | 317 ++++++++++++++
 .../sis/geometries/operation/OffsetCurveTest.java  | 129 ++++++
 .../sis/geometries/operation/ToPrimitiveTest.java  |  56 +++
 .../sis/referencing/dggs/AbstractDggrsTest.java    |   7 +-
 11 files changed, 1150 insertions(+), 16 deletions(-)

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 6d9942180a..280c08c578 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
@@ -75,6 +75,7 @@ import org.apache.sis.geometries.internal.shared.DefaultEmpty;
 import org.apache.sis.geometries.internal.shared.DefaultGeodesic;
 import org.apache.sis.geometries.internal.shared.DefaultGeometryCollection;
 import org.apache.sis.geometries.internal.shared.DefaultLineString;
+import org.apache.sis.geometries.internal.shared.DefaultOffsetLineString;
 import org.apache.sis.geometries.internal.shared.DefaultLinearRing;
 import org.apache.sis.geometries.internal.shared.DefaultMultiPoint;
 import org.apache.sis.geometries.internal.shared.DefaultNurbCurve;
@@ -502,6 +503,9 @@ public final class GeometryFactory {
      *                       or {@code null} if the spatial dimension is 2.
      */
     public OffsetCurve createOffsetCurve(Curve baseCurve, Quantity<?> 
distance, Bearing refDirection) {
+        if (baseCurve instanceof LineString ls && refDirection == null) {
+            return new DefaultOffsetLineString(ls, distance);
+        }
         return new DefaultOffsetCurve(baseCurve, distance, refDirection);
     }
 
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 5d02e25e6a..e77336cd61 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
@@ -115,19 +115,65 @@ public final class ArrayDataPoints implements DataPoints, 
DataPointsType {
         return attributes.get(name);
     }
 
+    /**
+     * Replaces all attributes at once.
+     * Used when all attributes are rebuilt with a new length, which {@link 
#setAttribute(String, Array)}
+     * cannot do one attribute at a time.
+     *
+     * @param attributes the new attributes, which must contain the positions.
+     * @throws IllegalArgumentException if positions are missing or the arrays 
have different lengths.
+     */
+    public void setAttributes(Map<String, Array> attributes) {
+        final Array newPositions = attributes.get(DataPointsType.ATT_POSITION);
+        ArgumentChecks.ensureNonNull("positions", newPositions);
+        ArgumentChecks.ensureNonNull("positions crs", 
newPositions.getCoordinateReferenceSystem());
+        final long size = newPositions.getLength();
+        for (Map.Entry<String,Array> entry : attributes.entrySet()) {
+            if (entry.getValue().getLength() != size) {
+                throw new IllegalArgumentException("Array must have the same 
length as the positions : " + entry.getKey());
+            }
+        }
+        this.attributes.clear();
+        this.attributes.putAll(attributes);
+        this.positions = newPositions;
+    }
+
+    /**
+     * Sets or removes an attribute.
+     *
+     * <p>Once positions are defined, all attributes must have the same length 
as the positions.
+     * While positions are empty, attributes of any length are accepted, so 
that the attributes
+     * of new data points can be set in any order. Setting positions of a 
different length then
+     * requires all non-empty attributes to have that length, the empty ones 
are removed.</p>
+     *
+     * @param name attribute name
+     * @param array attribute values, or {@code null} to remove the attribute
+     * @throws IllegalArgumentException if the array length does not match the 
other attributes.
+     */
     public void setAttribute(String name, Array array) {
         if (array == null) {
             if (DataPointsType.ATT_POSITION.equals(name)) {
                 throw new IllegalArgumentException("Positions attribute cannot 
be removed");
             }
             attributes.remove(name);
-        } else if (array.getLength() != size()) {
-            throw new IllegalArgumentException("Array must have the same 
length");
         } else if (DataPointsType.ATT_POSITION.equals(name)) {
+            final long length = array.getLength();
+            if (length != positions.getLength()) {
+                for (Map.Entry<String,Array> entry : attributes.entrySet()) {
+                    final long l = entry.getValue().getLength();
+                    if (l != 0 && l != length && 
!DataPointsType.ATT_POSITION.equals(entry.getKey())) {
+                        throw new IllegalArgumentException("Array must have 
the same length as attribute " + entry.getKey());
+                    }
+                }
+                //empty attributes do not match the new positions anymore
+                attributes.values().removeIf((Array a) -> a.getLength() == 0);
+            }
             this.positions = array;
             attributes.put(name, array);
-        } else {
+        } else if (array.getLength() == size() || size() == 0) {
             attributes.put(name, array);
+        } else {
+            throw new IllegalArgumentException("Array must have the same 
length");
         }
     }
 
diff --git 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultOffsetLineString.java
 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultOffsetLineString.java
new file mode 100644
index 0000000000..0c025798ee
--- /dev/null
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/DefaultOffsetLineString.java
@@ -0,0 +1,471 @@
+/*
+ * 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;
+
+import java.util.HashMap;
+import java.util.Map;
+import javax.measure.IncommensurableException;
+import javax.measure.Quantity;
+import javax.measure.Unit;
+import static java.lang.StrictMath.abs;
+import static java.lang.StrictMath.hypot;
+import static java.lang.StrictMath.max;
+import static java.lang.StrictMath.min;
+import org.opengis.geometry.Envelope;
+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.LineString;
+import org.apache.sis.geometries.curve.LinearRing;
+import org.apache.sis.geometries.operation.OperationException;
+import org.apache.sis.maths.Array;
+import org.apache.sis.maths.Cursor;
+import org.apache.sis.maths.DataType;
+import org.apache.sis.measure.Units;
+
+
+/**
+ * A curve at a constant distance from a base line string, in the plane of the 
first two axes.
+ *
+ * <p>Each segment of the base line string is translated along its normal, 
then consecutive
+ * translated segments are joined at the intersection of their supporting 
lines. The offset
+ * curve has the same number of points as the base line string, a closed base 
line string
+ * gives a closed offset curve. Only the first two coordinates of each 
position are modified,
+ * other coordinates and attributes are copied unchanged.</p>
+ *
+ * <p>With a negative distance, which is the right side (generated line string 
with ' character) :</p>
+ * <pre>
+ *                    B___________ C
+ *                    |  _______  \
+ *                    | |B'   C'\  \
+ *                    | |        \  \
+ *                    | |        D'  D
+ *                    A A'
+ * </pre>
+ *
+ * <p>With a positive distance, which is the left side :</p>
+ * <pre>
+ *                 B'________________C'
+ *                 |  B___________ C \
+ *                 |  |           \   \
+ *                 |  |            \   \
+ *                 |  |             \   \
+ *                 |  |              D   D'
+ *                 A' A
+ * </pre>
+ *
+ * @author Remi Marechal (Geomatys)
+ * @author Johann Sorel (Geomatys)
+ */
+public final class DefaultOffsetLineString extends DefaultOffsetCurve {
+
+    /**
+     * Accepted tolerance.
+     */
+    private static final double TOLERANCE = 1E-9;
+
+    /**
+     * Offset data points, computed when first requested.
+     */
+    private DataPoints dataPoints;
+
+    /**
+     * Creates a curve offset from the given line string.
+     *
+     * @param  baseCurve  line string from which this curve is offset.
+     * @param  distance   constant offset distance. A positive distance 
designates the left side
+     *                    of the base curve with respect to its direction, and 
a negative distance
+     *                    its right side. A dimensionless distance is in the 
unit of the CRS axes.
+     */
+    public DefaultOffsetLineString(final LineString baseCurve, final 
Quantity<?> distance) {
+        super(baseCurve, distance, null);
+    }
+
+    @Override
+    public LineString getBaseCurve() {
+        return (LineString) baseCurve;
+    }
+
+    /**
+     * Returns the offset positions.
+     *
+     * @throws OperationException if the offset cannot be computed.
+     */
+    @Override
+    public synchronized DataPoints getDataPoints() {
+        if (dataPoints == null) {
+            dataPoints = compute(getBaseCurve().getDataPoints(), 
getAxisDistance());
+        }
+        return dataPoints;
+    }
+
+    @Override
+    public Array getControlPoints() {
+        return getDataPoints().getAttributeArray(DataPointsType.ATT_POSITION);
+    }
+
+    @Override
+    public synchronized void 
setCoordinateReferenceSystem(CoordinateReferenceSystem crs) throws 
IllegalArgumentException {
+        super.setCoordinateReferenceSystem(crs);
+        dataPoints = null;
+    }
+
+    /**
+     * Returns the envelope of the offset positions.
+     */
+    @Override
+    public Envelope getEnvelope() {
+        if (isEmpty()) return null;
+        return getDataPoints().getEnvelope();
+    }
+
+    /**
+     * Returns the offset positions as a line string, or a linear ring if the 
base curve is a linear ring.
+     * The arguments are ignored since this curve is already linear.
+     */
+    @Override
+    public LineString asLine(final Quantity<?> spacing, final Quantity<?> 
offset) {
+        if (baseCurve instanceof LinearRing) {
+            return GeometryFactory.DEFAULT.createLinearRing(getDataPoints());
+        }
+        return GeometryFactory.DEFAULT.createLineString(getDataPoints());
+    }
+
+    /**
+     * Returns the offset distance in the unit of the first CRS axis.
+     *
+     * @throws UnsupportedOperationException if the distance unit cannot be 
converted to the axis unit.
+     */
+    private double getAxisDistance() {
+        final double value = distance.getValue().doubleValue();
+        final Unit<?> axisUnit = 
baseCurve.getCoordinateReferenceSystem().getCoordinateSystem().getAxis(0).getUnit();
+        if (axisUnit == null || Units.UNITY.equals(distance.getUnit())) {
+            return value;
+        }
+        try {
+            return 
distance.getUnit().getConverterToAny(axisUnit).convert(value);
+        } catch (IncommensurableException e) {
+            throw new UnsupportedOperationException("The offset distance in " 
+ distance.getUnit()
+                    + " cannot be converted to the unit of axis 0 (" + 
axisUnit + ").", e);
+        }
+    }
+
+    /**
+     * Computes the offset positions.
+     *
+     * @param base data points of the base line string.
+     * @param distance offset distance, positive for the left side and 
negative for the right side.
+     * @return copy of the base data points with offset positions.
+     * @throws OperationException if a translation is impossible.
+     */
+    private static DataPoints compute(DataPoints base, double distance) throws 
OperationException {
+        final ArrayDataPoints ps = copy(base);
+        final Array positions = ps.getAttribute(DataPointsType.ATT_POSITION);
+        final int nb = Math.toIntExact(positions.getLength());
+        if (distance == 0 || nb < 2) return ps;
+
+        //the algorithm translates on the right side for positive values
+        distance = -distance;
+
+        final double[] xs = new double[nb];
+        final double[] ys = new double[nb];
+        Cursor cursor = positions.cursor();
+        for (int i = 0; cursor.next(); i++) {
+            xs[i] = cursor.getSample(0);
+            ys[i] = cursor.getSample(1);
+        }
+
+        final double[] dxs = new double[nb];
+        final double[] dys = new double[nb];
+        //previous translated segment
+        final double[] seg0 = new double[4];
+        //current translated segment
+        final double[] seg1 = new double[4];
+        final double[] inter = new double[2];
+
+        translateSegment(xs[0], ys[0], xs[1], ys[1], seg0, distance);
+        dxs[0] = seg0[0];
+        dys[0] = seg0[1];
+        if (nb == 2) {
+            dxs[1] = seg0[2];
+            dys[1] = seg0[3];
+        } else {
+            final boolean closed = xs[0] == xs[nb - 1] && ys[0] == ys[nb - 1];
+            final int segmentNumber = nb - 1;
+            for (int c = 1; c < segmentNumber; c++) {
+                translateSegment(xs[c], ys[c], xs[c+1], ys[c+1], seg1, 
distance);
+
+                //intersection between current translated segment and 
previously generated segment
+                intersectionRightLine(seg0[0], seg0[1], seg0[2], seg0[3], 
seg1[0], seg1[1], seg1[2], seg1[3], inter);
+                dxs[c] = inter[0];
+                dys[c] = inter[1];
+                System.arraycopy(seg1, 0, seg0, 0, 4);
+
+                if (c == segmentNumber - 1) {
+                    if (closed) {
+                        //close the translated line string
+                        intersectionRightLine(seg0[0], seg0[1], seg0[2], 
seg0[3], dxs[0], dys[0], dxs[1], dys[1], inter);
+                        dxs[0] = inter[0];
+                        dys[0] = inter[1];
+                        dxs[c+1] = inter[0];
+                        dys[c+1] = inter[1];
+                    } else {
+                        dxs[c+1] = seg1[2];
+                        dys[c+1] = seg1[3];
+                    }
+                }
+            }
+        }
+
+        cursor = positions.cursor();
+        for (int i = 0; cursor.next(); i++) {
+            cursor.setSample(0, dxs[i]);
+            cursor.setSample(1, dys[i]);
+        }
+        return ps;
+    }
+
+    /**
+     * Copy all attributes, positions are converted to doubles to store the 
offset coordinates.
+     */
+    private static ArrayDataPoints copy(DataPoints ps) {
+        final Map<String,Array> attributes = new HashMap<>();
+        for (String name : ps.getType().getAttributeNames()) {
+            final Array array = ps.getAttributeArray(name);
+            attributes.put(name, DataPointsType.ATT_POSITION.equals(name) ? 
array.retype(DataType.DOUBLE) : array.copy());
+        }
+        return new ArrayDataPoints(attributes);
+    }
+
+    /**
+     * Translate a single segment along its normal.
+     *
+     * @param x0 segment start x
+     * @param y0 segment start y
+     * @param x1 segment end x
+     * @param y1 segment end y
+     * @param dst translated segment as x0,y0,x1,y1
+     * @param distance offset distance, positive for the right side and 
negative for the left side.
+     */
+    private static void translateSegment(double x0, double y0, double x1, 
double y1, double[] dst, double distance) {
+        final double vx = x1 - x0;
+        final double vy = y1 - y0;
+
+        //orthogonal vector
+        double ox = vy;
+        double oy = -vx;
+        final double normO = hypot(ox, oy);
+        ox = ox / normO * distance;
+        oy = oy / normO * distance;
+
+        dst[0] = x0 + ox;
+        dst[1] = y0 + oy;
+        dst[2] = x1 + ox;
+        dst[3] = y1 + oy;
+    }
+
+    /**
+     * Find the intersection point between the two lines (AB) and (CD).
+     *
+     * @param out receives the intersection point x,y.
+     * @throws OperationException if the two lines do not intersect.
+     */
+    private static void intersectionRightLine(double ax, double ay, double bx, 
double by,
+                                              double cx, double cy, double dx, 
double dy,
+                                              double[] out) throws 
OperationException {
+
+        //director vector of (AB) line
+        final double ux = bx - ax;
+        final double uy = by - ay;
+
+        //director vector of (CD) line
+        final double vx = dx - cx;
+        final double vy = dy - cy;
+
+        //B = C
+        if (abs(bx - cx) < TOLERANCE && abs(by - cy) < TOLERANCE) {
+            set(out, bx, by);
+            return;
+        }
+
+        //A = D, should not happen if the line string direction is respected
+        if (abs(ax - dx) < TOLERANCE && abs(ay - dy) < TOLERANCE) {
+            set(out, ax, ay);
+            return;
+        }
+
+        //A = B, should not happen
+        if (abs(ax - bx) < TOLERANCE && abs(ay - by) < TOLERANCE) {
+            //CA vector, if colinear with CD then AB is on CD
+            final double wx = ax - cx;
+            final double wy = ay - cy;
+            if (abs(wx * vy - wy * vx) < TOLERANCE) {
+                set(out, ax, ay);
+                return;
+            }
+            throw noIntersection();
+        }
+
+        //C = D, should not happen
+        if (abs(cx - dx) < TOLERANCE && abs(cy - dy) < TOLERANCE) {
+            //AC vector, if colinear with AB then CD is on AB
+            final double wx = cx - ax;
+            final double wy = cy - ay;
+            if (abs(ux * wy - uy * wx) < TOLERANCE) {
+                set(out, cx, cy);
+                return;
+            }
+            throw noIntersection();
+        }
+
+        if (abs(vy) < TOLERANCE && abs(ux) < TOLERANCE) {
+            /*
+             *     B
+             *     |
+             * C __|___ D
+             *     |
+             *     A
+             */
+            set(out, ax, cy);
+            return;
+        }
+
+        if (abs(vx) < TOLERANCE && abs(uy) < TOLERANCE) {
+            /*
+             *     D
+             *     |
+             * A __|___ B
+             *     |
+             *     C
+             */
+            set(out, cx, ay);
+            return;
+        }
+
+        if (abs(ux) < TOLERANCE && abs(vx) < TOLERANCE) {
+            /*
+             * B
+             * |  D
+             * A  |
+             *    C
+             */
+            if (abs(ax - cx) > TOLERANCE) throw noIntersection();
+            set(out, ax, getIntersectionOnOneDimension(ay, by, cy, dy));
+            return;
+        }
+
+        if (abs(uy) < TOLERANCE && abs(vy) < TOLERANCE) {
+            /*
+             * A_____B
+             *    C_____D
+             */
+            if (abs(ay - cy) > TOLERANCE) throw noIntersection();
+            set(out, getIntersectionOnOneDimension(ax, bx, cx, dx), ay);
+            return;
+        }
+
+        if (abs(ux) <= TOLERANCE) {
+            /*
+             *     B  D
+             *     | /
+             *     |/
+             *    /|
+             *   / A
+             *  C
+             */
+            set(out, ax, (ax - cx) * (vy / vx) + cy);
+            return;
+        }
+
+        if (abs(uy) <= TOLERANCE) {
+            /*
+             *       D
+             *      /
+             *  A__/____B
+             *    /
+             *   C
+             */
+            set(out, (ay - cy) * (vx / vy) + cx, ay);
+            return;
+        }
+
+        if (abs(vx) <= TOLERANCE) {
+            /*
+             *     D  B
+             *     | /
+             *     |/
+             *    /|
+             *   / C
+             *  A
+             */
+            set(out, cx, (cx - ax) * (uy / ux) + ay);
+            return;
+        }
+
+        if (abs(vy) <= TOLERANCE) {
+            /*
+             *       B
+             *      /
+             *  C__/____D
+             *    /
+             *   A
+             */
+            set(out, (cy - ay) * (ux / uy) + ax, cy);
+            return;
+        }
+
+        final double vx_vy = vx / vy;
+
+        /*
+         * (AB) parametric equation.
+         * {X(t) = t * ux + A.x
+         * {Y(t) = t * uy + A.y
+         */
+        final double t = ((ay - cy) * vx_vy + cx - ax) / (ux - uy * vx_vy);
+        final double x = t * ux + ax;
+        final double y = t * uy + ay;
+        assert abs(y - ((x - cx) / vx_vy + cy)) <= TOLERANCE : "Error 
computing y from lines should be equal";
+        set(out, x, y);
+    }
+
+    /**
+     * Returns the middle of the intersection between spans [a,b] and [c,d].
+     *
+     * @throws OperationException if the spans do not intersect.
+     */
+    private static double getIntersectionOnOneDimension(double a, double b, 
double c, double d) throws OperationException {
+        final double imin = max(a, c);
+        final double imax = min(b, d);
+        if (imax + TOLERANCE < imin - TOLERANCE) {
+            /*
+             * A_____B    C____D
+             */
+            throw noIntersection();
+        }
+        return (imin + imax) / 2.0;
+    }
+
+    private static void set(double[] out, double x, double y) {
+        out[0] = x;
+        out[1] = y;
+    }
+
+    private static OperationException noIntersection() {
+        return new OperationException("No intersection between lines.");
+    }
+}
diff --git 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/Vertex.java
 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/Vertex.java
index 2763cf886f..255a16b738 100644
--- 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/Vertex.java
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/internal/shared/Vertex.java
@@ -16,6 +16,8 @@
  */
 package org.apache.sis.geometries.internal.shared;
 
+import java.util.Arrays;
+import java.util.TreeSet;
 import org.apache.sis.geometries.DataPoints;
 import org.apache.sis.geometries.DataPointsType;
 import org.apache.sis.geometries.Point;
@@ -79,4 +81,19 @@ public final class Vertex implements Point {
         return parent.getType();
     }
 
+    /**
+     * Returns the index followed by the values of all attributes, sorted by 
name.
+     */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder("V:");
+        sb.append(getIndex());
+        final TreeSet<String> properties = new 
TreeSet<>(getDataPointsType().getAttributeNames());
+        for (String name : properties) {
+            sb.append(' ');
+            sb.append(name);
+            sb.append(Arrays.toString(getAttribute(name).toArrayDouble()));
+        }
+        return sb.toString();
+    }
 }
diff --git 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/mesh/MeshPrimitive.java
 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/mesh/MeshPrimitive.java
index e4dabfa4a7..1b7f6648eb 100644
--- 
a/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/mesh/MeshPrimitive.java
+++ 
b/incubator/src/org.apache.sis.geometry/main/org/apache/sis/geometries/mesh/MeshPrimitive.java
@@ -676,14 +676,9 @@ public sealed interface MeshPrimitive extends Geometry
 
         private void validate(String name, Array ta) {
 
-            final int dimension = ta.getDimension();
             final long length = ta.getLength();
-            final Cursor cursor = ta.cursor();
-            final Vector tuple = cursor.getSamples();
-
-            for (int i = 0; i <length; i++) {
-                cursor.moveTo(i);
-                if (!tuple.isFinite()) {
+            for (long i = 0; i < length; i++) {
+                if (!ta.get(i).isFinite()) {
                     throw new IllegalArgumentException("Found a NaN on 
attribute " + name +" at index "+ i);
                 }
             }
@@ -907,11 +902,13 @@ public sealed interface MeshPrimitive extends Geometry
 
             setIndex(NDArrays.of(newIndex, 1, index.getDataType()));
 
+            //all attributes change length, they must be replaced together
+            final Map<String,Array> rebuilt = new HashMap<>();
             for (String attName : attNames) {
                 final Array model = dataPoints.getAttribute(attName);
-                final Array array = NDArrays.of(atts.get(attName), 
model.getSampleSystem(), model.getDataType());
-                getDataPoints().setAttribute(attName, array);
+                rebuilt.put(attName, NDArrays.of(atts.get(attName), 
model.getSampleSystem(), model.getDataType()));
             }
+            dataPoints.setAttributes(rebuilt);
             positions = dataPoints.getAttribute(DataPointsType.ATT_POSITION);
         }
 
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 f0b6b46967..387630f3ab 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
@@ -35,11 +35,14 @@ 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.curve.OffsetCurve;
+import org.apache.sis.geometries.cs.Bearing;
 import org.apache.sis.geometries.internal.shared.ArrayDataPoints;
 import org.apache.sis.geometries.internal.shared.Vertex;
 import org.apache.sis.geometries.mesh.MeshPrimitive;
@@ -92,6 +95,42 @@ public final class GeometryProcessor {
         return fromJTS(jts(geom).buffer(radius.getValue().doubleValue()), 
geom);
     }
 
+    /**
+     * Returns a line string parallel to the given line string, at the given 
perpendicular distance.
+     * Each segment is translated along its normal and consecutive segments 
are joined
+     * at the intersection of their supporting lines.
+     * The result has the same number of points, closed line strings stay 
closed.
+     * Only the first two coordinates of each position are modified.
+     *
+     * @param geom a line string or a collection of line strings.
+     * @param distance offset distance in CRS units, positive for the left 
side and negative
+     *        for the right side relative to the line string direction, as in 
{@link OffsetCurve}.
+     * @return offset geometry of the same type.
+     * @throws OperationException if the offset curve cannot be computed.
+     *
+     * @see GeometryFactory#createOffsetCurve(Curve, Quantity, Bearing)
+     */
+    public Geometry offsetCurve(Geometry geom, double distance) throws 
OperationException {
+        if (distance == 0 || geom.isEmpty()) {
+            return geom;
+        }
+        if (geom instanceof LineString cdt) {
+            return GeometryFactory.DEFAULT.createOffsetCurve(cdt, 
Quantities.create(distance, Units.UNITY), null).asLine(null, null);
+        } else if (geom instanceof GeometryCollection<?> cdt) {
+            final int nb = cdt.getNumGeometries();
+            final Geometry[] children = new Geometry[nb];
+            for (int i = 0; i < nb; i++) {
+                final Geometry child = cdt.getGeometryN(i);
+                if (!(child instanceof LineString)) {
+                    throw new OperationException("Only collections of line 
strings can be offset, found " + child.getGeometryType());
+                }
+                children[i] = offsetCurve(child, distance);
+            }
+            return 
GeometryFactory.DEFAULT.createGeometryCollection(cdt.getElementType(), 
cdt.getCoordinateReferenceSystem(), children);
+        }
+        throw new UnsupportedOperationException();
+    }
+
     /**
      * Returns a geometric object that represents the convex hull of this 
geometric object.
      * Convex hulls, being dependent on straight lines, can be accurately 
represented in linear interpolations
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 363d32f372..7fd9e64e26 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,14 @@
  */
 package org.apache.sis.geometries.internal.shared;
 
+import java.util.LinkedHashMap;
+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.maths.Array;
+import org.apache.sis.maths.NDArrays;
+import org.apache.sis.referencing.CommonCRS;
 
 // Test dependencies
 import static org.junit.jupiter.api.Assertions.*;
@@ -41,4 +48,56 @@ public class ArrayDataPointsTest extends DataPointsTest {
         assertInstanceOf(ArrayDataPoints.class, points.subset(new int[] {1}));
         assertInstanceOf(ArrayDataPoints.class, points.subset(0, 1));
     }
+
+    private static final CoordinateReferenceSystem CRS2D = 
CommonCRS.WGS84.normalizedGeographic();
+
+    /**
+     * Tests that attributes of empty data points can be set before the 
positions.
+     */
+    @Test
+    public void testAttributeBeforePositions() {
+        final ArrayDataPoints points = new ArrayDataPoints(NDArrays.of(CRS2D, 
new double[0]));
+        points.setAttribute("TEST", NDArrays.of(1, new double[] {1, 2, 3}));
+        points.setAttribute(DataPointsType.ATT_POSITION, NDArrays.of(CRS2D, 
new double[] {0,0, 1,1, 2,2}));
+        assertEquals(3, points.size());
+        assertArrayEquals(new double[] {1, 2, 3}, 
points.getAttribute("TEST").toArrayDouble());
+    }
+
+    /**
+     * Tests that positions of a new length replace the positions of empty 
data points
+     * and that the empty attributes are removed.
+     */
+    @Test
+    public void testPositionsOfEmpty() {
+        final Map<String,Array> attributes = new LinkedHashMap<>();
+        attributes.put(DataPointsType.ATT_POSITION, NDArrays.of(CRS2D, new 
double[0]));
+        attributes.put("TEST", NDArrays.of(1, new double[0]));
+        final ArrayDataPoints points = new ArrayDataPoints(attributes);
+        points.setAttribute(DataPointsType.ATT_POSITION, NDArrays.of(CRS2D, 
new double[] {0,0, 1,1}));
+        assertEquals(2, points.size());
+        assertNull(points.getAttribute("TEST"));
+    }
+
+    /**
+     * Tests that attributes whose length does not match the positions are 
rejected.
+     */
+    @Test
+    public void testLengthMismatch() {
+        final ArrayDataPoints points = new ArrayDataPoints(NDArrays.of(CRS2D, 
new double[] {0,0, 1,1}));
+        assertThrows(IllegalArgumentException.class, () -> 
points.setAttribute("TEST", NDArrays.of(1, new double[] {1, 2, 3})));
+
+        //positions alone can be replaced by positions of another length
+        points.setAttribute(DataPointsType.ATT_POSITION, NDArrays.of(CRS2D, 
new double[] {0,0, 1,1, 2,2}));
+        assertEquals(3, points.size());
+
+        //but not when another attribute has the old length
+        points.setAttribute("TEST", NDArrays.of(1, new double[] {1, 2, 3}));
+        assertThrows(IllegalArgumentException.class,
+                () -> points.setAttribute(DataPointsType.ATT_POSITION, 
NDArrays.of(CRS2D, new double[] {0,0, 1,1})));
+
+        final ArrayDataPoints empty = new ArrayDataPoints(NDArrays.of(CRS2D, 
new double[0]));
+        empty.setAttribute("TEST", NDArrays.of(1, new double[] {1, 2, 3}));
+        assertThrows(IllegalArgumentException.class,
+                () -> empty.setAttribute(DataPointsType.ATT_POSITION, 
NDArrays.of(CRS2D, new double[] {0,0, 1,1})));
+    }
 }
diff --git 
a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/DefaultOffsetLineStringTest.java
 
b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/DefaultOffsetLineStringTest.java
new file mode 100644
index 0000000000..1aa5d89704
--- /dev/null
+++ 
b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/internal/shared/DefaultOffsetLineStringTest.java
@@ -0,0 +1,317 @@
+/*
+ * 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;
+
+import static java.lang.StrictMath.sqrt;
+import javax.measure.Quantity;
+import org.opengis.geometry.Envelope;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.geometries.DataPointsType;
+import org.apache.sis.geometries.Geometry;
+import org.apache.sis.geometries.GeometryFactory;
+import org.apache.sis.geometries.GeometryType;
+import org.apache.sis.geometries.curve.LineString;
+import org.apache.sis.geometries.curve.LinearRing;
+import org.apache.sis.geometries.curve.OffsetCurve;
+import org.apache.sis.maths.NDArrays;
+import org.apache.sis.measure.Quantities;
+import org.apache.sis.measure.Units;
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import org.junit.jupiter.api.Test;
+
+
+/**
+ * Tests {@link DefaultOffsetLineString}.
+ *
+ * @author Remi Marechal (Geomatys)
+ * @author Johann Sorel (Geomatys)
+ */
+public class DefaultOffsetLineStringTest {
+
+    private static final double TOLERANCE = 1E-9;
+
+    private static final double SQRT_2 = sqrt(2.0);
+
+    private static final CoordinateReferenceSystem CRS_2D = 
CommonCRS.WGS84.normalizedGeographic();
+
+    private static final CoordinateReferenceSystem CRS_3D = 
CommonCRS.WGS84.geographic3D();
+
+    /**
+     * Create a line string from coordinates ordered as x0, y0, ... xn, yn.
+     */
+    private static LineString line(double ... coords) {
+        return GeometryFactory.DEFAULT.createLineString(new 
ArrayDataPoints(NDArrays.of(CRS_2D, coords)));
+    }
+
+    private static OffsetCurve offset(LineString line, Quantity<?> distance) {
+        return GeometryFactory.DEFAULT.createOffsetCurve(line, distance, null);
+    }
+
+    private static OffsetCurve offset(LineString line, double distance) {
+        return offset(line, Quantities.create(distance, Units.UNITY));
+    }
+
+    private static double[] positions(Geometry geom) {
+        return 
geom.getDataPoints().getAttributeArray(DataPointsType.ATT_POSITION).toArrayDouble();
+    }
+
+    /**
+     * Verifies the positions of the offset curve.
+     *
+     * @param distance offset distance, positive for the left side and 
negative for the right side.
+     */
+    private static void assertOffset(LineString line, double distance, double 
... expected) {
+        final OffsetCurve result = offset(line, distance);
+        assertInstanceOf(DefaultOffsetLineString.class, result);
+        assertEquals(CRS_2D, result.getCoordinateReferenceSystem());
+        assertArrayEquals(expected, positions(result), TOLERANCE);
+    }
+
+    /**
+     * Tests that the factory creates a {@link DefaultOffsetLineString} only 
for line strings in 2D.
+     */
+    @Test
+    public void testFactory() {
+        final LineString line = line(2, 1, 2, 4);
+        final OffsetCurve result = offset(line, 3);
+        assertInstanceOf(DefaultOffsetLineString.class, result);
+        assertSame(line, result.getBaseCurve());
+        assertEquals(GeometryType.OFFSETCURVE, result.getGeometryType());
+        assertNull(result.getRefDirection());
+    }
+
+    /**
+     * Tests line strings composed of a single segment, in all directions.
+     */
+    @Test
+    public void testSingleSegment() {
+        /*
+         * B
+         * |
+         * A
+         */
+        LineString line = line(2, 1, 2, 4);
+        assertOffset(line, -3,  5, 1,  5, 4);
+        assertOffset(line,  5, -3, 1, -3, 4);
+
+        /*
+         *    B
+         *   /
+         *  A
+         */
+        line = line(2, 1, 4, 3);
+        assertOffset(line, -3 * SQRT_2,  5, -2,  7, 0);
+        assertOffset(line,  5 * SQRT_2, -3,  6, -1, 8);
+
+        /*
+         *  A ____ B
+         */
+        line = line(2, 1, 4, 1);
+        assertOffset(line, -3, 2, -2, 4, -2);
+        assertOffset(line,  7, 2,  8, 4,  8);
+
+        /*
+         *  A
+         *   \
+         *    \
+         *     B
+         */
+        line = line(-1, 3, 6, -4);
+        assertOffset(line, -7 * SQRT_2, -8, -4, -1, -11);
+        assertOffset(line,  7 * SQRT_2,  6, 10, 13,   3);
+
+        /*
+         * A
+         * |
+         * B
+         */
+        line = line(-1, 3, -1, -4);
+        assertOffset(line, -2, -3, 3, -3, -4);
+        assertOffset(line,  5,  4, 3,  4, -4);
+
+        /*
+         *       A
+         *      /
+         *     /
+         *    B
+         */
+        line = line(1, 3, -4, -2);
+        assertOffset(line, -5 * SQRT_2, -4,  8, -9,  3);
+        assertOffset(line,  5 * SQRT_2,  6, -2,  1, -7);
+
+        /*
+         *  B ____ A
+         */
+        line = line(4, 3, -10, 3);
+        assertOffset(line, -2, 4,  5, -10,  5);
+        assertOffset(line,  5, 4, -2, -10, -2);
+
+        /*
+         *  B
+         *   \
+         *    \
+         *     A
+         */
+        line = line(7, -3, -6, 10);
+        assertOffset(line, -9 * SQRT_2, 16,  6,   3, 19);
+        assertOffset(line,  5 * SQRT_2,  2, -8, -11,  5);
+    }
+
+    /**
+     * Tests line strings composed of several segments, closed and not closed.
+     */
+    @Test
+    public void testSeveralSegments() {
+        /*
+         * B_______C
+         * |       |
+         * |       |
+         * |       |
+         * A_______D
+         *
+         * Clockwise
+         */
+        LineString line = line(-2, -2, -2, 3, 5, 3, 5, -2, -2, -2);
+        //inside
+        assertOffset(line, -1.5,
+                -0.5, -0.5,  -0.5, 1.5,  3.5, 1.5,  3.5, -0.5,  -0.5, -0.5);
+        //outside
+        assertOffset(line, 7.2,
+                -9.2, -9.2,  -9.2, 10.2,  12.2, 10.2,  12.2, -9.2,  -9.2, 
-9.2);
+
+        /*
+         *       D
+         *      / \
+         *     /   \
+         *  A /     \
+         *    \     / C
+         *     \   /
+         *      \ /
+         *       B
+         *
+         * Counterclockwise
+         */
+        line = line(-1, -1, 3, -5, 7, -1, 3, 3, -1, -1);
+        //inside
+        assertOffset(line, SQRT_2,
+                1, -1,  3, -3,  5, -1,  3, 1,  1, -1);
+        //outside
+        assertOffset(line, -3.3 * SQRT_2,
+                -7.6, -1,  3, -11.6,  13.6, -1,  3, 9.6,  -7.6, -1);
+
+        /*
+         * B____C_____D     F          K
+         * |          \    /|         /
+         * |           \  / |        /
+         * |            \/  G       J
+         * A             E  |      /
+         *                  |_____/
+         *                  H     I
+         *
+         * Not closed.
+         */
+        line = line(-2, -3, -2, 5, 1, 5, 4, 5, 6, 1, 9, 5, 9, 2, 9, -1, 12, 
-1, 16, 0, 20, 1);
+        assertOffset(line, -1,
+                -1, -3,
+                -1, 4,
+                1, 4,
+                3.381966011250105, 4,
+                5.829179606750063, -0.8944271909999157,
+                8, 2,
+                8, 2,
+                8, -2,
+                12.12310562561766, -2,
+                16.242535625036332, -0.9701425001453319,
+                20.242535625036332, 0.029857499854668124);
+        assertOffset(line, 2,
+                -4, -3,
+                -4, 7,
+                1, 7,
+                5.23606797749979, 7,
+                6.341640786499873, 4.7888543819998315,
+                11, 11,
+                11, 2,
+                11, 1,
+                11.753788748764679, 1,
+                15.514928749927334, 1.9402850002906638,
+                19.514928749927336, 2.9402850002906638);
+    }
+
+    /**
+     * Tests that a linear ring gives a linear ring when linearized.
+     */
+    @Test
+    public void testLinearRing() {
+        final LinearRing ring = GeometryFactory.DEFAULT.createLinearRing(new 
ArrayDataPoints(NDArrays.of(CRS_2D,
+                -2.0, -2.0, -2.0, 3.0, 5.0, 3.0, 5.0, -2.0, -2.0, -2.0)));
+        final LineString result = offset(ring, -1.5).asLine(null, null);
+        assertInstanceOf(LinearRing.class, result);
+        assertArrayEquals(new double[]{-0.5, -0.5,  -0.5, 1.5,  3.5, 1.5,  
3.5, -0.5,  -0.5, -0.5},
+                positions(result), TOLERANCE);
+    }
+
+    /**
+     * Tests that coordinates after the first two are preserved.
+     */
+    @Test
+    public void testThirdDimensionPreserved() {
+        final LineString line = GeometryFactory.DEFAULT.createLineString(new 
ArrayDataPoints(NDArrays.of(CRS_3D,
+                2.0, 1.0, 10.0,
+                2.0, 4.0, 20.0)));
+        final OffsetCurve result = offset(line, -3);
+        assertEquals(CRS_3D, result.getCoordinateReferenceSystem());
+        assertArrayEquals(new double[]{5, 1, 10,  5, 4, 20}, 
positions(result), TOLERANCE);
+    }
+
+    /**
+     * Tests that integer positions are not truncated.
+     */
+    @Test
+    public void testIntegerPositions() {
+        final LineString line = GeometryFactory.DEFAULT.createLineString(new 
ArrayDataPoints(NDArrays.of(CRS_2D,
+                2, 1,
+                2, 4)));
+        assertArrayEquals(new double[]{3.5, 1,  3.5, 4}, 
positions(offset(line, -1.5)), TOLERANCE);
+    }
+
+    /**
+     * Tests that a distance in a unit of measurement is converted to the unit 
of the CRS axes.
+     */
+    @Test
+    public void testDistanceUnit() {
+        final LineString line = line(2, 1, 2, 4);
+        final OffsetCurve result = offset(line, Quantities.create(-3 * 60, 
Units.ARC_MINUTE));
+        assertArrayEquals(new double[]{5, 1,  5, 4}, positions(result), 
TOLERANCE);
+    }
+
+    /**
+     * Tests that the envelope is the one of the offset positions.
+     */
+    @Test
+    public void testEnvelope() {
+        final Envelope env = offset(line(2, 1, 2, 4), -3).getEnvelope();
+        assertArrayEquals(new double[]{5, 1}, 
env.getLowerCorner().getCoordinates(), TOLERANCE);
+        assertArrayEquals(new double[]{5, 4}, 
env.getUpperCorner().getCoordinates(), TOLERANCE);
+    }
+}
diff --git 
a/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/OffsetCurveTest.java
 
b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/OffsetCurveTest.java
new file mode 100644
index 0000000000..7d466dbcf0
--- /dev/null
+++ 
b/incubator/src/org.apache.sis.geometry/test/org/apache/sis/geometries/operation/OffsetCurveTest.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.operation;
+
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.geometries.DataPointsType;
+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.curve.LineString;
+import org.apache.sis.geometries.curve.LinearRing;
+import org.apache.sis.geometries.internal.shared.ArrayDataPoints;
+import org.apache.sis.maths.NDArrays;
+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.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import org.junit.jupiter.api.Test;
+
+
+/**
+ * Tests the {@code offsetCurve} operation of {@link GeometryProcessor}.
+ * The offset algorithm itself is tested by {@code 
DefaultOffsetLineStringTest}.
+ *
+ * @author Johann Sorel (Geomatys)
+ */
+public class OffsetCurveTest {
+
+    private static final double TOLERANCE = 1E-9;
+
+    private static final CoordinateReferenceSystem CRS_2D = 
CommonCRS.WGS84.normalizedGeographic();
+
+    /**
+     * Create a line string from coordinates ordered as x0, y0, ... xn, yn.
+     */
+    private static LineString line(double ... coords) {
+        return GeometryFactory.DEFAULT.createLineString(new 
ArrayDataPoints(NDArrays.of(CRS_2D, coords)));
+    }
+
+    private static Geometry offset(Geometry geom, double distance) {
+        return new GeometryProcessor().offsetCurve(geom, distance);
+    }
+
+    private static double[] positions(Geometry geom) {
+        return 
geom.getDataPoints().getAttributeArray(DataPointsType.ATT_POSITION).toArrayDouble();
+    }
+
+    /**
+     * Tests that a line string gives a line string, a negative distance being 
the right side.
+     */
+    @Test
+    public void testLineString() {
+        final Geometry result = offset(line(2, 1, 2, 4), -3);
+        assertInstanceOf(LineString.class, result);
+        assertEquals(CRS_2D, result.getCoordinateReferenceSystem());
+        assertArrayEquals(new double[]{5, 1,  5, 4}, positions(result), 
TOLERANCE);
+    }
+
+    /**
+     * Tests that a zero distance returns the geometry unchanged.
+     */
+    @Test
+    public void testZeroDistance() {
+        final LineString line = line(2, 1, 2, 4);
+        assertSame(line, offset(line, 0));
+    }
+
+    /**
+     * Tests that a linear ring stays a linear ring.
+     */
+    @Test
+    public void testLinearRing() {
+        final LinearRing ring = GeometryFactory.DEFAULT.createLinearRing(new 
ArrayDataPoints(NDArrays.of(CRS_2D,
+                -2.0, -2.0, -2.0, 3.0, 5.0, 3.0, 5.0, -2.0, -2.0, -2.0)));
+        final Geometry result = offset(ring, -1.5);
+        assertInstanceOf(LinearRing.class, result);
+        assertArrayEquals(new double[]{-0.5, -0.5,  -0.5, 1.5,  3.5, 1.5,  
3.5, -0.5,  -0.5, -0.5},
+                positions(result), TOLERANCE);
+    }
+
+    /**
+     * Tests that each line string of a collection is offset.
+     */
+    @Test
+    public void testCollection() {
+        final GeometryCollection<LineString> lines = 
GeometryFactory.DEFAULT.createMultiLineString(CRS_2D,
+                line(2, 1, 2, 4),
+                line(2, 1, 4, 1));
+        final Geometry result = offset(lines, -3);
+        final GeometryCollection<?> col = 
assertInstanceOf(GeometryCollection.class, result);
+        assertEquals(GeometryType.LINESTRING, col.getElementType());
+        assertEquals(CRS_2D, col.getCoordinateReferenceSystem());
+        assertEquals(2, col.getNumGeometries());
+        assertInstanceOf(LineString.class, col.getGeometryN(0));
+        assertArrayEquals(new double[]{5, 1,  5, 4},  
positions(col.getGeometryN(0)), TOLERANCE);
+        assertArrayEquals(new double[]{2, -2, 4, -2}, 
positions(col.getGeometryN(1)), TOLERANCE);
+    }
+
+    /**
+     * Tests that geometries other than line strings are rejected.
+     */
+    @Test
+    public void testUnsupported() {
+        assertThrows(UnsupportedOperationException.class,
+                () -> offset(GeometryFactory.DEFAULT.createPoint(CRS_2D, 1.0, 
2.0), 1));
+        final GeometryCollection<?> mixed = 
GeometryFactory.DEFAULT.createGeometryCollection(CRS_2D,
+                GeometryFactory.DEFAULT.createPoint(CRS_2D, 1.0, 2.0));
+        assertThrows(OperationException.class, () -> offset(mixed, 1));
+    }
+}
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 1bd3e9c2c6..71d17b02b0 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
@@ -544,4 +544,60 @@ public class ToPrimitiveTest {
         final MeshPrimitive primitive1 = (MeshPrimitive) result;
         assertEquals(MeshPrimitive.Type.POINTS, primitive1.getType());
     }
+
+    /**
+     * Creates two-dimensional data points with a color and a temperature 
attribute,
+     * as in the geometry builder samples. The attribute order shall not 
matter.
+     */
+    private static DataPoints attributedPoints(final double... coordinates) {
+        final int n = coordinates.length / 2;
+        final Map<String,Array> attributes = new LinkedHashMap<>();
+        attributes.put(DataPointsType.ATT_COLOR, NDArrays.of(4, new float[n * 
4]));
+        attributes.put("TEMPERATURE", NDArrays.of(1, new double[n]));
+        attributes.put(DataPointsType.ATT_POSITION, NDArrays.of(CRS2D, 
coordinates));
+        return GeometryFactory.DEFAULT.createDataPoints(attributes);
+    }
+
+    /**
+     * Verifies that the mesh has the given number of vertices and keeps the 
attributes.
+     */
+    private static void assertAttributes(final MeshPrimitive mesh, final int 
count) {
+        assertEquals(count, mesh.getPositions().getLength());
+        assertEquals(count, 
mesh.getAttribute(DataPointsType.ATT_COLOR).getLength());
+        assertEquals(count, mesh.getAttribute("TEMPERATURE").getLength());
+    }
+
+    /**
+     * Tests conversion of points and line strings having attributes other 
than positions.
+     */
+    @Test
+    public void testAttributes() {
+        final GeometryFactory f = GeometryFactory.DEFAULT;
+        final GeometryProcessor processor = new GeometryProcessor();
+
+        Geometry result = 
processor.toPrimitive(f.createPoint(attributedPoints(1, 2)), null, null);
+        assertAttributes((MeshPrimitive) result, 1);
+
+        result = 
processor.toPrimitive(f.createMultiPoint(attributedPoints(0,0, 1,1, 2,2)), 
null, null);
+        assertAttributes((MeshPrimitive) result, 3);
+
+        result = 
processor.toPrimitive(f.createLineString(attributedPoints(0,0, 1,1, 2,0)), 
null, null);
+        assertAttributes((MeshPrimitive) result, 3);
+
+        //only lines of 2 points, a single primitive of lines
+        result = processor.toPrimitive(f.createMultiLineString(CRS2D,
+                f.createLineString(attributedPoints(0,0, 1,1)),
+                f.createLineString(attributedPoints(2,0, 3,1))), null, null);
+        assertAttributes((MeshPrimitive) result, 4);
+
+        //longer lines, one line strip per line
+        result = processor.toPrimitive(f.createMultiLineString(CRS2D,
+                f.createLineString(attributedPoints(0,0, 1,1, 2,0)),
+                f.createLineString(attributedPoints(3,0, 4,1, 5,0))), null, 
null);
+        final MultiMeshPrimitive<?> multi = 
assertInstanceOf(MultiMeshPrimitive.class, result);
+        assertEquals(2, multi.getNumGeometries());
+        for (int i = 0; i < 2; i++) {
+            assertAttributes((MeshPrimitive) multi.getGeometryN(i), 3);
+        }
+    }
 }
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 0e50026b91..0f90aeac49 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
@@ -37,7 +37,6 @@ import org.apache.sis.geometries.curve.LinearRing;
 import org.apache.sis.geometries.surface.Polygon;
 import org.apache.sis.maths.Vector2D;
 import org.apache.sis.maths.NDArrays;
-import org.apache.sis.maths.Vectors;
 import org.apache.sis.maths.Vector;
 import org.apache.sis.referencing.GeodeticCalculator;
 import org.apache.sis.referencing.CommonCRS;
@@ -192,15 +191,15 @@ public abstract class AbstractDggrsTest {
                     final Vector corner = ps.getPosition(i);
 
                     calculator.setStartPoint(center);
-                    calculator.setEndPoint(Vectors.asDirectPostion(corner, 
ps.getCoordinateReferenceSystem()));
+                    
calculator.setEndPoint(corner.asDirectPostion(ps.getCoordinateReferenceSystem()));
                     double distance = calculator.getGeodesicDistance();
                     calculator.setGeodesicDistance(distance * 0.99);
                     final Vector2D.Double candidateInside = new 
Vector2D.Double(calculator.getEndPoint().getCoordinates());
                     calculator.setGeodesicDistance(distance * 1.01);
                     final Vector2D.Double candidateOutside = new 
Vector2D.Double(calculator.getEndPoint().getCoordinates());
 
-                    final String candidateInsideHash = 
coder.encode(Vectors.asDirectPostion(candidateInside));
-                    final String candidateOutsideHash = 
coder.encode(Vectors.asDirectPostion(candidateOutside));
+                    final String candidateInsideHash = 
coder.encode(candidateInside.asDirectPostion(null));
+                    final String candidateOutsideHash = 
coder.encode(candidateOutside.asDirectPostion(null));
                     System.out.println(z.getGeographicExtent().toString());
                     System.out.println(candidateInside + " " 
+candidateOutside);
                     System.out.println(candidateInsideHash + " " 
+candidateOutsideHash);

Reply via email to