Skip to content
GitLab
Projects
Groups
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in
Toggle navigation
Menu
Open sidebar
Eric Duminil
RegionChooser
Commits
9e3cdd50
Commit
9e3cdd50
authored
Oct 11, 2022
by
Eric Duminil
Browse files
Write data directly to a file.
parent
9f282455
Changes
8
Hide whitespace changes
Inline
Side-by-side
src/main/java/eu/simstadt/regionchooser/RegionChooserBrowser.java
View file @
9e3cdd50
package
eu.simstadt.regionchooser
;
import
java.io.BufferedWriter
;
import
java.io.File
;
import
java.io.IOException
;
import
java.nio.file.Files
;
import
java.nio.file.Path
;
import
java.nio.file.Paths
;
import
java.util.logging.Logger
;
...
...
@@ -76,16 +78,22 @@ public Void call() throws IOException {
/**
* This method is called from Javascript, with a prepared wktPolygon written in local coordinates.
*/
public
void
downloadRegionFromCityGMLs
(
String
wktPolygon
,
String
project
,
String
csvCitygmls
,
String
srsName
)
public
int
downloadRegionFromCityGMLs
(
String
wktPolygon
,
String
project
,
String
csvCitygmls
,
String
srsName
)
throws
IOException
,
ParseException
,
XPathParseException
,
NavException
{
// It doesn't seem possible to pass arrays or list from JS to Java. So csvCitygmls contains names separated by ;
Path
[]
paths
=
Stream
.
of
(
csvCitygmls
.
split
(
";"
)).
map
(
s
->
citygmlPath
(
project
,
s
)).
toArray
(
Path
[]::
new
);
StringBuilder
sb
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
srsName
,
paths
);
File
buildingIds
File
=
selectSaveFileWithDialog
(
project
,
File
output
File
=
selectSaveFileWithDialog
(
project
,
csvCitygmls
.
replace
(
";"
,
"_"
).
replace
(
".gml"
,
""
),
"selected_region"
);
RegionChooserUtils
.
writeStringBuilderToFile
(
sb
,
buildingIdsFile
.
toPath
());
int
count
;
try
(
BufferedWriter
gmlWriter
=
Files
.
newBufferedWriter
(
outputFile
.
toPath
()))
{
count
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
srsName
,
gmlWriter
,
paths
);
}
return
count
;
}
...
...
src/main/java/eu/simstadt/regionchooser/RegionChooserCLI.java
View file @
9e3cdd50
package
eu.simstadt.regionchooser
;
import
java.io.BufferedWriter
;
import
java.nio.charset.StandardCharsets
;
import
java.nio.file.Files
;
import
java.nio.file.Path
;
...
...
@@ -101,9 +102,10 @@ public Integer call() throws Exception {
logInfo
(
"WKT Polygon expressed in local coordinates: "
+
wktPolygon
);
StringBuilder
sb
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
localCRS
.
toString
(),
citygmls
);
RegionChooserUtils
.
writeStringBuilderToFile
(
sb
,
outputCityGML
);
try
(
BufferedWriter
gmlWriter
=
Files
.
newBufferedWriter
(
outputCityGML
))
{
int
count
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
localCRS
.
toString
(),
gmlWriter
,
citygmls
);
logInfo
(
"Found buildings : "
+
count
);
}
return
0
;
}
...
...
src/main/java/eu/simstadt/regionchooser/RegionExtractor.java
View file @
9e3cdd50
package
eu.simstadt.regionchooser
;
import
java.io.IOException
;
import
java.io.Writer
;
import
java.nio.file.Path
;
import
java.util.logging.Logger
;
import
java.util.regex.Matcher
;
...
...
@@ -34,10 +35,12 @@
*
* @param wktPolygon
* @param srsName
* @param output
* @param citygmlPaths
*
*
* @return a StringBuffer, full with the extracted Citygml, including header, buildings and footer.
* @writes the extracted Citygml, including header, buildings and footer to output
* @return counts of found building.
* @throws ParseException
* @throws IOException
* @throws XPathEvalException
...
...
@@ -45,15 +48,11 @@
* @throws XPathParseException
* @throws NumberFormatException
*/
static
StringBuilder
selectRegionDirectlyFromCityGML
(
String
wktPolygon
,
String
srsName
,
Path
...
citygmlPaths
)
throws
ParseException
,
XPathParseException
,
NavException
,
IOException
{
//TODO: Should actually write directly to a bufferedwriter
//TODO: Should return the number of found buildings.
static
int
selectRegionDirectlyFromCityGML
(
String
wktPolygon
,
String
srsName
,
Writer
sb
,
Path
...
citygmlPaths
)
throws
ParseException
,
XPathParseException
,
NavException
,
IOException
{
int
buildingsCount
=
0
;
int
foundBuildingsCount
=
0
;
StringBuilder
sb
=
new
StringBuilder
();
Geometry
poly
=
WKT_READER
.
read
(
wktPolygon
);
CityGmlIterator
citygml
=
null
;
...
...
@@ -90,7 +89,7 @@ static StringBuilder selectRegionDirectlyFromCityGML(String wktPolygon, String s
LOGGER
.
info
(
"Buildings found in selected region "
+
foundBuildingsCount
);
sb
.
append
(
citygml
.
getFooter
());
return
sb
;
return
foundBuildingsCount
;
}
/**
...
...
src/main/java/eu/simstadt/regionchooser/fast_xml_parser/CityGmlIterator.java
View file @
9e3cdd50
...
...
@@ -97,7 +97,7 @@ public String getHeader() throws NavException {
* @return Citygml footer
* @throws NavException
*/
public
Object
getFooter
()
throws
IOException
,
NavException
{
public
String
getFooter
()
throws
IOException
,
NavException
{
int
footerOffset
=
buildingOffset
+
buildingLength
;
int
footerLength
=
(
int
)
(
Files
.
size
(
citygmlPath
)
-
footerOffset
);
return
navigator
.
toRawString
(
footerOffset
,
footerLength
);
...
...
src/main/resources/eu/simstadt/regionchooser/website/script/simstadt_openlayers.js
View file @
9e3cdd50
...
...
@@ -243,8 +243,9 @@ var regionChooser = (function(){
if
(
proj4
.
defs
(
srsName
)){
console
.
log
(
"
Selected region is written in
"
+
srsName
+
"
coordinate system.
"
);
try
{
fxapp
.
downloadRegionFromCityGMLs
(
sketchAsWKT
(
srsName
),
project
,
citygmlNames
.
join
(
"
;
"
),
srsName
);
dataPanel
.
prepend
(
"
<h2 class='ok'>Done!</h2><br/>
\n
"
);
var
count
=
fxapp
.
downloadRegionFromCityGMLs
(
sketchAsWKT
(
srsName
),
project
,
citygmlNames
.
join
(
"
;
"
),
srsName
);
//FIXME: count looks wrong. too high.
dataPanel
.
prepend
(
"
<h2 class='ok'>Done! (
"
+
count
+
"
buildings found) </h2><br/>
\n
"
);
}
catch
(
e
)
{
console
.
warn
(
"
ERROR :
"
+
e
);
dataPanel
.
prepend
(
"
<h2 class='error'>Some problem occured!</h2><br/>
\n
"
);
...
...
src/test/java/eu/simstadt/regionchooser/RegionChooserTests.java
View file @
9e3cdd50
...
...
@@ -94,10 +94,6 @@ void testExtractRegionFromTwoCitygmlsInWGS84() throws IOException {
assertEquals
(
22
,
countBuildings
(
outGML
));
}
private
long
countBuildings
(
Path
outGML
)
throws
IOException
{
return
Files
.
readAllLines
(
outGML
).
stream
().
filter
(
line
->
line
.
contains
(
"bldg:Building gml:id="
)).
count
();
}
@Test
void
testExtractRegionWithStandardInput
()
throws
IOException
{
String
wktPolygon
=
"POLYGON((-73.9959209576448 40.73286384885367, -73.996317924579 40.732359794090684, -73.9947515145143 40.7315061442504, -73.99422580154739 40.73214841515045, -73.9959209576448 40.73286384885367))"
;
...
...
@@ -129,5 +125,9 @@ void testExtractRegionWithMissingInput() throws IOException {
assertTrue
(
err
.
toString
().
contains
(
expectedLog
),
err
.
toString
()
+
" should contain "
+
expectedLog
);
assertFalse
(
Files
.
exists
(
outGML
));
}
private
long
countBuildings
(
Path
outGML
)
throws
IOException
{
return
Files
.
readAllLines
(
outGML
).
stream
().
filter
(
line
->
line
.
contains
(
"bldg:Building gml:id="
)).
count
();
}
}
src/test/java/eu/simstadt/regionchooser/RegionExtractorTests.java
View file @
9e3cdd50
...
...
@@ -3,6 +3,7 @@
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertEquals
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertFalse
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertTrue
;
import
java.io.StringWriter
;
import
java.nio.file.Path
;
import
java.nio.file.Paths
;
import
java.util.regex.Matcher
;
...
...
@@ -35,8 +36,10 @@ void testExtract3BuildingsFromGSK3Model() throws Throwable {
//NOTE: Small region around Martinskirche in Grünbühl
String
wktPolygon
=
"POLYGON((3515848.896028535 5415823.108586172,3515848.9512289143 5415803.590347393,3515829.0815150724 5415803.338023346,3515830.9784850604 5415793.437034622,3515842.0946056456 5415793.272282251,3515843.3515515197 5415766.204935087,3515864.1064344468 5415766.557899496,3515876.489172751 5415805.433782301,3515876.343844858 5415822.009293416,3515848.896028535 5415823.108586172))"
;
Path
citygmlPath
=
TEST_REPOSITORY
.
resolve
(
"Gruenbuehl.proj/20140218_Gruenbuehl_LOD2.gml"
);
String
churchGMLString
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
"EPSG:31467"
,
citygmlPath
)
.
toString
();
StringWriter
gmlWriter
=
new
StringWriter
();
int
count
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
"EPSG:31467"
,
gmlWriter
,
citygmlPath
);
String
churchGMLString
=
gmlWriter
.
toString
();
assertEquals
(
3
,
count
);
assertEquals
(
3
,
countRegexMatches
(
churchGMLString
,
CITY_OBJECT_MEMBER_REGEX
));
assertTrue
(
churchGMLString
.
contains
(
"Donaustr"
));
assertTrue
(
churchGMLString
.
contains
(
"DEBW_LOD2_203056"
));
...
...
@@ -56,9 +59,11 @@ void testExtract3BuildingsFromGSK3Model() throws Throwable {
void
testExtractBuildingsWithCommentsInBetween
()
throws
Throwable
{
String
wktPolygon
=
"POLYGON((300259.78663489706 62835.835907766595,300230.33294975647 62792.0482567884,300213.5667431851 62770.83143720031,300183.6592861123 62730.20347659383,300252.9947486632 62676.938468840905,300273.3862256562 62701.767105345614,300257.5250407747 62715.760413539596,300308.2754543957 62805.14198211394,300259.78663489706 62835.835907766595))"
;
Path
citygmlPath
=
TEST_REPOSITORY
.
resolve
(
"NewYork.proj/ManhattanSmall.gml"
);
String
archGMLString
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
EPSG_32118
,
citygmlPath
)
.
toString
();
assertEquals
(
countRegexMatches
(
archGMLString
,
CITY_OBJECT_MEMBER_REGEX
),
2
);
StringWriter
gmlWriter
=
new
StringWriter
();
int
count
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
EPSG_32118
,
gmlWriter
,
citygmlPath
);
String
archGMLString
=
gmlWriter
.
toString
();
assertEquals
(
2
,
count
);
assertEquals
(
2
,
countRegexMatches
(
archGMLString
,
CITY_OBJECT_MEMBER_REGEX
));
assertTrue
(
archGMLString
.
contains
(
"WASHINGTON SQUARE"
));
assertTrue
(
archGMLString
.
contains
(
"uuid_c0980a6e-05ea-4d09-bc83-efab226945a1"
));
assertTrue
(
archGMLString
.
contains
(
"uuid_0985cebb-922d-4b3e-95e5-15dc6089cd28"
));
...
...
@@ -70,8 +75,10 @@ void testExtractBuildingsWithCommentsInBetween() throws Throwable {
void
testExtractBuildingsAndChangeEnvelope
()
throws
Throwable
{
String
wktPolygon
=
"POLYGON((299761.8123557725 61122.68126771413,299721.46983062755 61058.11626595352,299780.84627343423 61021.99295737501,299823.9079725632 61083.3979344517,299761.8123557725 61122.68126771413))"
;
Path
citygmlPath
=
TEST_REPOSITORY
.
resolve
(
"NewYork.proj/FamilyCourt_LOD2_with_PLUTO_attributes.gml"
);
String
familyCourtBuilding
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
EPSG_32118
,
citygmlPath
).
toString
();
StringWriter
gmlWriter
=
new
StringWriter
();
int
count
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
EPSG_32118
,
gmlWriter
,
citygmlPath
);
String
familyCourtBuilding
=
gmlWriter
.
toString
();
assertEquals
(
1
,
count
);
assertEquals
(
1
,
countRegexMatches
(
familyCourtBuilding
,
CITY_OBJECT_MEMBER_REGEX
));
assertTrue
(
familyCourtBuilding
.
contains
(
"Bldg_12210021066"
));
assertFalse
(
...
...
@@ -95,8 +102,10 @@ void testExtract0BuildingsWithWrongCoordinates() throws Throwable {
//NOTE: Small region, far away from NYC
String
wktPolygon
=
"POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))"
;
Path
citygmlPath
=
TEST_REPOSITORY
.
resolve
(
"NewYork.proj/ManhattanSmall.gml"
);
String
emptyGMLString
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
EPSG_32118
,
citygmlPath
)
.
toString
();
StringWriter
gmlWriter
=
new
StringWriter
();
int
count
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
EPSG_32118
,
gmlWriter
,
citygmlPath
);
String
emptyGMLString
=
gmlWriter
.
toString
();
assertEquals
(
0
,
count
);
assertEquals
(
0
,
countRegexMatches
(
emptyGMLString
,
CITY_OBJECT_MEMBER_REGEX
));
assertTrue
(
emptyGMLString
.
contains
(
CITY_MODEL_HEADER
));
assertTrue
(
emptyGMLString
.
contains
(
CITY_MODEL_FOOTER
));
...
...
@@ -107,8 +116,10 @@ void testExtract0BuildingsFromEmptyGML() throws Throwable {
//NOTE: Small region, with too many spaces between coordinates
String
wktPolygon
=
"POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))"
;
Path
citygmlPath
=
TEST_REPOSITORY
.
resolve
(
"NewYork.proj/empty_model.gml"
);
String
emptyGMLString
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
EPSG_32118
,
citygmlPath
)
.
toString
();
StringWriter
gmlWriter
=
new
StringWriter
();
int
count
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
EPSG_32118
,
gmlWriter
,
citygmlPath
);
String
emptyGMLString
=
gmlWriter
.
toString
();
assertEquals
(
0
,
count
);
assertEquals
(
0
,
countRegexMatches
(
emptyGMLString
,
CITY_OBJECT_MEMBER_REGEX
));
assertTrue
(
emptyGMLString
.
contains
(
CORE_CITY_MODEL_HEADER
));
assertTrue
(
emptyGMLString
.
contains
(
CORE_CITY_MODEL_FOOTER
));
...
...
@@ -119,8 +130,10 @@ void testExtract0BuildingsFromWeirdGML() throws Throwable {
//NOTE: Small region, with too many spaces between coordinates
String
wktPolygon
=
"POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))"
;
Path
citygmlPath
=
TEST_REPOSITORY
.
resolve
(
"NewYork.proj/broken_nyc_lod2.gml"
);
String
emptyGMLString
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
EPSG_32118
,
citygmlPath
)
.
toString
();
StringWriter
gmlWriter
=
new
StringWriter
();
int
count
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
EPSG_32118
,
gmlWriter
,
citygmlPath
);
String
emptyGMLString
=
gmlWriter
.
toString
();
assertEquals
(
0
,
count
);
assertEquals
(
0
,
countRegexMatches
(
emptyGMLString
,
CITY_OBJECT_MEMBER_REGEX
));
assertTrue
(
emptyGMLString
.
contains
(
CORE_CITY_MODEL_HEADER
));
assertTrue
(
emptyGMLString
.
contains
(
CORE_CITY_MODEL_FOOTER
));
...
...
@@ -130,9 +143,11 @@ void testExtract0BuildingsFromWeirdGML() throws Throwable {
void
testExtractBuildingsFromCitygmlWithoutZinEnvelope
()
throws
Throwable
{
String
wktPolygon
=
"POLYGON((3512683.1280912133 5404783.732132129,3512719.1608604863 5404714.627650777,3512831.40076119 5404768.344155442,3512790.239106708 5404838.614891164,3512683.1280912133 5404783.732132129))"
;
Path
citygmlPath
=
TEST_REPOSITORY
.
resolve
(
"Stuttgart.proj/Stuttgart_LOD0_LOD1_small.gml"
);
String
emptyGMLString
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
"EPSG:31463"
,
citygmlPath
)
.
toString
();
assertEquals
(
2
,
countRegexMatches
(
emptyGMLString
,
"<bldg:Building gml:id"
));
StringWriter
gmlWriter
=
new
StringWriter
();
int
count
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
"EPSG:31463"
,
gmlWriter
,
citygmlPath
);
String
twoBuildings
=
gmlWriter
.
toString
();
assertEquals
(
2
,
count
);
assertEquals
(
2
,
countRegexMatches
(
twoBuildings
,
"<bldg:Building gml:id"
));
}
@Test
...
...
@@ -141,9 +156,12 @@ void testExtractBuildingsFrom2Citygmls() throws Throwable {
Path
citygml1
=
TEST_REPOSITORY
.
resolve
(
"Stuttgart.proj/Stuttgart_LOD0_LOD1_small.gml"
);
Path
citygml2
=
TEST_REPOSITORY
.
resolve
(
"Stuttgart.proj/Stöckach_überarbeitete GML-NoBuildingPart.gml"
);
String
emptyGMLString
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
"EPSG:31463"
,
citygml1
,
citygml2
).
toString
();
assertEquals
(
17
+
3
,
countRegexMatches
(
emptyGMLString
,
"<bldg:Building gml:id"
));
StringWriter
gmlWriter
=
new
StringWriter
();
int
count
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
"EPSG:31463"
,
gmlWriter
,
citygml1
,
citygml2
);
String
gmlFromTwoGMLs
=
gmlWriter
.
toString
();
assertEquals
(
17
+
3
,
count
);
assertEquals
(
17
+
3
,
countRegexMatches
(
gmlFromTwoGMLs
,
"<bldg:Building gml:id"
));
}
...
...
src/test/java/eu/simstadt/regionchooser/RegionExtractorWithDifferentInputTests.java
View file @
9e3cdd50
...
...
@@ -2,6 +2,7 @@
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertTrue
;
import
java.io.IOException
;
import
java.io.StringWriter
;
import
java.nio.file.Path
;
import
java.nio.file.Paths
;
import
java.util.Arrays
;
...
...
@@ -28,8 +29,9 @@ void testExtractRegionWithLocalCRS()
Path
citygmlPath
=
project
.
resolve
(
citygml
);
CoordinateReferenceSystem
localCRS
=
RegionChooserUtils
.
crsFromCityGMLHeader
(
citygmlPath
);
StringBuilder
sb
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
localCRS
.
getName
(),
citygmlPath
);
assertTrue
(
sb
.
toString
().
contains
(
"gml_ZVHMQQ6BZGRT0O3Q6RGXF12BDOV49QIZ58XB"
),
StringWriter
gmlWriter
=
new
StringWriter
();
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
wktPolygon
,
localCRS
.
getName
(),
gmlWriter
,
citygmlPath
);
assertTrue
(
gmlWriter
.
toString
().
contains
(
"gml_ZVHMQQ6BZGRT0O3Q6RGXF12BDOV49QIZ58XB"
),
"One weird shaped roof should be inside the region"
);
}
...
...
@@ -44,9 +46,9 @@ void testExtractRegionWithWGS84()
CoordinateReferenceSystem
localCRS
=
RegionChooserUtils
.
crsFromCityGMLHeader
(
citygmlPath
);
String
localWktPolygon
=
RegionChooserUtils
.
wktPolygonToLocalCRS
(
wgs84WktPolygon
,
localCRS
);
String
Builder
sb
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
localWktPolygon
,
localCRS
.
getName
()
,
citygmlPath
);
assertTrue
(
sb
.
toString
().
contains
(
"gml_ZVHMQQ6BZGRT0O3Q6RGXF12BDOV49QIZ58XB"
),
String
Writer
gmlWriter
=
new
StringWriter
()
;
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
localWktPolygon
,
localCRS
.
getName
(),
gmlWriter
,
citygmlPath
);
assertTrue
(
gmlWriter
.
toString
().
contains
(
"gml_ZVHMQQ6BZGRT0O3Q6RGXF12BDOV49QIZ58XB"
),
"One weird shaped roof should be inside the region"
);
}
...
...
@@ -90,10 +92,10 @@ void testExtractRegionWithOldCoordinates()
CoordinateReferenceSystem
localCRS
=
RegionChooserUtils
.
crsFromCityGMLHeader
(
citygmlPath
);
String
localWktPolygon
=
RegionChooserUtils
.
wktPolygonToLocalCRS
(
wgs84WktPolygon
,
localCRS
);
String
Builder
sb
=
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
localWktPolygon
,
localCRS
.
getName
()
,
citygmlPath
);
String
Writer
gmlWriter
=
new
StringWriter
()
;
RegionExtractor
.
selectRegionDirectlyFromCityGML
(
localWktPolygon
,
localCRS
.
getName
(),
gmlWriter
,
citygmlPath
);
assertTrue
(
sb
.
toString
().
contains
(
"gml_ZVHMQQ6BZGRT0O3Q6RGXF12BDOV49QIZ58XB"
),
assertTrue
(
gmlWriter
.
toString
().
contains
(
"gml_ZVHMQQ6BZGRT0O3Q6RGXF12BDOV49QIZ58XB"
),
"One weird shaped roof should be inside the region"
);
}
}
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment