Commit 7eb83cde authored by Matthias Betz's avatar Matthias Betz
Browse files

fixes

parent 0a760af4
Pipeline #12384 failed with stage
in 1 minute and 35 seconds
package de.hft.stuttgart.citydoctor2.datastructure.bht;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.ArrayList;
import java.util.function.Function;
import java.util.Objects;
import java.util.function.Function;
import de.hft.stuttgart.citydoctor2.datastructure.ConcretePolygon;
/**
* Generic Bounding Volume Hierarchy (BVH) for AABBs.
*
* Supports:
* - binary BVH (degree 2)
* - octonary / 8-ary BVH (degree 8)
* Supports: - binary BVH (degree 2) - octonary / 8-ary BVH (degree 8)
*
* Split strategies:
* - binary: object median, object mean, spatial median
* - octonary: object median, object mean, spatial median
* Split strategies: - binary: object median, object mean, spatial median -
* octonary: object median, object mean, spatial median
*
* Notes:
* - This implementation caches AABBs and centers during build time via BuildItem.
* - The input list is NOT reordered.
* - The octonary variant is implemented as an 8-ary BVH with tight child AABBs
* built from bucket contents, not as a strict spatial octree with fixed cell boxes.
* Notes: - This implementation caches AABBs and centers during build time via
* BuildItem. - The input list is NOT reordered. - The octonary variant is
* implemented as an 8-ary BVH with tight child AABBs built from bucket
* contents, not as a strict spatial octree with fixed cell boxes.
*
* @param <E> element type
*/
......@@ -42,107 +37,97 @@ public class BoundingVolumeHierarchyTree<E> {
public enum SplitStrategy {
AUTO,
BINARY_OBJECT_MEDIAN,
BINARY_OBJECT_MEAN,
BINARY_SPATIAL_MEDIAN,
BINARY_OBJECT_MEDIAN, BINARY_OBJECT_MEAN, BINARY_SPATIAL_MEDIAN,
OCTONARY_OBJECT_MEDIAN,
OCTONARY_OBJECT_MEAN,
OCTONARY_SPATIAL_MEDIAN
OCTONARY_OBJECT_MEDIAN, OCTONARY_OBJECT_MEAN, OCTONARY_SPATIAL_MEDIAN
}
public static final class BuildConfig {
private final int degree;
private final SplitStrategy splitStrategy;
private final int maxLeafSize;
private final int maxDepth;
private final double degenerateTolerance;
public static final class Builder<E> {
private int degree = 2;
private SplitStrategy splitStrategy = SplitStrategy.AUTO;
private int maxLeafSize = DEFAULT_BINARY_LEAF_SIZE;
private int maxDepth = DEFAULT_MAX_DEPTH;
private double degenerateTolerance = DEFAULT_DEGENERATE_TOL;
private List<E> elements;
private Function<E, AABB> aabbFunction;
private BuildConfig(Builder b) {
this.degree = b.degree;
this.splitStrategy = Objects.requireNonNull(b.splitStrategy, "splitStrategy");
this.maxLeafSize = b.maxLeafSize;
this.maxDepth = b.maxDepth;
this.degenerateTolerance = b.degenerateTolerance;
public Builder() {
}
public int getDegree() {
return degree;
public Builder<E> elements(List<E> elements) {
this.elements = elements;
return this;
}
public SplitStrategy getSplitStrategy() {
return splitStrategy;
public Builder<E> function(Function<E, AABB> aabbFunction) {
this.aabbFunction = aabbFunction;
return this;
}
public int getMaxLeafSize() {
return maxLeafSize;
public Builder<E> degree(int degree) {
this.degree = degree;
return this;
}
public int getMaxDepth() {
return maxDepth;
public Builder<E> splitStrategy(SplitStrategy splitStrategy) {
this.splitStrategy = splitStrategy;
return this;
}
public double getDegenerateTolerance() {
return degenerateTolerance;
public Builder<E> maxLeafSize(int maxLeafSize) {
this.maxLeafSize = maxLeafSize;
return this;
}
public static Builder builder() {
return new Builder();
public Builder<E> maxDepth(int maxDepth) {
this.maxDepth = maxDepth;
return this;
}
public static BuildConfig binaryDefault() {
return builder()
.degree(2)
.splitStrategy(SplitStrategy.AUTO)
.maxLeafSize(DEFAULT_BINARY_LEAF_SIZE)
.maxDepth(DEFAULT_MAX_DEPTH)
.degenerateTolerance(DEFAULT_DEGENERATE_TOL)
.build();
public Builder<E> degenerateTolerance(double degenerateTolerance) {
this.degenerateTolerance = degenerateTolerance;
return this;
}
public static BuildConfig octonaryDefault() {
return builder()
.degree(8)
.splitStrategy(SplitStrategy.AUTO)
.maxLeafSize(DEFAULT_OCTONARY_LEAF_SIZE)
.maxDepth(DEFAULT_MAX_DEPTH)
.degenerateTolerance(DEFAULT_DEGENERATE_TOL)
.build();
public Function<E, AABB> getAabbFunction() {
return aabbFunction;
}
public static final class Builder {
private int degree = 2;
private SplitStrategy splitStrategy = SplitStrategy.AUTO;
private int maxLeafSize = DEFAULT_BINARY_LEAF_SIZE;
private int maxDepth = DEFAULT_MAX_DEPTH;
private double degenerateTolerance = DEFAULT_DEGENERATE_TOL;
public double getDegenerateTolerance() {
return degenerateTolerance;
}
public Builder degree(int degree) {
this.degree = degree;
return this;
public int getDegree() {
return degree;
}
public Builder splitStrategy(SplitStrategy splitStrategy) {
this.splitStrategy = splitStrategy;
return this;
public List<E> getElements() {
return elements;
}
public Builder maxLeafSize(int maxLeafSize) {
this.maxLeafSize = maxLeafSize;
return this;
public int getMaxDepth() {
return maxDepth;
}
public Builder maxDepth(int maxDepth) {
this.maxDepth = maxDepth;
return this;
public int getMaxLeafSize() {
return maxLeafSize;
}
public Builder degenerateTolerance(double degenerateTolerance) {
this.degenerateTolerance = degenerateTolerance;
return this;
public SplitStrategy getSplitStrategy() {
return splitStrategy;
}
public Builder<E> binaryDefault() {
return new Builder<E>().degree(2).splitStrategy(SplitStrategy.AUTO).maxLeafSize(DEFAULT_BINARY_LEAF_SIZE)
.maxDepth(DEFAULT_MAX_DEPTH).degenerateTolerance(DEFAULT_DEGENERATE_TOL);
}
public Builder<E> octonaryDefault() {
return new Builder<E>().degree(8).splitStrategy(SplitStrategy.AUTO).maxLeafSize(DEFAULT_OCTONARY_LEAF_SIZE)
.maxDepth(DEFAULT_MAX_DEPTH).degenerateTolerance(DEFAULT_DEGENERATE_TOL);
}
public BuildConfig build() {
public BoundingVolumeHierarchyTree<E> build() {
if (degree != 2 && degree != 8) {
throw new IllegalArgumentException("Only degree 2 and 8 are supported.");
}
......@@ -155,8 +140,7 @@ public class BoundingVolumeHierarchyTree<E> {
if (degenerateTolerance < 0.0) {
throw new IllegalArgumentException("degenerateTolerance must be >= 0.");
}
return new BuildConfig(this);
}
return new BoundingVolumeHierarchyTree<E>(this);
}
}
......@@ -180,84 +164,68 @@ public class BoundingVolumeHierarchyTree<E> {
double center(int axis) {
switch (axis) {
case 0: return centerX;
case 1: return centerY;
case 2: return centerZ;
default: throw new IllegalArgumentException("axis must be 0, 1, or 2");
}
}
case 0:
return centerX;
case 1:
return centerY;
case 2:
return centerZ;
default:
throw new IllegalArgumentException("axis must be 0, 1, or 2");
}
/**
* Creates an empty BVH.
*/
public BoundingVolumeHierarchyTree(Function<E, AABB> aabbFunction) {
this.root = null;
this.aabbFunction = Objects.requireNonNull(aabbFunction, "aabbFunction");
}
/**
* Creates a binary BVH with default config (AUTO -> binary spatial median).
*/
public BoundingVolumeHierarchyTree(List<E> elements, Function<E, AABB> aabbFunction) {
this(elements, aabbFunction, BuildConfig.binaryDefault());
}
/**
* Creates a BVH with a full config.
*/
public BoundingVolumeHierarchyTree(List<E> elements, Function<E, AABB> aabbFunction, BuildConfig config) {
this.aabbFunction = Objects.requireNonNull(aabbFunction, "aabbFunction");
Objects.requireNonNull(config, "config");
private BoundingVolumeHierarchyTree(Builder<E> builder) {
Objects.requireNonNull(builder, "config");
this.aabbFunction = Objects.requireNonNull(builder.aabbFunction);
List<E> elements = Objects.requireNonNull(builder.getElements());
if (elements == null || elements.isEmpty()) {
this.root = null;
return;
}
List<BuildItem<E>> items = toBuildItems(elements);
SplitStrategy resolvedStrategy = resolveSplitStrategy(config);
SplitStrategy resolvedStrategy = resolveSplitStrategy(builder);
if (config.getDegree() == 2) {
this.root = buildBinaryRecursive(items, 0, resolvedStrategy, config);
if (builder.getDegree() == 2) {
this.root = buildBinaryRecursive(items, 0, resolvedStrategy, builder);
} else {
this.root = buildOctonaryRecursive(items, 0, resolvedStrategy, config);
this.root = buildOctonaryRecursive(items, 0, resolvedStrategy, builder);
}
}
/**
* Convenience factory: binary BVH with AUTO/defaults.
*/
public static <E> BoundingVolumeHierarchyTree<E> newBinary(
List<E> elements,
Function<E, AABB> aabbFunction) {
return new BoundingVolumeHierarchyTree<>(elements, aabbFunction, BuildConfig.binaryDefault());
public static <E> BoundingVolumeHierarchyTree<E> newBinary(List<E> elements, Function<E, AABB> aabbFunction) {
Builder<E> binaryDefault = new Builder<E>().binaryDefault();
binaryDefault.elements(elements);
binaryDefault.function(aabbFunction);
return new BoundingVolumeHierarchyTree<>(binaryDefault);
}
/**
* Convenience factory: octonary BVH with AUTO/defaults.
*/
public static <E> BoundingVolumeHierarchyTree<E> newOctonary(
List<E> elements,
Function<E, AABB> aabbFunction) {
return new BoundingVolumeHierarchyTree<>(elements, aabbFunction, BuildConfig.octonaryDefault());
public static <E> BoundingVolumeHierarchyTree<E> newOctonary(List<E> elements, Function<E, AABB> aabbFunction) {
Builder<E> octonaryDefault = new Builder<E>().octonaryDefault();
octonaryDefault.elements(elements);
octonaryDefault.function(aabbFunction);
return new BoundingVolumeHierarchyTree<>(octonaryDefault);
}
/**
* Convenience factory: octonary BVH with a custom maxLeafSize.
*/
public static <E> BoundingVolumeHierarchyTree<E> newOctonary(
List<E> elements,
Function<E, AABB> aabbFunction,
public static <E> BoundingVolumeHierarchyTree<E> newOctonary(List<E> elements, Function<E, AABB> aabbFunction,
int maxLeafSize) {
BuildConfig cfg = BuildConfig.builder()
.degree(8)
.splitStrategy(SplitStrategy.AUTO)
.maxLeafSize(maxLeafSize)
.maxDepth(computeDefaultMaxDepth(elements != null ? elements.size() : 0))
.degenerateTolerance(DEFAULT_DEGENERATE_TOL)
.build();
return new BoundingVolumeHierarchyTree<>(elements, aabbFunction, cfg);
Builder<E> octonaryDefault = new Builder<E>().octonaryDefault();
octonaryDefault.elements(elements);
octonaryDefault.function(aabbFunction);
octonaryDefault.maxLeafSize(maxLeafSize);
octonaryDefault.maxDepth(computeDefaultMaxDepth(elements != null ? elements.size() : 0));
return new BoundingVolumeHierarchyTree<>(octonaryDefault);
}
public Node<E> getRoot() {
......@@ -337,25 +305,21 @@ public class BoundingVolumeHierarchyTree<E> {
return items;
}
private SplitStrategy resolveSplitStrategy(BuildConfig config) {
private SplitStrategy resolveSplitStrategy(Builder<E> config) {
if (config.getSplitStrategy() != SplitStrategy.AUTO) {
validateStrategyMatchesDegree(config.getDegree(), config.getSplitStrategy());
return config.getSplitStrategy();
}
return (config.getDegree() == 2)
? SplitStrategy.BINARY_SPATIAL_MEDIAN
: SplitStrategy.OCTONARY_OBJECT_MEAN;
return (config.getDegree() == 2) ? SplitStrategy.BINARY_SPATIAL_MEDIAN : SplitStrategy.OCTONARY_OBJECT_MEAN;
}
private void validateStrategyMatchesDegree(int degree, SplitStrategy strategy) {
boolean binary = strategy == SplitStrategy.BINARY_OBJECT_MEDIAN
|| strategy == SplitStrategy.BINARY_OBJECT_MEAN
boolean binary = strategy == SplitStrategy.BINARY_OBJECT_MEDIAN || strategy == SplitStrategy.BINARY_OBJECT_MEAN
|| strategy == SplitStrategy.BINARY_SPATIAL_MEDIAN;
boolean octonary = strategy == SplitStrategy.OCTONARY_OBJECT_MEDIAN
|| strategy == SplitStrategy.OCTONARY_OBJECT_MEAN
|| strategy == SplitStrategy.OCTONARY_SPATIAL_MEDIAN;
|| strategy == SplitStrategy.OCTONARY_OBJECT_MEAN || strategy == SplitStrategy.OCTONARY_SPATIAL_MEDIAN;
if (degree == 2 && !binary) {
throw new IllegalArgumentException("Strategy " + strategy + " does not match degree 2.");
......@@ -365,11 +329,8 @@ public class BoundingVolumeHierarchyTree<E> {
}
}
private Node<E> buildBinaryRecursive(
List<BuildItem<E>> items,
int depth,
SplitStrategy strategy,
BuildConfig config) {
private Node<E> buildBinaryRecursive(List<BuildItem<E>> items, int depth, SplitStrategy strategy,
Builder<E> config) {
AABB totalAabb = getAggregateAABBFromItems(items);
......@@ -404,11 +365,8 @@ public class BoundingVolumeHierarchyTree<E> {
return node;
}
private Node<E> buildOctonaryRecursive(
List<BuildItem<E>> items,
int depth,
SplitStrategy strategy,
BuildConfig config) {
private Node<E> buildOctonaryRecursive(List<BuildItem<E>> items, int depth, SplitStrategy strategy,
Builder<E> config) {
AABB totalAabb = getAggregateAABBFromItems(items);
......@@ -448,9 +406,8 @@ public class BoundingVolumeHierarchyTree<E> {
return node;
}
private boolean shouldStop(List<BuildItem<E>> items, int depth, BuildConfig config, AABB totalAabb) {
return items.size() <= config.getMaxLeafSize()
|| depth >= config.getMaxDepth()
private boolean shouldStop(List<BuildItem<E>> items, int depth, Builder<E> config, AABB totalAabb) {
return items.size() <= config.getMaxLeafSize() || depth >= config.getMaxDepth()
|| totalAabb.isDegenerate(config.getDegenerateTolerance());
}
......@@ -531,10 +488,17 @@ public class BoundingVolumeHierarchyTree<E> {
private SplitResult<E> splitBinarySpatialMedian(List<BuildItem<E>> items, int axis, AABB totalAabb) {
double splitValue;
switch (axis) {
case 0: splitValue = totalAabb.getCenterX(); break;
case 1: splitValue = totalAabb.getCenterY(); break;
case 2: splitValue = totalAabb.getCenterZ(); break;
default: throw new IllegalArgumentException("axis must be 0, 1, or 2");
case 0:
splitValue = totalAabb.getCenterX();
break;
case 1:
splitValue = totalAabb.getCenterY();
break;
case 2:
splitValue = totalAabb.getCenterZ();
break;
default:
throw new IllegalArgumentException("axis must be 0, 1, or 2");
}
List<BuildItem<E>> left = new ArrayList<>();
......@@ -635,7 +599,8 @@ public class BoundingVolumeHierarchyTree<E> {
}
@SuppressWarnings("unchecked")
private OctSplit<E> bucketizeOctonary(List<BuildItem<E>> items, double sx, double sy, double sz, boolean fallbackToMeanOnFailure) {
private OctSplit<E> bucketizeOctonary(List<BuildItem<E>> items, double sx, double sy, double sz,
boolean fallbackToMeanOnFailure) {
List<BuildItem<E>>[] buckets = new List[8];
for (int i = 0; i < 8; i++) {
buckets[i] = new ArrayList<>();
......@@ -666,9 +631,12 @@ public class BoundingVolumeHierarchyTree<E> {
private int octantIndex(double x, double y, double z, double sx, double sy, double sz) {
int idx = 0;
if (x >= sx) idx |= 1;
if (y >= sy) idx |= 2;
if (z >= sz) idx |= 4;
if (x >= sx)
idx |= 1;
if (y >= sy)
idx |= 2;
if (z >= sz)
idx |= 4;
return idx;
}
......@@ -683,7 +651,6 @@ public class BoundingVolumeHierarchyTree<E> {
return new OctSplit<>(buckets);
}
@SuppressWarnings("unchecked")
static <E> OctSplit<E> invalid() {
return new OctSplit<>(null);
}
......
......@@ -89,7 +89,10 @@ public class SolidSelfIntCheckAABB extends Check {
// Build BVH on polygons, but compute AABBs from the *original* polygons
BoundingVolumeHierarchyTree<Polygon> tree =
new BoundingVolumeHierarchyTree<>(polys, p -> AABB.of(p.getOriginal()));
new BoundingVolumeHierarchyTree.Builder<Polygon>()
.elements(polys)
.function(p -> AABB.of(p.getOriginal()))
.build();
// TODO: comparison with older version without tree
List<PolygonIntersection> intersections =
SelfIntersectionUtil.calculateSolidSelfIntersection(g, 0.001, tree);
......
......@@ -84,7 +84,7 @@ public class NestedRingsCheck extends Check {
@Override
public void check(Polygon p) {
if (useAabbFilter) {
if (p.getInnerRings().size() > 3) {
checkWithBoundingBoxFilter(p);
} else {
checkOriginal(p);
......
......@@ -204,10 +204,8 @@ public class RingSelfIntCheck extends Check {
}
}
BoundingVolumeHierarchyTree<Edge> edgeTree = new BoundingVolumeHierarchyTree<>(
edges,
e -> AABB.of(e.getFrom(), e.getTo(), epsilon),
BoundingVolumeHierarchyTree.BuildConfig.binaryDefault());
BoundingVolumeHierarchyTree<Edge> edgeTree = BoundingVolumeHierarchyTree.newBinary(
edges, e -> AABB.of(e.getFrom(), e.getTo(), epsilon));
for (int i = 0; i < edges.size(); i++) {
Edge e1 = edges.get(i);
......@@ -264,10 +262,8 @@ public class RingSelfIntCheck extends Check {
List<Edge> edges = getEdgesForRing(lr);
BoundingVolumeHierarchyTree<Vertex> vertexTree = new BoundingVolumeHierarchyTree<>(
vertices,
v -> AABB.of(v, epsilon),
BoundingVolumeHierarchyTree.BuildConfig.binaryDefault());
BoundingVolumeHierarchyTree<Vertex> vertexTree =
BoundingVolumeHierarchyTree.newBinary(vertices, v -> AABB.of(v, epsilon));
for (Edge e : edges) {
if (checkForPointsTouchingEdgeTree(lr, e, vertexTree)) {
......@@ -275,10 +271,8 @@ public class RingSelfIntCheck extends Check {
}
}
BoundingVolumeHierarchyTree<Edge> edgeTree = new BoundingVolumeHierarchyTree<>(
edges,
e -> AABB.of(e.getFrom(), e.getTo(), epsilon),
BoundingVolumeHierarchyTree.BuildConfig.binaryDefault());
BoundingVolumeHierarchyTree<Edge> edgeTree = BoundingVolumeHierarchyTree.newBinary(
edges, e -> AABB.of(e.getFrom(), e.getTo(), epsilon));
for (int i = 0; i < edges.size(); i++) {
Edge e1 = edges.get(i);
......
......@@ -133,7 +133,7 @@ public class SelfIntersectionUtil {
public static List<PolygonIntersection> calculateSolidSelfIntersectionWithTree(
Geometry g,
double delta,
BoundingVolumeHierarchyTree.BuildConfig treeConfig) {
BoundingVolumeHierarchyTree.Builder<Integer> treeConfig) {
List<TesselatedPolygon> tesselatedPolygons = tesselateAndFilter(g, delta);
List<Integer> indices = new ArrayList<>(tesselatedPolygons.size());
......@@ -141,13 +141,10 @@ public class SelfIntersectionUtil {
indices.add(i);
}
treeConfig.elements(indices).function(index -> AABB.of(tesselatedPolygons.get(index).getOriginal()));
// Build BVH on polygon indices, while computing AABBs from the original polygons
BoundingVolumeHierarchyTree<Integer> tree =
new BoundingVolumeHierarchyTree<>(
indices,
index -> AABB.of(tesselatedPolygons.get(index).getOriginal()),
treeConfig
);
BoundingVolumeHierarchyTree<Integer> tree = treeConfig.build();
List<PolygonIntersection> intersections = new ArrayList<>();
......@@ -187,7 +184,7 @@ public class SelfIntersectionUtil {
return calculateSolidSelfIntersectionWithTree(
g,
delta,
BoundingVolumeHierarchyTree.BuildConfig.binaryDefault()
new BoundingVolumeHierarchyTree.Builder<Integer>().binaryDefault()
);
}
......
package de.hft.stuttgart.citydoctor2.checks.util;
import static org.junit.Assert.*;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import java.io.File;
import java.util.List;
import org.junit.Test;
import org.citygml4j.core.model.CityGMLVersion;
import org.citygml4j.core.model.core.CityModel;
import org.junit.jupiter.api.Test;
import de.hft.stuttgart.citydoctor2.check.ValidationConfiguration;
import de.hft.stuttgart.citydoctor2.database.UnconnectedCache;
import de.hft.stuttgart.citydoctor2.datastructure.Building;
import de.hft.stuttgart.citydoctor2.datastructure.CityDoctorModel;
import de.hft.stuttgart.citydoctor2.datastructure.Geometry;
......@@ -15,14 +20,31 @@ import de.hft.stuttgart.citydoctor2.datastructure.Lod;
import de.hft.stuttgart.citydoctor2.datastructure.Polygon;
import de.hft.stuttgart.citydoctor2.datastructure.bht.AABB;
import de.hft.stuttgart.citydoctor2.datastructure.bht.BoundingVolumeHierarchyTree;
import de.hft.stuttgart.citydoctor2.exceptions.CityDoctorWriteException;
import de.hft.stuttgart.citydoctor2.parser.CityGmlParseException;
import de.hft.stuttgart.citydoctor2.parser.CityGmlParser;
import de.hft.stuttgart.citydoctor2.parser.InvalidGmlFileException;
import de.hft.stuttgart.citydoctor2.parser.ParserConfiguration;
import de.hft.stuttgart.citydoctor2.utils.PolygonIntersection;
public class SolidSelfIntersectionBVHUtilTest {
@Test
public void testWriteModel() throws CityDoctorWriteException {
Building b = new Building();
b.addGeometry(GeometryTestUtils.createGoodGeometry());
b.setGmlObject(new org.citygml4j.core.model.building.Building());
UnconnectedCache unconnectedCache = new UnconnectedCache();
CityDoctorModel model = new CityDoctorModel(
new ParserConfiguration(8, false), new File("test.gml"), unconnectedCache);
model.setParsedCityGMLVersion(CityGMLVersion.v2_0);
model.setCityModel(new CityModel());
model.addBuilding(b);
model.saveAs("test.gml", false);
}
@Test
public void testBVHCalculateOnKnownGoodModel() throws CityGmlParseException, InvalidGmlFileException {
ValidationConfiguration config = ValidationConfiguration.loadStandardValidationConfig();
......@@ -42,7 +64,7 @@ public class SolidSelfIntersectionBVHUtilTest {
assertTrue("Expected at least 2 polygons", polys.size() > 1);
BoundingVolumeHierarchyTree<Polygon> tree =
new BoundingVolumeHierarchyTree<>(polys, p -> AABB.of(p.getOriginal()));
BoundingVolumeHierarchyTree.newBinary(polys, p -> AABB.of(p.getOriginal()));
double delta = 0.001;
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment