diff --git a/.classpath b/.classpath index 23303aedd9d49ecab8fd18c2cde12cea9b6c3e49..c765133e2a10bdce9a6ddca81c16523a48995267 100644 --- a/.classpath +++ b/.classpath @@ -5,6 +5,9 @@ <classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/> <classpathentry kind="con" path="org.eclipse.fx.ide.jdt.core.JAVAFX_CONTAINER"/> <classpathentry kind="con" path="org.eclipse.jdt.junit.JUNIT_CONTAINER/4"/> - <classpathentry combineaccessrules="false" kind="src" path="/GeoLibs"/> + <classpathentry kind="lib" path="/GeoLibs/lib/proj4j-0.1.0.jar" sourcepath="/GeoLibs/lib/proj4j-0.1.0-sources.jar"/> + <classpathentry kind="lib" path="/GeoLibs/lib/citygml4j-2.10.2.jar" sourcepath="/GeoLibs/lib/citygml4j-2.10.2.zip"/> + <classpathentry kind="lib" path="/GeoLibs/lib/vtd-xml_2_13_1.jar"/> + <classpathentry kind="lib" path="/GeoLibs/lib/jts-core-1.16.1.jar" sourcepath="/GeoLibs/lib/jts-core-1.16.1-sources.jar"/> <classpathentry kind="output" path="bin"/> </classpath> diff --git a/src/eu/simstadt/geo/GeoUtils.java b/src/eu/simstadt/geo/GeoUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..0eedfb99941cf8978f7bd0ccdb3c9c1cace87d34 --- /dev/null +++ b/src/eu/simstadt/geo/GeoUtils.java @@ -0,0 +1,189 @@ +package eu.simstadt.geo; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Polygon; +import org.osgeo.proj4j.BasicCoordinateTransform; +import org.osgeo.proj4j.CRSFactory; +import org.osgeo.proj4j.CoordinateReferenceSystem; +import org.osgeo.proj4j.ProjCoordinate; + + +public class GeoUtils +{ + private static final CRSFactory CRS_FACTORY = new CRSFactory(); + public static final CoordinateReferenceSystem WGS84 = CRS_FACTORY.createFromName("EPSG:4326"); + private static final Pattern srsNamePattern = Pattern.compile("(?i)(?<=srsName=[\"'])[^\"']+(?=[\"'])"); + + private GeoUtils() { + // only static use + } + + /** + * Writes down the input coordinates in a string, without "." or "-". E.g. "N48_77__E9_18" for (48.77, 9.18) and + * "S12_345__W6_789" for (-12.345, -6.789). + * + * Useful for caching geolocated information. The process should be reversible. + * + * WARNING: LATITUDE FIRST! + * + * @param latitude + * @param longitude + * @return Filename friendly string for given coordinates + */ + public static String coordinatesForFilename(double latitude, double longitude) { + DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(); + symbols.setDecimalSeparator('.'); + DecimalFormat df = new DecimalFormat("+#.######;-#.######", symbols); + String lat = df.format(latitude).replace('-', 'S').replace('+', 'N'); + String lon = df.format(longitude).replace('-', 'W').replace('+', 'E'); + return (lat + "__" + lon).replace('.', '_'); + } + + /** + * The srsName (The name by which this reference system is identified) inside the CityGML file can have multiple + * formats. This method tries to parse the string and detect the corresponding reference system. If it is found, it + * returns a proj4j.CoordinateReferenceSystem. It throws an IllegalArgumentException otherwise. + * + * This method should be able to parse any EPSG id : e.g. "EPSG:1234". German Citygmls might also have "DE_DHDN_3GK3" + * or "ETRS89_UTM32" as srsName, so those are also included. It isn't guaranteed that those formats are correctly + * parsed, though. + * + * The EPSG ids and parameters are defined in resources ('nad/epsg') inside proj4j-0.1.0.jar. Some EPSG ids are + * missing though, e.g. 7415 + * + * @param srsName + * @return CoordinateReferenceSystem + */ + public static CoordinateReferenceSystem crsFromSrsName(String srsName) { + // EPSG:31467 + Pattern pEPSG = Pattern.compile("^(EPSG:\\d+)$"); + Matcher mEPSG = pEPSG.matcher(srsName); + if (mEPSG.find()) { + return CRS_FACTORY.createFromName(srsName); + } + + // urn:ogc:def:crs,crs:EPSG:6.12:31467,crs:EPSG:6.12:5783 + // or + // urn:ogc:def:crs,crs:EPSG::28992 + Pattern pOGC = Pattern.compile("urn:ogc:def:crs(?:,crs)?:EPSG:[\\d\\.]*:([\\d]+)\\D*"); + Matcher mOGC = pOGC.matcher(srsName); + if (mOGC.find()) { + return CRS_FACTORY.createFromName("EPSG:" + mOGC.group(1)); + } + // urn:adv:crs:DE_DHDN_3GK3*DE_DHHN92_NH + // urn:adv:crs:ETRS89_UTM32*DE_DHHN92_NH + Pattern pURN = Pattern.compile("urn:adv:crs:([^\\*]+)"); + Matcher mURN = pURN.matcher(srsName); + //NOTE: Could use a HashMap if the switch/case becomes too long. + if (mURN.find()) { + switch (mURN.group(1)) { + case "DE_DHDN_3GK2": + return CRS_FACTORY.createFromName("EPSG:31466"); + case "DE_DHDN_3GK3": + return CRS_FACTORY.createFromName("EPSG:31467"); + case "DE_DHDN_3GK4": + return CRS_FACTORY.createFromName("EPSG:31468"); + case "DE_DHDN_3GK5": + return CRS_FACTORY.createFromName("EPSG:31469"); + case "ETRS89_UTM32": + return CRS_FACTORY.createFromName("EPSG:25832"); + default: + // nothing found + } + } + throw new IllegalArgumentException("Unknown srsName format: " + srsName); + } + + /** + * Converts a jts.geom.Polygon from one CoordinateReferenceSystem to another. + * + * NOTE: It would be easier with org.geotools.referencing.CRS instead of Proj4J + * + * @param polygonInOriginalCRS + * @param originalCRS + * @param newCRS + * @return + */ + public static Polygon changePolygonCRS(Polygon polygonInOriginalCRS, CoordinateReferenceSystem originalCRS, + CoordinateReferenceSystem newCRS) { + GeometryFactory geometryFactory = new GeometryFactory(); + Coordinate[] convexHullcoordinates = polygonInOriginalCRS.getCoordinates(); + BasicCoordinateTransform transformToWgs84 = new BasicCoordinateTransform(originalCRS, newCRS); + for (int i = 0; i < convexHullcoordinates.length; i++) { + ProjCoordinate wgs84Coordinate = transformToWgs84.transform( + new ProjCoordinate(convexHullcoordinates[i].x, convexHullcoordinates[i].y), new ProjCoordinate()); + convexHullcoordinates[i] = new Coordinate(wgs84Coordinate.x, wgs84Coordinate.y); + } + + return geometryFactory.createPolygon(convexHullcoordinates); + } + + /** + * + * Fast scan of the 50 first lines of a Citygml file to look for srsName. It might not be as reliable as parsing the + * whole CityGML, but it should be much faster and use much less memory. For a more reliable solution, use + * GeoCoordinatesAccessor. This solution can be convenient for Web services, RegionChooser or HullExtractor. + * + * @param citygmlPath + * @return + * @throws IOException + */ + public static CoordinateReferenceSystem crsFromCityGMLHeader(Path citygmlPath) throws IOException { + Optional<String> line = Files.lines(citygmlPath).limit(50).filter(srsNamePattern.asPredicate()).findFirst(); + if (line.isPresent()) { + Matcher matcher = srsNamePattern.matcher(line.get()); + matcher.find(); + return crsFromSrsName(matcher.group()); + } else { + throw new IllegalArgumentException("No srsName found in the header of " + citygmlPath); + } + } + + /** + * The haversine formula determines the great-circle distance between two points on a sphere given their longitudes + * and latitudes. https://en.wikipedia.org/wiki/Haversine_formula + * + * @param latitude1 + * @param longitude1 + * @param latitude2 + * @param longitude2 + * @return distance in kilometer + */ + public static Double greatCircleDistance(Double latitude1, Double longitude1, Double latitude2, Double longitude2) { + final int earthRadius = 6371; // [km]. Radius of the earth + + double latDistance = Math.toRadians(latitude2 - latitude1); + double lonDistance = Math.toRadians(longitude2 - longitude1); + double a = Math.sin(latDistance / 2) * Math.sin(latDistance / 2) + Math.cos(Math.toRadians(latitude1)) + * Math.cos(Math.toRadians(latitude2)) * Math.sin(lonDistance / 2) * Math.sin(lonDistance / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return earthRadius * c; + } + + /** + * Finds every CityGML in every .proj folder in a repository. + * + * @param repository + * @return a stream of CityGML Paths + * @throws IOException + */ + public static Stream<Path> everyCityGML(Path repository) throws IOException { + return Files.walk(repository, 2) + .sorted() + .filter( + p -> Files.isRegularFile(p) && + p.getParent().toString().endsWith(".proj") && + p.toString().toLowerCase().endsWith(".gml")); + } + +} diff --git a/src/eu/simstadt/geo/fast_xml_parser/BuildingXmlNode.java b/src/eu/simstadt/geo/fast_xml_parser/BuildingXmlNode.java new file mode 100644 index 0000000000000000000000000000000000000000..2e4a6ae3deec34c5ebdc590d84f5d9b0eb74e8b1 --- /dev/null +++ b/src/eu/simstadt/geo/fast_xml_parser/BuildingXmlNode.java @@ -0,0 +1,91 @@ +package eu.simstadt.geo.fast_xml_parser; + +import com.ximpleware.AutoPilot; +import com.ximpleware.NavException; +import com.ximpleware.VTDNav; +import com.ximpleware.XPathEvalException; +import com.ximpleware.XPathParseException; + + +public class BuildingXmlNode +{ + + private int buildingOffset; + private int buildingLength; + private VTDNav navigator; + private AutoPilot coordinatesFinder; + public Double x; + public Double xMin; + public Double xMax; + public Double yMin; + public Double yMax; + public Double y; + private int coordinatesCount = 0; + + public BuildingXmlNode(VTDNav navigator, int buildingOffset, int buildingLength) + throws XPathParseException, XPathEvalException, NavException { + this.navigator = navigator; + this.coordinatesFinder = new AutoPilot(navigator); + this.buildingLength = buildingLength; + this.buildingOffset = buildingOffset; + extractCoordinates(); //NOTE: Should it be done lazily? Is there any reason to extract a BuildingXmlNode without coordinates? + } + + public boolean hasCoordinates() { + return coordinatesCount > 0; + } + + private void extractCoordinates() + throws XPathParseException, XPathEvalException, NavException { + double xTotal = 0; + double yTotal = 0; + double tempX; + double tempY; + double tempXMin = Double.MAX_VALUE; + double tempXMax = Double.MIN_VALUE; + double tempYMin = Double.MAX_VALUE; + double tempYMax = Double.MIN_VALUE; + + coordinatesFinder.selectXPath(".//posList|.//pos"); + while (coordinatesFinder.evalXPath() != -1) { + long offsetAndLength = navigator.getContentFragment(); + int coordinatesOffset = (int) offsetAndLength; + int coordinatesLength = (int) (offsetAndLength >> 32); + String posList = navigator.toRawString(coordinatesOffset, coordinatesLength); + String[] coordinates = posList.trim().split("\\s+"); + for (int k = 0; k < coordinates.length; k = k + 3) { + coordinatesCount++; + tempX = Double.valueOf(coordinates[k]); + tempY = Double.valueOf(coordinates[k + 1]); + if (tempX < tempXMin) { + tempXMin = tempX; + } + if (tempY < tempYMin) { + tempYMin = tempY; + } + if (tempX > tempXMax) { + tempXMax = tempX; + } + if (tempY > tempYMax) { + tempYMax = tempY; + } + xTotal += tempX; + yTotal += tempY; + } + } + this.xMin = tempXMin; + this.xMax = tempXMax; + this.yMin = tempYMin; + this.yMax = tempYMax; + this.x = xTotal / coordinatesCount; + this.y = yTotal / coordinatesCount; + } + + public String toString() { + try { + return navigator.toRawString(buildingOffset, buildingLength); + } catch (NavException ex) { + return ""; + } + } +} diff --git a/src/eu/simstadt/geo/fast_xml_parser/CityGmlIterator.java b/src/eu/simstadt/geo/fast_xml_parser/CityGmlIterator.java new file mode 100644 index 0000000000000000000000000000000000000000..547d44830072dd9be08225f8e35d751eadffb9d6 --- /dev/null +++ b/src/eu/simstadt/geo/fast_xml_parser/CityGmlIterator.java @@ -0,0 +1,105 @@ +package eu.simstadt.geo.fast_xml_parser; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Iterator; +import java.util.logging.Logger; +import com.ximpleware.AutoPilot; +import com.ximpleware.NavException; +import com.ximpleware.VTDGen; +import com.ximpleware.VTDNav; +import com.ximpleware.XPathEvalException; +import com.ximpleware.XPathParseException; + + +public class CityGmlIterator implements Iterable<BuildingXmlNode> +{ + + private static final Logger LOGGER = Logger.getLogger(CityGmlIterator.class.getName()); + + private AutoPilot buildingsFinder; + private VTDNav navigator; + private int buildingOffset = 0; + private int buildingLength = 0; + private Path citygmlPath; + + /* + * Simple class to parse a CityGML and extract cityObjectMember XML nodes and their coordinates. No other attribute + * is extracted. Not suitable for building simulation, perfect for quick and dirty extract of coordinates (e.g. for + * RegionChooser or HullExtractor). It should be fast and not use much memory. A SaxParser would use even less memory + * but might be harder to code and possibly slower to run. + * + * For a more complete and more robust (but slower) implementation, use eu.simstadt.geo.GeoCoordinatesAccessor + * + * Based on VTD XML, it provides a Building iterator. + * + */ + public CityGmlIterator(Path citygmlPath) throws XPathParseException { + this.citygmlPath = citygmlPath; + VTDGen parser = new VTDGen(); + parser.parseFile(citygmlPath.toString(), false); + this.navigator = parser.getNav(); + this.buildingsFinder = new AutoPilot(navigator); + buildingsFinder.selectXPath("/CityModel/cityObjectMember[Building]"); //TODO: Check it's the only correct possibility. //FIXME: BuildingPart too! + } + + @Override + public Iterator<BuildingXmlNode> iterator() { + return new Iterator<BuildingXmlNode>() { + + @Override + public boolean hasNext() { + try { + return buildingsFinder.evalXPath() != -1; + } catch (XPathEvalException | NavException ex) { + LOGGER.warning("Error while parsing " + citygmlPath); + return false; + } + } + + @Override + public BuildingXmlNode next() { + try { + long offsetAndLength = navigator.getElementFragment(); + buildingOffset = (int) offsetAndLength; + buildingLength = (int) (offsetAndLength >> 32); + return new BuildingXmlNode(navigator, buildingOffset, buildingLength); + } catch (NavException | NumberFormatException | XPathParseException | XPathEvalException ex) { + LOGGER.warning("Error while parsing " + citygmlPath); + } + return null; + } + + @Override + public void remove() { + throw new UnsupportedOperationException(); + } + }; + } + + /** + * Returns Header of the Citygml, from the start of the file to the current building. This method needs to be called + * directly after the first building has been found. + * + * @return Citygml header + * @throws NavException + */ + public String getHeader() throws NavException { + return navigator.toRawString(0, buildingOffset); + } + + /** + * Returns footer of the Citygml, from the end of the last building to the end of file. This method needs to be + * called after the last building has been found. + * + * @return Citygml footer + * @throws NavException + */ + public Object getFooter() throws IOException, NavException { + int footerOffset = buildingOffset + buildingLength; + int footerLength = (int) (Files.size(citygmlPath) - footerOffset); + return navigator.toRawString(footerOffset, footerLength); + } + +} diff --git a/src/eu/simstadt/geo/fast_xml_parser/ConvexHullCalculator.java b/src/eu/simstadt/geo/fast_xml_parser/ConvexHullCalculator.java new file mode 100644 index 0000000000000000000000000000000000000000..8cf54e7c236c56d4dc0ddbf338b10504519dc8ec --- /dev/null +++ b/src/eu/simstadt/geo/fast_xml_parser/ConvexHullCalculator.java @@ -0,0 +1,209 @@ +package eu.simstadt.geo.fast_xml_parser; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.logging.Logger; +import org.locationtech.jts.algorithm.ConvexHull; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Polygon; +import org.osgeo.proj4j.CoordinateReferenceSystem; +import com.ximpleware.XPathParseException; +import eu.simstadt.geo.GeoUtils; + + +public class ConvexHullCalculator +{ + private static final Logger LOGGER = Logger.getLogger(ConvexHullCalculator.class.getName()); + + /** + * Relatively fast method to extract a convex hull in WGS84 for any Citygml file for which CRS is known and whose + * size is less than 2GB (VTD XML limitation). It iterates over every building, gets the bounding box as 4 + * coordinates and calculates a convex hull of all those coordinates at the end of the iteration. It finally converts + * this convex hull to WGS84 coordinates. + * + * The convex hull is needed for RegionChooser. No SimstadtModel or CityDoctor model is needed, thanks to + * eu.simstadt.geo.fast_xml_parser.CityGmlIterator. + * + * E.g. : POLYGON ((9.219282617376651 48.876828283254675, 9.2175568365387 48.87695546490524, 9.213228008654541 + * 48.87741235218009, 9.21293830332426 48.8774437308139, 9.212628150503749 48.87995232037036, 9.21263222062228 + * 48.880912500705406, 9.213601288895058 48.88138432918416, 9.213701498914048 48.8813841435154, 9.217160380858063 + * 48.88092123714512, 9.217396359933812 48.88085245173012, 9.217558989421159 48.880784074008496, 9.219328059150042 + * 48.879894270949485, 9.219367632516049 48.876847095121995, 9.219367549551574 48.87682812171015, 9.219282617376651 + * 48.876828283254675)) + * + * for "20140218_Gruenbuehl_LOD2.gml" + * + * @param citygmlPath + * @return convex Hull as jts.geom.Geometry, with the originalCRS written in userData. + */ + public static Geometry calculateFromCityGML(Path citygmlPath) throws XPathParseException, IOException { + + GeometryFactory geometryFactory = new GeometryFactory(); + ArrayList<Coordinate> allPoints = new ArrayList<>(); + CityGmlIterator citygml = new CityGmlIterator(citygmlPath); + for (BuildingXmlNode buildingXmlNode : citygml) { + if (buildingXmlNode.hasCoordinates()) { + allPoints.add(new Coordinate(buildingXmlNode.xMin, buildingXmlNode.yMin)); + allPoints.add(new Coordinate(buildingXmlNode.xMin, buildingXmlNode.yMax)); + allPoints.add(new Coordinate(buildingXmlNode.xMax, buildingXmlNode.yMin)); + allPoints.add(new Coordinate(buildingXmlNode.xMax, buildingXmlNode.yMax)); + } + } + + Coordinate[] pointsArray = allPoints.toArray(new Coordinate[allPoints.size()]); + ConvexHull ch = new org.locationtech.jts.algorithm.ConvexHull(pointsArray, geometryFactory); + + // Convert convex hull in original coordinates to WGS84 coordinates. + // NOTE: It's faster to convert to WGS84 once the convex hull is calculated, because there are fewer points + Polygon originalConvexHull = (Polygon) ch.getConvexHull(); + + CoordinateReferenceSystem originalCRS = GeoUtils.crsFromCityGMLHeader(citygmlPath); + Polygon convexHull = GeoUtils.changePolygonCRS(originalConvexHull, originalCRS, GeoUtils.WGS84); + convexHull.setUserData(originalCRS.toString()); + return convexHull; + } + + /** + * Finds every CityGML in a folder recursively, extract their convex hull and send them to callback. Used by + * RegionChooser Javascript to display the CityGMLs. + * + * If hull has been extracted, get it from cache, calculate it otherwise. + * + * @param repository + * @param callback + * @throws IOException + */ + public static void extractHullsForEveryCityGML(Path repository, Consumer<String> callback) throws IOException { + LOGGER.info("Parsing " + repository); + GeoUtils.everyCityGML(repository) + .map(gmlPath -> { + LOGGER.fine("Importing " + repository.relativize(gmlPath)); + try { + Path kmlPath = getHullPath(repository, gmlPath); + if (Files.exists(kmlPath)) { + LOGGER.fine("Using cache from " + repository.relativize(kmlPath)); + return new String(Files.readAllBytes(kmlPath), StandardCharsets.UTF_8); + } else { + LOGGER.info("No hull found in cache for " + repository.relativize(gmlPath) + ". Creating it!"); + return calculateConvexHullPlacemark(gmlPath, kmlPath, false); + } + } catch (IOException ex) { + ex.printStackTrace(); + } + return null; + }) + .filter(Objects::nonNull) + .forEach(hullKML -> { + if (hullKML.contains("<LinearRing>")) { + callback.accept(hullKML); + } else { + LOGGER.warning("Cache is empty!"); + } + }); + } + + /** + * Writes a KML cache file for the convex hull of a CityGML file and returns the KML content as string. Depending on + * throwIfError, the method will either ignore any problem (and write the message inside the KML file (preventing the + * process from happening again next time) or stop the process, without returning or caching anything. + * + * @param gmlPath + * @param kmlPath + * @param throwIfError + * @return the KML content as a String + */ + public static String calculateConvexHullPlacemark(Path gmlPath, Path kmlPath, boolean throwIfError) { + String result; + try { + Geometry convexHull = ConvexHullCalculator.calculateFromCityGML(gmlPath); + //NOTE: Header and footer with <kml><Document> don't seem to be needed. They're easier to concatenate without. + StringBuilder kmlPlacemark = new StringBuilder(); + kmlPlacemark.append(" <Placemark>\n"); + kmlPlacemark.append(" <name>"); + kmlPlacemark.append(gmlPath.getFileName()); + kmlPlacemark.append("</name>\n"); + kmlPlacemark.append(" <ExtendedData>\n"); + kmlPlacemark.append(" <Data name=\"project\">\n"); + kmlPlacemark.append(" <value>"); + kmlPlacemark.append(gmlPath.getParent().getFileName().toString().replace(".proj", "")); + kmlPlacemark.append("</value>\n"); + kmlPlacemark.append(" </Data>\n"); + kmlPlacemark.append(" <Data name=\"srsName\">\n"); + kmlPlacemark.append(" <value>"); + kmlPlacemark.append(convexHull.getUserData()); + kmlPlacemark.append("</value>\n"); + kmlPlacemark.append(" </Data>\n"); + kmlPlacemark.append(" </ExtendedData>\n"); + kmlPlacemark.append(" <Polygon>\n"); + kmlPlacemark.append(" <outerBoundaryIs>\n"); + kmlPlacemark.append(" <LinearRing>\n"); + kmlPlacemark.append(" <tessellate>1</tessellate>\n"); + kmlPlacemark.append(" <coordinates>"); + for (Coordinate coordinate : convexHull.getCoordinates()) { + kmlPlacemark.append(coordinate.x); + kmlPlacemark.append(","); + kmlPlacemark.append(coordinate.y); + kmlPlacemark.append(",0 "); + } + kmlPlacemark.append("</coordinates>\n"); + kmlPlacemark.append(" </LinearRing>\n"); + kmlPlacemark.append(" </outerBoundaryIs>\n"); + kmlPlacemark.append(" </Polygon>\n"); + kmlPlacemark.append(" </Placemark>\n"); + result = kmlPlacemark.toString(); + } catch (Exception ex) { + // Something went wrong. + LOGGER.severe("Couldn't calculate hull : " + ex.getMessage()); + // Either throw again: + if (throwIfError) { + throw new RuntimeException(ex); + } + // Or write the error message inside a comment in the KML cache file. It could be dangerous (the file doesn't contain any geometry) + // but will prevent the same process from happening next time again. This could be desirable if the Error happens at the end of a 2GB file. + StringWriter errors = new StringWriter(); + ex.printStackTrace(new PrintWriter(errors)); + result = "<kml>\n<!--\n" + errors.toString() + "\n-->\n</kml>"; + } + try (BufferedWriter writer = Files.newBufferedWriter(kmlPath)) { + writer.write(result); + } catch (IOException ex) { + ex.printStackTrace(); + } + LOGGER.info("Convex hull written to cache."); + return result; + } + + /** + * For a given CityGML Path, returns the Path at which KML hull should be cached. If needed it creates the '.cache' + * folder and hides it. The '.cache' folder isn't specific to the project: every kml cache file is written inside the + * same repository '.cache' folder. + * + * @param repository + * @param citygmlPath + * + * @return KML Path + * @throws IOException + */ + private static Path getHullPath(Path repository, Path citygmlPath) throws IOException { + String kmlFilename = citygmlPath.getFileName().toString().replaceAll("(?i)\\.gml$", ".kml"); + Path cacheFolder = repository.resolve(".cache/"); + Path hullsFolder = cacheFolder.resolve("hulls/"); + Files.createDirectories(hullsFolder); + try { + Files.setAttribute(cacheFolder, "dos:hidden", true); + } catch (UnsupportedOperationException ex) { + // do nothing for non-windows os + } + return hullsFolder.resolve(kmlFilename); + } +} diff --git a/test/eu/simstadt/geo/fast_xml_parser/CitygmlParserTests.java b/test/eu/simstadt/geo/fast_xml_parser/CitygmlParserTests.java new file mode 100644 index 0000000000000000000000000000000000000000..7e926ee650b1dd14b70960910dd14ead48a41295 --- /dev/null +++ b/test/eu/simstadt/geo/fast_xml_parser/CitygmlParserTests.java @@ -0,0 +1,78 @@ +package eu.simstadt.geo.fast_xml_parser; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.Test; +import com.ximpleware.NavException; +import com.ximpleware.XPathEvalException; +import com.ximpleware.XPathParseException; + + +public class CitygmlParserTests +{ + private void testNoNanInCoordinates(Path citygmlPath) + throws NumberFormatException, XPathParseException, NavException, XPathEvalException, IOException { + CityGmlIterator buildingXmlNodes = new CityGmlIterator(citygmlPath); + for (BuildingXmlNode buildingXmlNode : buildingXmlNodes) { + assertTrue("Buildings should have coordinates", buildingXmlNode.hasCoordinates()); + assertFalse("Coordinate should be a double", Double.isNaN(buildingXmlNode.x)); + assertFalse("Coordinate should be a double", Double.isNaN(buildingXmlNode.y)); + assertFalse("Coordinate should be a double", Double.isNaN(buildingXmlNode.xMax)); + assertFalse("Coordinate should be a double", Double.isNaN(buildingXmlNode.yMax)); + assertFalse("Coordinate should be a double", Double.isNaN(buildingXmlNode.xMin)); + assertFalse("Coordinate should be a double", Double.isNaN(buildingXmlNode.yMin)); + assertTrue("Coordinates Min/Max should be plausible", buildingXmlNode.xMax > buildingXmlNode.x); + assertTrue("Coordinates Min/Max should be plausible", buildingXmlNode.yMax > buildingXmlNode.y); + assertTrue("Coordinates Min/Max should be plausible", buildingXmlNode.xMin < buildingXmlNode.x); + assertTrue("Coordinates Min/Max should be plausible", buildingXmlNode.yMin < buildingXmlNode.y); + } + } + + @Test + public void testExtractCoordsFromStuttgart() + throws NumberFormatException, XPathParseException, NavException, XPathEvalException, IOException { + Path repo = Paths.get("../TestRepository"); + Path citygmlPath = repo.resolve("Stuttgart.proj/Stuttgart_LOD0_LOD1_buildings_and_trees.gml"); + testNoNanInCoordinates(citygmlPath); + } + + @Test + public void testExtractCoordsFromGruenbuehl() throws Throwable { + Path repo = Paths.get("../TestRepository"); + Path citygmlPath = repo.resolve("Gruenbuehl.proj/20140218_Gruenbuehl_LOD2_1building.gml"); + testNoNanInCoordinates(citygmlPath); + } + + @Test + public void testExtractCoordsFromMunich() throws Throwable { + Path repo = Paths.get("../TestRepository"); + Path citygmlPath = repo.resolve("Muenchen.proj/Munich_v_1_0_0.gml"); + testNoNanInCoordinates(citygmlPath); + } + + @Test + public void testExtractCoordsFromNYC() throws Throwable { + Path repo = Paths.get("../TestRepository"); + Path citygmlPath = repo.resolve("NewYork.proj/ManhattanSmall.gml"); + testNoNanInCoordinates(citygmlPath); + } + + @Test + public void testExtractNoCoordsFromEmptyBuilding() throws Throwable { + Path repo = Paths.get("../TestRepository"); + Path citygmlPath = repo.resolve("Ensource.proj/Stöckach_empty_buildings.gml"); + CityGmlIterator buildingXmlNodes = new CityGmlIterator(citygmlPath); + int counter = 0; + for (BuildingXmlNode buildingXmlNode : buildingXmlNodes) { + assertFalse("Empty Buildings shouldn't have coordinates", buildingXmlNode.hasCoordinates()); + assertTrue("Coordinate should be a Nan", Double.isNaN(buildingXmlNode.x)); + assertTrue("Coordinate should be a Nan", Double.isNaN(buildingXmlNode.y)); + counter++; + } + assertEquals("3 buildings should have been analyzed", counter, 3); + } +} diff --git a/test/eu/simstadt/geo/fast_xml_parser/ConvexHullCalculatorTests.java b/test/eu/simstadt/geo/fast_xml_parser/ConvexHullCalculatorTests.java new file mode 100644 index 0000000000000000000000000000000000000000..16422d46d59c088f122048947468ec62b1820d45 --- /dev/null +++ b/test/eu/simstadt/geo/fast_xml_parser/ConvexHullCalculatorTests.java @@ -0,0 +1,66 @@ +package eu.simstadt.geo.fast_xml_parser; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import eu.simstadt.geo.GeoUtils; + + +public class ConvexHullCalculatorTests +{ + private static final GeometryFactory gf = new GeometryFactory(); + private static final Path repository = Paths.get("../TestRepository"); + + @Test + public void testExtractConvexHullFromOneBuilding() throws Throwable { + Path citygmlPath = repository.resolve("Gruenbuehl.proj/20140218_Gruenbuehl_LOD2_1building.gml"); + Geometry hull = ConvexHullCalculator.calculateFromCityGML(citygmlPath); + assertEquals(hull.getCoordinates().length, 4 + 1); // Convex hull of a building should be a closed rectangle + Point someBuildingPoint = gf.createPoint(new Coordinate(9.216845, 48.878196)); // WGS84 + assertTrue("Hull should contain every building point", hull.contains(someBuildingPoint)); + } + + @Test + public void testExtractConvexHullFromOneSmallRegion() throws Throwable { + Path citygmlPath = repository.resolve("Gruenbuehl.proj/Gruenbuehl_LOD2_ALKIS_1010.gml"); + Geometry hull = ConvexHullCalculator.calculateFromCityGML(citygmlPath); + assertTrue(hull.getCoordinates().length > 4); // Convex hull should have at least 4 corners + // Point somewhereBetweenBuildings = gf.createPoint(new Coordinate(3515883.6668538367, 5415843.300640578)); // Original coordinates, GSK3 + Point somewhereBetweenBuildings = gf.createPoint(new Coordinate(9.21552249084, 48.87980446)); // WGS84 + assertTrue("Hull should contain region between buildings", hull.contains(somewhereBetweenBuildings)); + } + + @Test + public void testExtractConvexHullFromStoeckachNoBuildingPart() throws Throwable { + Path citygmlPath = repository.resolve("Ensource.proj/Stöckach_überarbeitete GML-NoBuildingPart.gml"); + Geometry hull = ConvexHullCalculator.calculateFromCityGML(citygmlPath); + assertTrue(hull.getCoordinates().length > 4); // Convex hull should have at least 4 corners + Point somewhereBetweenBuildings = gf.createPoint(new Coordinate(9.195212, 48.789062)); // WGS84 + assertTrue("Hull should contain region between buildings", hull.contains(somewhereBetweenBuildings)); + } + + @Test + public void testExtractConvexHullFromEveryCitygmlInRepository() throws Throwable { + int minHullCount = 70; + //NOTE: Should cache be deleted first? + // Files.walk(repository.resolve(".cache/hulls"), 1).forEach(System.out::println); + long gmlCount = GeoUtils.everyCityGML(repository).count(); + AtomicInteger hullCount = new AtomicInteger(0); + ConvexHullCalculator.extractHullsForEveryCityGML(repository, kmlHull -> { + assertTrue("KML hull should contain project name", kmlHull.contains("Data name=\"project\"")); + assertTrue("KML hull should contain srs name", kmlHull.contains("Data name=\"srsName\"")); + assertTrue("KML hull should contain epsg id", kmlHull.contains("<value>EPSG:")); + assertTrue("KML hull should contain coordinates", kmlHull.contains("<coordinates>")); + hullCount.getAndIncrement(); + }); + assertTrue("At least " + minHullCount + " citygmls should be present in repository", gmlCount >= minHullCount); + assertTrue("At least " + minHullCount + " hulls should have been calculated", hullCount.get() >= minHullCount); + } +}