diff --git a/config/configuration.json b/config/configuration.json index bc4947066..216fdf437 100644 --- a/config/configuration.json +++ b/config/configuration.json @@ -277,6 +277,14 @@ "tags":"highway" } }, + "ShadowDetectionCheck": { + "challenge": { + "description": "Verify the height and level tags to make sure the building does not float in 3D", + "blurb": "Fix floating buildings", + "instruction": "Open your favorite editor and check the height and placement of the building or building part for 3D alignment.", + "difficulty": "NORMAL" + } + }, "SharpAngleCheck": { "threshold.degrees": 97.0, "challenge": { diff --git a/docs/checks/shadowDetectionCheck.md b/docs/checks/shadowDetectionCheck.md new file mode 100644 index 000000000..ba8f9968d --- /dev/null +++ b/docs/checks/shadowDetectionCheck.md @@ -0,0 +1,37 @@ +# Shadow Detection Check + +This check flags buildings that are floating in 3D, casting abnormal shadows on the base map when rendered. + +In OSM, 3D buildings are achieved by assigning `height` and/or `level` tags to ways or relations that have a `building` or `building:part` tag. +The [Simple 3D Buildings](https://wiki.openstreetmap.org/wiki/Simple_3D_buildings) osm wiki page documents much of this tagging scheme. +Important to this check is the use of `building:min_level` and `min_height` tags to define the bottom z level of a part of a building. +When improperly used these tags will cause building parts to become disconnected and float. + +#### Live Examples + +1. Way [id:260580125](https://www.openstreetmap.org/way/260580125) has a `building:min_level` tag that is > 0, but their is nothing below it to connect it to the ground. +2. Way [id:462577211](https://www.openstreetmap.org/way/462577211) has a `building:min_level` tag of 5, but the part below it, +way [id:462577208](https://www.openstreetmap.org/way/462577208), has a `level` tag of 4. This causes half the building to float one level above the other half. + +#### Code Review + +In [Atlas](https://github.com/osmlab/atlas), OSM elements are represented as Edges, Points, Lines, Nodes, Areas & Relations; in our case, we’re are looking at +[Edges](https://github.com/osmlab/atlas/blob/dev/src/main/java/org/openstreetmap/atlas/geography/atlas/items/Edge.java), and +[Relations](https://github.com/osmlab/atlas/blob/dev/src/main/java/org/openstreetmap/atlas/geography/atlas/items/Relation.java). + +The first process in this check is to validate incoming objects, to see if they have the potential to be floating building parts. +This is done by testing that each object: + +* is an Area or multipolygon Relation +* has a `building` or `building:part` tag +* has a `min_height` or `building:min_level` tag + +Once an object has been validated it has its `min_height` or `building:min_level` tag checked to see if it is > 0, indicating it is off the ground. +If it is, a BFS walker is used to find connected building parts that form a path to the ground (see the `getFloatingParts` method). +The walker uses geographic indices and polygon overlap calculations to locate parts that overlap in 2D (see the `neighboringPart` method), +and then checks height and level tags to determine 3D overlap (see the `neighborsHeightContains` method). +When the walker is unable to find a 3D path to the ground, all the collected building parts are flagged as floating. + +To learn more about the code, please look at the comments in the source code for the check. +[ShadowDetectionCheck](../../src/main/java/org/openstreetmap/atlas/checks/validation/areas/ShadowDetectionCheck.java) + diff --git a/src/main/java/org/openstreetmap/atlas/checks/validation/areas/ShadowDetectionCheck.java b/src/main/java/org/openstreetmap/atlas/checks/validation/areas/ShadowDetectionCheck.java new file mode 100644 index 000000000..91177e2cc --- /dev/null +++ b/src/main/java/org/openstreetmap/atlas/checks/validation/areas/ShadowDetectionCheck.java @@ -0,0 +1,428 @@ +package org.openstreetmap.atlas.checks.validation.areas; + +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.openstreetmap.atlas.checks.base.BaseCheck; +import org.openstreetmap.atlas.checks.flag.CheckFlag; +import org.openstreetmap.atlas.exception.CoreException; +import org.openstreetmap.atlas.geography.GeometricSurface; +import org.openstreetmap.atlas.geography.MultiPolygon; +import org.openstreetmap.atlas.geography.Polygon; +import org.openstreetmap.atlas.geography.Rectangle; +import org.openstreetmap.atlas.geography.atlas.Atlas; +import org.openstreetmap.atlas.geography.atlas.items.Area; +import org.openstreetmap.atlas.geography.atlas.items.AtlasEntity; +import org.openstreetmap.atlas.geography.atlas.items.AtlasObject; +import org.openstreetmap.atlas.geography.atlas.items.Relation; +import org.openstreetmap.atlas.geography.atlas.items.complex.RelationOrAreaToMultiPolygonConverter; +import org.openstreetmap.atlas.geography.index.PackedSpatialIndex; +import org.openstreetmap.atlas.geography.index.RTree; +import org.openstreetmap.atlas.geography.index.SpatialIndex; +import org.openstreetmap.atlas.tags.BuildingLevelsTag; +import org.openstreetmap.atlas.tags.BuildingMinLevelTag; +import org.openstreetmap.atlas.tags.BuildingPartTag; +import org.openstreetmap.atlas.tags.BuildingTag; +import org.openstreetmap.atlas.tags.HeightTag; +import org.openstreetmap.atlas.tags.MinHeightTag; +import org.openstreetmap.atlas.tags.RelationTypeTag; +import org.openstreetmap.atlas.tags.annotations.validation.Validators; +import org.openstreetmap.atlas.utilities.collections.Iterables; +import org.openstreetmap.atlas.utilities.configuration.Configuration; + +import com.google.common.collect.Range; + +/** + * This flags buildings that are floating in 3D, thus casting a shadow on the base map when rendered + * in 3D. Buildings are defined as Areas with a building or building:part tag or are part of a + * building relation, or a relation of type multipolygon with a building tag. + * + * @author bbreithaupt + */ +public class ShadowDetectionCheck extends BaseCheck +{ + + private static final long serialVersionUID = -6968080042879358551L; + + private static final List FALLBACK_INSTRUCTIONS = Arrays.asList( + "The building(s) and/or building part(s) float(s) above the ground. Please check the height/building:levels " + + "and min_height/building:min_level tags for all of the buildings parts.", + "Relation {0,number,#} is floating."); + + // OSM standard level conversion factor + private static final double LEVEL_TO_METERS_CONVERSION = 3.5; + private static final String ZERO_STRING = "0"; + private static final RelationOrAreaToMultiPolygonConverter MULTI_POLYGON_CONVERTER = new RelationOrAreaToMultiPolygonConverter(); + + private final Map> relationSpatialIndices = new HashMap<>(); + + /** + * The default constructor that must be supplied. The Atlas Checks framework will generate the + * checks with this constructor, supplying a configuration that can be used to adjust any + * parameters that the check uses during operation. + * + * @param configuration + * the JSON configuration for this check + */ + public ShadowDetectionCheck(final Configuration configuration) + { + super(configuration); + } + + /** + * This function will validate if the supplied atlas object is valid for the check. + * + * @param object + * the atlas object supplied by the Atlas-Checks framework for evaluation + * @return {@code true} if this object should be checked + */ + @Override + public boolean validCheckForObject(final AtlasObject object) + { + return !this.isFlagged(object.getIdentifier()) + && (object instanceof Area + || (object instanceof Relation && ((Relation) object).isMultiPolygon())) + && this.hasMinKey(object) + && (this.isBuildingOrPart(object) || this.isBuildingRelationMember(object)); + } + + /** + * This is the actual function that will check to see whether the object needs to be flagged. + * + * @param object + * the atlas object supplied by the Atlas-Checks framework for evaluation + * @return an optional {@link CheckFlag} object that + */ + @Override + protected Optional flag(final AtlasObject object) + { + // Gather connected building parts and check for a connection to the ground + final Set floatingParts = this.getFloatingParts(object); + if (!floatingParts.isEmpty()) + { + final CheckFlag flag; + // If object is a relation, flatten it and add a relation instruction + if (object instanceof Relation) + { + flag = this.createFlag(((Relation) object).flatten(), + this.getLocalizedInstruction(0)); + flag.addInstruction(this.getLocalizedInstruction(1, object.getOsmIdentifier())); + } + else + { + flag = this.createFlag(object, this.getLocalizedInstruction(0)); + } + // Flag all the connected floating parts together + for (final AtlasObject part : floatingParts) + { + this.markAsFlagged(part.getIdentifier()); + if (!part.equals(object)) + { + if (part instanceof Relation) + { + flag.addObjects(((Relation) part).flatten()); + flag.addInstruction( + this.getLocalizedInstruction(1, part.getOsmIdentifier())); + } + else + { + flag.addObject(part); + } + } + } + return Optional.of(flag); + } + return Optional.empty(); + } + + @Override + protected List getFallbackInstructions() + { + return FALLBACK_INSTRUCTIONS; + } + + /** + * Uses a BFS to gather all connected building parts. If a part is found that is on the ground, + * an empty {@link Set} is returned because the parts are not floating. + * + * @param startingPart + * {@link AtlasObject} to start the walker from + * @return a {@link Set} of {@link AtlasObject}s that are all floating + */ + private Set getFloatingParts(final AtlasObject startingPart) + { + final Set connectedParts = new HashSet<>(); + final ArrayDeque toCheck = new ArrayDeque<>(); + connectedParts.add(startingPart); + toCheck.add(startingPart); + + while (!toCheck.isEmpty()) + { + final AtlasObject checking = toCheck.poll(); + // If a connection to the ground is found the parts are not floating + if (!isOffGround(checking)) + { + return new HashSet<>(); + } + // Get parts connected in 3D + final Set neighboringParts = new HashSet<>(); + final Rectangle checkingBounds = checking.bounds(); + // Get Areas + neighboringParts + .addAll(Iterables.asSet(checking.getAtlas().areasIntersecting(checkingBounds, + area -> this.neighboringPart(area, checking, connectedParts)))); + // Get Relations + if (!this.relationSpatialIndices.containsKey(checking.getAtlas())) + { + this.relationSpatialIndices.put(checking.getAtlas(), + this.buildRelationSpatialIndex(checking.getAtlas())); + } + neighboringParts.addAll(Iterables + .asSet(this.relationSpatialIndices.get(checking.getAtlas()).get(checkingBounds, + relation -> this.neighboringPart(relation, checking, connectedParts)))); + // Add the parts to the Set and Queue + connectedParts.addAll(neighboringParts); + toCheck.addAll(neighboringParts); + } + return connectedParts; + } + + /** + * Checks if two {@link AtlasObject}s are building parts and overlap each other. + * + * @param part + * a known building part to check against + * @return true if {@code object} is a building part and overlaps {@code part} + */ + private boolean neighboringPart(final AtlasObject object, final AtlasObject part, + final Set checked) + { + try + { + // Get the polygons of the parts, either single or multi + final GeometricSurface partPolygon = part instanceof Area ? ((Area) part).asPolygon() + : MULTI_POLYGON_CONVERTER.convert((Relation) part); + final GeometricSurface objectPolygon = object instanceof Area + ? ((Area) object).asPolygon() + : MULTI_POLYGON_CONVERTER.convert((Relation) object); + // Check if it is a building part, and overlaps. + return !checked.contains(object) && !this.isFlagged(object.getIdentifier()) + && (this.isBuildingOrPart(object) || this.isBuildingRelationMember(object)) + // Check 2D overlap + && (partPolygon instanceof Polygon + ? objectPolygon.overlaps((Polygon) partPolygon) + : objectPolygon.overlaps((MultiPolygon) partPolygon)) + // Check 3D overlap + && neighborsHeightContains(part, object); + } + // Ignore malformed MultiPolygons + catch (final CoreException invalidMultiPolygon) + { + return false; + } + } + + /** + * Given two {@link AtlasObject}s, checks that they have any intersecting or touching height + * values. The range of height values for the {@link AtlasObject}s are calculated using height + * and layer tags. Height tags get precedence over level tags. Heights are calculated from level + * tags using a conversion factor. A {@code min_height} or {@code building:min_layer} tag must + * exist for {@code part}. All other tags will use defaults if not found. + * + * @param part + * {@link AtlasObject} being checked + * @param neighbor + * {@link AtlasObject} being checked against + * @return true if {@code part} intersects or touches {@code neighbor}, by default neighbor is + * flat on the ground. + */ + private boolean neighborsHeightContains(final AtlasObject part, final AtlasObject neighbor) + { + final Map neighborTags = neighbor.getOsmTags(); + final Map partTags = part.getOsmTags(); + + try + { + // Set partMinHeight + final double partMinHeight = partTags.containsKey(MinHeightTag.KEY) + ? Double.parseDouble(partTags.get(MinHeightTag.KEY)) + : Double.parseDouble(partTags.get(BuildingMinLevelTag.KEY)) + * LEVEL_TO_METERS_CONVERSION; + // Set partMaxHeight + final double partMaxHeight; + if (partTags.containsKey(HeightTag.KEY)) + { + partMaxHeight = Double.parseDouble(partTags.get(HeightTag.KEY)); + } + else if (partTags.containsKey(BuildingLevelsTag.KEY)) + { + partMaxHeight = Double.parseDouble(partTags.get(BuildingLevelsTag.KEY)) + * LEVEL_TO_METERS_CONVERSION; + } + else + { + // Default to 0 height above the minimum + partMaxHeight = partMinHeight; + } + + // Set neighborMinHeight + final double neighborMinHeight; + if (neighborTags.containsKey(MinHeightTag.KEY)) + { + neighborMinHeight = Double.parseDouble(neighborTags.get(MinHeightTag.KEY)); + } + else if (neighborTags.containsKey(BuildingMinLevelTag.KEY)) + { + neighborMinHeight = Double.parseDouble(neighborTags.get(BuildingMinLevelTag.KEY)) + * LEVEL_TO_METERS_CONVERSION; + } + else + { + // Default to 0 + neighborMinHeight = 0; + } + + // Set neighborMaxHeight + final double neighborMaxHeight; + if (neighborTags.containsKey(HeightTag.KEY)) + { + neighborMaxHeight = Double.parseDouble(neighborTags.get(HeightTag.KEY)); + } + else if (neighborTags.containsKey(BuildingLevelsTag.KEY)) + { + neighborMaxHeight = Double.parseDouble(neighborTags.get(BuildingLevelsTag.KEY)) + * LEVEL_TO_METERS_CONVERSION; + } + else + { + // Default to 0 + neighborMaxHeight = 0; + } + + // Check the range of heights for overlap. + return Range.closed(partMinHeight, partMaxHeight) + .isConnected(Range.closed(neighborMinHeight, neighborMaxHeight)); + } + // Ignore buildings with a min value larger than its height + // Ignore features with bad tags (like 2;10) + catch (final IllegalArgumentException exc) + { + return false; + } + } + + /** + * Checks if an {@link AtlasObject} is a building or building:part that is valid for this check. + * + * @param object + * {@link AtlasObject} to check + * @return true if {@code object} has a {@code building:part=yes} tag + */ + private boolean isBuildingOrPart(final AtlasObject object) + { + return (BuildingTag.isBuilding(object) + // Ignore roofs, as the are often used for items that have supports that are too + // small to effectively map (such as a carport) + && Validators.isNotOfType(object, BuildingTag.class, BuildingTag.ROOF)) + || Validators.isNotOfType(object, BuildingPartTag.class, BuildingPartTag.NO); + } + + /** + * Checks if an {@link AtlasObject} is a outline or part member of a building relation. This is + * an equivalent tagging to building=* or building:part=yes. + * + * @param object + * {@link AtlasObject} to check + * @return true if the object is part of any relation where it has role outline or part + */ + private boolean isBuildingRelationMember(final AtlasObject object) + { + return object instanceof AtlasEntity && ((AtlasEntity) object).relations().stream() + .anyMatch(relation -> Validators.isOfType(relation, RelationTypeTag.class, + RelationTypeTag.BUILDING) + && relation.members().stream() + .anyMatch(member -> member.getEntity().equals(object) + && (member.getRole().equals("outline")) + || member.getRole().equals("part"))); + } + + /** + * Checks if an {@link AtlasObject} has a tag defining the minimum height of a building. + * + * @param object + * {@link AtlasObject} to check + * @return true if {@code object} has a tag defining the minimum height of a building + */ + private boolean hasMinKey(final AtlasObject object) + { + return Validators.hasValuesFor(object, BuildingMinLevelTag.class) + || Validators.hasValuesFor(object, MinHeightTag.class); + } + + /** + * Checks if an {@link AtlasObject} has tags indicating it is off the ground. + * + * @param object + * {@link AtlasObject} to check + * @return true if the area is off the ground + */ + private boolean isOffGround(final AtlasObject object) + { + Double minHeight; + Double minLevel; + try + { + minHeight = Double + .parseDouble(object.getOsmTags().getOrDefault(MinHeightTag.KEY, ZERO_STRING)); + minLevel = Double.parseDouble( + object.getOsmTags().getOrDefault(BuildingMinLevelTag.KEY, ZERO_STRING)); + } + // We want to flag if there is a bad value + catch (final NumberFormatException badTagValue) + { + minHeight = 1.0; + minLevel = 1.0; + } + return minHeight > 0 || minLevel > 0; + } + + /** + * Create a new spatial index that pre filters building relations. Pre-filtering drastically + * decreases runtime by eliminating very large non-building relations. Copied from + * {@link org.openstreetmap.atlas.geography.atlas.AbstractAtlas}. + * + * @return A newly created spatial index + */ + private SpatialIndex buildRelationSpatialIndex(final Atlas atlas) + { + final SpatialIndex index = new PackedSpatialIndex(new RTree<>()) + { + @Override + protected Long compress(final Relation item) + { + return item.getIdentifier(); + } + + @Override + protected boolean isValid(final Relation item, final Rectangle bounds) + { + return item.intersects(bounds); + } + + @Override + protected Relation restore(final Long packed) + { + return atlas.relation(packed); + } + }; + atlas.relations(relation -> relation.isMultiPolygon() && BuildingTag.isBuilding(relation)) + .forEach(index::add); + return index; + } +} diff --git a/src/test/java/org/openstreetmap/atlas/checks/validation/areas/ShadowDetectionCheckTest.java b/src/test/java/org/openstreetmap/atlas/checks/validation/areas/ShadowDetectionCheckTest.java new file mode 100644 index 000000000..d8e3b3b37 --- /dev/null +++ b/src/test/java/org/openstreetmap/atlas/checks/validation/areas/ShadowDetectionCheckTest.java @@ -0,0 +1,232 @@ +package org.openstreetmap.atlas.checks.validation.areas; + +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.openstreetmap.atlas.checks.configuration.ConfigurationResolver; +import org.openstreetmap.atlas.checks.validation.verifier.ConsumerBasedExpectedCheckVerifier; + +/** + * Unit tests for {@link ShadowDetectionCheck}. + * + * @author bbreithaupt + */ +public class ShadowDetectionCheckTest +{ + @Rule + public ShadowDetectionCheckTestRule setup = new ShadowDetectionCheckTestRule(); + @Rule + public ConsumerBasedExpectedCheckVerifier verifier = new ConsumerBasedExpectedCheckVerifier(); + + @Test + public void validBuildingTest() + { + this.verifier.actual(this.setup.validBuildingAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(0, flags.size())); + } + + @Test + public void validBuildingBadMinTest() + { + this.verifier.actual(this.setup.validBuildingBadMinAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(0, flags.size())); + } + + @Test + public void invalidBuildingBadMinTest() + { + this.verifier.actual(this.setup.invalidBuildingBadMinAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(1, flags.size())); + } + + @Test + public void validBuildingRoofTest() + { + this.verifier.actual(this.setup.validBuildingRoofAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(0, flags.size())); + } + + @Test + public void invalidFloatingHeightBuildingTest() + { + this.verifier.actual(this.setup.invalidFloatingHeightBuildingAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(1, flags.size())); + } + + @Test + public void invalidFloatingLevelBuildingTest() + { + this.verifier.actual(this.setup.invalidFloatingLevelBuildingAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(1, flags.size())); + } + + @Test + public void validFloatingLevelRelationBuildingTest() + { + this.verifier.actual(this.setup.validFloatingLevelRelationBuildingAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(0, flags.size())); + } + + @Test + public void invalidFloatingLevelRelationBuildingTest() + { + this.verifier.actual(this.setup.invalidFloatingLevelRelationBuildingAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(1, flags.size())); + } + + @Test + public void validBuildingPartsTouchTest() + { + this.verifier.actual(this.setup.validBuildingPartsTouchAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(0, flags.size())); + } + + @Test + public void validBuildingPartsTouchGroundTest() + { + this.verifier.actual(this.setup.validBuildingPartsTouchGroundAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(0, flags.size())); + } + + @Test + public void validBuildingPartsIntersectTest() + { + this.verifier.actual(this.setup.validBuildingPartsIntersectAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(0, flags.size())); + } + + @Test + public void invalidBuildingPartsIntersectTest() + { + this.verifier.actual(this.setup.invalidBuildingPartsIntersectAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(1, flags.size())); + } + + @Test + public void invalidBuildingPartsDisparateTest() + { + this.verifier.actual(this.setup.invalidBuildingPartsDisparateAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(1, flags.size())); + } + + @Test + public void validBuildingPartsEnclosePartTest() + { + this.verifier.actual(this.setup.validBuildingPartsEnclosePartAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(0, flags.size())); + } + + @Test + public void validBuildingPartsEncloseNeighborTest() + { + this.verifier.actual(this.setup.validBuildingPartsEncloseNeighborAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(0, flags.size())); + } + + @Test + public void validBuildingPartsStackedTest() + { + this.verifier.actual(this.setup.validBuildingPartsStackedAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(0, flags.size())); + } + + @Test + public void validBuildingPartsStackedMixedTagsTest() + { + this.verifier.actual(this.setup.validBuildingPartsStackedMixedTagsAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(0, flags.size())); + } + + @Test + public void validBuildingAndPartStackedTest() + { + this.verifier.actual(this.setup.validBuildingAndPartStackedAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(0, flags.size())); + } + + @Test + public void invalidBuildingAndPartStackedTest() + { + this.verifier.actual(this.setup.invalidBuildingAndPartStackedAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(1, flags.size())); + this.verifier.verify(flag -> Assert.assertEquals(2, flag.getFlaggedObjects().size())); + } + + @Test + public void validBuildingRelationAndPartStackedTest() + { + this.verifier.actual(this.setup.validBuildingRelationAndPartStackedAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(0, flags.size())); + } + + @Test + public void invalidBuildingRelationAndPartStackedTest() + { + this.verifier.actual(this.setup.invalidBuildingRelationAndPartStackedAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(1, flags.size())); + this.verifier.verify(flag -> Assert.assertEquals(2, flag.getFlaggedObjects().size())); + } + + @Test + public void invalidBuildingsStackedTest() + { + this.verifier.actual(this.setup.invalidBuildingsStackedAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(1, flags.size())); + this.verifier.verify(flag -> Assert.assertEquals(2, flag.getFlaggedObjects().size())); + } + + @Test + public void invalidUntaggedAreasStackedTest() + { + this.verifier.actual(this.setup.invalidUntaggedAreasStackedAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(0, flags.size())); + } + + @Test + public void validUntaggedAreasStackedBuildingRelationTest() + { + this.verifier.actual(this.setup.validUntaggedAreasStackedBuildingRelationAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(0, flags.size())); + } + + @Test + public void invalidBuildingPartsManyFloatTest() + { + this.verifier.actual(this.setup.invalidBuildingPartsManyFloatAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(1, flags.size())); + this.verifier.verify(flag -> Assert.assertEquals(3, flag.getFlaggedObjects().size())); + } + + @Test + public void invalidBuildingPartSingleAtlas() + { + this.verifier.actual(this.setup.invalidBuildingPartSingleAtlas(), + new ShadowDetectionCheck(ConfigurationResolver.emptyConfiguration())); + this.verifier.globallyVerify(flags -> Assert.assertEquals(1, flags.size())); + } +} diff --git a/src/test/java/org/openstreetmap/atlas/checks/validation/areas/ShadowDetectionCheckTestRule.java b/src/test/java/org/openstreetmap/atlas/checks/validation/areas/ShadowDetectionCheckTestRule.java new file mode 100644 index 000000000..8bd973553 --- /dev/null +++ b/src/test/java/org/openstreetmap/atlas/checks/validation/areas/ShadowDetectionCheckTestRule.java @@ -0,0 +1,439 @@ +package org.openstreetmap.atlas.checks.validation.areas; + +import org.openstreetmap.atlas.geography.atlas.Atlas; +import org.openstreetmap.atlas.utilities.testing.CoreTestRule; +import org.openstreetmap.atlas.utilities.testing.TestAtlas; +import org.openstreetmap.atlas.utilities.testing.TestAtlas.Area; +import org.openstreetmap.atlas.utilities.testing.TestAtlas.Loc; +import org.openstreetmap.atlas.utilities.testing.TestAtlas.Relation; +import org.openstreetmap.atlas.utilities.testing.TestAtlas.Relation.Member; + +/** + * Unit test atlases for {@link ShadowDetectionCheck}. + * + * @author bbreithaupt + */ +public class ShadowDetectionCheckTestRule extends CoreTestRule +{ + private static final String TEST_1 = "47.2464747377508,-122.438262777482"; + private static final String TEST_2 = "47.2464428615188,-122.438065168917"; + private static final String TEST_3 = "47.2464096570902,-122.438284299207"; + private static final String TEST_4 = "47.2463698117484,-122.438047560233"; + private static final String TEST_5 = "47.246340591812,-122.438307777452"; + private static final String TEST_6 = "47.2462967618771,-122.438029951549"; + private static final String TEST_7 = "47.2462635573569,-122.438335168739"; + private static final String TEST_8 = "47.2462223837229,-122.438018212427"; + private static final String TEST_9 = "47.2463100436794,-122.438112125408"; + private static final String TEST_10 = "47.2464295797499,-122.437949734211"; + + @TestAtlas( + // areas + areas = { @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_4), + @Loc(value = TEST_3) }, tags = { "building=yes", "height=20" }) }) + private Atlas validBuildingAtlas; + + @TestAtlas( + // areas + areas = { @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_4), @Loc(value = TEST_3) }, tags = { "building=yes", + "height=20", "min_height=-8" }) }) + private Atlas validBuildingBadMinAtlas; + + @TestAtlas( + // areas + areas = { @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_4), @Loc(value = TEST_3) }, tags = { "building=yes", + "height=20", "min_height=bad" }) }) + private Atlas invalidBuildingBadMinAtlas; + + @TestAtlas( + // areas + areas = { @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_4), @Loc(value = TEST_3) }, tags = { "building=roof", + "height=20", "min_height=bad" }) }) + private Atlas validBuildingRoofAtlas; + + @TestAtlas( + // areas + areas = { @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_4), @Loc(value = TEST_3) }, tags = { "building=yes", + "height=20", "min_height=3" }) }) + private Atlas invalidFloatingHeightBuildingAtlas; + + @TestAtlas( + // areas + areas = { @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_4), @Loc(value = TEST_3) }, tags = { "building=yes", + "building:levels=5", "building:min_level=1" }) }) + private Atlas invalidFloatingLevelBuildingAtlas; + + @TestAtlas( + // areas + areas = { @Area(id = "1000000", coordinates = { @Loc(value = TEST_1), + @Loc(value = TEST_2), @Loc(value = TEST_4), @Loc(value = TEST_3) }) }, + // relation + relations = { @Relation(members = { + @Member(id = "1000000", type = "area", role = "outer") }, tags = { + "type=multipolygon", "building=yes", "building:levels=5" }) }) + private Atlas validFloatingLevelRelationBuildingAtlas; + + @TestAtlas( + // areas + areas = { @Area(id = "1000000", coordinates = { @Loc(value = TEST_1), + @Loc(value = TEST_2), @Loc(value = TEST_4), @Loc(value = TEST_3) }) }, + // relation + relations = { @Relation(members = { + @Member(id = "1000000", type = "area", role = "outer") }, tags = { + "type=multipolygon", "building=yes", "building:levels=5", + "building:min_level=1" }) }) + private Atlas invalidFloatingLevelRelationBuildingAtlas; + + @TestAtlas( + // areas + areas = { + @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_4), @Loc(value = TEST_3) }, tags = { + "building:part=yes", "building:levels=5" }), + @Area(coordinates = { @Loc(value = TEST_3), @Loc(value = TEST_4), + @Loc(value = TEST_6), @Loc(value = TEST_5) }, tags = { + "building:part=yes", "building:levels=8", + "building:min_level=1" }) }) + private Atlas validBuildingPartsTouchAtlas; + + @TestAtlas( + // areas + areas = { + @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_4), @Loc(value = TEST_3) }, tags = { + "building:part=yes", "building:levels=5" }), + @Area(coordinates = { @Loc(value = TEST_3), @Loc(value = TEST_4), + @Loc(value = TEST_6), @Loc(value = TEST_5) }, tags = { + "building:part=yes", "building:levels=8", + "building:min_level=0" }) }) + private Atlas validBuildingPartsTouchGroundAtlas; + + @TestAtlas( + // areas + areas = { + @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_6), @Loc(value = TEST_5) }, tags = { + "building:part=yes", "building:levels=5" }), + @Area(coordinates = { @Loc(value = TEST_3), @Loc(value = TEST_4), + @Loc(value = TEST_7), @Loc(value = TEST_8) }, tags = { + "building:part=yes", "building:levels=8", + "building:min_level=1" }) }) + private Atlas validBuildingPartsIntersectAtlas; + + @TestAtlas( + // areas + areas = { + @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_6), @Loc(value = TEST_5) }, tags = { + "building:part=yes", "building:levels=5" }), + @Area(coordinates = { @Loc(value = TEST_3), @Loc(value = TEST_4), + @Loc(value = TEST_7), @Loc(value = TEST_8) }, tags = { + "building:part=yes", "building:levels=8", + "building:min_level=6" }) }) + private Atlas invalidBuildingPartsIntersectAtlas; + + @TestAtlas( + // areas + areas = { + @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_4), @Loc(value = TEST_3) }, tags = { + "building:part=yes", "building:levels=5" }), + @Area(coordinates = { @Loc(value = TEST_5), @Loc(value = TEST_6), + @Loc(value = TEST_8), @Loc(value = TEST_7) }, tags = { + "building:part=yes", "building:levels=8", + "building:min_level=4" }) }) + private Atlas invalidBuildingPartsDisparateAtlas; + + @TestAtlas( + // areas + areas = { + @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_10), + @Loc(value = TEST_8), @Loc(value = TEST_7) }, tags = { + "building:part=yes", "building:levels=5" }), + @Area(coordinates = { @Loc(value = TEST_4), @Loc(value = TEST_6), + @Loc(value = TEST_9) }, tags = { "building:part=yes", + "building:levels=8", "building:min_level=1" }) }) + private Atlas validBuildingPartsEnclosePartAtlas; + + @TestAtlas( + // areas + areas = { @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_10), + @Loc(value = TEST_8), @Loc(value = TEST_7) }, tags = { "building:part=yes", + "building:levels=5", "building:min_level=1" }), + @Area(coordinates = { @Loc(value = TEST_4), @Loc(value = TEST_6), + @Loc(value = TEST_9) }, tags = { "building:part=yes", + "building:levels=8" }) }) + private Atlas validBuildingPartsEncloseNeighborAtlas; + + @TestAtlas( + // areas + areas = { + @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_4), @Loc(value = TEST_3) }, tags = { + "building:part=yes", "building:levels=5" }), + @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_6), @Loc(value = TEST_5) }, tags = { + "building:part=yes", "building:levels=8", + "building:min_level=5" }) }) + private Atlas validBuildingPartsStackedAtlas; + + @TestAtlas( + // areas + areas = { + @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_4), @Loc(value = TEST_3) }, tags = { + "building:part=yes", "building:levels=5" }), + @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_6), @Loc(value = TEST_5) }, tags = { + "building:part=yes", "height=30", "min_height=17.5" }) }) + private Atlas validBuildingPartsStackedMixedTagsAtlas; + + @TestAtlas( + // areas + areas = { + @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_4), + @Loc(value = TEST_3) }, tags = { "building=yes", "building:levels=5" }), + @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_6), @Loc(value = TEST_5) }, tags = { + "building:part=yes", "building:levels=8", + "building:min_level=5" }) }) + private Atlas validBuildingAndPartStackedAtlas; + + @TestAtlas( + // areas + areas = { + @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_4), @Loc(value = TEST_3) }, tags = { "building=yes", + "building:levels=5", "building:min_level=2" }), + @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_6), @Loc(value = TEST_5) }, tags = { + "building:part=yes", "building:levels=8", + "building:min_level=5" }) }) + private Atlas invalidBuildingAndPartStackedAtlas; + + @TestAtlas( + // areas + areas = { + @Area(id = "1000000", coordinates = { @Loc(value = TEST_1), + @Loc(value = TEST_2), @Loc(value = TEST_4), @Loc(value = TEST_3) }), + @Area(id = "2000000", coordinates = { @Loc(value = TEST_1), + @Loc(value = TEST_2), @Loc(value = TEST_6), + @Loc(value = TEST_5) }, tags = { "building:part=yes", + "building:levels=8", "building:min_level=5" }) }, + // relation + relations = { @Relation(members = { + @Member(id = "1000000", type = "area", role = "outer") }, tags = { + "type=multipolygon", "building=yes", "building:levels=5" }) }) + private Atlas validBuildingRelationAndPartStackedAtlas; + + @TestAtlas( + // areas + areas = { + @Area(id = "1000000", coordinates = { @Loc(value = TEST_1), + @Loc(value = TEST_2), @Loc(value = TEST_4), @Loc(value = TEST_3) }), + @Area(id = "2000000", coordinates = { @Loc(value = TEST_1), + @Loc(value = TEST_2), @Loc(value = TEST_6), + @Loc(value = TEST_5) }, tags = { "building:part=yes", + "building:levels=8", "building:min_level=5" }) }, + // relation + relations = { @Relation(members = { + @Member(id = "1000000", type = "area", role = "outer") }, tags = { + "type=multipolygon", "building=yes", "building:levels=5", + "building:min_level=1" }) }) + private Atlas invalidBuildingRelationAndPartStackedAtlas; + + @TestAtlas( + // areas + areas = { + @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_4), @Loc(value = TEST_3) }, tags = { "building=yes", + "building:levels=5", "building:min_level=2" }), + @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_6), @Loc(value = TEST_5) }, tags = { "building=yes", + "building:levels=8", "building:min_level=5" }) }) + private Atlas invalidBuildingsStackedAtlas; + + @TestAtlas( + // areas + areas = { @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_4), @Loc(value = TEST_3) }, tags = { "building:levels=5" }), + @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_6), @Loc(value = TEST_5) }, tags = { + "building:levels=8", "building:min_level=5" }) }) + private Atlas invalidUntaggedAreasStackedAtlas; + + @TestAtlas( + // areas + areas = { + @Area(id = "1000000", coordinates = { @Loc(value = TEST_1), + @Loc(value = TEST_2), @Loc(value = TEST_4), + @Loc(value = TEST_3) }, tags = { "building:levels=5" }), + @Area(id = "2000000", coordinates = { @Loc(value = TEST_1), + @Loc(value = TEST_2), @Loc(value = TEST_6), + @Loc(value = TEST_5) }, tags = { "building:levels=8", + "building:min_level=5" }) }, + // relation + relations = { + @Relation(members = { @Member(id = "1000000", type = "area", role = "outline"), + @Member(id = "2000000", type = "area", role = "part") }, tags = { + "type=building" }) }) + private Atlas validUntaggedAreasStackedBuildingRelationAtlas; + + @TestAtlas( + // areas + areas = { @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_4), @Loc(value = TEST_3) }, tags = { "building:part=yes", + "building:levels=8", "building:min_level=5" }), + @Area(coordinates = { @Loc(value = TEST_3), @Loc(value = TEST_4), + @Loc(value = TEST_6), @Loc(value = TEST_5) }, tags = { + "building:part=yes", "building:levels=8", + "building:min_level=5" }), + @Area(coordinates = { @Loc(value = TEST_5), @Loc(value = TEST_6), + @Loc(value = TEST_7), @Loc(value = TEST_8) }, tags = { + "building:part=yes", "building:levels=8", + "building:min_level=5" }) }) + private Atlas invalidBuildingPartsManyFloatAtlas; + + @TestAtlas( + // areas + areas = { @Area(coordinates = { @Loc(value = TEST_1), @Loc(value = TEST_2), + @Loc(value = TEST_4), @Loc(value = TEST_3) }, tags = { "building:part=yes", + "height=20", "min_height=5" }) }) + private Atlas invalidBuildingPartSingleAtlas; + + public Atlas validBuildingAtlas() + { + return this.validBuildingAtlas; + } + + public Atlas validBuildingBadMinAtlas() + { + return this.validBuildingBadMinAtlas; + } + + public Atlas invalidBuildingBadMinAtlas() + { + return this.invalidBuildingBadMinAtlas; + } + + public Atlas validBuildingRoofAtlas() + { + return this.validBuildingRoofAtlas; + } + + public Atlas invalidFloatingHeightBuildingAtlas() + { + return this.invalidFloatingHeightBuildingAtlas; + } + + public Atlas invalidFloatingLevelBuildingAtlas() + { + return this.invalidFloatingLevelBuildingAtlas; + } + + public Atlas validFloatingLevelRelationBuildingAtlas() + { + return this.validFloatingLevelRelationBuildingAtlas; + } + + public Atlas invalidFloatingLevelRelationBuildingAtlas() + { + return this.invalidFloatingLevelRelationBuildingAtlas; + } + + public Atlas validBuildingPartsTouchAtlas() + { + return this.validBuildingPartsTouchAtlas; + } + + public Atlas validBuildingPartsTouchGroundAtlas() + { + return this.validBuildingPartsTouchGroundAtlas; + } + + public Atlas validBuildingPartsIntersectAtlas() + { + return this.validBuildingPartsIntersectAtlas; + } + + public Atlas invalidBuildingPartsIntersectAtlas() + { + return this.invalidBuildingPartsIntersectAtlas; + } + + public Atlas invalidBuildingPartsDisparateAtlas() + { + return this.invalidBuildingPartsDisparateAtlas; + } + + public Atlas validBuildingPartsEnclosePartAtlas() + { + return this.validBuildingPartsEnclosePartAtlas; + } + + public Atlas validBuildingPartsEncloseNeighborAtlas() + { + return this.validBuildingPartsEncloseNeighborAtlas; + } + + public Atlas validBuildingPartsStackedAtlas() + { + return this.validBuildingPartsStackedAtlas; + } + + public Atlas validBuildingPartsStackedMixedTagsAtlas() + { + return this.validBuildingPartsStackedMixedTagsAtlas; + } + + public Atlas validBuildingAndPartStackedAtlas() + { + return this.validBuildingAndPartStackedAtlas; + } + + public Atlas invalidBuildingAndPartStackedAtlas() + { + return this.invalidBuildingAndPartStackedAtlas; + } + + public Atlas validBuildingRelationAndPartStackedAtlas() + { + return this.validBuildingRelationAndPartStackedAtlas; + } + + public Atlas invalidBuildingRelationAndPartStackedAtlas() + { + return this.invalidBuildingRelationAndPartStackedAtlas; + } + + public Atlas invalidBuildingsStackedAtlas() + { + return this.invalidBuildingsStackedAtlas; + } + + public Atlas invalidUntaggedAreasStackedAtlas() + { + return this.invalidUntaggedAreasStackedAtlas; + } + + public Atlas validUntaggedAreasStackedBuildingRelationAtlas() + { + return this.validUntaggedAreasStackedBuildingRelationAtlas; + } + + public Atlas invalidBuildingPartsManyFloatAtlas() + { + return this.invalidBuildingPartsManyFloatAtlas; + } + + public Atlas invalidBuildingPartSingleAtlas() + { + return this.invalidBuildingPartSingleAtlas; + } +}