001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data.imagery;
003
004import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_DCP;
005import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_GET;
006import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_HTTP;
007import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_IDENTIFIER;
008import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_LOWER_CORNER;
009import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_OPERATION;
010import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_OPERATIONS_METADATA;
011import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_SUPPORTED_CRS;
012import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_TITLE;
013import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_UPPER_CORNER;
014import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_WGS84_BOUNDING_BOX;
015import static org.openstreetmap.josm.tools.I18n.tr;
016
017import java.awt.Point;
018import java.io.ByteArrayInputStream;
019import java.io.IOException;
020import java.io.InputStream;
021import java.nio.charset.StandardCharsets;
022import java.nio.file.InvalidPathException;
023import java.util.ArrayList;
024import java.util.Arrays;
025import java.util.Collection;
026import java.util.Collections;
027import java.util.Deque;
028import java.util.LinkedHashSet;
029import java.util.LinkedList;
030import java.util.List;
031import java.util.Map;
032import java.util.Map.Entry;
033import java.util.Objects;
034import java.util.Optional;
035import java.util.SortedSet;
036import java.util.TreeSet;
037import java.util.concurrent.ConcurrentHashMap;
038import java.util.function.BiFunction;
039import java.util.regex.Matcher;
040import java.util.regex.Pattern;
041import java.util.stream.Collectors;
042
043import javax.imageio.ImageIO;
044import javax.xml.namespace.QName;
045import javax.xml.stream.XMLStreamException;
046import javax.xml.stream.XMLStreamReader;
047
048import org.openstreetmap.gui.jmapviewer.Coordinate;
049import org.openstreetmap.gui.jmapviewer.Projected;
050import org.openstreetmap.gui.jmapviewer.Tile;
051import org.openstreetmap.gui.jmapviewer.TileRange;
052import org.openstreetmap.gui.jmapviewer.TileXY;
053import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
054import org.openstreetmap.gui.jmapviewer.interfaces.IProjected;
055import org.openstreetmap.gui.jmapviewer.interfaces.TemplatedTileSource;
056import org.openstreetmap.gui.jmapviewer.tilesources.AbstractTMSTileSource;
057import org.openstreetmap.josm.data.ProjectionBounds;
058import org.openstreetmap.josm.data.coor.EastNorth;
059import org.openstreetmap.josm.data.coor.LatLon;
060import org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.TransferMode;
061import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryType;
062import org.openstreetmap.josm.data.osm.BBox;
063import org.openstreetmap.josm.data.projection.Projection;
064import org.openstreetmap.josm.data.projection.ProjectionRegistry;
065import org.openstreetmap.josm.data.projection.Projections;
066import org.openstreetmap.josm.gui.ExtendedDialog;
067import org.openstreetmap.josm.gui.MainApplication;
068import org.openstreetmap.josm.gui.layer.NativeScaleLayer.ScaleList;
069import org.openstreetmap.josm.gui.layer.imagery.WMTSLayerSelection;
070import org.openstreetmap.josm.io.CachedFile;
071import org.openstreetmap.josm.spi.preferences.Config;
072import org.openstreetmap.josm.tools.CheckParameterUtil;
073import org.openstreetmap.josm.tools.Logging;
074import org.openstreetmap.josm.tools.Utils;
075
076/**
077 * Tile Source handling WMTS providers
078 *
079 * @author Wiktor Niesiobędzki
080 * @since 8526
081 */
082public class WMTSTileSource extends AbstractTMSTileSource implements TemplatedTileSource {
083    /**
084     * WMTS namespace address
085     */
086    public static final String WMTS_NS_URL = "http://www.opengis.net/wmts/1.0";
087
088    // CHECKSTYLE.OFF: SingleSpaceSeparator
089    private static final QName QN_CONTENTS            = new QName(WMTS_NS_URL, "Contents");
090    private static final QName QN_DEFAULT             = new QName(WMTS_NS_URL, "Default");
091    private static final QName QN_DIMENSION           = new QName(WMTS_NS_URL, "Dimension");
092    private static final QName QN_FORMAT              = new QName(WMTS_NS_URL, "Format");
093    private static final QName QN_LAYER               = new QName(WMTS_NS_URL, "Layer");
094    private static final QName QN_MATRIX_WIDTH        = new QName(WMTS_NS_URL, "MatrixWidth");
095    private static final QName QN_MATRIX_HEIGHT       = new QName(WMTS_NS_URL, "MatrixHeight");
096    private static final QName QN_RESOURCE_URL        = new QName(WMTS_NS_URL, "ResourceURL");
097    private static final QName QN_SCALE_DENOMINATOR   = new QName(WMTS_NS_URL, "ScaleDenominator");
098    private static final QName QN_STYLE               = new QName(WMTS_NS_URL, "Style");
099    private static final QName QN_TILEMATRIX          = new QName(WMTS_NS_URL, "TileMatrix");
100    private static final QName QN_TILEMATRIXSET       = new QName(WMTS_NS_URL, "TileMatrixSet");
101    private static final QName QN_TILEMATRIX_SET_LINK = new QName(WMTS_NS_URL, "TileMatrixSetLink");
102    private static final QName QN_TILE_WIDTH          = new QName(WMTS_NS_URL, "TileWidth");
103    private static final QName QN_TILE_HEIGHT         = new QName(WMTS_NS_URL, "TileHeight");
104    private static final QName QN_TOPLEFT_CORNER      = new QName(WMTS_NS_URL, "TopLeftCorner");
105    private static final QName QN_VALUE               = new QName(WMTS_NS_URL, "Value");
106    // CHECKSTYLE.ON: SingleSpaceSeparator
107
108    private static final String PATTERN_HEADER = "\\{header\\(([^,]+),([^}]+)\\)\\}";
109
110    private static final String URL_GET_ENCODING_PARAMS = "SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER={layer}&STYLE={style}&"
111            + "FORMAT={format}&tileMatrixSet={TileMatrixSet}&tileMatrix={TileMatrix}&tileRow={TileRow}&tileCol={TileCol}";
112
113    private static final String[] ALL_PATTERNS = {
114        PATTERN_HEADER,
115    };
116
117    private int cachedTileSize = -1;
118
119    private static class TileMatrix {
120        private String identifier;
121        private double scaleDenominator;
122        private EastNorth topLeftCorner;
123        private int tileWidth;
124        private int tileHeight;
125        private int matrixWidth = -1;
126        private int matrixHeight = -1;
127    }
128
129    private static class TileMatrixSetBuilder {
130        // sorted by zoom level
131        SortedSet<TileMatrix> tileMatrix = new TreeSet<>((o1, o2) -> -1 * Double.compare(o1.scaleDenominator, o2.scaleDenominator));
132        private String crs;
133        private String identifier;
134
135        TileMatrixSet build() {
136            return new TileMatrixSet(this);
137        }
138    }
139
140    /**
141     *
142     * class representing WMTS TileMatrixSet
143     * This connects projection and TileMatrix (how the map is divided in tiles)
144     *
145     */
146    public static class TileMatrixSet {
147
148        private final List<TileMatrix> tileMatrix;
149        private final String crs;
150        private final String identifier;
151
152        TileMatrixSet(TileMatrixSet tileMatrixSet) {
153            if (tileMatrixSet != null) {
154                tileMatrix = new ArrayList<>(tileMatrixSet.tileMatrix);
155                crs = tileMatrixSet.crs;
156                identifier = tileMatrixSet.identifier;
157            } else {
158                tileMatrix = Collections.emptyList();
159                crs = null;
160                identifier = null;
161            }
162        }
163
164        TileMatrixSet(TileMatrixSetBuilder builder) {
165            tileMatrix = new ArrayList<>(builder.tileMatrix);
166            crs = builder.crs;
167            identifier = builder.identifier;
168        }
169
170        @Override
171        public String toString() {
172            return "TileMatrixSet [crs=" + crs + ", identifier=" + identifier + ']';
173        }
174
175        /**
176         *
177         * @return identifier of this TileMatrixSet
178         */
179        public String getIdentifier() {
180            return identifier;
181        }
182
183        /**
184         *
185         * @return projection of this tileMatrix
186         */
187        public String getCrs() {
188            return crs;
189        }
190
191        /**
192         * Returns tile matrix max zoom. Assumes first zoom starts at 0, with continuous zoom levels.
193         * @return tile matrix max zoom
194         * @since 15409
195         */
196        public int getMaxZoom() {
197            return tileMatrix.size() - 1;
198        }
199    }
200
201    private static class Dimension {
202        private String identifier;
203        private String defaultValue;
204        private final List<String> values = new ArrayList<>();
205    }
206
207    /**
208     * Class representing WMTS Layer information
209     *
210     */
211    public static class Layer {
212        private String format;
213        private String identifier;
214        private String title;
215        private TileMatrixSet tileMatrixSet;
216        private String baseUrl;
217        private String style;
218        private BBox bbox;
219        private final Collection<String> tileMatrixSetLinks = new ArrayList<>();
220        private final Collection<Dimension> dimensions = new ArrayList<>();
221
222        Layer(Layer l) {
223            Objects.requireNonNull(l);
224            format = l.format;
225            identifier = l.identifier;
226            title = l.title;
227            baseUrl = l.baseUrl;
228            style = l.style;
229            bbox = l.bbox;
230            tileMatrixSet = new TileMatrixSet(l.tileMatrixSet);
231            dimensions.addAll(l.dimensions);
232        }
233
234        Layer() {
235        }
236
237        /**
238         * Get title of the layer for user display.
239         *
240         * This is either the content of the Title element (if available) or
241         * the layer identifier (as fallback)
242         * @return title of the layer for user display
243         */
244        public String getUserTitle() {
245            return title != null ? title : identifier;
246        }
247
248        @Override
249        public String toString() {
250            return "Layer [identifier=" + identifier + ", title=" + title + ", tileMatrixSet="
251                    + tileMatrixSet + ", baseUrl=" + baseUrl + ", style=" + style + ']';
252        }
253
254        /**
255         *
256         * @return identifier of this layer
257         */
258        public String getIdentifier() {
259            return identifier;
260        }
261
262        /**
263         *
264         * @return style of this layer
265         */
266        public String getStyle() {
267            return style;
268        }
269
270        /**
271         *
272         * @return tileMatrixSet of this layer
273         */
274        public TileMatrixSet getTileMatrixSet() {
275            return tileMatrixSet;
276        }
277
278        /**
279         * Returns layer max zoom.
280         * @return layer max zoom
281         * @since 15409
282         */
283        public int getMaxZoom() {
284            return tileMatrixSet != null ? tileMatrixSet.getMaxZoom() : 0;
285        }
286
287        /**
288         * Returns the WGS84 bounding box.
289         * @return WGS84 bounding box
290         * @since 15410
291         */
292        public BBox getBbox() {
293            return bbox;
294        }
295    }
296
297    /**
298     * Exception thrown when parser doesn't find expected information in GetCapabilities document
299     *
300     */
301    public static class WMTSGetCapabilitiesException extends Exception {
302
303        /**
304         * Create WMTS exception
305         * @param cause description of cause
306         */
307        public WMTSGetCapabilitiesException(String cause) {
308            super(cause);
309        }
310
311        /**
312         * Create WMTS exception
313         * @param cause description of cause
314         * @param t nested exception
315         */
316        public WMTSGetCapabilitiesException(String cause, Throwable t) {
317            super(cause, t);
318        }
319    }
320
321    private static final class SelectLayerDialog extends ExtendedDialog {
322        private final WMTSLayerSelection list;
323
324        SelectLayerDialog(Collection<Layer> layers) {
325            super(MainApplication.getMainFrame(), tr("Select WMTS layer"), tr("Add layers"), tr("Cancel"));
326            this.list = new WMTSLayerSelection(groupLayersByNameAndTileMatrixSet(layers));
327            setContent(list);
328        }
329
330        public DefaultLayer getSelectedLayer() {
331            Layer selectedLayer = list.getSelectedLayer();
332            return new DefaultLayer(ImageryType.WMTS, selectedLayer.identifier, selectedLayer.style, selectedLayer.tileMatrixSet.identifier);
333        }
334
335    }
336
337    private final Map<String, String> headers = new ConcurrentHashMap<>();
338    private final Collection<Layer> layers;
339    private Layer currentLayer;
340    private TileMatrixSet currentTileMatrixSet;
341    private double crsScale;
342    private final TransferMode transferMode;
343
344    private ScaleList nativeScaleList;
345
346    private final DefaultLayer defaultLayer;
347
348    private Projection tileProjection;
349
350    /**
351     * Creates a tile source based on imagery info
352     * @param info imagery info
353     * @throws IOException if any I/O error occurs
354     * @throws WMTSGetCapabilitiesException when document didn't contain any layers
355     * @throws IllegalArgumentException if any other error happens for the given imagery info
356     */
357    public WMTSTileSource(ImageryInfo info) throws IOException, WMTSGetCapabilitiesException {
358        super(info);
359        CheckParameterUtil.ensureThat(info.getDefaultLayers().size() < 2, "At most 1 default layer for WMTS is supported");
360        this.headers.putAll(info.getCustomHttpHeaders());
361        this.baseUrl = GetCapabilitiesParseHelper.normalizeCapabilitiesUrl(handleTemplate(info.getUrl()));
362        WMTSCapabilities capabilities = getCapabilities(baseUrl, headers);
363        this.layers = capabilities.getLayers();
364        this.baseUrl = capabilities.getBaseUrl();
365        this.transferMode = capabilities.getTransferMode();
366        if (info.getDefaultLayers().isEmpty()) {
367            Logging.warn(tr("No default layer selected, choosing first layer."));
368            if (!layers.isEmpty()) {
369                Layer first = layers.iterator().next();
370                // If max zoom lower than expected, try to find a better layer
371                final int maxZoom = info.getMaxZoom();
372                if (first.getMaxZoom() < maxZoom) {
373                    first = layers.stream().filter(l -> l.getMaxZoom() >= maxZoom).findFirst().orElse(first);
374                }
375                // If center of josm bbox not in layer bbox, try to find a better layer
376                if (info.getBounds() != null && first.getBbox() != null) {
377                    LatLon center = info.getBounds().getCenter();
378                    if (!first.getBbox().bounds(center)) {
379                        final Layer ffirst = first;
380                        first = layers.stream()
381                                .filter(l -> l.getMaxZoom() >= maxZoom && l.getBbox() != null && l.getBbox().bounds(center)).findFirst()
382                                .orElseGet(() -> layers.stream().filter(l -> l.getBbox() != null && l.getBbox().bounds(center)).findFirst()
383                                        .orElse(ffirst));
384                    }
385                }
386                this.defaultLayer = new DefaultLayer(info.getImageryType(), first.identifier, first.style, first.tileMatrixSet.identifier);
387            } else {
388                this.defaultLayer = null;
389            }
390        } else {
391            this.defaultLayer = info.getDefaultLayers().get(0);
392        }
393        if (this.layers.isEmpty())
394            throw new IllegalArgumentException(tr("No layers defined by getCapabilities document: {0}", info.getUrl()));
395    }
396
397    /**
398     * Creates a tile source based on imagery info and initializes it with given projection.
399     * @param info imagery info
400     * @param projection projection to be used by this TileSource
401     * @throws IOException if any I/O error occurs
402     * @throws WMTSGetCapabilitiesException when document didn't contain any layers
403     * @throws IllegalArgumentException if any other error happens for the given imagery info
404     * @since 14507
405     */
406    public WMTSTileSource(ImageryInfo info, Projection projection) throws IOException, WMTSGetCapabilitiesException {
407        this(info);
408        initProjection(projection);
409    }
410
411    /**
412     * Creates a dialog based on this tile source with all available layers and returns the name of selected layer
413     * @return Name of selected layer
414     */
415    public DefaultLayer userSelectLayer() {
416        Map<String, List<Layer>> layerById = layers.stream().collect(
417                Collectors.groupingBy(x -> x.identifier));
418        if (layerById.size() == 1) { // only one layer
419            List<Layer> ls = layerById.entrySet().iterator().next().getValue()
420                    .stream().filter(
421                            u -> u.tileMatrixSet.crs.equals(ProjectionRegistry.getProjection().toCode()))
422                    .collect(Collectors.toList());
423            if (ls.size() == 1) {
424                // only one tile matrix set with matching projection - no point in asking
425                Layer selectedLayer = ls.get(0);
426                return new DefaultLayer(ImageryType.WMTS, selectedLayer.identifier, selectedLayer.style, selectedLayer.tileMatrixSet.identifier);
427            }
428        }
429
430        final SelectLayerDialog layerSelection = new SelectLayerDialog(layers);
431        if (layerSelection.showDialog().getValue() == 1) {
432            return layerSelection.getSelectedLayer();
433        }
434        return null;
435    }
436
437    private String handleTemplate(String url) {
438        Pattern pattern = Pattern.compile(PATTERN_HEADER);
439        StringBuffer output = new StringBuffer();
440        Matcher matcher = pattern.matcher(url);
441        while (matcher.find()) {
442            this.headers.put(matcher.group(1), matcher.group(2));
443            matcher.appendReplacement(output, "");
444        }
445        matcher.appendTail(output);
446        return output.toString();
447    }
448
449
450    /**
451     * Call remote server and parse response to WMTSCapabilities object
452     *
453     * @param url of the getCapabilities document
454     * @param headers HTTP headers to set when calling getCapabilities url
455     * @return capabilities
456     * @throws IOException in case of any I/O error
457     * @throws WMTSGetCapabilitiesException when document didn't contain any layers
458     * @throws IllegalArgumentException in case of any other error
459     */
460    public static WMTSCapabilities getCapabilities(String url, Map<String, String> headers) throws IOException, WMTSGetCapabilitiesException {
461        try (CachedFile cf = new CachedFile(url); InputStream in = cf.setHttpHeaders(headers).
462                setMaxAge(Config.getPref().getLong("wmts.capabilities.cache.max_age", 7 * CachedFile.DAYS)).
463                setCachingStrategy(CachedFile.CachingStrategy.IfModifiedSince).
464                getInputStream()) {
465            byte[] data = Utils.readBytesFromStream(in);
466            if (data.length == 0) {
467                cf.clear();
468                throw new IllegalArgumentException("Could not read data from: " + url);
469            }
470
471            try {
472                XMLStreamReader reader = GetCapabilitiesParseHelper.getReader(new ByteArrayInputStream(data));
473                WMTSCapabilities ret = null;
474                Collection<Layer> layers = null;
475                for (int event = reader.getEventType(); reader.hasNext(); event = reader.next()) {
476                    if (event == XMLStreamReader.START_ELEMENT) {
477                        QName qName = reader.getName();
478                        if (QN_OWS_OPERATIONS_METADATA.equals(qName)) {
479                            ret = parseOperationMetadata(reader);
480                        } else if (QN_CONTENTS.equals(qName)) {
481                            layers = parseContents(reader);
482                        }
483                    }
484                }
485                if (ret == null) {
486                    /*
487                     *  see #12168 - create dummy operation metadata - not all WMTS services provide this information
488                     *
489                     *  WMTS Standard:
490                     *  > Resource oriented architecture style HTTP encodings SHALL not be described in the OperationsMetadata section.
491                     *
492                     *  And OperationMetada is not mandatory element. So REST mode is justifiable
493                     */
494                    ret = new WMTSCapabilities(url, TransferMode.REST);
495                }
496                if (layers == null) {
497                    throw new WMTSGetCapabilitiesException(tr("WMTS Capabilities document did not contain layers in url: {0}", url));
498                }
499                ret.addLayers(layers);
500                return ret;
501            } catch (XMLStreamException e) {
502                cf.clear();
503                Logging.warn(new String(data, StandardCharsets.UTF_8));
504                throw new WMTSGetCapabilitiesException(tr("Error during parsing of WMTS Capabilities document: {0}", e.getMessage()), e);
505            }
506        } catch (InvalidPathException e) {
507            throw new WMTSGetCapabilitiesException(tr("Invalid path for GetCapabilities document: {0}", e.getMessage()), e);
508        }
509    }
510
511    /**
512     * Parse Contents tag. Returns when reader reaches Contents closing tag
513     *
514     * @param reader StAX reader instance
515     * @return collection of layers within contents with properly linked TileMatrixSets
516     * @throws XMLStreamException See {@link XMLStreamReader}
517     */
518    private static Collection<Layer> parseContents(XMLStreamReader reader) throws XMLStreamException {
519        Map<String, TileMatrixSet> matrixSetById = new ConcurrentHashMap<>();
520        Collection<Layer> layers = new ArrayList<>();
521        for (int event = reader.getEventType();
522                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && QN_CONTENTS.equals(reader.getName()));
523                event = reader.next()) {
524            if (event == XMLStreamReader.START_ELEMENT) {
525                QName qName = reader.getName();
526                if (QN_LAYER.equals(qName)) {
527                    Layer l = parseLayer(reader);
528                    if (l != null) {
529                        layers.add(l);
530                    }
531                } else if (QN_TILEMATRIXSET.equals(qName)) {
532                    TileMatrixSet entry = parseTileMatrixSet(reader);
533                    matrixSetById.put(entry.identifier, entry);
534                }
535            }
536        }
537        Collection<Layer> ret = new ArrayList<>();
538        // link layers to matrix sets
539        for (Layer l: layers) {
540            for (String tileMatrixId: l.tileMatrixSetLinks) {
541                Layer newLayer = new Layer(l); // create a new layer object for each tile matrix set supported
542                newLayer.tileMatrixSet = matrixSetById.get(tileMatrixId);
543                ret.add(newLayer);
544            }
545        }
546        return ret;
547    }
548
549    /**
550     * Parse Layer tag. Returns when reader will reach Layer closing tag
551     *
552     * @param reader StAX reader instance
553     * @return Layer object, with tileMatrixSetLinks and no tileMatrixSet attribute set.
554     * @throws XMLStreamException See {@link XMLStreamReader}
555     */
556    private static Layer parseLayer(XMLStreamReader reader) throws XMLStreamException {
557        Layer layer = new Layer();
558        Deque<QName> tagStack = new LinkedList<>();
559        List<String> supportedMimeTypes = new ArrayList<>(Arrays.asList(ImageIO.getReaderMIMETypes()));
560        supportedMimeTypes.add("image/jpgpng");         // used by ESRI
561        supportedMimeTypes.add("image/png8");           // used by geoserver
562        if (supportedMimeTypes.contains("image/jpeg")) {
563            supportedMimeTypes.add("image/jpg"); // sometimes misspelled by Arcgis
564        }
565        Collection<String> unsupportedFormats = new ArrayList<>();
566
567        for (int event = reader.getEventType();
568                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && QN_LAYER.equals(reader.getName()));
569                event = reader.next()) {
570            if (event == XMLStreamReader.START_ELEMENT) {
571                QName qName = reader.getName();
572                tagStack.push(qName);
573                if (tagStack.size() == 2) {
574                    if (QN_FORMAT.equals(qName)) {
575                        String format = reader.getElementText();
576                        if (supportedMimeTypes.contains(format)) {
577                            layer.format = format;
578                        } else {
579                            unsupportedFormats.add(format);
580                        }
581                    } else if (QN_OWS_IDENTIFIER.equals(qName)) {
582                        layer.identifier = reader.getElementText();
583                    } else if (QN_OWS_TITLE.equals(qName)) {
584                        layer.title = reader.getElementText();
585                    } else if (QN_RESOURCE_URL.equals(qName) &&
586                            "tile".equals(reader.getAttributeValue("", "resourceType"))) {
587                        layer.baseUrl = reader.getAttributeValue("", "template");
588                    } else if (QN_STYLE.equals(qName) &&
589                            "true".equals(reader.getAttributeValue("", "isDefault"))) {
590                        if (GetCapabilitiesParseHelper.moveReaderToTag(reader, QN_OWS_IDENTIFIER)) {
591                            layer.style = reader.getElementText();
592                            tagStack.push(reader.getName()); // keep tagStack in sync
593                        }
594                    } else if (QN_DIMENSION.equals(qName)) {
595                        layer.dimensions.add(parseDimension(reader));
596                    } else if (QN_TILEMATRIX_SET_LINK.equals(qName)) {
597                        layer.tileMatrixSetLinks.add(parseTileMatrixSetLink(reader));
598                    } else if (QN_OWS_WGS84_BOUNDING_BOX.equals(qName)) {
599                        layer.bbox = parseBoundingBox(reader);
600                    } else {
601                        GetCapabilitiesParseHelper.moveReaderToEndCurrentTag(reader);
602                    }
603                }
604            }
605            // need to get event type from reader, as parsing might have change position of reader
606            if (reader.getEventType() == XMLStreamReader.END_ELEMENT) {
607                QName start = tagStack.pop();
608                if (!start.equals(reader.getName())) {
609                    throw new IllegalStateException(tr("WMTS Parser error - start element {0} has different name than end element {2}",
610                            start, reader.getName()));
611                }
612            }
613        }
614        if (layer.style == null) {
615            layer.style = "";
616        }
617        if (layer.format == null) {
618            // no format found - it's mandatory parameter - can't use this layer
619            Logging.warn(tr("Can''t use layer {0} because no supported formats where found. Layer is available in formats: {1}",
620                    layer.getUserTitle(),
621                    String.join(", ", unsupportedFormats)));
622            return null;
623        }
624        return layer;
625    }
626
627    /**
628     * Gets Dimension value. Returns when reader is on Dimension closing tag
629     *
630     * @param reader StAX reader instance
631     * @return dimension
632     * @throws XMLStreamException See {@link XMLStreamReader}
633     */
634    private static Dimension parseDimension(XMLStreamReader reader) throws XMLStreamException {
635        Dimension ret = new Dimension();
636        for (int event = reader.getEventType();
637                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT &&
638                        QN_DIMENSION.equals(reader.getName()));
639                event = reader.next()) {
640            if (event == XMLStreamReader.START_ELEMENT) {
641                QName qName = reader.getName();
642                if (QN_OWS_IDENTIFIER.equals(qName)) {
643                    ret.identifier = reader.getElementText();
644                } else if (QN_DEFAULT.equals(qName)) {
645                    ret.defaultValue = reader.getElementText();
646                } else if (QN_VALUE.equals(qName)) {
647                    ret.values.add(reader.getElementText());
648                }
649            }
650        }
651        return ret;
652    }
653
654    /**
655     * Gets TileMatrixSetLink value. Returns when reader is on TileMatrixSetLink closing tag
656     *
657     * @param reader StAX reader instance
658     * @return TileMatrixSetLink identifier
659     * @throws XMLStreamException See {@link XMLStreamReader}
660     */
661    private static String parseTileMatrixSetLink(XMLStreamReader reader) throws XMLStreamException {
662        String ret = null;
663        for (int event = reader.getEventType();
664                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT &&
665                        QN_TILEMATRIX_SET_LINK.equals(reader.getName()));
666                event = reader.next()) {
667            if (event == XMLStreamReader.START_ELEMENT && QN_TILEMATRIXSET.equals(reader.getName())) {
668                ret = reader.getElementText();
669            }
670        }
671        return ret;
672    }
673
674    /**
675     * Parses TileMatrixSet section. Returns when reader is on TileMatrixSet closing tag
676     * @param reader StAX reader instance
677     * @return TileMatrixSet object
678     * @throws XMLStreamException See {@link XMLStreamReader}
679     */
680    private static TileMatrixSet parseTileMatrixSet(XMLStreamReader reader) throws XMLStreamException {
681        TileMatrixSetBuilder matrixSet = new TileMatrixSetBuilder();
682        for (int event = reader.getEventType();
683                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && QN_TILEMATRIXSET.equals(reader.getName()));
684                event = reader.next()) {
685                    if (event == XMLStreamReader.START_ELEMENT) {
686                        QName qName = reader.getName();
687                        if (QN_OWS_IDENTIFIER.equals(qName)) {
688                            matrixSet.identifier = reader.getElementText();
689                        } else if (QN_OWS_SUPPORTED_CRS.equals(qName)) {
690                            matrixSet.crs = GetCapabilitiesParseHelper.crsToCode(reader.getElementText());
691                        } else if (QN_TILEMATRIX.equals(qName)) {
692                            matrixSet.tileMatrix.add(parseTileMatrix(reader, matrixSet.crs));
693                        }
694                    }
695        }
696        return matrixSet.build();
697    }
698
699    /**
700     * Parses TileMatrix section. Returns when reader is on TileMatrix closing tag.
701     * @param reader StAX reader instance
702     * @param matrixCrs projection used by this matrix
703     * @return TileMatrix object
704     * @throws XMLStreamException See {@link XMLStreamReader}
705     */
706    private static TileMatrix parseTileMatrix(XMLStreamReader reader, String matrixCrs) throws XMLStreamException {
707        Projection matrixProj = Optional.ofNullable(Projections.getProjectionByCode(matrixCrs))
708                .orElseGet(ProjectionRegistry::getProjection); // use current projection if none found. Maybe user is using custom string
709        TileMatrix ret = new TileMatrix();
710        for (int event = reader.getEventType();
711                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && QN_TILEMATRIX.equals(reader.getName()));
712                event = reader.next()) {
713            if (event == XMLStreamReader.START_ELEMENT) {
714                QName qName = reader.getName();
715                if (QN_OWS_IDENTIFIER.equals(qName)) {
716                    ret.identifier = reader.getElementText();
717                } else if (QN_SCALE_DENOMINATOR.equals(qName)) {
718                    ret.scaleDenominator = Double.parseDouble(reader.getElementText());
719                } else if (QN_TOPLEFT_CORNER.equals(qName)) {
720                    ret.topLeftCorner = parseEastNorth(reader.getElementText(), matrixProj.switchXY());
721                } else if (QN_TILE_HEIGHT.equals(qName)) {
722                    ret.tileHeight = Integer.parseInt(reader.getElementText());
723                } else if (QN_TILE_WIDTH.equals(qName)) {
724                    ret.tileWidth = Integer.parseInt(reader.getElementText());
725                } else if (QN_MATRIX_HEIGHT.equals(qName)) {
726                    ret.matrixHeight = Integer.parseInt(reader.getElementText());
727                } else if (QN_MATRIX_WIDTH.equals(qName)) {
728                    ret.matrixWidth = Integer.parseInt(reader.getElementText());
729                }
730            }
731        }
732        if (ret.tileHeight != ret.tileWidth) {
733            throw new AssertionError(tr("Only square tiles are supported. {0}x{1} returned by server for TileMatrix identifier {2}",
734                    ret.tileHeight, ret.tileWidth, ret.identifier));
735        }
736        return ret;
737    }
738
739    private static <T> T parseCoor(String coor, boolean switchXY, BiFunction<String, String, T> function) {
740        String[] parts = coor.split(" ");
741        if (switchXY) {
742            return function.apply(parts[1], parts[0]);
743        } else {
744            return function.apply(parts[0], parts[1]);
745        }
746    }
747
748    private static EastNorth parseEastNorth(String coor, boolean switchXY) {
749        return parseCoor(coor, switchXY, (e, n) -> new EastNorth(Double.parseDouble(e), Double.parseDouble(n)));
750    }
751
752    private static LatLon parseLatLon(String coor, boolean switchXY) {
753        return parseCoor(coor, switchXY, (lon, lat) -> new LatLon(Double.parseDouble(lat), Double.parseDouble(lon)));
754    }
755
756    /**
757     * Parses WGS84BoundingBox section. Returns when reader is on WGS84BoundingBox closing tag.
758     * @param reader StAX reader instance
759     * @return WGS84 bounding box
760     * @throws XMLStreamException See {@link XMLStreamReader}
761     */
762    private static BBox parseBoundingBox(XMLStreamReader reader) throws XMLStreamException {
763        LatLon lowerCorner = null;
764        LatLon upperCorner = null;
765        for (int event = reader.getEventType();
766                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT &&
767                        QN_OWS_WGS84_BOUNDING_BOX.equals(reader.getName()));
768                event = reader.next()) {
769            if (event == XMLStreamReader.START_ELEMENT) {
770                QName qName = reader.getName();
771                if (QN_OWS_LOWER_CORNER.equals(qName)) {
772                    lowerCorner = parseLatLon(reader.getElementText(), false);
773                } else if (QN_OWS_UPPER_CORNER.equals(qName)) {
774                    upperCorner = parseLatLon(reader.getElementText(), false);
775                }
776            }
777        }
778        if (lowerCorner != null && upperCorner != null) {
779            return new BBox(lowerCorner, upperCorner);
780        }
781        return null;
782    }
783
784    /**
785     * Parses OperationMetadata section. Returns when reader is on OperationsMetadata closing tag.
786     * return WMTSCapabilities with baseUrl and transferMode
787     *
788     * @param reader StAX reader instance
789     * @return WMTSCapabilities with baseUrl and transferMode set
790     * @throws XMLStreamException See {@link XMLStreamReader}
791     */
792    private static WMTSCapabilities parseOperationMetadata(XMLStreamReader reader) throws XMLStreamException {
793        for (int event = reader.getEventType();
794                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT &&
795                        QN_OWS_OPERATIONS_METADATA.equals(reader.getName()));
796                event = reader.next()) {
797            if (event == XMLStreamReader.START_ELEMENT &&
798                    QN_OWS_OPERATION.equals(reader.getName()) &&
799                    "GetTile".equals(reader.getAttributeValue("", "name")) &&
800                    GetCapabilitiesParseHelper.moveReaderToTag(reader, QN_OWS_DCP, QN_OWS_HTTP, QN_OWS_GET)) {
801                return new WMTSCapabilities(
802                        reader.getAttributeValue(GetCapabilitiesParseHelper.XLINK_NS_URL, "href"),
803                        GetCapabilitiesParseHelper.getTransferMode(reader)
804                        );
805            }
806        }
807        return null;
808    }
809
810    /**
811     * Initializes projection for this TileSource with projection
812     * @param proj projection to be used by this TileSource
813     */
814    public void initProjection(Projection proj) {
815        if (proj.equals(tileProjection))
816            return;
817        List<Layer> matchingLayers = layers.stream().filter(
818                l -> l.identifier.equals(defaultLayer.getLayerName()) && l.tileMatrixSet.crs.equals(proj.toCode()))
819                .collect(Collectors.toList());
820        if (matchingLayers.size() > 1) {
821            this.currentLayer = matchingLayers.stream().filter(
822                    l -> l.tileMatrixSet.identifier.equals(defaultLayer.getTileMatrixSet()))
823                    .findFirst().orElse(matchingLayers.get(0));
824            this.tileProjection = proj;
825        } else if (matchingLayers.size() == 1) {
826            this.currentLayer = matchingLayers.get(0);
827            this.tileProjection = proj;
828        } else {
829            // no tile matrix sets with current projection
830            if (this.currentLayer == null) {
831                this.tileProjection = null;
832                for (Layer layer : layers) {
833                    if (!layer.identifier.equals(defaultLayer.getLayerName())) {
834                        continue;
835                    }
836                    Projection pr = Projections.getProjectionByCode(layer.tileMatrixSet.crs);
837                    if (pr != null) {
838                        this.currentLayer = layer;
839                        this.tileProjection = pr;
840                        break;
841                    }
842                }
843                if (this.currentLayer == null)
844                    throw new IllegalArgumentException(
845                            layers.stream().map(l -> l.tileMatrixSet).collect(Collectors.toList()).toString());
846            } // else: keep currentLayer and tileProjection as is
847        }
848        if (this.currentLayer != null) {
849            this.currentTileMatrixSet = this.currentLayer.tileMatrixSet;
850            Collection<Double> scales = new ArrayList<>(currentTileMatrixSet.tileMatrix.size());
851            for (TileMatrix tileMatrix : currentTileMatrixSet.tileMatrix) {
852                scales.add(tileMatrix.scaleDenominator * 0.28e-03);
853            }
854            this.nativeScaleList = new ScaleList(scales);
855        }
856        this.crsScale = getTileSize() * 0.28e-03 / this.tileProjection.getMetersPerUnit();
857    }
858
859    @Override
860    public int getTileSize() {
861        if (cachedTileSize > 0) {
862            return cachedTileSize;
863        }
864        if (currentTileMatrixSet != null) {
865            // no support for non-square tiles (tileHeight != tileWidth)
866            // and for different tile sizes at different zoom levels
867            cachedTileSize = currentTileMatrixSet.tileMatrix.get(0).tileHeight;
868            return cachedTileSize;
869        }
870        // Fallback to default mercator tile size. Maybe it will work
871        Logging.warn("WMTS: Could not determine tile size. Using default tile size of: {0}", getDefaultTileSize());
872        return getDefaultTileSize();
873    }
874
875    @Override
876    public String getTileUrl(int zoom, int tilex, int tiley) {
877        if (currentLayer == null) {
878            return "";
879        }
880
881        String url;
882        if (currentLayer.baseUrl != null && transferMode == null) {
883            url = currentLayer.baseUrl;
884        } else {
885            switch (transferMode) {
886            case KVP:
887                url = baseUrl + URL_GET_ENCODING_PARAMS;
888                break;
889            case REST:
890                url = currentLayer.baseUrl;
891                break;
892            default:
893                url = "";
894                break;
895            }
896        }
897
898        TileMatrix tileMatrix = getTileMatrix(zoom);
899
900        if (tileMatrix == null) {
901            return ""; // no matrix, probably unsupported CRS selected.
902        }
903
904        url = url.replaceAll("\\{layer\\}", this.currentLayer.identifier)
905                .replaceAll("\\{format\\}", this.currentLayer.format)
906                .replaceAll("\\{TileMatrixSet\\}", this.currentTileMatrixSet.identifier)
907                .replaceAll("\\{TileMatrix\\}", tileMatrix.identifier)
908                .replaceAll("\\{TileRow\\}", Integer.toString(tiley))
909                .replaceAll("\\{TileCol\\}", Integer.toString(tilex))
910                .replaceAll("(?i)\\{style\\}", this.currentLayer.style);
911
912        for (Dimension d : currentLayer.dimensions) {
913            url = url.replaceAll("(?i)\\{"+d.identifier+"\\}", d.defaultValue);
914        }
915
916        return url;
917    }
918
919    /**
920     *
921     * @param zoom zoom level
922     * @return TileMatrix that's working on this zoom level
923     */
924    private TileMatrix getTileMatrix(int zoom) {
925        if (zoom > getMaxZoom()) {
926            return null;
927        }
928        if (zoom < 0) {
929            return null;
930        }
931        return this.currentTileMatrixSet.tileMatrix.get(zoom);
932    }
933
934    @Override
935    public double getDistance(double lat1, double lon1, double lat2, double lon2) {
936        throw new UnsupportedOperationException("Not implemented");
937    }
938
939    @Override
940    public ICoordinate tileXYToLatLon(Tile tile) {
941        return tileXYToLatLon(tile.getXtile(), tile.getYtile(), tile.getZoom());
942    }
943
944    @Override
945    public ICoordinate tileXYToLatLon(TileXY xy, int zoom) {
946        return tileXYToLatLon(xy.getXIndex(), xy.getYIndex(), zoom);
947    }
948
949    @Override
950    public ICoordinate tileXYToLatLon(int x, int y, int zoom) {
951        TileMatrix matrix = getTileMatrix(zoom);
952        if (matrix == null) {
953            return CoordinateConversion.llToCoor(tileProjection.getWorldBoundsLatLon().getCenter());
954        }
955        double scale = matrix.scaleDenominator * this.crsScale;
956        EastNorth ret = new EastNorth(matrix.topLeftCorner.east() + x * scale, matrix.topLeftCorner.north() - y * scale);
957        return CoordinateConversion.llToCoor(tileProjection.eastNorth2latlon(ret));
958    }
959
960    @Override
961    public TileXY latLonToTileXY(double lat, double lon, int zoom) {
962        TileMatrix matrix = getTileMatrix(zoom);
963        if (matrix == null) {
964            return new TileXY(0, 0);
965        }
966
967        EastNorth enPoint = tileProjection.latlon2eastNorth(new LatLon(lat, lon));
968        double scale = matrix.scaleDenominator * this.crsScale;
969        return new TileXY(
970                (enPoint.east() - matrix.topLeftCorner.east()) / scale,
971                (matrix.topLeftCorner.north() - enPoint.north()) / scale
972                );
973    }
974
975    @Override
976    public TileXY latLonToTileXY(ICoordinate point, int zoom) {
977        return latLonToTileXY(point.getLat(), point.getLon(), zoom);
978    }
979
980    @Override
981    public int getTileXMax(int zoom) {
982        return getTileXMax(zoom, tileProjection);
983    }
984
985    @Override
986    public int getTileYMax(int zoom) {
987        return getTileYMax(zoom, tileProjection);
988    }
989
990    @Override
991    public Point latLonToXY(double lat, double lon, int zoom) {
992        TileMatrix matrix = getTileMatrix(zoom);
993        if (matrix == null) {
994            return new Point(0, 0);
995        }
996        double scale = matrix.scaleDenominator * this.crsScale;
997        EastNorth point = tileProjection.latlon2eastNorth(new LatLon(lat, lon));
998        return new Point(
999                    (int) Math.round((point.east() - matrix.topLeftCorner.east()) / scale),
1000                    (int) Math.round((matrix.topLeftCorner.north() - point.north()) / scale)
1001                );
1002    }
1003
1004    @Override
1005    public Point latLonToXY(ICoordinate point, int zoom) {
1006        return latLonToXY(point.getLat(), point.getLon(), zoom);
1007    }
1008
1009    @Override
1010    public Coordinate xyToLatLon(Point point, int zoom) {
1011        return xyToLatLon(point.x, point.y, zoom);
1012    }
1013
1014    @Override
1015    public Coordinate xyToLatLon(int x, int y, int zoom) {
1016        TileMatrix matrix = getTileMatrix(zoom);
1017        if (matrix == null) {
1018            return new Coordinate(0, 0);
1019        }
1020        double scale = matrix.scaleDenominator * this.crsScale;
1021        EastNorth ret = new EastNorth(
1022                matrix.topLeftCorner.east() + x * scale,
1023                matrix.topLeftCorner.north() - y * scale
1024                );
1025        LatLon ll = tileProjection.eastNorth2latlon(ret);
1026        return new Coordinate(ll.lat(), ll.lon());
1027    }
1028
1029    @Override
1030    public Map<String, String> getHeaders() {
1031        return headers;
1032    }
1033
1034    @Override
1035    public int getMaxZoom() {
1036        if (this.currentTileMatrixSet != null) {
1037            return this.currentTileMatrixSet.getMaxZoom();
1038        }
1039        return 0;
1040    }
1041
1042    @Override
1043    public String getTileId(int zoom, int tilex, int tiley) {
1044        return getTileUrl(zoom, tilex, tiley);
1045    }
1046
1047    /**
1048     * Checks if url is acceptable by this Tile Source
1049     * @param url URL to check
1050     */
1051    public static void checkUrl(String url) {
1052        CheckParameterUtil.ensureParameterNotNull(url, "url");
1053        Matcher m = Pattern.compile("\\{[^}]*\\}").matcher(url);
1054        while (m.find()) {
1055            boolean isSupportedPattern = false;
1056            for (String pattern : ALL_PATTERNS) {
1057                if (m.group().matches(pattern)) {
1058                    isSupportedPattern = true;
1059                    break;
1060                }
1061            }
1062            if (!isSupportedPattern) {
1063                throw new IllegalArgumentException(
1064                        tr("{0} is not a valid WMS argument. Please check this server URL:\n{1}", m.group(), url));
1065            }
1066        }
1067    }
1068
1069    /**
1070     * @param layers to be grouped
1071     * @return list with entries - grouping identifier + list of layers
1072     */
1073    public static List<Entry<String, List<Layer>>> groupLayersByNameAndTileMatrixSet(Collection<Layer> layers) {
1074        Map<String, List<Layer>> layerByName = layers.stream().collect(
1075                Collectors.groupingBy(x -> x.identifier + '\u001c' + x.tileMatrixSet.identifier));
1076        return layerByName.entrySet().stream().sorted(Map.Entry.comparingByKey()).collect(Collectors.toList());
1077    }
1078
1079
1080    /**
1081     * @return set of projection codes that this TileSource supports
1082     */
1083    public Collection<String> getSupportedProjections() {
1084        Collection<String> ret = new LinkedHashSet<>();
1085        if (currentLayer == null) {
1086            for (Layer layer: this.layers) {
1087                ret.add(layer.tileMatrixSet.crs);
1088            }
1089        } else {
1090            for (Layer layer: this.layers) {
1091                if (currentLayer.identifier.equals(layer.identifier)) {
1092                    ret.add(layer.tileMatrixSet.crs);
1093                }
1094            }
1095        }
1096        return ret;
1097    }
1098
1099    private int getTileYMax(int zoom, Projection proj) {
1100        TileMatrix matrix = getTileMatrix(zoom);
1101        if (matrix == null) {
1102            return 0;
1103        }
1104
1105        if (matrix.matrixHeight != -1) {
1106            return matrix.matrixHeight;
1107        }
1108
1109        double scale = matrix.scaleDenominator * this.crsScale;
1110        EastNorth min = matrix.topLeftCorner;
1111        EastNorth max = proj.latlon2eastNorth(proj.getWorldBoundsLatLon().getMax());
1112        return (int) Math.ceil(Math.abs(max.north() - min.north()) / scale);
1113    }
1114
1115    private int getTileXMax(int zoom, Projection proj) {
1116        TileMatrix matrix = getTileMatrix(zoom);
1117        if (matrix == null) {
1118            return 0;
1119        }
1120        if (matrix.matrixWidth != -1) {
1121            return matrix.matrixWidth;
1122        }
1123
1124        double scale = matrix.scaleDenominator * this.crsScale;
1125        EastNorth min = matrix.topLeftCorner;
1126        EastNorth max = proj.latlon2eastNorth(proj.getWorldBoundsLatLon().getMax());
1127        return (int) Math.ceil(Math.abs(max.east() - min.east()) / scale);
1128    }
1129
1130    /**
1131     * Get native scales of tile source.
1132     * @return {@link ScaleList} of native scales
1133     */
1134    public ScaleList getNativeScales() {
1135        return nativeScaleList;
1136    }
1137
1138    /**
1139     * Returns the tile projection.
1140     * @return the tile projection
1141     */
1142    public Projection getTileProjection() {
1143        return tileProjection;
1144    }
1145
1146    @Override
1147    public IProjected tileXYtoProjected(int x, int y, int zoom) {
1148        TileMatrix matrix = getTileMatrix(zoom);
1149        if (matrix == null) {
1150            return new Projected(0, 0);
1151        }
1152        double scale = matrix.scaleDenominator * this.crsScale;
1153        return new Projected(
1154                matrix.topLeftCorner.east() + x * scale,
1155                matrix.topLeftCorner.north() - y * scale);
1156    }
1157
1158    @Override
1159    public TileXY projectedToTileXY(IProjected projected, int zoom) {
1160        TileMatrix matrix = getTileMatrix(zoom);
1161        if (matrix == null) {
1162            return new TileXY(0, 0);
1163        }
1164        double scale = matrix.scaleDenominator * this.crsScale;
1165        return new TileXY(
1166                (projected.getEast() - matrix.topLeftCorner.east()) / scale,
1167                -(projected.getNorth() - matrix.topLeftCorner.north()) / scale);
1168    }
1169
1170    private EastNorth tileToEastNorth(int x, int y, int z) {
1171        return CoordinateConversion.projToEn(this.tileXYtoProjected(x, y, z));
1172    }
1173
1174    private ProjectionBounds getTileProjectionBounds(Tile tile) {
1175        ProjectionBounds pb = new ProjectionBounds(tileToEastNorth(tile.getXtile(), tile.getYtile(), tile.getZoom()));
1176        pb.extend(tileToEastNorth(tile.getXtile() + 1, tile.getYtile() + 1, tile.getZoom()));
1177        return pb;
1178    }
1179
1180    @Override
1181    public boolean isInside(Tile inner, Tile outer) {
1182        ProjectionBounds pbInner = getTileProjectionBounds(inner);
1183        ProjectionBounds pbOuter = getTileProjectionBounds(outer);
1184        // a little tolerance, for when inner tile touches the border of the outer tile
1185        double epsilon = 1e-7 * (pbOuter.maxEast - pbOuter.minEast);
1186        return pbOuter.minEast <= pbInner.minEast + epsilon &&
1187                pbOuter.minNorth <= pbInner.minNorth + epsilon &&
1188                pbOuter.maxEast >= pbInner.maxEast - epsilon &&
1189                pbOuter.maxNorth >= pbInner.maxNorth - epsilon;
1190    }
1191
1192    @Override
1193    public TileRange getCoveringTileRange(Tile tile, int newZoom) {
1194        TileMatrix matrixNew = getTileMatrix(newZoom);
1195        if (matrixNew == null) {
1196            return new TileRange(new TileXY(0, 0), new TileXY(0, 0), newZoom);
1197        }
1198        IProjected p0 = tileXYtoProjected(tile.getXtile(), tile.getYtile(), tile.getZoom());
1199        IProjected p1 = tileXYtoProjected(tile.getXtile() + 1, tile.getYtile() + 1, tile.getZoom());
1200        TileXY tMin = projectedToTileXY(p0, newZoom);
1201        TileXY tMax = projectedToTileXY(p1, newZoom);
1202        // shrink the target tile a little, so we don't get neighboring tiles, that
1203        // share an edge, but don't actually cover the target tile
1204        double epsilon = 1e-7 * (tMax.getX() - tMin.getX());
1205        int minX = (int) Math.floor(tMin.getX() + epsilon);
1206        int minY = (int) Math.floor(tMin.getY() + epsilon);
1207        int maxX = (int) Math.ceil(tMax.getX() - epsilon) - 1;
1208        int maxY = (int) Math.ceil(tMax.getY() - epsilon) - 1;
1209        return new TileRange(new TileXY(minX, minY), new TileXY(maxX, maxY), newZoom);
1210    }
1211
1212    @Override
1213    public String getServerCRS() {
1214        return tileProjection != null ? tileProjection.toCode() : null;
1215    }
1216
1217    /**
1218     * Layers that can be used with this tile source
1219     * @return unmodifiable collection of layers available in this tile source
1220     * @since 13879
1221     */
1222    public Collection<Layer> getLayers() {
1223        return Collections.unmodifiableCollection(layers);
1224    }
1225}