You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2022/06/18 19:16:12 UTC

[sis] branch geoapi-4.0 updated (b99960e228 -> 549b647c88)

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

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


    from b99960e228 Show mouse cursor position in target canvas in addition of following zoom/translations/rotations.
     new 3ad66ff006 Attempt to reduce flickering effect sometime visible at the moment when image data are replaced.
     new 2c027ab90b Allow `AffineTransform2D` to be temporarily modifiable during its construction phase. It was possible in an older version, so this commit is a partial revert.
     new 549b647c88 More immediate feedback to user about the changes in source canvas that are replicated in the target canvas. The interim JavaFX transform is used without waiting for the background thread to complete the re-rendering.

The 3 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:
 .../apache/sis/gui/coverage/CoverageCanvas.java    |   5 +-
 .../org/apache/sis/gui/map/GestureFollower.java    |  87 +++++++++--
 .../java/org/apache/sis/gui/map/MapCanvas.java     | 133 ++++++++++++++--
 .../java/org/apache/sis/gui/map/MapCanvasAWT.java  |   5 +-
 .../org/apache/sis/portrayal/CanvasFollower.java   | 167 +++++++++++++++++----
 .../java/org/apache/sis/portrayal/Observable.java  |  24 +--
 .../org/apache/sis/portrayal/PlanarCanvas.java     |  16 +-
 .../apache/sis/portrayal/TransformChangeEvent.java |  40 ++++-
 .../sis/internal/referencing/j2d/AffineMatrix.java |  42 +++---
 .../referencing/j2d/AffineTransform2D.java         |  57 ++++---
 .../sis/internal/referencing/j2d/package-info.java |   2 +-
 11 files changed, 450 insertions(+), 128 deletions(-)


[sis] 01/03: Attempt to reduce flickering effect sometime visible at the moment when image data are replaced.

Posted by de...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 3ad66ff00612d37a11cfad38a215cd67302a81ce
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Sat Jun 18 11:46:54 2022 +0200

    Attempt to reduce flickering effect sometime visible at the moment when image data are replaced.
---
 .../apache/sis/gui/coverage/CoverageCanvas.java    |  5 +--
 .../java/org/apache/sis/gui/map/MapCanvas.java     | 38 +++++++++++++++++++---
 .../java/org/apache/sis/gui/map/MapCanvasAWT.java  |  5 +--
 3 files changed, 40 insertions(+), 8 deletions(-)

diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java
index 1b741939d2..21e4bc55ba 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java
@@ -1218,7 +1218,7 @@ public class CoverageCanvas extends MapCanvasAWT {
             final Rectangle2D aoi = getAreaOfInterest();
             final DirectPosition poi = getPointOfInterest(true);
             if (aoi != null && poi != null) {
-                table.append(String.format("A/P of interest in objective CRS (x,y):%n"
+                table.append(String.format("Area of interest in objective CRS (x,y):%n"
                              + "Max: %, 16.4f  %, 16.4f%n"
                              + "POI: %, 16.4f  %, 16.4f%n"
                              + "Min: %, 16.4f  %, 16.4f%n",
@@ -1231,8 +1231,9 @@ public class CoverageCanvas extends MapCanvasAWT {
             if (source != null) {
                 table.append("Extent in source coverage:").append(lineSeparator)
                      .append(String.valueOf(new GridExtent(source))).append(lineSeparator)
-                     .nextLine();
+                     .appendHorizontalSeparator();
             }
+            table.append(super.toString()).nextLine();
             table.nextLine('═');
             table.flush();
         } catch (RenderException | TransformException | IOException e) {
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java
index ad04ffa89a..1f0cf49dc1 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java
@@ -19,6 +19,7 @@ package org.apache.sis.gui.map;
 import java.util.Locale;
 import java.util.Arrays;
 import java.util.Objects;
+import java.util.Formatter;
 import java.awt.geom.AffineTransform;
 import java.awt.geom.NoninvertibleTransformException;
 import java.beans.PropertyChangeEvent;
@@ -154,10 +155,13 @@ public abstract class MapCanvas extends PlanarCanvas {
      * It does not apply to the immediate feedback that the user gets from JavaFX affine transforms
      * (an image with lower quality used until the higher quality image become ready).
      *
+     * <p>This value should not be too small for reducing flickering effects that are sometime visible
+     * at the moment when image data are replaced.</p>
+     *
      * @see #requestRepaint()
      * @see Delayed
      */
-    private static final long REPAINT_DELAY = 100;
+    private static final long REPAINT_DELAY = 500;
 
     /**
      * Number of nanoseconds to wait before to set mouse cursor shape to {@link Cursor#WAIT} during rendering.
@@ -165,7 +169,7 @@ public abstract class MapCanvas extends PlanarCanvas {
      *
      * @see #renderingStartTime
      */
-    private static final long WAIT_CURSOR_DELAY = (500 - REPAINT_DELAY) * NANOS_PER_MILLISECOND;
+    private static final long WAIT_CURSOR_DELAY = (1000 - REPAINT_DELAY) * NANOS_PER_MILLISECOND;
 
     /**
      * The pane showing the map and any other JavaFX nodes to scale and translate together with the map.
@@ -1124,7 +1128,7 @@ public abstract class MapCanvas extends PlanarCanvas {
                  * use it as-is. Otherwise we will compute it from the bounds of data.
                  */
                 CoordinateReferenceSystem objectiveCRS;
-                LinearTransform crsToDisplay = null;
+                LinearTransform crsToDisplay;
                 final GridGeometry init = initialState;
                 initialState = null;                                    // For using `objectiveBounds` next times.
                 if (init != null && init.isDefined(GridGeometry.GRID_TO_CRS)) {
@@ -1200,7 +1204,7 @@ public abstract class MapCanvas extends PlanarCanvas {
         final Renderer context = createRenderer();
         if (context != null && context.initialize(floatingPane)) {
             final Task<?> worker = createWorker(context);
-            assert renderingInProgress == null;
+            assert renderingInProgress == null : renderingInProgress;
             BackgroundThreads.execute(worker);
             renderingInProgress = worker;       // Set after we know that the task has been scheduled.
             if (!isCursorChangeScheduled) {
@@ -1257,6 +1261,7 @@ public abstract class MapCanvas extends PlanarCanvas {
      */
     final void renderingCompleted(final Task<?> task) {
         assert Platform.isFxApplicationThread();
+        assert renderingInProgress == task : "Expected " + renderingInProgress + " but was " + task;
         // Keep cursor unchanged if contents changed, because caller will invoke `repaint()` again.
         if (!contentsChanged() || task.getState() != Task.State.SUCCEEDED) {
             restoreCursorAfterPaint();
@@ -1513,4 +1518,29 @@ public abstract class MapCanvas extends PlanarCanvas {
         isRendering.set(false);
         requestRepaint();
     }
+
+    /**
+     * Returns a string representation of this canvas for debugging purposes.
+     * This string spans multiple lines.
+     *
+     * @return debug string (may change in any future version).
+     *
+     * @since 1.3
+     */
+    @Override
+    public String toString() {
+        final Formatter buffer = new Formatter();
+        final double tx = transform.getTx();
+        final double ty = transform.getTy();
+        try {
+            final AffineTransform displayToObjective = objectiveToDisplay.createInverse();
+            java.awt.geom.Point2D p = new java.awt.geom.Point2D.Double(-tx, -ty);
+            p = displayToObjective.transform(p, p);
+            buffer.format("Upper-left corner:   %+7.2f %+7.2f%n", p.getX(), p.getY());
+        } catch (NoninvertibleTransformException e) {
+            buffer.format("%s%n", e);
+        }
+        buffer.format("Pending translation: %+7.2f %+7.2f px%n", tx, ty);
+        return buffer.toString();
+    }
 }
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvasAWT.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvasAWT.java
index 5f81b6047e..5ad205d46c 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvasAWT.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvasAWT.java
@@ -527,7 +527,7 @@ public abstract class MapCanvasAWT extends MapCanvas {
                 } while (invalid && !isCancelled());
             } finally {
                 if (invalid) {
-                    drawTo.flush();         // Release native resources.
+                    drawTo.flush();         // Release native resources on cancellation or exception thrown.
                 }
             }
             return drawTo;
@@ -578,11 +578,12 @@ public abstract class MapCanvasAWT extends MapCanvas {
             }
         }
 
-        /** Clears the image in the same way than failure. */
+        /** Clears the image in the same way than failure. Defined for safety but should not happen. */
         @Override protected void cancelled() {failed();}
 
         /**
          * Invoked in JavaFX thread on failure. No result is available. The JavaFX image is set to an empty image.
+         * {@link VolatileImage#flush()} has already been invoked by the finally block in {@link #call()}.
          */
         @Override
         protected void failed() {


[sis] 03/03: More immediate feedback to user about the changes in source canvas that are replicated in the target canvas. The interim JavaFX transform is used without waiting for the background thread to complete the re-rendering.

Posted by de...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 549b647c883efa919537ea123791fdfaebec4996
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Sat Jun 18 17:42:57 2022 +0200

    More immediate feedback to user about the changes in source canvas that are replicated in the target canvas.
    The interim JavaFX transform is used without waiting for the background thread to complete the re-rendering.
---
 .../org/apache/sis/gui/map/GestureFollower.java    |  87 +++++++++--
 .../java/org/apache/sis/gui/map/MapCanvas.java     |  95 ++++++++++--
 .../org/apache/sis/portrayal/CanvasFollower.java   | 167 +++++++++++++++++----
 .../java/org/apache/sis/portrayal/Observable.java  |  24 +--
 .../org/apache/sis/portrayal/PlanarCanvas.java     |  16 +-
 .../apache/sis/portrayal/TransformChangeEvent.java |  40 ++++-
 6 files changed, 346 insertions(+), 83 deletions(-)

diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/GestureFollower.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/GestureFollower.java
index 776e3b2798..6f4e97bd09 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/GestureFollower.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/GestureFollower.java
@@ -34,6 +34,7 @@ import javafx.beans.property.BooleanProperty;
 import javafx.beans.property.SimpleBooleanProperty;
 import org.opengis.referencing.operation.MathTransform2D;
 import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.portrayal.TransformChangeEvent;
 import org.apache.sis.portrayal.CanvasFollower;
 import org.apache.sis.internal.system.Modules;
 import org.apache.sis.util.logging.Logging;
@@ -81,7 +82,7 @@ public class GestureFollower extends CanvasFollower implements EventHandler<Mous
      * The effect applied on the cursor. The intend is to make it more visible if the cursor color
      * is close to the color of features rendered on the map.
      */
-    private static final DropShadow CURSOR_EFFECT = new DropShadow(BlurType.ONE_PASS_BOX, Color.BLACK, 5, 0, 0, 0);
+    private static final DropShadow CURSOR_EFFECT = new DropShadow(BlurType.ONE_PASS_BOX, Color.DEEPPINK, 5, 0, 0, 0);
 
     /**
      * Whether changes in the "objective to display" transforms should be propagated from source to target canvas.
@@ -96,9 +97,9 @@ public class GestureFollower extends CanvasFollower implements EventHandler<Mous
     public final BooleanProperty cursorEnabled;
 
     /**
-     * Cursor position of the mouse over source canvas, expressed in coordinates of the target canvas.
+     * Cursor position of the mouse over source canvas, expressed in coordinates of the source and target canvas.
      */
-    private final Point2D.Double cursorPosition;
+    private final Point2D.Double cursorSourcePosition, cursorTargetPosition;
 
     /**
      * The shape used for drawing a cursor on the target canvas. Constructed when first requested.
@@ -122,7 +123,8 @@ public class GestureFollower extends CanvasFollower implements EventHandler<Mous
     public GestureFollower(final MapCanvas source, final MapCanvas target) {
         super(source, target);
         super.setDisabled(true);
-        cursorPosition   = new Point2D.Double();
+        cursorSourcePosition = new Point2D.Double(Double.NaN, Double.NaN);
+        cursorTargetPosition = new Point2D.Double(Double.NaN, Double.NaN);
         transformEnabled = new SimpleBooleanProperty(this, "transformEnabled");
         cursorEnabled    = new SimpleBooleanProperty(this, "cursorEnabled");
         transformEnabled.addListener((p,o,n) -> setDisabled(!n));
@@ -139,6 +141,7 @@ public class GestureFollower extends CanvasFollower implements EventHandler<Mous
         if (enabled) {
             if (cursor == null) {
                 cursor = new Path(CURSOR_SHAPE);
+                cursor.setStrokeWidth(3);
                 cursor.setStroke(Color.LIGHTPINK);
                 cursor.setEffect(CURSOR_EFFECT);
                 cursor.setMouseTransparent(true);
@@ -150,10 +153,12 @@ public class GestureFollower extends CanvasFollower implements EventHandler<Mous
             pane.addEventHandler(MouseEvent.MOUSE_ENTERED, this);
             pane.addEventHandler(MouseEvent.MOUSE_EXITED,  this);
             pane.addEventHandler(MouseEvent.MOUSE_MOVED,   this);
+            pane.addEventHandler(MouseEvent.MOUSE_DRAGGED, this);
         } else {
             pane.removeEventHandler(MouseEvent.MOUSE_ENTERED, this);
             pane.removeEventHandler(MouseEvent.MOUSE_EXITED,  this);
             pane.removeEventHandler(MouseEvent.MOUSE_MOVED,   this);
+            pane.removeEventHandler(MouseEvent.MOUSE_DRAGGED, this);
             if (cursor != null) {
                 (((MapCanvas) target).floatingPane).getChildren().remove(cursor);
             }
@@ -168,27 +173,77 @@ public class GestureFollower extends CanvasFollower implements EventHandler<Mous
      */
     @Override
     public void handle(final MouseEvent event) {
+        cursorSourcePosition.x = event.getX();
+        cursorSourcePosition.y = event.getY();
         final EventType<? extends MouseEvent> type = event.getEventType();
-        if (type == MouseEvent.MOUSE_MOVED || type == MouseEvent.MOUSE_ENTERED) {
+        if (type == MouseEvent.MOUSE_MOVED) {
+            updateCursorPosition();
+        } else if (type == MouseEvent.MOUSE_ENTERED) {
+            cursor.setVisible(true);
+            updateCursorPosition();
+        } else if (type == MouseEvent.MOUSE_EXITED) {
+            cursor.setVisible(false);
+        }
+    }
+
+    /**
+     * Sets the cursor location in the target canvas to a position computed from current value
+     * of {@link #cursorSourcePosition}.
+     */
+    private void updateCursorPosition() {
+        if (cursor.isVisible()) {
             final MathTransform2D tr = getDisplayTransform().orElse(null);
             if (tr != null) try {
-                cursorPosition.x = event.getX();
-                cursorPosition.y = event.getY();
-                final Point2D  p = tr.transform(cursorPosition, cursorPosition);
+                final Point2D  p = tr.transform(cursorSourcePosition, cursorTargetPosition);
                 cursor.setTranslateX(p.getX());
                 cursor.setTranslateY(p.getY());
-                if (type == MouseEvent.MOUSE_ENTERED) {
-                    cursor.setVisible(true);
-                }
-                return;
             } catch (TransformException e) {
                 Logging.recoverableException(Logger.getLogger(Modules.APPLICATION), GestureFollower.class, "handle", e);
-                // Handle as a mouse exit.
+                cursor.setVisible(false);
             }
-        } else if (type != MouseEvent.MOUSE_EXITED) {
-            return;
         }
-        cursor.setVisible(false);
+    }
+
+    /**
+     * Returns {@code true} if this listener should replicate the following changes on the target canvas.
+     * This implementation returns {@code true} if the transform reason is {@link TransformChangeEvent.Reason#INTERM}.
+     * It allows immediate feedback to users without waiting for the background thread to complete rendering.
+     *
+     * @param  event  a transform change event that occurred on the source canvas.
+     * @return  whether to replicate that change on the target canvas.
+     */
+    @Override
+    protected boolean filter(final TransformChangeEvent event) {
+        return event.getReason() == TransformChangeEvent.Reason.INTERIM;
+    }
+
+    /**
+     * Invoked after the source "objective to display" transform has been updated.
+     *
+     * @hidden
+     */
+    @Override
+    protected void transformedSource(final TransformChangeEvent event) {
+        super.transformedSource(event);
+        if (event.getReason() != TransformChangeEvent.Reason.INTERIM) {
+            event.getDisplayChange2D().ifPresent((change) -> {
+                change.transform(cursorSourcePosition, cursorSourcePosition);
+            });
+        }
+    }
+
+    /**
+     * Invoked after the target "objective to display" transform has been updated.
+     * This method recomputes the cursor position.
+     *
+     * @hidden
+     */
+    @Override
+    protected void transformedTarget(final TransformChangeEvent event) {
+        super.transformedTarget(event);
+        if (event.getReason() != TransformChangeEvent.Reason.INTERIM) {
+            updateCursorPosition();
+        }
     }
 
     /**
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java
index 1f0cf49dc1..455062f2b4 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java
@@ -83,8 +83,10 @@ import org.apache.sis.internal.gui.GUIUtilities;
 import org.apache.sis.internal.gui.MouseDrags;
 import org.apache.sis.internal.gui.Resources;
 import org.apache.sis.internal.referencing.AxisDirections;
+import org.apache.sis.internal.referencing.j2d.AffineTransform2D;
 import org.apache.sis.portrayal.PlanarCanvas;
 import org.apache.sis.portrayal.RenderException;
+import org.apache.sis.portrayal.TransformChangeEvent;
 import org.apache.sis.referencing.IdentifiedObjects;
 
 import static java.util.logging.Logger.getLogger;
@@ -271,6 +273,8 @@ public abstract class MapCanvas extends PlanarCanvas {
      * and the completion of latest {@link #repaint()} event. This is used for giving immediate feedback to user
      * while waiting for the new rendering to be ready. Since this transform is a member of {@link #floatingPane}
      * {@linkplain Pane#getTransforms() transform list}, changes in this transform are immediately visible to user.
+     *
+     * @see #getInterimTransform(boolean)
      */
     private final Affine transform;
 
@@ -506,7 +510,11 @@ public abstract class MapCanvas extends PlanarCanvas {
      */
     private void applyTranslation(final double tx, final double ty, final boolean isFinal) {
         if (tx != 0 || ty != 0) {
+            final AffineTransform2D interim = getInterimTransformForListeners();
             transform.appendTranslation(tx, ty);
+            if (interim != null) {
+                fireInterimTransform(interim, AffineTransform.getTranslateInstance(tx, ty));
+            }
             if (!isFinal) {
                 requestRepaint();
                 return;
@@ -575,12 +583,16 @@ public abstract class MapCanvas extends PlanarCanvas {
                     unexpectedException("onKeyTyped", e);
                 }
             }
+            final AffineTransform2D interim = getInterimTransformForListeners();
             if (zoom != 1) {
                 transform.appendScale(zoom, zoom, x, y);
             }
             if (angle != 0) {
                 transform.appendRotation(angle, x, y);
             }
+            if (interim != null) {
+                fireInterimTransform(interim, null);
+            }
             requestRepaint();
         }
         if (event != null) {
@@ -906,10 +918,13 @@ public abstract class MapCanvas extends PlanarCanvas {
      * This method must be invoked in the JavaFX thread. The visual is updated immediately by transforming
      * the current image, then a more accurate image is prepared in a background thread.
      *
-     * <p>Contrarily to the method defined in the {@link PlanarCanvas} parent class,
-     * this method does not guarantee that an {@value #OBJECTIVE_TO_DISPLAY_PROPERTY} event is fired immediately.
-     * The event may be fired at an undetermined amount of time after this method call.
-     * However the event will always be fired in the JavaFX thread.</p>
+     * <h4>Transform events</h4>
+     * This method fires immediately an {@value #OBJECTIVE_TO_DISPLAY_PROPERTY} event with
+     * {@link TransformChangeEvent.Reason#INTERIM}. This event does not yet reflect the state of the
+     * {@linkplain #getObjectiveToDisplay() objective to display} transform. At some arbitrary time in the future,
+     * another {@value #OBJECTIVE_TO_DISPLAY_PROPERTY} event will occur (still in JavaFX thread)
+     * with {@link TransformChangeEvent.Reason#DISPLAY_NAVIGATION} (really display, not objective).
+     * That event will consolidate all {@code INTERIM} events that happened since the last non-interim event.
      *
      * @param  before  coordinate conversion to apply before the current <cite>objective to display</cite> transform.
      *
@@ -918,11 +933,15 @@ public abstract class MapCanvas extends PlanarCanvas {
     @Override
     public void transformObjectiveCoordinates(final AffineTransform before) {
         if (!before.isIdentity()) try {
+            final AffineTransform2D interim = getInterimTransformForListeners();
             AffineTransform t = objectiveToDisplay.createInverse();
             t.preConcatenate(before);
             t.preConcatenate(objectiveToDisplay);
             transform.prepend(t.getScaleX(), t.getShearX(), t.getTranslateX(),
                               t.getShearY(), t.getScaleY(), t.getTranslateY());
+            if (interim != null) {
+                fireInterimTransform(interim, null);
+            }
             requestRepaint();
         } catch (NoninvertibleTransformException e) {
             errorOccurred(e);
@@ -934,10 +953,13 @@ public abstract class MapCanvas extends PlanarCanvas {
      * This method must be invoked in the JavaFX thread. The visual is updated immediately by transforming
      * the current image, then a more accurate image is prepared in a background thread.
      *
-     * <p>Contrarily to the method defined in the {@link PlanarCanvas} parent class,
-     * this method does not guarantee that an {@value #OBJECTIVE_TO_DISPLAY_PROPERTY} event is fired immediately.
-     * The event may be fired at an undetermined amount of time after this method call.
-     * However the event will always be fired in the JavaFX thread.</p>
+     * <h4>Transform events</h4>
+     * This method fires immediately an {@value #OBJECTIVE_TO_DISPLAY_PROPERTY} event with
+     * {@link TransformChangeEvent.Reason#INTERIM}. This event does not yet reflect the state of the
+     * {@linkplain #getObjectiveToDisplay() objective to display} transform. At some arbitrary time in the future,
+     * another {@value #OBJECTIVE_TO_DISPLAY_PROPERTY} event will occur (still in JavaFX thread)
+     * with {@link TransformChangeEvent.Reason#DISPLAY_NAVIGATION}. That event will consolidate
+     * all {@code INTERIM} events that happened since the last non-interim event.
      *
      * @param  after  coordinate conversion to apply after the current <cite>objective to display</cite> transform.
      *
@@ -946,12 +968,64 @@ public abstract class MapCanvas extends PlanarCanvas {
     @Override
     public void transformDisplayCoordinates(final AffineTransform after) {
         if (!after.isIdentity()) {
+            final AffineTransform2D interim = getInterimTransformForListeners();
             transform.append(after.getScaleX(), after.getShearX(), after.getTranslateX(),
                              after.getShearY(), after.getScaleY(), after.getTranslateY());
+            if (interim != null) {
+                fireInterimTransform(interim, after);
+            }
             requestRepaint();
         }
     }
 
+    /**
+     * Fires a {@link TransformChangeEvent} for a change in the {@link #transform}.
+     * This method needs a modifiable {@code before} instance; it will be modified.
+     *
+     * @param before  value of {@link #getInterimTransform(boolean)} before the change.
+     * @param change  change in pixel coordinates, or {@code null} for lazy computation.
+     */
+    private void fireInterimTransform(final AffineTransform2D before, final AffineTransform change) {
+        final AffineTransform2D after = getInterimTransform(true);
+        after .concatenate(objectiveToDisplay); after .freeze();
+        before.concatenate(objectiveToDisplay); before.freeze();
+        firePropertyChange(new TransformChangeEvent(this, before, after, null, change,
+                               TransformChangeEvent.Reason.INTERIM));
+    }
+
+    /**
+     * Returns the {@linkplain #getInterimTransform(boolean) interim transform} if at least one listener
+     * is registered, or {@code null} otherwise. This method should be used with the following pattern:
+     *
+     * {@preformat java
+     *     AffineTransform2D interim = getInterimTransformForListeners();
+     *     transform.something(…);
+     *     if (interim != null) {
+     *         fireInterimTransform(interim, change);
+     *     }
+     * }
+     *
+     * @return a copy of {@link #transform} as a modifiable Java2D object, or {@code null} if not needed.
+     */
+    private AffineTransform2D getInterimTransformForListeners() {
+        return hasPropertyChangeListener(OBJECTIVE_TO_DISPLAY_PROPERTY) ? getInterimTransform(true) : null;
+    }
+
+    /**
+     * Returns {@link #transform} as a Java2D affine transform. This is the change to append to
+     * {@link #objectiveToDisplay} for getting the transform that user currently see on screen.
+     * This is a temporary transform, for immediate feedback to user before the map is re-rendered.
+     *
+     * @param modifiable  whether the returned transform should be modifiable.
+     *         If true, then it is caller's responsibility to invoke {@link AffineTransform2D#freeze()}.
+     * @return a copy of {@link #transform} as a (potentially immutable) Java2D object.
+     */
+    private AffineTransform2D getInterimTransform(final boolean modifiable) {
+        return new AffineTransform2D(transform.getMxx(), transform.getMyx(),
+                                     transform.getMxy(), transform.getMyy(),
+                                     transform.getTx(),  transform.getTy(), modifiable);
+    }
+
     /**
      * Invoked in JavaFX thread for creating a renderer to be executed in a background thread.
      * Subclasses shall copy in this method all {@code MapCanvas} properties that the background thread
@@ -1189,10 +1263,7 @@ public abstract class MapCanvas extends PlanarCanvas {
          */
         changeInProgress.setToTransform(transform);
         if (!transform.isIdentity()) {
-            super.transformDisplayCoordinates(new AffineTransform(
-                    transform.getMxx(), transform.getMyx(),
-                    transform.getMxy(), transform.getMyy(),
-                    transform.getTx(),  transform.getTy()));
+            super.transformDisplayCoordinates(getInterimTransform(false));
         }
         /*
          * Invoke `createWorker(…)` only after we finished above configuration, because that method
diff --git a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/CanvasFollower.java b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/CanvasFollower.java
index b7080ec8d9..a2161d7717 100644
--- a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/CanvasFollower.java
+++ b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/CanvasFollower.java
@@ -47,13 +47,11 @@ import org.apache.sis.referencing.operation.transform.MathTransforms;
  * account the differences in zoom levels and map projections. For example a translation of 10 pixels in one
  * canvas may map to a translation of 20 pixels in the other canvas for reproducing the same "real world" translation.
  *
- * <p>This class implements an unidirectional binding: changes in source are applied on target, but not the converse.
- * It is recommended to avoid bidirectional binding in current implementation
- * (this limitation may be fixed in a future version).</p>
- *
  * <h2>Listeners</h2>
  * {@code CanvasFollower} listeners need to be registered explicitly by a call to the {@link #initialize()} method.
  * The {@link #dispose()} convenience method is provided for unregistering all those listeners.
+ * The listeners registered by this class implement an unidirectional binding:
+ * changes in source are applied on target, but not the converse.
  *
  * <h2>Multi-threading</h2>
  * This class is <strong>not</strong> thread-safe.
@@ -240,6 +238,33 @@ public class CanvasFollower implements PropertyChangeListener, Disposable {
         }
     }
 
+    /**
+     * Returns the objective coordinates of the Point Of Interest (POI) in source canvas.
+     * This information is used when the source and target canvases do not use the same CRS.
+     * Changes in "real world" coordinates on the {@linkplain #target} canvas are guaranteed
+     * to reflect the changes in "real world" coordinates of the {@linkplain #source} canvas
+     * at that location only. At all other locations, the "real world" coordinate changes
+     * may differ because of map projection deformations.
+     *
+     * <p>The default implementation is as below. Subclasses can override this method for
+     * using a different point of interest, for example at the location of mouse cursor.</p>
+     *
+     * {@preformat java
+     *     return source.getPointOfInterest(true);
+     * }
+     *
+     * The CRS associated to the position shall be {@link PlanarCanvas#getObjectiveCRS()}.
+     * For performance reason, this is not verified by this {@code CanvasFollower} class.
+     *
+     * @return objective coordinates in source canvas where displacements, zooms and rotations
+     *         applied on the source canvas should be mirrored exactly on the target canvas.
+     *
+     * @see PlanarCanvas#getPointOfInterest(boolean)
+     */
+    public DirectPosition getSourceObjectivePOI() {
+        return source.getPointOfInterest(true);
+    }
+
     /**
      * Returns the transform from source display coordinates to target display coordinates.
      * This transform may change every time that a zoom; translation or rotation is applied
@@ -288,41 +313,58 @@ public class CanvasFollower implements PropertyChangeListener, Disposable {
      * If the event is an instance of {@link TransformChangeEvent}, then this method applies the same change
      * on the {@linkplain #target} canvas.
      *
+     * <p>This method delegates part of its work to the following methods,
+     * which can be overridden for altering the changes:</p>
+     *
+     * <ul>
+     *   <li>{@link #transformObjectiveCoordinates(TransformChangeEvent, AffineTransform)}
+     *        if {@linkplain #getFollowRealWorld() following real world coordinates}.</li>
+     *   <li>{@link #transformDisplayCoordinates(TransformChangeEvent, AffineTransform)}
+     *        if following pixel coordinates instead of real world.</li>
+     *   <li>{@link #transformedSource(TransformChangeEvent)} after the change has been applied on {@linkplain #source}.</li>
+     *   <li>{@link #transformedTarget(TransformChangeEvent)} after the change has been applied on {@linkplain #target}.</li>
+     * </ul>
+     *
      * @param  event  a change in the canvas that this listener is tracking.
      */
     @Override
     public void propertyChange(final PropertyChangeEvent event) {
-        if (event instanceof TransformChangeEvent) {
+        if (!changing && event instanceof TransformChangeEvent) try {
             final TransformChangeEvent te = (TransformChangeEvent) event;
             displayTransformStatus = OUTDATED;
-            if (!disabled && !changing && te.isSameSource(source) && te.getReason().isNavigation()) try {
-                changing = true;
-                if (followRealWorld && (objectiveTransformStatus == VALID || findObjectiveTransform("propertyChange"))) {
-                    AffineTransform before = te.getObjectiveChange2D().orElse(null);
-                    if (before != null) try {
-                        /*
-                         * Converts a change from units of the source CRS to units of the target CRS.
-                         * If that change can not be computed, fallback on a change in display units.
-                         * The POI may be null, but this is okay if the transform is linear.
-                         */
-                        if (objectiveTransform != null) {
-                            DirectPosition poi = target.getPointOfInterest(true);
-                            AffineTransform t = AffineTransforms2D.castOrCopy(MathTransforms.linear(objectiveTransform, poi));
-                            AffineTransform c = t.createInverse();
-                            c.preConcatenate(before);
-                            c.preConcatenate(t);
-                            before = c;
+            changing = true;
+            if (te.isSameSource(source)) {
+                transformedSource(te);
+                if (!disabled && filter(te)) {
+                    if (followRealWorld && (objectiveTransformStatus == VALID || findObjectiveTransform("propertyChange"))) {
+                        AffineTransform before = te.getObjectiveChange2D().orElse(null);
+                        if (before != null) try {
+                            /*
+                             * Converts a change from units of the source CRS to units of the target CRS.
+                             * If that change can not be computed, fallback on a change in display units.
+                             * The POI may be null, but this is okay if the transform is linear.
+                             */
+                            if (objectiveTransform != null) {
+                                DirectPosition poi = getSourceObjectivePOI();
+                                AffineTransform t = AffineTransforms2D.castOrCopy(MathTransforms.linear(objectiveTransform, poi));
+                                AffineTransform c = t.createInverse();
+                                c.preConcatenate(before);
+                                c.preConcatenate(t);
+                                before = c;
+                            }
+                            transformObjectiveCoordinates(te, before);
+                            return;
+                        } catch (NullArgumentException | TransformException | NoninvertibleTransformException e) {
+                            canNotCompute("propertyChange", e);
                         }
-                        target.transformObjectiveCoordinates(before);
-                        return;
-                    } catch (NullArgumentException | TransformException | NoninvertibleTransformException e) {
-                        canNotCompute("propertyChange", e);
                     }
+                    te.getDisplayChange2D().ifPresent((after) -> transformDisplayCoordinates(te, after));
                 }
-                te.getDisplayChange2D().ifPresent(target::transformDisplayCoordinates);
-            } finally {
-                changing = false;
+            } else if (te.isSameSource(target)) {
+                transformedTarget(te);
             }
+        } finally {
+            changing = false;
         } else if (PlanarCanvas.OBJECTIVE_CRS_PROPERTY.equals(event.getPropertyName())) {
             displayTransform         = null;
             objectiveTransform       = null;
@@ -331,6 +373,73 @@ public class CanvasFollower implements PropertyChangeListener, Disposable {
         }
     }
 
+    /**
+     * Returns {@code true} if this listener should replicate the following changes on the target canvas.
+     * The default implementation returns {@code true} if the transform reason is
+     * {@link TransformChangeEvent.Reason#OBJECTIVE_NAVIGATION} or
+     * {@link TransformChangeEvent.Reason#DISPLAY_NAVIGATION}.
+     *
+     * @param  event  a transform change event that occurred on the {@linkplain #source} canvas.
+     * @return  whether to replicate that change on the {@linkplain #target} canvas.
+     */
+    protected boolean filter(final TransformChangeEvent event) {
+        return event.getReason().isNavigation();
+    }
+
+    /**
+     * Invoked by {@link #propertyChange(PropertyChangeEvent)} for updating the transform of the target canvas
+     * in units of the objective CRS. The {@linkplain #target} canvas is updated by this method as if the given
+     * transform was applied <em>before</em> its current <cite>objective to display</cite> transform.
+     *
+     * <p>The default implementation delegates to {@link PlanarCanvas#transformObjectiveCoordinates(AffineTransform)}.
+     * Subclasses can override if they need to transform additional data.</p>
+     *
+     * @param  event   the change in the {@linkplain #source} canvas.
+     * @param  before  the change to apply on the {@linkplain #target} canvas, in unit of objective CRS.
+     *
+     * @see PlanarCanvas#transformObjectiveCoordinates(AffineTransform)
+     */
+    protected void transformObjectiveCoordinates(final TransformChangeEvent event, final AffineTransform before) {
+        target.transformObjectiveCoordinates(before);
+    }
+
+    /**
+     * Invoked by {@link #propertyChange(PropertyChangeEvent)} for updating the transform of the target canvas
+     * in display units (typically pixels). The {@linkplain #target} canvas is updated by this method as if the
+     * given transform was applied <em>after</em> its current <cite>objective to display</cite> transform.
+     *
+     * <p>The default implementation delegates to {@link PlanarCanvas#transformDisplayCoordinates(AffineTransform)}.
+     * Subclasses can override if they need to transform additional data.</p>
+     *
+     * @param  event  the change in the {@linkplain #source} canvas.
+     * @param  after  the change to apply on the {@linkplain #target} canvas, in display units (typically pixels).
+     *
+     * @see PlanarCanvas#transformDisplayCoordinates(AffineTransform)
+     */
+    protected void transformDisplayCoordinates(final TransformChangeEvent event, final AffineTransform after) {
+        target.transformDisplayCoordinates(after);
+    }
+
+    /**
+     * Invoked after the source "objective to display" transform has been updated.
+     * The default implementation does nothing.
+     * Subclasses can override if they need to transform additional data.
+     *
+     * @param  event  the change which has been applied on the {@linkplain #source} canvas.
+     */
+    protected void transformedSource(TransformChangeEvent event) {
+    }
+
+    /**
+     * Invoked after the target "objective to display" transform has been updated.
+     * The default implementation does nothing.
+     * Subclasses can override if they need to transform additional data.
+     *
+     * @param  event  the change which has been applied on the {@linkplain #target} canvas.
+     */
+    protected void transformedTarget(TransformChangeEvent event) {
+    }
+
     /**
      * Finds the transform to use for converting changes from {@linkplain #source} canvas to {@linkplain #target} canvas.
      * This method should be invoked only if {@link #objectiveTransformStatus} is not {@link #VALID}. After this method
diff --git a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/Observable.java b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/Observable.java
index 5fc33b669c..73a4302026 100644
--- a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/Observable.java
+++ b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/Observable.java
@@ -36,7 +36,7 @@ import org.apache.sis.util.ArraysExt;
  *       like the index of the element modified in a list.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.1
+ * @version 1.3
  * @since   1.1
  * @module
  */
@@ -104,6 +104,18 @@ abstract class Observable {
         }
     }
 
+    /**
+     * Returns {@code true} if the given property has at least one listener.
+     *
+     * @param  propertyName  name of the property to test.
+     * @return {@code true} if the given property has at least one listener.
+     *
+     * @since 1.3
+     */
+    protected final boolean hasPropertyChangeListener(final String propertyName) {
+        return (listeners != null) && listeners.containsKey(propertyName);
+    }
+
     /**
      * Notifies all registered listeners that a property of the given name changed its value.
      * The {@linkplain PropertyChangeEvent#getSource() change event source} will be {@code this}.
@@ -149,14 +161,4 @@ abstract class Observable {
             }
         }
     }
-
-    /**
-     * Returns {@code true} if the given property has at least one listener.
-     *
-     * @param  propertyName  name of the property.
-     * @return {@code true} if the given property has at least one listener.
-     */
-    final boolean hasListener(final String propertyName) {
-        return (listeners != null) && listeners.containsKey(propertyName);
-    }
 }
diff --git a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/PlanarCanvas.java b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/PlanarCanvas.java
index a6eae370a3..27ed26af5f 100644
--- a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/PlanarCanvas.java
+++ b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/PlanarCanvas.java
@@ -174,14 +174,12 @@ public abstract class PlanarCanvas extends Canvas {
      */
     public void transformObjectiveCoordinates(final AffineTransform before) {
         if (!before.isIdentity()) {
-            final LinearTransform old = hasListener(OBJECTIVE_TO_DISPLAY_PROPERTY) ? getObjectiveToDisplay() : null;
+            final LinearTransform old = hasPropertyChangeListener(OBJECTIVE_TO_DISPLAY_PROPERTY) ? getObjectiveToDisplay() : null;
             objectiveToDisplay.concatenate(before);
             super.setObjectiveToDisplayImpl(null);
             if (old != null) {
-                final TransformChangeEvent event = new TransformChangeEvent(this, old, null,
-                        TransformChangeEvent.Reason.OBJECTIVE_NAVIGATION);
-                event.objectiveChange2D = before;
-                firePropertyChange(event);
+                firePropertyChange(new TransformChangeEvent(this, old, null, before, null,
+                                       TransformChangeEvent.Reason.OBJECTIVE_NAVIGATION));
             }
         }
     }
@@ -203,14 +201,12 @@ public abstract class PlanarCanvas extends Canvas {
      */
     public void transformDisplayCoordinates(final AffineTransform after) {
         if (!after.isIdentity()) {
-            final LinearTransform old = hasListener(OBJECTIVE_TO_DISPLAY_PROPERTY) ? getObjectiveToDisplay() : null;
+            final LinearTransform old = hasPropertyChangeListener(OBJECTIVE_TO_DISPLAY_PROPERTY) ? getObjectiveToDisplay() : null;
             objectiveToDisplay.preConcatenate(after);
             super.setObjectiveToDisplayImpl(null);
             if (old != null) {
-                final TransformChangeEvent event = new TransformChangeEvent(this, old, null,
-                        TransformChangeEvent.Reason.DISPLAY_NAVIGATION);
-                event.displayChange2D = after;
-                firePropertyChange(event);
+                firePropertyChange(new TransformChangeEvent(this, old, null, null, after,
+                                       TransformChangeEvent.Reason.DISPLAY_NAVIGATION));
             }
         }
     }
diff --git a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/TransformChangeEvent.java b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/TransformChangeEvent.java
index d4632fcce0..30aacbcf00 100644
--- a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/TransformChangeEvent.java
+++ b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/TransformChangeEvent.java
@@ -101,7 +101,15 @@ public class TransformChangeEvent extends PropertyChangeEvent {
          *
          * @see PlanarCanvas#transformDisplayCoordinates(AffineTransform)
          */
-        DISPLAY_NAVIGATION;
+        DISPLAY_NAVIGATION,
+
+        /**
+         * A relative interim change has been applied but is not yet reflected in the "objective to display" transform.
+         * This kind of change is not fired by {@link PlanarCanvas} but may be fired by subclasses such as
+         * {@link org.apache.sis.gui.map.MapCanvas}. That class provides immediate feedback to users
+         * with a temporary visual change before to perform more expansive rendering in background.
+         */
+        INTERIM;
 
         /**
          * Returns {@code true} if the "objective to display" transform changed because of a change
@@ -129,8 +137,9 @@ public class TransformChangeEvent extends PropertyChangeEvent {
 
     /**
      * Value of {@link #displayChange} or {@link #objectiveChange} precomputed by the code that fired this event.
+     * If not precomputed, will be computed when first needed.
      */
-    AffineTransform displayChange2D, objectiveChange2D;
+    private AffineTransform displayChange2D, objectiveChange2D;
 
     /**
      * Non-null if {@link #canNotCompute(String, NoninvertibleTransformException)} already reported an error.
@@ -140,11 +149,11 @@ public class TransformChangeEvent extends PropertyChangeEvent {
 
     /**
      * Creates a new event for a change of the "objective to display" property.
-     * The old and new transforms should not be null, except for lazy computation:
+     * The old and new transforms should not be null, except on initialization or for lazy computation:
      * a null {@code newValue} means to take the value from {@link Canvas#getObjectiveToDisplay()} when needed.
      *
      * @param  source    the canvas that fired the event.
-     * @param  oldValue  the old "objective to display" transform.
+     * @param  oldValue  the old "objective to display" transform, or {@code null} if none.
      * @param  newValue  the new transform, or {@code null} for lazy computation.
      * @param  reason    the reason why the "objective to display" transform changed..
      * @throws IllegalArgumentException if {@code source} is {@code null}.
@@ -157,6 +166,27 @@ public class TransformChangeEvent extends PropertyChangeEvent {
         this.reason = reason;
     }
 
+    /**
+     * Creates a new event for an incremental change of the "objective to display" property.
+     * The incremental change can be specified by the {@code objective} and/or the {@code display} argument.
+     * Usually only one of those two arguments is non-null.
+     *
+     * @param  source     the canvas that fired the event.
+     * @param  oldValue   the old "objective to display" transform, or {@code null} if none.
+     * @param  newValue   the new transform, or {@code null} for lazy computation.
+     * @param  objective  the incremental change in objective coordinates, or {@code null} for lazy computation.
+     * @param  display    the incremental change in display coordinates, or {@code null} for lazy computation.
+     * @param  reason     the reason why the "objective to display" transform changed..
+     * @throws IllegalArgumentException if {@code source} is {@code null}.
+     */
+    public TransformChangeEvent(final Canvas source, final LinearTransform oldValue, final LinearTransform newValue,
+                                final AffineTransform objective, final AffineTransform display, final Reason reason)
+    {
+        this(source, oldValue, newValue, reason);
+        objectiveChange2D = objective;
+        displayChange2D   = display;
+    }
+
     /**
      * Quick and non-overrideable check about whether the specified source is the source of this event.
      */
@@ -188,7 +218,7 @@ public class TransformChangeEvent extends PropertyChangeEvent {
     /**
      * Gets the old "objective to display" transform.
      *
-     * @return the old "objective to display" transform.
+     * @return the old "objective to display" transform, or {@code null} if none.
      */
     @Override
     public LinearTransform getOldValue() {


[sis] 02/03: Allow `AffineTransform2D` to be temporarily modifiable during its construction phase. It was possible in an older version, so this commit is a partial revert.

Posted by de...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 2c027ab90b0af3ee4181ea753c4bc41dfe7a1c56
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Sat Jun 18 17:41:08 2022 +0200

    Allow `AffineTransform2D` to be temporarily modifiable during its construction phase.
    It was possible in an older version, so this commit is a partial revert.
---
 .../sis/internal/referencing/j2d/AffineMatrix.java | 42 +++++++++-------
 .../referencing/j2d/AffineTransform2D.java         | 57 ++++++++++++++--------
 .../sis/internal/referencing/j2d/package-info.java |  2 +-
 3 files changed, 64 insertions(+), 37 deletions(-)

diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/AffineMatrix.java b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/AffineMatrix.java
index c934204fff..dbbb4239a8 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/AffineMatrix.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/AffineMatrix.java
@@ -31,7 +31,7 @@ import org.apache.sis.util.ArgumentChecks;
  * used in double-double arithmetic.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.8
+ * @version 1.3
  * @since   0.5
  * @module
  */
@@ -73,30 +73,38 @@ final class AffineMatrix implements ExtendedPrecisionMatrix, Serializable, Clone
      */
     private final double[] errors;
 
+    /**
+     * Creates a new matrix wrapping the given transform without error terms.
+     *
+     * @param transform  the transform to wrap.
+     */
+    AffineMatrix(final AffineTransform transform) {
+        this.transform = transform;
+        errors = null;
+    }
+
     /**
      * Creates a new matrix wrapping the given transform.
      *
      * @param transform  the transform to wrap.
-     * @param elements   the elements used for creating the matrix (optionally with error terms), or {@code null}.
+     * @param elements   the elements used for creating the matrix (optionally with error terms).
      */
     AffineMatrix(final AffineTransform transform, final double[] elements) {
         this.transform = transform;
-        if (elements != null) {
-            assert elements.length == LENGTH || elements.length == LENGTH_EXTENDED;
-            if (elements.length == LENGTH_EXTENDED) {
-                errors = Arrays.copyOfRange(elements, LENGTH, LENGTH + LENGTH_STORED);
-                /*
-                 * At this point we could check:
-                 *
-                 *   assert Arrays.equals(elements, getExtendedElements());
-                 *
-                 * but we do not, because the terms in the last row may not be exactly 0 or 1
-                 * because of rounding errors.
-                 */
-                return;
-            }
+        if (elements.length == LENGTH_EXTENDED) {
+            errors = Arrays.copyOfRange(elements, LENGTH, LENGTH + LENGTH_STORED);
+            /*
+             * At this point we could check:
+             *
+             *   assert Arrays.equals(elements, getExtendedElements());
+             *
+             * but we do not, because the terms in the last row may not be exactly 0 or 1
+             * because of rounding errors.
+             */
+        } else {
+            assert elements.length == LENGTH;
+            errors = null;
         }
-        errors = null;
     }
 
     /**
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/AffineTransform2D.java b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/AffineTransform2D.java
index 01869be131..535024d3bb 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/AffineTransform2D.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/AffineTransform2D.java
@@ -34,21 +34,20 @@ import org.apache.sis.referencing.operation.transform.LinearTransform;
 import org.apache.sis.internal.referencing.ExtendedPrecisionMatrix;
 import org.apache.sis.internal.referencing.provider.Affine;
 import org.apache.sis.io.wkt.Formatter;
+import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.LenientComparable;
 import org.apache.sis.util.ComparisonMode;
 import org.apache.sis.util.Workaround;
 
-import static org.apache.sis.util.ArgumentChecks.ensureDimensionMatches;
-
 
 /**
  * Transforms two-dimensional coordinate points using an affine transform. This class both extends
  * {@link AffineTransform} and implements {@link MathTransform2D}, so it can be used as a bridge
- * between Java2D and the referencing module. Note that this bridge role involve a tricky issue with
+ * between Java2D and the referencing module. Note that this bridge role involves a tricky issue with
  * the {@link #equals(Object) equals} method, hopefully to occur only in exceptional corner cases.
  *
  * @author  Martin Desruisseaux (IRD, Geomatys)
- * @version 1.2
+ * @version 1.3
  * @since   0.5
  * @module
  */
@@ -68,40 +67,41 @@ public class AffineTransform2D extends ImmutableAffineTransform
      * this affine transform is still under construction. This field <strong>must</strong> be
      * set to a non-null value before an {@link AffineTransform2D} instance is published.</p>
      *
+     * @see #freeze()
      * @see #getMatrix()
      */
-    private final AffineMatrix matrix;
+    private AffineMatrix matrix;
 
     /**
      * The inverse transform. This field will be computed only when needed.
+     *
+     * @see #inverse()
      */
     private transient volatile AffineTransform2D inverse;
 
     /**
-     * Constructs a new affine transform with the same coefficients than the specified transform.
+     * Creates a new affine transform with the same coefficients than the specified transform.
      *
      * @param transform  the affine transform to copy.
      */
     public AffineTransform2D(final AffineTransform transform) {
         super(transform);
-        forcePositiveZeros();   // Must be invoked before to set the `matrix` value.
-        matrix = new AffineMatrix(this, null);
+        freeze();
     }
 
     /**
-     * Constructs a new transform from an array of values representing either the 4 non-translation
+     * Creates a new transform from an array of values representing either the 4 non-translation
      * entries or the 6 specifiable entries of the 3×3 matrix.
      *
      * @param elements  the matrix elements in an array of length 4 or 6.
      */
     public AffineTransform2D(final double[] elements) {
         super(elements);
-        forcePositiveZeros();
-        matrix = new AffineMatrix(this, null);
+        freeze();
     }
 
     /**
-     * Constructs a new {@code AffineTransform2D} from the 9 or 18 values of the given matrix.
+     * Creates a new {@code AffineTransform2D} from the 9 or 18 values of the given matrix.
      *
      * @param matrix  the matrix from which to get the (potentially extended) elements.
      */
@@ -122,7 +122,7 @@ public class AffineTransform2D extends ImmutableAffineTransform
     }
 
     /**
-     * Constructs a new {@code AffineTransform2D} from 6 values representing the 6 specifiable
+     * Creates a new {@code AffineTransform2D} from 6 values representing the 6 specifiable
      * entries of the 3×3 transformation matrix. Those values are given unchanged to the
      * {@link AffineTransform#AffineTransform(double,double,double,double,double,double) super
      * class constructor}, except for negative zeros that are replaced by positive zeros.
@@ -136,7 +136,26 @@ public class AffineTransform2D extends ImmutableAffineTransform
      */
     public AffineTransform2D(double m00, double m10, double m01, double m11, double m02, double m12) {
         super(pz(m00), pz(m10), pz(m01), pz(m11), pz(m02), pz(m12));
-        matrix = new AffineMatrix(this, null);
+        matrix = new AffineMatrix(this);
+    }
+
+    /**
+     * Creates a potentially modifiable transform initialized to the 6 specifiable entries.
+     * Caller shall invoke {@link #freeze()} before to publish this transform.
+     *
+     * @param m00 the X coordinate scaling.
+     * @param m10 the Y coordinate shearing.
+     * @param m01 the X coordinate shearing.
+     * @param m11 the Y coordinate scaling.
+     * @param m02 the X coordinate translation.
+     * @param m12 the Y coordinate translation.
+     * @param modifiable  whether the transform should be modifiable.
+     */
+    public AffineTransform2D(double m00, double m10, double m01, double m11, double m02, double m12, final boolean modifiable) {
+        super(m00, m10, m01, m11, m02, m12);
+        if (!modifiable) {
+            freeze();
+        }
     }
 
     /**
@@ -153,12 +172,13 @@ public class AffineTransform2D extends ImmutableAffineTransform
     }
 
     /**
-     * Ensures that this transform contains only positive zeros.
+     * Ensures that this transform contains only positive zeros, then marks this transform as unmodifiable.
      */
-    public final void forcePositiveZeros() {
+    public final void freeze() {
         super.setTransform(pz(super.getScaleX()),     pz(super.getShearY()),
                            pz(super.getShearX()),     pz(super.getScaleY()),
                            pz(super.getTranslateX()), pz(super.getTranslateY()));
+        matrix = new AffineMatrix(this);
     }
 
     /**
@@ -221,7 +241,7 @@ public class AffineTransform2D extends ImmutableAffineTransform
      */
     @Override
     public final DirectPosition transform(final DirectPosition ptSrc, DirectPosition ptDst) {
-        ensureDimensionMatches("ptSrc", 2, ptSrc);
+        ArgumentChecks.ensureDimensionMatches("ptSrc", 2, ptSrc);
         /*
          * Try to write directly in the destination point if possible. Following
          * code avoid the creation of temporary objects (except if ptDst is null).
@@ -238,7 +258,7 @@ public class AffineTransform2D extends ImmutableAffineTransform
                 super.transform(point, point);
                 return point;
             }
-            ensureDimensionMatches("ptDst", 2, ptDst);
+            ArgumentChecks.ensureDimensionMatches("ptDst", 2, ptDst);
             if (ptDst instanceof Point2D) {
                 final Point2D point = (Point2D) ptDst;
                 point.setLocation(ptSrc.getOrdinate(0), ptSrc.getOrdinate(1));
@@ -319,7 +339,6 @@ public class AffineTransform2D extends ImmutableAffineTransform
                      *
                      *     AffineTransform2D work = new AffineTransform2D(this, true);
                      *     work.invert();
-                     *     work.forcePositiveZeros();
                      *     work.freeze();
                      *
                      * Current version now uses the SIS code instead in order to get the double-double precision.
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/package-info.java b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/package-info.java
index 24dbb46bea..3305b7d2f9 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/package-info.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/package-info.java
@@ -26,7 +26,7 @@
  * may change in incompatible ways in any future version without notice.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.2
+ * @version 1.3
  *
  * @see org.apache.sis.internal.feature.j2d
  *