diff --git a/docs/usage/parameters/PostProcessing.md b/docs/usage/parameters/PostProcessing.md index 116e1b9b5..ef4b7d5e3 100644 --- a/docs/usage/parameters/PostProcessing.md +++ b/docs/usage/parameters/PostProcessing.md @@ -15,6 +15,7 @@ Each post-processor has a field `type` which is used to identify it. * [Metadata parse](#metadata-parse) * [Metadata remap](#metadata-remap) * [Metadata truncate](#metadata-truncate) + * [Metadata conversion](#metadata-conversion) * [Geometry post-processors](#geometry-post-processors) * [Geometry buffer](#geometry-buffer) @@ -224,6 +225,36 @@ Avaliable policies: | `ignore` | Metadata is not modified. | | `error` | An error occurs, and the generation stops. | +### Metadata conversion + +Post-processor that converts metadata distances/altitude into voxel units. +This does not perform unit conversion. It assumes that the model uses the same unit as the chosen coordinate reference system used for projection. + +**Type**: `conversion` + +**Extra parameters** +- `metadata` (required): Name of the metadata to apply conversion. +- `convertAs` (required): The type of conversion to perform. +Since it is possible to provide two different scales (a vertical and a horizontal), the conversion of distance may not be the same whether it is a vertical distance (height for example) +or a horizontal distance (road width for example). As for the conversion of altitude it differs from a vertical distance as they might be an offset. +| convertAs | Explanation | +|:---------------------|:-----------------------------------------| +| `altitude` | Converts altitude into voxel. | +| `horizontalDistance` | Converts horizontal distance into voxel. | +| `verticalDistance` | Converts vertical distance into voxel. | + +- `ifMissing` (optional, default `error`): Policy to apply if metadata is missing (see below). +- `ifConversionFail` (optional, default `error`): Policy to apply if conversion fails (see below). + Available policies: + +| Policy | Explanation | +|:-----------------|:-----------------------------------------------------| +| `discardModel` | The model is discarded. | +| `removeMetadata` | The metadata is removed (no effect for `ifMissing`). | +| `ignore` | Metadata is not modified. | +| `error` | An error occurs, and the generation stops. | + + ## Geometry post-processors These post-processors can be applied to models resulting from the [`geoToolsVector` processor](FetchDataTask.md#geotoolsvector-geotools-vector-processor), and they only modifies the model, without changing its type. diff --git a/docs/usage/parameters/TileTasks.md b/docs/usage/parameters/TileTasks.md index 5dc7cf3c2..ea3acafcd 100644 --- a/docs/usage/parameters/TileTasks.md +++ b/docs/usage/parameters/TileTasks.md @@ -106,7 +106,7 @@ If the heightmap value is lower than or equal to the altitude, the placeable use - `models` (required, models must be voxelizable in 2d): [Selection of models](ModelSelection.md) to use. - `heightmap` (required): [Heightmap](Heightmaps.md) to use. -- `altitudeMetadata` (required): Name of the model metadata containing the altitude value. +- `altitudeMetadata` (required): Name of the model metadata containing the altitude value. Must be expressed in voxel units. - `placeAbove` (optional, default [`Nothing`](Placeables.md#nothing)): [Placeable](Placeables.md) placed above the altitude value. - `placeBelow` (optional, default [`Nothing`](Placeables.md#nothing)): [Placeable](Placeables.md) placed below the altitude value. @@ -138,7 +138,7 @@ Building height is given by `height` metadata (This task does nothing if the val - `window`: [Placeable](Placeables.md) used to render windows. #### Required model metadata - +All the below metadata must be expressed in voxel unit. - `height`: Height of the building to render (must be a positive integer). The height is defined as the distance between the minimum ground altitude and the gutter altitude. - `minimum-ground-altitude`: Minimum altitude inside the shape of the model. - `ground-floor-altitude`: Ground floor altitude of the model. diff --git a/examples/processes/full.yaml b/examples/processes/full.yaml index 707fd50ee..f0da6c3c0 100644 --- a/examples/processes/full.yaml +++ b/examples/processes/full.yaml @@ -39,6 +39,10 @@ forEachTile: metadata: height method: round ifMissing: ignore + - type: conversion + metadata: height + convertAs: verticalDistance + ifMissing: ignore - type: default metadata: height value: 5 diff --git a/src/main/java/com/ignfab/minalac/generator/MinalacGenerator.java b/src/main/java/com/ignfab/minalac/generator/MinalacGenerator.java index 9af365780..2a96ccdea 100644 --- a/src/main/java/com/ignfab/minalac/generator/MinalacGenerator.java +++ b/src/main/java/com/ignfab/minalac/generator/MinalacGenerator.java @@ -27,6 +27,7 @@ import com.ignfab.minalac.generator.parameters.processors.post.DiscardPostProcessorParams; import com.ignfab.minalac.generator.parameters.processors.post.IdentityPostProcessorParams; import com.ignfab.minalac.generator.parameters.processors.post.JTSGeometryBufferPostProcessorParams; +import com.ignfab.minalac.generator.parameters.processors.post.MetadataConvertPostProcessorParams; import com.ignfab.minalac.generator.parameters.processors.post.MetadataCopyPostProcessorParams; import com.ignfab.minalac.generator.parameters.processors.post.MetadataDefaultPostProcessorParams; import com.ignfab.minalac.generator.parameters.processors.post.MetadataParsePostProcessorParams; @@ -129,6 +130,7 @@ public static void main(String[] args) throws FactoryException, InterruptedExcep parser.registerParams("truncate", MetadataTruncatePostProcessorParams.class); parser.registerParams("geometryBuffer", JTSGeometryBufferPostProcessorParams.class); parser.registerParams("remap", MetadataValueMappingPostProcessorParams.class); + parser.registerParams("conversion", MetadataConvertPostProcessorParams.class); Generation generation = parser.parse(parameters).create(maxTileSize); diff --git a/src/main/java/com/ignfab/minalac/generator/generation/Generation.java b/src/main/java/com/ignfab/minalac/generator/generation/Generation.java index a47a0a0b6..d123213c5 100644 --- a/src/main/java/com/ignfab/minalac/generator/generation/Generation.java +++ b/src/main/java/com/ignfab/minalac/generator/generation/Generation.java @@ -2,14 +2,11 @@ import java.util.Collection; -import org.geotools.api.referencing.FactoryException; import org.geotools.api.referencing.crs.CoordinateReferenceSystem; import org.geotools.geometry.jts.ReferencedEnvelope; -import org.geotools.referencing.CRS; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.GeometryFactory; -import org.locationtech.jts.geom.util.AffineTransformation; import com.ignfab.minalac.generator.exceptions.TransformException; import com.ignfab.minalac.generator.generation.heightmaps.HeightmapDeclarationStore; @@ -33,12 +30,13 @@ public class Generation { // Target coordinate reference system (CRS used for voxel world rendering) private final CoordinateReferenceSystem crs; - // Vertical size of voxel in target CRS units + private final double centerX; + private final double centerY; + // Horizontal size of voxel in target CRS units (map unit / voxel) + private final double horizontalScale; + // Vertical size of voxel in target CRS units (map unit / voxel) private final double verticalScale; - - // Transformations from and to target CRS - private final AffineTransformation crsToVoxel; - private final AffineTransformation voxelToCrs; + private final double angle; private final VoxelWorld world; private final HeightmapDeclarationStore heightmaps = new HeightmapDeclarationStore(); @@ -78,8 +76,6 @@ public Generation( double angle, int maxTileSize) { - this.seed = seed; - WorldBBox3d maximumLimits = world.maxLimits(); world.setLimits(new WorldBBox3d( -extentX / 2, @@ -90,25 +86,16 @@ public Generation( maximumLimits.sizeZ() )); + this.seed = seed; + this.crs = crs; + this.centerX = centerX; + this.centerY = centerY; + this.horizontalScale = horizontalScale; + this.verticalScale = verticalScale; + this.angle = angle; this.world = world; - this.maxTileSize = maxTileSize; tiles = world.tiles(maxTileSize); - - this.crs = crs; - this.verticalScale = verticalScale; - - // CRS to Voxel transformation (basically, translates, rotates and scale) - crsToVoxel = new AffineTransformation(); - crsToVoxel.translate(-centerX, -centerY); - crsToVoxel.scale(1.0 / horizontalScale, 1.0 / horizontalScale); - crsToVoxel.rotate(-angle); - - // Voxel to CRS transformation (reverse of crsToVoxel transformation) - voxelToCrs = new AffineTransformation(); - voxelToCrs.rotate(angle); - voxelToCrs.scale(horizontalScale, horizontalScale); - voxelToCrs.translate(centerX, centerY); } /** @@ -143,7 +130,7 @@ public Scheduler scheduler() { * @param bbox Bounding box to get envelope for. * @return ReferencedEnvelope covering given bounding box in CRS. */ - public ReferencedEnvelope getEnvelopeForCRS(CoordinateReferenceSystem crs, WorldBBox3d bbox) throws FactoryException, TransformException { + public ReferencedEnvelope getEnvelopeForCRS(CoordinateReferenceSystem crs, WorldBBox3d bbox) throws TransformException { WorldToMapConverter converter = makeCoordsConverter(crs).inverse(); int minX = bbox.minX(); @@ -151,14 +138,14 @@ public ReferencedEnvelope getEnvelopeForCRS(CoordinateReferenceSystem crs, World int maxX = bbox.maxX() + 1; int maxY = bbox.maxY() + 1; Geometry geom = new GeometryFactory().createLinearRing(new Coordinate[] { - new Coordinate(minX, minY), - new Coordinate(maxX, minY), - new Coordinate(maxX, maxY), - new Coordinate(minX, maxY), - new Coordinate(minX, minY) + converter.convert(new Coordinate(minX, minY)), + converter.convert(new Coordinate(maxX, minY)), + converter.convert(new Coordinate(maxX, maxY)), + converter.convert(new Coordinate(minX, maxY)), + converter.convert(new Coordinate(minX, minY)) }); - return new ReferencedEnvelope(converter.convert(geom).getEnvelopeInternal(), crs); + return new ReferencedEnvelope(geom.getEnvelopeInternal(), crs); } /** @@ -166,18 +153,11 @@ public ReferencedEnvelope getEnvelopeForCRS(CoordinateReferenceSystem crs, World * * @param sourceCrs CRS from which convert map coordinates. * @return A converter to be used to convert any map coordinates into generated world coordinates. - * @throws FactoryException If not suitable transformation found for conversion. - */ - public MapToWorldConverter makeCoordsConverter(CoordinateReferenceSystem sourceCrs) throws FactoryException { - return new MapToWorldConverter(CRS.findMathTransform(sourceCrs, crs), crsToVoxel); - } - - /** - * {@return the vertical scale} */ - // To be removed when vertical is used by this class. (Renderers will probably contain that value) - public double getVerticalScale() { - return verticalScale; + public MapToWorldConverter makeCoordsConverter(CoordinateReferenceSystem sourceCrs) { + // altitudeOffset will be possibly be used to store offset between different CRS. + // If that is the case, the world CRS should be a CRS containing an altitude of reference . + return new MapToWorldConverter(sourceCrs, crs, -centerX, -centerY, 1.0 / horizontalScale, 1.0 / verticalScale, -angle, -0.0); } /** diff --git a/src/main/java/com/ignfab/minalac/generator/models/FloatMatrixModel.java b/src/main/java/com/ignfab/minalac/generator/models/FloatMatrixModel.java index d6dbc8098..333a04192 100644 --- a/src/main/java/com/ignfab/minalac/generator/models/FloatMatrixModel.java +++ b/src/main/java/com/ignfab/minalac/generator/models/FloatMatrixModel.java @@ -2,7 +2,7 @@ import com.ignfab.minalac.generator.exceptions.TransformException; import com.ignfab.minalac.generator.inputs.FloatGeographicDataMatrix2d; -import com.ignfab.minalac.generator.utils.coordinates.MapCoordinates; +import com.ignfab.minalac.generator.utils.coordinates.MapCoordinates2d; import com.ignfab.minalac.generator.utils.coordinates.MapToWorldConverter; import com.ignfab.minalac.generator.utils.coordinates.WorldToMapConverter; import com.ignfab.minalac.generator.utils.world2d.WorldBBox2d; @@ -11,24 +11,25 @@ /** * A model based on a matrix of floats (usually a heightmap model). + * The coordinates used to access its values are world coordinates. + * Beware its values are not converted, it is up to the caller to make the necessary changes (For example convert altitude in world unit) */ public class FloatMatrixModel extends ModelImpl implements Matrix2d { - private FloatGeographicDataMatrix2d data; - private MapToWorldConverter mapToWorld; - private WorldToMapConverter worldToMap; - private WorldBBox2d bbox; + private final FloatGeographicDataMatrix2d data; + private final WorldToMapConverter worldToMap; + private final WorldBBox2d bbox; /** * Creates a new {@code FloatMatrixModel}. * * @param data underlying geographic data * @param converter coordinates converter from matrix to world - * @throws TransformException + * @throws TransformException if converter is not invertible */ public FloatMatrixModel(FloatGeographicDataMatrix2d data, MapToWorldConverter converter) throws TransformException { - mapToWorld = converter; + super(converter); try { - worldToMap = mapToWorld.inverse(); + worldToMap = converter.inverse(); } catch (TransformException e) { throw new IllegalArgumentException("converter must be invertible"); } @@ -39,10 +40,10 @@ public FloatMatrixModel(FloatGeographicDataMatrix2d data, MapToWorldConverter co double maxY = data.offsetY() + data.sizeY() * data.cellSizeY() - 1.0; this.bbox = new WorldBBox2d( - mapToWorld.convert(new MapCoordinates(data.offsetX(), data.offsetY())), - mapToWorld.convert(new MapCoordinates(data.offsetX(), maxY)), - mapToWorld.convert(new MapCoordinates(maxX, data.offsetY())), - mapToWorld.convert(new MapCoordinates(maxX, maxY)) + converter.convert(new MapCoordinates2d(data.offsetX(), data.offsetY())), + converter.convert(new MapCoordinates2d(data.offsetX(), maxY)), + converter.convert(new MapCoordinates2d(maxX, data.offsetY())), + converter.convert(new MapCoordinates2d(maxX, maxY)) ); } @@ -55,7 +56,7 @@ public WorldBBox2d bbox() { public Float get(WorldCoords2d coords) { // Here we perform coordinates conversion and value interpolation - MapCoordinates coordinates; + MapCoordinates2d coordinates; try { coordinates = worldToMap.convert(coords); } catch (TransformException e) { diff --git a/src/main/java/com/ignfab/minalac/generator/models/JTSGeometryModel.java b/src/main/java/com/ignfab/minalac/generator/models/JTSGeometryModel.java index 804f8578b..aa09245d4 100644 --- a/src/main/java/com/ignfab/minalac/generator/models/JTSGeometryModel.java +++ b/src/main/java/com/ignfab/minalac/generator/models/JTSGeometryModel.java @@ -40,9 +40,7 @@ public class JTSGeometryModel extends ModelImpl implements ShapesVoxelizable2d, * @param converter Converter from geometry CRS to world coordinates */ public JTSGeometryModel(Geometry geom, MapToWorldConverter converter) throws TransformException { - super(); - // Until there is no need of it we don't keep original geometry. - // Geometry is stored transformed into world coordinates + super(converter); this.geom = converter.convert(geom); } diff --git a/src/main/java/com/ignfab/minalac/generator/models/Model.java b/src/main/java/com/ignfab/minalac/generator/models/Model.java index 34143c5fd..c668f96ab 100644 --- a/src/main/java/com/ignfab/minalac/generator/models/Model.java +++ b/src/main/java/com/ignfab/minalac/generator/models/Model.java @@ -1,5 +1,6 @@ package com.ignfab.minalac.generator.models; +import com.ignfab.minalac.generator.utils.coordinates.MapToWorldConverter; import com.ignfab.minalac.generator.utils.random.Salting; /** @@ -45,4 +46,10 @@ public interface Model extends Salting { * See docs/development/RandomNumbers.md for more detailed information. */ String salt(); + + /** + * {@return the {@link MapToWorldConverter } of this model.} + * It allows to convert the coordinates/distances of this model into world (game) coordinates/distances. + */ + MapToWorldConverter converter(); } diff --git a/src/main/java/com/ignfab/minalac/generator/models/ModelImpl.java b/src/main/java/com/ignfab/minalac/generator/models/ModelImpl.java index a01e3003d..af8a82ff2 100644 --- a/src/main/java/com/ignfab/minalac/generator/models/ModelImpl.java +++ b/src/main/java/com/ignfab/minalac/generator/models/ModelImpl.java @@ -3,6 +3,8 @@ import java.util.HashMap; import java.util.Map; +import com.ignfab.minalac.generator.utils.coordinates.MapToWorldConverter; + /** * Models are objects that can be rendered. * They optionally can have some metadata attached to them. @@ -12,6 +14,19 @@ public abstract class ModelImpl implements Model { * Model metadata. */ private final Map metadata = new HashMap<>(); + /** + * Converter allowing to convert the coordinates/distances of this model into world (game) coordinates/distances. + */ + private final MapToWorldConverter converter; + + /** + * Base constructor for models. + * + * @param converter the {@link MapToWorldConverter} to use. + */ + protected ModelImpl(MapToWorldConverter converter) { + this.converter = converter; + } @Override public boolean hasMetadata(String name) { @@ -31,4 +46,9 @@ public void setMetadata(String name, Object value) { else metadata.put(name, value); } + + @Override + public MapToWorldConverter converter() { + return this.converter; + } } diff --git a/src/main/java/com/ignfab/minalac/generator/parameters/processors/post/MetadataConvertPostProcessorParams.java b/src/main/java/com/ignfab/minalac/generator/parameters/processors/post/MetadataConvertPostProcessorParams.java new file mode 100644 index 000000000..e3f789aca --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/parameters/processors/post/MetadataConvertPostProcessorParams.java @@ -0,0 +1,111 @@ +package com.ignfab.minalac.generator.parameters.processors.post; + +import java.beans.ConstructorProperties; +import java.util.function.Function; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; + +import com.ignfab.minalac.generator.models.Model; +import com.ignfab.minalac.generator.processors.post.MetadataFunctionPostProcessor; +import com.ignfab.minalac.generator.processors.post.PostProcessor; + +/** + * Parameters for converting distances and altitude into voxel units. + */ +public class MetadataConvertPostProcessorParams extends PostProcessorParams { + /** + * Name of metadata to apply conversion (required). + */ + @JsonSetter(nulls = Nulls.FAIL) + public String metadata; + + /** + * The type of conversion to perform (required). + */ + @JsonSetter(nulls = Nulls.FAIL) + public ConversionFunctionParams convertAs; + + /** + * Policy to apply when the metadata is absent (optional). + */ + @JsonSetter(nulls = Nulls.SKIP) + public FailurePolicyParams ifMissing = FailurePolicyParams.ERROR; + + /** + * Policy to apply if conversion fails (optional). + */ + @JsonSetter(nulls = Nulls.SKIP) + public FailurePolicyParams ifConversionFail = FailurePolicyParams.ERROR; + + /** + * Constructor used to ensure that the required fields are present during deserialization. + * + * @param metadata the name of the metadata to convert + * @param convertAs the type of conversion to apply + */ + @ConstructorProperties({ "metadata", "convertAs" }) + public MetadataConvertPostProcessorParams(String metadata, ConversionFunctionParams convertAs) { + this.metadata = metadata; + this.convertAs = convertAs; + } + + @Override + public void validate() throws IllegalArgumentException { + if (metadata.isBlank()) + throw new IllegalArgumentException("The metadata field cannot be empty or contain only whitespace."); + } + + @Override + public PostProcessor create() { + return new MetadataFunctionPostProcessor<>( + metadata, + (model) -> convertAs.create(model), + ifMissing.create(), + ifConversionFail.create() + ); + } + + /** + * Type of conversion that be done. + */ + public enum ConversionFunctionParams { + /** + * Converts into voxel unit altitude. + * No unit conversion is performed. + * It assumes that the model uses the same unit as the chosen CRS. + */ + @JsonProperty("altitude") + ALTITUDE((Model m) -> m.converter()::convertAltitude), + /** + * Converts into voxel horizontal distance. + * No unit conversion is performed. + * It assumes that the model uses the same unit as the chosen CRS. + */ + @JsonProperty("horizontalDistance") + HORIZONTAL_DISTANCE((Model m) -> m.converter()::convertHorizontalDistance), + /** + * Converts into voxel vertical distance. + * No unit conversion is performed. + * It assumes that the model uses the same unit as the chosen CRS. + */ + @JsonProperty("verticalDistance") + VERTICAL_DISTANCE((Model m) -> m.converter()::convertVerticalDistance); + + private final Function> provider; + + ConversionFunctionParams(Function> provider) { + this.provider = provider; + } + + Function create(Model model) { + return (obj) -> { + if (obj instanceof Number value) { + return provider.apply(model).apply(value.doubleValue()); + } + throw new IllegalArgumentException("Conversion failed: metadata value is not a number"); + }; + } + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/parameters/processors/post/MetadataParsePostProcessorParams.java b/src/main/java/com/ignfab/minalac/generator/parameters/processors/post/MetadataParsePostProcessorParams.java index 09ae04349..a15c88004 100644 --- a/src/main/java/com/ignfab/minalac/generator/parameters/processors/post/MetadataParsePostProcessorParams.java +++ b/src/main/java/com/ignfab/minalac/generator/parameters/processors/post/MetadataParsePostProcessorParams.java @@ -63,9 +63,8 @@ public PostProcessor create() { ValueParser parser = (ValueParser) as; return new MetadataFunctionPostProcessor<>( - parser.type(), metadata, - parser.parser(), + (model) -> parser::parse, ifMissing.create(), ifNotParsable.create() ); diff --git a/src/main/java/com/ignfab/minalac/generator/parameters/processors/post/MetadataTruncatePostProcessorParams.java b/src/main/java/com/ignfab/minalac/generator/parameters/processors/post/MetadataTruncatePostProcessorParams.java index e4bcbe811..f05d8efc9 100644 --- a/src/main/java/com/ignfab/minalac/generator/parameters/processors/post/MetadataTruncatePostProcessorParams.java +++ b/src/main/java/com/ignfab/minalac/generator/parameters/processors/post/MetadataTruncatePostProcessorParams.java @@ -62,9 +62,8 @@ public void validate() throws IllegalArgumentException { @Override public PostProcessor create() { return new MetadataFunctionPostProcessor<>( - Integer.class, metadata, - method.create(), + (model) -> method.create(), ifMissing.create(), ifTruncationFail.create() ); diff --git a/src/main/java/com/ignfab/minalac/generator/processors/post/MetadataFunctionPostProcessor.java b/src/main/java/com/ignfab/minalac/generator/processors/post/MetadataFunctionPostProcessor.java index 68e756417..363e991ab 100644 --- a/src/main/java/com/ignfab/minalac/generator/processors/post/MetadataFunctionPostProcessor.java +++ b/src/main/java/com/ignfab/minalac/generator/processors/post/MetadataFunctionPostProcessor.java @@ -1,42 +1,36 @@ package com.ignfab.minalac.generator.processors.post; -import java.util.function.Function; - import com.ignfab.minalac.generator.exceptions.GenerationFailedException; import com.ignfab.minalac.generator.exceptions.IgnorableException; import com.ignfab.minalac.generator.models.Model; /** - * Post-processor parsing a metadata value in-place. + * Post-processor applying a function to a metadata value. * * @param type of the resulting value */ public class MetadataFunctionPostProcessor extends PostProcessor.Generic { private final String name; - private final Class type; - private final Function function; + private final ModelFunctionProvider provider; private final FailurePolicy ifMissingMetadata; private final FailurePolicy ifFunctionFails; /** * Creates a new post-processor that applies a function on a metadata value. * - * @param type type of the resulting value * @param name name of the metadata to process - * @param function function to apply + * @param provider model function provider containing function to apply * @param ifMissingMetadata policy to apply when the metadata is absent * @param ifFunctionFails policy to apply when the {@code function} returns an error */ public MetadataFunctionPostProcessor( - Class type, String name, - Function function, + ModelFunctionProvider provider, FailurePolicy ifMissingMetadata, FailurePolicy ifFunctionFails ) { - this.type = type; this.name = name; - this.function = function; + this.provider = provider; this.ifMissingMetadata = ifMissingMetadata; this.ifFunctionFails = ifFunctionFails; } @@ -53,12 +47,9 @@ public Model process(Model model) throws GenerationFailedException, IgnorableExc } Object value = model.getMetadata(name); - if (type.isInstance(value)) - return model; try { - T parsed = function.apply(value); - model.setMetadata(name, parsed); + model.setMetadata(name, provider.function(model).apply(value)); } catch (Throwable e) { switch (ifFunctionFails) { case IGNORE -> {} diff --git a/src/main/java/com/ignfab/minalac/generator/processors/post/ModelFunctionProvider.java b/src/main/java/com/ignfab/minalac/generator/processors/post/ModelFunctionProvider.java new file mode 100644 index 000000000..685d095a8 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/processors/post/ModelFunctionProvider.java @@ -0,0 +1,21 @@ +package com.ignfab.minalac.generator.processors.post; + +import java.util.function.Function; + +import com.ignfab.minalac.generator.models.Model; + +/** + * Provides a function from a model. + * This is useful when function needs context from the model. + * + * @param type of the function's return value + */ +@FunctionalInterface +public interface ModelFunctionProvider { + /** + * {@return a function from a model} + * + * @param model the model from which the function is based. + */ + Function function(Model model); +} diff --git a/src/main/java/com/ignfab/minalac/generator/tasks/FillBetweenHeightmapAndMetadataTask.java b/src/main/java/com/ignfab/minalac/generator/tasks/FillBetweenHeightmapAndMetadataTask.java index 58e20d5a5..60a03ff15 100644 --- a/src/main/java/com/ignfab/minalac/generator/tasks/FillBetweenHeightmapAndMetadataTask.java +++ b/src/main/java/com/ignfab/minalac/generator/tasks/FillBetweenHeightmapAndMetadataTask.java @@ -13,6 +13,7 @@ * A {@link TileTask} which, for each model in {@link ModelSelection}, * fills with {@link Placeable} the gap between a heightmap and an altitude (given by a model metadata) * within the model's boundaries. + * The altitude metadata value must be expressed in voxel unit. */ public class FillBetweenHeightmapAndMetadataTask extends ModelTask { private final ReadableHeightmapSpec heightmapSpec; diff --git a/src/main/java/com/ignfab/minalac/generator/tasks/HeightmapStatsTask.java b/src/main/java/com/ignfab/minalac/generator/tasks/HeightmapStatsTask.java index d2130e223..16c89d12a 100644 --- a/src/main/java/com/ignfab/minalac/generator/tasks/HeightmapStatsTask.java +++ b/src/main/java/com/ignfab/minalac/generator/tasks/HeightmapStatsTask.java @@ -9,6 +9,7 @@ /** * A {@link TileTask} which computes heightmap statistics over a model surface and adds the results as a metadata. + * The heightmap values must be expressed in voxel units in order to have metadata in voxel unit. */ public class HeightmapStatsTask extends ModelTask { private final ReadableHeightmapSpec heightmapSpec; diff --git a/src/main/java/com/ignfab/minalac/generator/tasks/PopulateHeightmapTask.java b/src/main/java/com/ignfab/minalac/generator/tasks/PopulateHeightmapTask.java index b8f3d95e9..8aced5ad9 100644 --- a/src/main/java/com/ignfab/minalac/generator/tasks/PopulateHeightmapTask.java +++ b/src/main/java/com/ignfab/minalac/generator/tasks/PopulateHeightmapTask.java @@ -35,7 +35,8 @@ protected void run(FloatMatrixModel model, GenerationTile tile) { for (Matrix2d.Value value : model) { WorldCoords2d c = value.coords(); if (intersection.contains(c)) - heightmap.set(c, Math.round(value.value())); + // TODO: Here we assume that the model always has altitude data, if that change we should create a specific MatrixModel for that + heightmap.set(c, model.converter().convertAltitude(value.value())); } } } diff --git a/src/main/java/com/ignfab/minalac/generator/tasks/RenderBuildingsTask.java b/src/main/java/com/ignfab/minalac/generator/tasks/RenderBuildingsTask.java index 868013ccc..9b557c0ab 100644 --- a/src/main/java/com/ignfab/minalac/generator/tasks/RenderBuildingsTask.java +++ b/src/main/java/com/ignfab/minalac/generator/tasks/RenderBuildingsTask.java @@ -45,6 +45,7 @@ protected void run(ShapesVoxelizable2d model, GenerationTile tile) { // Obtain them by executing the 'computeHeightmapStats' task // // The metadata is hardcoded because this renderer will be deleted + // All metadata must be in voxel units Integer zMinFoundation = model.getMetadata("minimum-ground-altitude"); Integer zMaxFoundation = model.getMetadata("ground-floor-altitude"); if (zMinFoundation == null || zMaxFoundation == null) diff --git a/src/main/java/com/ignfab/minalac/generator/utils/coordinates/Converter.java b/src/main/java/com/ignfab/minalac/generator/utils/coordinates/Converter.java deleted file mode 100644 index a5662f010..000000000 --- a/src/main/java/com/ignfab/minalac/generator/utils/coordinates/Converter.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.ignfab.minalac.generator.utils.coordinates; - -import org.geotools.api.geometry.MismatchedDimensionException; -import org.geotools.api.geometry.Position; -import org.geotools.api.referencing.operation.MathTransform; -import org.geotools.api.referencing.operation.NoninvertibleTransformException; -import org.geotools.geometry.Position2D; -import org.geotools.geometry.jts.JTS; -import org.locationtech.jts.geom.Coordinate; -import org.locationtech.jts.geom.CoordinateXY; -import org.locationtech.jts.geom.Geometry; -import org.locationtech.jts.geom.util.AffineTransformation; -import org.locationtech.jts.geom.util.NoninvertibleTransformationException; - -import com.ignfab.minalac.generator.exceptions.TransformException; - -/** - * Converts coordinates from a CRS to world coordinates. - */ -public class Converter { - private final AffineTransformation preTransform; - private final MathTransform crsTransform; - private final AffineTransformation postTransform; - - /** - * Creates a new {@code CoordsConverter}. - * - * This is up to the caller to give proper transformations: - * @param preTransform Affine transformation applied before CRS transformation (usually for translation). - * @param crsTransform Transformation from source CRS to world CRS. - * @param postTransform Affine transformation applied after CRS transformation (usually for scale and rotation). - * @throws IllegalArgumentException if crsTransformation is not two dimensional or any transformation is null. - */ - protected Converter(AffineTransformation preTransform, MathTransform crsTransform, AffineTransformation postTransform) { - if (preTransform == null || crsTransform == null || postTransform == null) - throw new IllegalArgumentException("No transformation should be null"); - - if (crsTransform.getSourceDimensions() != 2 || crsTransform.getTargetDimensions() != 2) - throw new IllegalArgumentException("crsTransform must be from two dimensions to two dimensions"); - - this.preTransform = preTransform; - this.crsTransform = crsTransform; - this.postTransform = postTransform; - } - - /** - * Constructs a new {@code CoordsConverter} from another, adding a preTransformation. - * - * This is up to the caller to give proper transformations: - * @param preTransform Affine transformation applied before CRS transformation (usually for translation). - * @param converter Converter to start from - */ - protected Converter(AffineTransformation preTransform, Converter converter) { - this(preTransform.composeBefore(converter.preTransform), converter.crsTransform, converter.postTransform); - } - - /** - * Creates a new {@code CoordinatesConverter} performing inverse transformation. - * - * @return inverse coverter - * @throws TransformException if coverter cannot be inverted - */ - protected Converter inverse() throws TransformException { - try { - return new Converter(postTransform.getInverse(), crsTransform.inverse(), preTransform.getInverse()); - } catch (NoninvertibleTransformationException | NoninvertibleTransformException e) { - throw new TransformException("Converter is not invertible", e); - } - } - - /** - * Converts coordinates. - * - * @param coords Coordinates to convert - * - * @return converted coordiantes - * - * @throws TransformException if conversion cannot be performed - */ - public MapCoordinates convert(MapCoordinates coords) throws TransformException { - Coordinate coordinate = new Coordinate(); - try { - - preTransform.transform(new CoordinateXY(coords.x(), coords.y()), coordinate); - Position position = crsTransform.transform(new Position2D(coordinate.x, coordinate.y), null); - postTransform.transform(new Coordinate(position.getOrdinate(0), position.getOrdinate(1)), coordinate); - return new MapCoordinates(coordinate.getX(), coordinate.getY()); - } catch (MismatchedDimensionException e) { - // This should never occur as we check dimensions in constructor - throw new TransformException("Dimension mismatch", e); - } catch (org.geotools.api.referencing.operation.TransformException e) { - throw new TransformException("Could not transform coordinates", e); - } - } - - /** - * Converts a JTS geometry. - * - * @param geom Geometry to transform. - * @return Transformed geometry. - * @throws TransformException if unable to perform transformation. - */ - public Geometry convert(Geometry geom) throws TransformException { - try { - return postTransform.transform(JTS.transform(preTransform.transform(geom), crsTransform)); - } catch (org.geotools.api.referencing.operation.TransformException e) { - throw new TransformException("Could not transform geometry", e); - } - } -} diff --git a/src/main/java/com/ignfab/minalac/generator/utils/coordinates/MapCoordinates.java b/src/main/java/com/ignfab/minalac/generator/utils/coordinates/MapCoordinates2d.java similarity index 83% rename from src/main/java/com/ignfab/minalac/generator/utils/coordinates/MapCoordinates.java rename to src/main/java/com/ignfab/minalac/generator/utils/coordinates/MapCoordinates2d.java index 0d8790d25..3fbc89eaa 100644 --- a/src/main/java/com/ignfab/minalac/generator/utils/coordinates/MapCoordinates.java +++ b/src/main/java/com/ignfab/minalac/generator/utils/coordinates/MapCoordinates2d.java @@ -10,14 +10,14 @@ * @param x The x-component value * @param y The y-component value */ -public record MapCoordinates(double x, double y) { +public record MapCoordinates2d(double x, double y) { /** * Creates a {@code MapCoordinates} from {@code org.locationtech.jts.geom.Coordinate}. * * @param coordinate {@code Coordinate} to create {@code MapCoordinates} from */ - public MapCoordinates(Coordinate coordinate) { - this(coordinate.x, coordinate.y); + public MapCoordinates2d(Coordinate coordinate) { + this(coordinate.getX(), coordinate.getY()); } private static Position check2d(Position position) { @@ -29,7 +29,7 @@ private static Position check2d(Position position) { * * @param pos {@code Position} to create {@code MapCoordinates} from */ - public MapCoordinates(Position pos) { + public MapCoordinates2d(Position pos) { this(check2d(pos).getOrdinate(0), pos.getOrdinate(1)); } } diff --git a/src/main/java/com/ignfab/minalac/generator/utils/coordinates/MapCoordinates3d.java b/src/main/java/com/ignfab/minalac/generator/utils/coordinates/MapCoordinates3d.java new file mode 100644 index 000000000..f13310efc --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/utils/coordinates/MapCoordinates3d.java @@ -0,0 +1,36 @@ +package com.ignfab.minalac.generator.utils.coordinates; + +import org.geotools.api.geometry.MismatchedDimensionException; +import org.geotools.api.geometry.Position; +import org.locationtech.jts.geom.Coordinate; + +/** + * The {@code MapCoordinates} class represents three dimensional decimal (double float) coordinates. + * + * @param x The x-component value + * @param y The y-component value + * @param z The z-component value + */ +public record MapCoordinates3d(double x, double y, double z) { + /** + * Creates a {@code MapCoordinates} from {@code org.locationtech.jts.geom.Coordinate}. + * + * @param coordinate {@code Coordinate} to create {@code MapCoordinates} from + */ + public MapCoordinates3d(Coordinate coordinate) { + this(coordinate.getX(), coordinate.getY(), coordinate.getZ()); + } + + private static Position check3d(Position position) { + if (position.getDimension() != 3) throw new MismatchedDimensionException(); + return position; + } + /** + * Creates a {@code MapCoordinates} from {@code org.geotools.geometry.Position}. + * + * @param pos {@code Position} to create {@code MapCoordinates} from + */ + public MapCoordinates3d(Position pos) { + this(check3d(pos).getOrdinate(0), pos.getOrdinate(1), pos.getOrdinate(2)); + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/utils/coordinates/MapToWorldConverter.java b/src/main/java/com/ignfab/minalac/generator/utils/coordinates/MapToWorldConverter.java index b23d95024..cd3e5de3d 100644 --- a/src/main/java/com/ignfab/minalac/generator/utils/coordinates/MapToWorldConverter.java +++ b/src/main/java/com/ignfab/minalac/generator/utils/coordinates/MapToWorldConverter.java @@ -1,95 +1,211 @@ package com.ignfab.minalac.generator.utils.coordinates; +import java.awt.geom.AffineTransform; +import javax.measure.Unit; + +import org.geotools.api.geometry.Position; +import org.geotools.api.referencing.FactoryException; +import org.geotools.api.referencing.crs.CoordinateReferenceSystem; import org.geotools.api.referencing.operation.MathTransform; +import org.geotools.api.referencing.operation.MathTransformFactory; +import org.geotools.api.referencing.operation.NoninvertibleTransformException; +import org.geotools.geometry.Position2D; +import org.geotools.geometry.jts.JTS; +import org.geotools.referencing.CRS; +import org.geotools.referencing.operation.DefaultMathTransformFactory; +import org.geotools.referencing.operation.transform.AffineTransform2D; +import org.geotools.referencing.util.CRSUtilities; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateFilter; import org.locationtech.jts.geom.Geometry; -import org.locationtech.jts.geom.util.AffineTransformation; import com.ignfab.minalac.generator.exceptions.TransformException; import com.ignfab.minalac.generator.utils.world2d.WorldCoords2d; +import com.ignfab.minalac.generator.utils.world3d.WorldCoords3d; + /** - * A map (real) to world (game) coordinates converter. - * - * This wraps a {@link Converter} and provide method with adapted types. + * Converter capable of converting coordinates/distances from a map CRS (real) to world (game) coordinates/distances. */ public class MapToWorldConverter { - private final Converter converter; + private final MathTransform transform; + private final double horizontalScale; // (voxel / map unit) + // There might be an additional vertical scale to mitigate the fact that Minecraft is limited in vertical length. + private final double verticalScale; // (voxel / map unit) + // TODO: See also comment on Generation makeCoordsConverter() + // Unused for now, this value is meant to store the possible offset between different CRS + // and possibly a non-geographical offset to mitigate the fact that Minecraft is limited in vertical length. + private final double altitudeOffset; /** - * Creates a new {@code MapToWorldConverter} out of a {@code CoordinatesConverter}. + * Creates a new {@code MapToWorldConverter} from the given parameters. + * It allows conversion of coordinates expressed in map CRS (real) into world coordinates (game). + * Roughly it applies two transformations + *
    + *
  • Map CRS to world CRS
  • + *
  • Post affine transformation: translation, then horizontal scaling, then rotation
  • + *
+ * Since post affine transformation can be applied, world CRS must be a cartesian coordinate system otherwise result might be awry. * - * @param converter base converter to use + * @param mapCrs Coordinate reference system used by the map + * @param worldCrs Coordinate reference system used for generated world, it must a Cartesian coordinate system + * @param translateX x-translation to apply in voxel unit + * @param translateY y-translation to apply in voxel unit + * @param horizontalScale Horizontal size of voxel in world CRS units (voxel / world unit) + * @param verticalScale Vertical size of voxel in world CRS units (voxel / world unit) + * @param angle Rotation angle around center in radians + * @param altitudeOffset the altitude offset to apply in voxel units */ - private MapToWorldConverter(Converter converter) { - this.converter = converter; + @SuppressWarnings("checkstyle:ParameterNumber") + public MapToWorldConverter( + CoordinateReferenceSystem mapCrs, + CoordinateReferenceSystem worldCrs, + double translateX, + double translateY, + double horizontalScale, + double verticalScale, + double angle, + double altitudeOffset + ) { + // Checking units once + Unit unitMap = CRSUtilities.getUnit(mapCrs.getCoordinateSystem()); + Unit unitWorld = CRSUtilities.getUnit(worldCrs.getCoordinateSystem()); + if (!unitMap.equals(unitWorld)) + System.out.printf("WARNING: the two CRS do not use the same unit. They might be awry results when converting distances. Unit map: %s, Unit world: %s%n", unitMap.getName(), unitWorld.getName()); + + MathTransform transform; + try { + transform = chain( + CRS.findMathTransform(mapCrs, worldCrs), + new AffineTransform2D(AffineTransform.getTranslateInstance(translateX, translateY)), + new AffineTransform2D(AffineTransform.getScaleInstance(horizontalScale, horizontalScale)), + new AffineTransform2D(AffineTransform.getRotateInstance(angle)) + ); + } catch (FactoryException e) { + // FactoryException will most likely occur if the CRS transform can not be created + throw new IllegalArgumentException("Could not create map transformation from CRS parameters", e); + } + + this.transform = transform; + // The scales might be passed to the transform object, but it lost during creation + // The same value should be passed explicitly as it is required for convertAltitude(), convertHorizontalDistance(), convertVerticalDistance() + this.horizontalScale = horizontalScale; + this.verticalScale = verticalScale; + this.altitudeOffset = altitudeOffset; } /** - * Creates a new {@code MapToWorldConverter}. + * {@return a new {@link WorldToMapConverter} capable of performing the inverse coordinates conversion} + * @throws TransformException if it is not possible to create the inverse conversion. + */ + public WorldToMapConverter inverse() throws TransformException { + if (verticalScale == 0.0 || horizontalScale == 0.0) + throw new TransformException("Converter is not invertible because of scales"); + try { + return new WorldToMapConverter(transform.inverse()); + } catch (NoninvertibleTransformException e) { + throw new TransformException("Converter is not invertible because of map transformation", e); + } + } + + /** + * Convert {@code MapCoordinates2d} coordinates. + * The result is rounded down. * - * This is up to the caller to give proper transformations: - * @param preTransform Affine transformation applied before CRS transformation (usually for translation). - * @param crsTransform Transformation from source CRS to world CRS. - * @param postTransform Affine transformation applied after CRS transformation (usually for scale and rotation). - * @throws IllegalArgumentException if crsTransformation is not two dimensional or any transformation is null. + * @param coords the coordinates expressed in map CRS. + * @return the converted coordinates as a {@code WorldCoords2d}. + * @throws TransformException if conversion can not be performed */ - public MapToWorldConverter(AffineTransformation preTransform, MathTransform crsTransform, AffineTransformation postTransform) { - this(new Converter(preTransform, crsTransform, postTransform)); + public WorldCoords2d convert(MapCoordinates2d coords) throws TransformException { + Position position = convert(coords.x(), coords.y()); + return WorldCoords2d.floor(position.getOrdinate(0), position.getOrdinate(1)); } /** - * Creates a new {@code MapToWorldConverter}. + * Convert {@code MapCoordinates3d} coordinates. + * The result is rounded down. * - * This is up to the caller to give proper transformations: - * @param crsTransform Transformation from source CRS to world CRS. - * @param postTransform Affine transformation applied after CRS transformation (usualy for scale and rotation). - * @throws IllegalArgumentException if crsTransformation is not two dimensional or any transformation is null. + * @param coords the coordinates expressed in map CRS. + * @return the converted coordinates as a {@code WorldCoords3d}. + * @throws TransformException if conversion can not be performed */ - public MapToWorldConverter(MathTransform crsTransform, AffineTransformation postTransform) { - this(new AffineTransformation(), crsTransform, postTransform); + public WorldCoords3d convert(MapCoordinates3d coords) throws TransformException { + Position position = convert(coords.x(), coords.y()); + return WorldCoords3d.floor(position.getOrdinate(0), position.getOrdinate(1), convertAltitude(coords.z())); + } + + private Position convert(double x, double y) throws TransformException { + try { + return transform.transform(new Position2D(x, y), null); + } catch (org.geotools.api.referencing.operation.TransformException e) { + throw new TransformException("Could not transform coordinates", e); + } } /** - * Creates a new {@code MapToWorldConverter} from another, adding a preTransformation. + * Converts a JTS geometry. * - * This is up to the caller to give proper transformations: - * @param preTransform Affine transformation applied before CRS transformation (usually for translation). - * @param converter Converter to start from + * @param geom the geometry to transform + * @return the transformed geometry + * @throws TransformException if transformation can not be performed */ - public MapToWorldConverter(AffineTransformation preTransform, MapToWorldConverter converter) { - this(new Converter(preTransform, converter.converter)); + public Geometry convert(Geometry geom) throws TransformException { + try { + Geometry g = JTS.transform(geom, transform); + g.apply(new AltitudeFilter()); + return g; + } catch (org.geotools.api.referencing.operation.TransformException e) { + throw new TransformException("Could not transform geometry", e); + } } /** - * Creates a new {@code WorldToMapCoordsConverter} performing inverse transformation. + * Converts the altitude. + * No unit conversion is performed, it assumes that map CRS and world CRS use the same unit. * - * @return inverse coverter - * @throws TransformException if coverter cannot be inverted + * @param altitude expressed in map CRS unit. + * @return the converted altitude expressed in voxel unit. */ - public WorldToMapConverter inverse() throws TransformException { - return new WorldToMapConverter(converter.inverse()); + public int convertAltitude(double altitude) { + return (int) Math.floor((altitude + altitudeOffset) * verticalScale); } /** - * Converts map (real) coordinates into world (game) coordinates. + * Converts the given horizontal distance. + * No unit conversion is performed, it assumes that map CRS and world CRS use the same unit. * - * @param coords Coordinates in map - * @return Corresponding coordinates in voxels, in decimal numbers - * @throws TransformException if unable to perform transformation. + * @param distance expressed in map CRS unit. + * @return the converted distance expressed in voxel unit. */ - public WorldCoords2d convert(MapCoordinates coords) throws TransformException { - MapCoordinates converted = converter.convert(coords); - return WorldCoords2d.floor(converted.x(), converted.y()); + public int convertHorizontalDistance(double distance) { + return (int) Math.floor(distance * horizontalScale); } /** - * Converts a JTS geometry. + * Converts a given vertical distance. + * No unit conversion is performed, it assumes that map CRS and world CRS use the same unit. * - * @param geom Geometry to transform. - * @return Transformed geometry. - * @throws TransformException if unable to perform transformation. + * @param distance expressed in map CRS unit. + * @return the converted distance expressed in voxel unit. */ - public Geometry convert(Geometry geom) throws TransformException { - return converter.convert(geom); + public int convertVerticalDistance(double distance) { + return (int) Math.floor(distance * verticalScale); } + + private static MathTransform chain(MathTransform first, MathTransform... others) throws FactoryException { + MathTransformFactory factory = new DefaultMathTransformFactory(); + MathTransform result = first; + for (MathTransform other : others) + result = factory.createConcatenatedTransform(result, other); + return result; + } + + private final class AltitudeFilter implements CoordinateFilter { + + @Override + public void filter(Coordinate coord) { + coord.z = convertAltitude(coord.z); + } + } + } diff --git a/src/main/java/com/ignfab/minalac/generator/utils/coordinates/WorldToMapConverter.java b/src/main/java/com/ignfab/minalac/generator/utils/coordinates/WorldToMapConverter.java index c99fb75dd..63d1395a7 100644 --- a/src/main/java/com/ignfab/minalac/generator/utils/coordinates/WorldToMapConverter.java +++ b/src/main/java/com/ignfab/minalac/generator/utils/coordinates/WorldToMapConverter.java @@ -1,46 +1,59 @@ package com.ignfab.minalac.generator.utils.coordinates; -import org.locationtech.jts.geom.Geometry; +import org.geotools.api.geometry.Position; +import org.geotools.api.referencing.operation.MathTransform; +import org.geotools.geometry.Position2D; +import org.locationtech.jts.geom.Coordinate; import com.ignfab.minalac.generator.exceptions.TransformException; import com.ignfab.minalac.generator.utils.world2d.WorldCoords2d; /** - * A world (game) to map (real) coordinates converter. - * - * This wraps a {@link Converter} and provide method with adapted types. + * Converter capable of converting coordinates expressed in voxels coordinates (game) to map coordinates (real). + * It should be constructed using {@link MapToWorldConverter#inverse()}. */ public class WorldToMapConverter { - private final Converter converter; + private final MathTransform transform; /** - * Creates a new {@code WorldToMapConverter} out of a {@code CoordinatesConverter}. + * Constructs a new {@code WorldToMapConverter} by passing a {@code MathTransform}. * - * @param converter base converter to use + * @param transform the transformation object to use. */ - protected WorldToMapConverter(Converter converter) { - this.converter = converter; + protected WorldToMapConverter(MathTransform transform) { + this.transform = transform; } /** - * Converts world (game) coordinates into map (real) coordinates. + * Convert {@code WorldCoords2d} coordinates. * - * @param coords Coordinates in map - * @return Corresponding coordinates in voxels, in decimal numbers - * @throws TransformException if unable to perform transformation. + * @param coords the world (game) coordinates. + * @return the converted coordinates as a {@code MapCoordinates2d}. + * @throws TransformException if conversion can not be performed */ - public MapCoordinates convert(WorldCoords2d coords) throws TransformException { - return converter.convert(new MapCoordinates(coords.x(), coords.y())); + public MapCoordinates2d convert(WorldCoords2d coords) throws TransformException { + Position position = convert(coords.x(), coords.y()); + return new MapCoordinates2d(position.getOrdinate(0), position.getOrdinate(1)); + } /** - * Converts a JTS geometry. + * Convert JTS coordinates. * - * @param geom Geometry to transform. - * @return Transformed geometry. - * @throws TransformException if unable to perform transformation. + * @param coords the world (game) coordinates as a {@code Coordinate} (JTS). + * @return the converted coordinates as a JTS coordinates. + * @throws TransformException if conversion can not be performed */ - public Geometry convert(Geometry geom) throws TransformException { - return converter.convert(geom); + public Coordinate convert(Coordinate coords) throws TransformException { + Position position = convert(coords.x, coords.y); + return new Coordinate(position.getOrdinate(0), position.getOrdinate(1)); + } + + private Position convert(double x, double y) throws TransformException { + try { + return transform.transform(new Position2D(x, y), null); + } catch (org.geotools.api.referencing.operation.TransformException e) { + throw new TransformException("Could not transform coordinates", e); + } } } diff --git a/src/test/java/com/ignfab/minalac/generator/models/FloatMatrixModelTest.java b/src/test/java/com/ignfab/minalac/generator/models/FloatMatrixModelTest.java index cbcd064c8..c93a5e1a9 100644 --- a/src/test/java/com/ignfab/minalac/generator/models/FloatMatrixModelTest.java +++ b/src/test/java/com/ignfab/minalac/generator/models/FloatMatrixModelTest.java @@ -1,13 +1,11 @@ package com.ignfab.minalac.generator.models; -import org.geotools.referencing.operation.transform.IdentityTransform; import org.junit.jupiter.api.Test; -import org.locationtech.jts.geom.util.AffineTransformation; import com.ignfab.minalac.generator.exceptions.TransformException; import com.ignfab.minalac.generator.inputs.FloatArrayGeographicDataMatrix2d; import com.ignfab.minalac.generator.inputs.FloatGeographicDataMatrix2d; -import com.ignfab.minalac.generator.utils.coordinates.MapToWorldConverter; +import com.ignfab.minalac.generator.utils.coordinates.TestingConverter; import com.ignfab.minalac.generator.utils.world2d.WorldBBox2d; import com.ignfab.minalac.generator.utils.world2d.WorldCoords2d; @@ -16,19 +14,15 @@ public class FloatMatrixModelTest { @Test public void testBBox() throws TransformException { - FloatGeographicDataMatrix2d data = new FloatArrayGeographicDataMatrix2d(3, 4, 1.0, 2.0, 1.0, 1.0); - MapToWorldConverter converter = new MapToWorldConverter(IdentityTransform.create(2), new AffineTransformation()); - FloatMatrixModel model = new FloatMatrixModel(data, converter); + FloatMatrixModel model = new FloatMatrixModel(data, TestingConverter.IDENTITY); assertEquals(new WorldBBox2d(1, 2, 3, 4), model.bbox()); } @Test public void testGet() throws TransformException { - // Identity converter - MapToWorldConverter converter = new MapToWorldConverter(IdentityTransform.create(2), new AffineTransformation()); // Beware, Y is upside down in this matrix float[] values = { 9.0f, 0.0f, 13.0f, @@ -39,7 +33,7 @@ public void testGet() throws TransformException { FloatGeographicDataMatrix2d data = new FloatArrayGeographicDataMatrix2d(values, 3, 4, 1.0, 2.0, 1.0, 1.0); - FloatMatrixModel model = new FloatMatrixModel(data, converter); + FloatMatrixModel model = new FloatMatrixModel(data, TestingConverter.IDENTITY); assertNull(model.get(new WorldCoords2d(0, 2))); assertEquals(1, model.get(new WorldCoords2d(1, 2))); assertEquals(2, model.get(new WorldCoords2d(2, 2))); @@ -56,7 +50,6 @@ public void testGet() throws TransformException { @Test public void testGetInterpolation() throws TransformException { - MapToWorldConverter converter = new MapToWorldConverter(IdentityTransform.create(2), new AffineTransformation()); // Beware, Y is upside down in this matrix float[] values = { -1.0f, 1.0f, @@ -64,7 +57,7 @@ public void testGetInterpolation() throws TransformException { }; FloatGeographicDataMatrix2d data = new FloatArrayGeographicDataMatrix2d(values, 2, 2, 0.0, 0.0, 10.0, 10.0); - FloatMatrixModel model = new FloatMatrixModel(data, converter); + FloatMatrixModel model = new FloatMatrixModel(data, TestingConverter.IDENTITY); // Borders assertNull(model.get(new WorldCoords2d(0, 11))); assertNull(model.get(new WorldCoords2d(11, 0))); diff --git a/src/test/java/com/ignfab/minalac/generator/models/ModelStoreTest.java b/src/test/java/com/ignfab/minalac/generator/models/ModelStoreTest.java index b12bebf94..4a872611c 100644 --- a/src/test/java/com/ignfab/minalac/generator/models/ModelStoreTest.java +++ b/src/test/java/com/ignfab/minalac/generator/models/ModelStoreTest.java @@ -2,10 +2,16 @@ import org.junit.jupiter.api.Test; +import com.ignfab.minalac.generator.utils.coordinates.TestingConverter; + import static org.junit.jupiter.api.Assertions.*; public class ModelStoreTest { static class TestModel extends ModelImpl { + TestModel() { + super(TestingConverter.UNUSED); + } + @Override public String salt() { return ""; diff --git a/src/test/java/com/ignfab/minalac/generator/models/TestingModel.java b/src/test/java/com/ignfab/minalac/generator/models/TestingModel.java index 080dfaf16..8ee32ffc1 100644 --- a/src/test/java/com/ignfab/minalac/generator/models/TestingModel.java +++ b/src/test/java/com/ignfab/minalac/generator/models/TestingModel.java @@ -2,6 +2,9 @@ import java.util.Map; +import com.ignfab.minalac.generator.utils.coordinates.MapToWorldConverter; +import com.ignfab.minalac.generator.utils.coordinates.TestingConverter; + import static org.junit.jupiter.api.Assertions.*; /** @@ -10,11 +13,16 @@ public class TestingModel extends ModelImpl { private final String name; - public TestingModel(String name, Map metadata) { + public TestingModel(String name, Map metadata, MapToWorldConverter converter) { + super(converter); this.name = name; metadata.forEach(this::setMetadata); } + public TestingModel(String name, Map metadata) { + this(name, metadata, TestingConverter.UNUSED); + } + public TestingModel(Map metadata) { this(null, metadata); } @@ -23,6 +31,10 @@ public TestingModel(String name) { this(name, Map.of()); } + public TestingModel(MapToWorldConverter converter) { + this(null, Map.of(), converter); + } + public TestingModel() { this(null, Map.of()); } diff --git a/src/test/java/com/ignfab/minalac/generator/models/TestingRectangleShapeVoxelizable2dModel.java b/src/test/java/com/ignfab/minalac/generator/models/TestingRectangleShapeVoxelizable2dModel.java index 7b845ed0b..80eaee978 100644 --- a/src/test/java/com/ignfab/minalac/generator/models/TestingRectangleShapeVoxelizable2dModel.java +++ b/src/test/java/com/ignfab/minalac/generator/models/TestingRectangleShapeVoxelizable2dModel.java @@ -1,5 +1,6 @@ package com.ignfab.minalac.generator.models; +import com.ignfab.minalac.generator.utils.coordinates.TestingConverter; import com.ignfab.minalac.generator.utils.world2d.WorldBBox2d; import com.ignfab.minalac.generator.utils.world2d.WorldCoords2d; import com.ignfab.minalac.generator.voxelization.shape2d.Polygon2d; @@ -18,7 +19,7 @@ public class TestingRectangleShapeVoxelizable2dModel extends ModelImpl implement * @param bbox rectangle of this model in world */ public TestingRectangleShapeVoxelizable2dModel(WorldBBox2d bbox) { - super(); + super(TestingConverter.UNUSED); this.bbox = bbox; } diff --git a/src/test/java/com/ignfab/minalac/generator/parameters/GenerationParamsTest.java b/src/test/java/com/ignfab/minalac/generator/parameters/GenerationParamsTest.java index 365c5a0e7..cb1a3e3a0 100644 --- a/src/test/java/com/ignfab/minalac/generator/parameters/GenerationParamsTest.java +++ b/src/test/java/com/ignfab/minalac/generator/parameters/GenerationParamsTest.java @@ -135,7 +135,6 @@ public void testCreate() throws ParseException { assertEquals("test", generation.world().getMetadata().getWorldName()); assertEquals(50, generation.world().limits().sizeX()); assertEquals(75, generation.world().limits().sizeY()); - assertEquals(3.0, generation.getVerticalScale(), 0.001); HeightmapDeclaration ground = assertDoesNotThrow(() -> generation.heightmaps().get("ground")); assertEquals(3, ground.create(WorldBBox2d.ORIGIN).get(0, 0)); diff --git a/src/test/java/com/ignfab/minalac/generator/parameters/processors/post/MetadataConvertPostProcessorParamsTest.java b/src/test/java/com/ignfab/minalac/generator/parameters/processors/post/MetadataConvertPostProcessorParamsTest.java new file mode 100644 index 000000000..a7be83642 --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/parameters/processors/post/MetadataConvertPostProcessorParamsTest.java @@ -0,0 +1,96 @@ +package com.ignfab.minalac.generator.parameters.processors.post; + +import java.util.function.Function; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.NamedType; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.geotools.api.referencing.FactoryException; +import org.geotools.referencing.CRS; +import org.junit.jupiter.api.Test; + +import com.ignfab.minalac.generator.models.Model; +import com.ignfab.minalac.generator.models.TestingModel; +import com.ignfab.minalac.generator.utils.coordinates.MapToWorldConverter; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class MetadataConvertPostProcessorParamsTest { + + @Test + public void testCreate() { + assertDoesNotThrow(() -> + new MetadataConvertPostProcessorParams( + "metadata", + MetadataConvertPostProcessorParams.ConversionFunctionParams.HORIZONTAL_DISTANCE + ) + ); + } + + @Test + public void testValidate() { + assertDoesNotThrow(new MetadataConvertPostProcessorParams("metadata", MetadataConvertPostProcessorParams.ConversionFunctionParams.ALTITUDE)::validate); + assertThrows(IllegalArgumentException.class, new MetadataConvertPostProcessorParams("", MetadataConvertPostProcessorParams.ConversionFunctionParams.ALTITUDE)::validate); + } + + @Test + public void testDeserialize() { + ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + mapper.registerSubtypes(new NamedType(MetadataConvertPostProcessorParams.class, "conversion")); + + MetadataConvertPostProcessorParams min = assertDoesNotThrow(() -> mapper.readValue( + """ + type: conversion + metadata: height + convertAs: verticalDistance + ifMissing: ignore + ifConversionFail: discardModel + """, + MetadataConvertPostProcessorParams.class + )); + assertEquals("height", min.metadata); + assertEquals(MetadataConvertPostProcessorParams.ConversionFunctionParams.VERTICAL_DISTANCE, min.convertAs); + assertEquals(FailurePolicyParams.IGNORE, min.ifMissing); + assertEquals(FailurePolicyParams.DISCARD_MODEL, min.ifConversionFail); + + MetadataConvertPostProcessorParams max = assertDoesNotThrow(() -> mapper.readValue( + """ + type: conversion + metadata: height + convertAs: altitude + """, + MetadataConvertPostProcessorParams.class + )); + assertEquals("height", max.metadata); + assertEquals(MetadataConvertPostProcessorParams.ConversionFunctionParams.ALTITUDE, max.convertAs); + assertEquals(FailurePolicyParams.ERROR, max.ifMissing); + assertEquals(FailurePolicyParams.ERROR, max.ifConversionFail); + } + + @Test + public void testCreateConversionFunctionParams() throws FactoryException { + MapToWorldConverter converter = new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + // horizontalScale + 3, + // verticalScale + 0.5, + 0, + 1 + ); + Model model = new TestingModel(converter); + + Function horizontal = assertDoesNotThrow(() -> MetadataConvertPostProcessorParams.ConversionFunctionParams.HORIZONTAL_DISTANCE.create(model)); + // This is to make sure the function is properly created. + assertThrows(IllegalArgumentException.class, () -> horizontal.apply("60.34")); + assertEquals(181, horizontal.apply(60.34)); + + Function vertical = assertDoesNotThrow(() -> MetadataConvertPostProcessorParams.ConversionFunctionParams.VERTICAL_DISTANCE.create(model)); + assertEquals(70, vertical.apply(141.7)); + } +} diff --git a/src/test/java/com/ignfab/minalac/generator/processors/post/MetadataFunctionPostProcessorTest.java b/src/test/java/com/ignfab/minalac/generator/processors/post/MetadataFunctionPostProcessorTest.java index 0b69ae267..943ee9b65 100644 --- a/src/test/java/com/ignfab/minalac/generator/processors/post/MetadataFunctionPostProcessorTest.java +++ b/src/test/java/com/ignfab/minalac/generator/processors/post/MetadataFunctionPostProcessorTest.java @@ -32,9 +32,8 @@ private TestingModel getProcessed(MetadataFunctionPostProcessor postProcessor @Test public void testSimpleParse() { TestingModel processed = getProcessed(new MetadataFunctionPostProcessor<>( - String.class, "int", - Objects::toString, + (model) -> Objects::toString, // Failure policy is irrelevant for this test as there is no failure FailurePolicy.ERROR, FailurePolicy.ERROR @@ -46,7 +45,6 @@ public void testSimpleParse() { @Test public void testParsingError() { assertThrows(GenerationFailedException.class, () -> new MetadataFunctionPostProcessor<>( - String.class, "int", obj -> { throw new RuntimeException(); @@ -59,9 +57,8 @@ public void testParsingError() { @Test public void testMissingAllowed() { TestingModel processed = getProcessed(new MetadataFunctionPostProcessor<>( - String.class, "missing", - Objects::toString, + (model) -> Objects::toString, FailurePolicy.IGNORE, FailurePolicy.IGNORE )); @@ -71,9 +68,8 @@ public void testMissingAllowed() { @Test public void testMissingForbidden() { assertThrows(GenerationFailedException.class, () -> new MetadataFunctionPostProcessor<>( - String.class, "missing", - Objects::toString, + (model) -> Objects::toString, FailurePolicy.ERROR, FailurePolicy.ERROR ).process(model)); @@ -82,7 +78,6 @@ public void testMissingForbidden() { @Test public void testNullAllowed() { TestingModel processed = getProcessed(new MetadataFunctionPostProcessor<>( - String.class, "int", obj -> null, FailurePolicy.ERROR, @@ -94,7 +89,6 @@ public void testNullAllowed() { @Test public void testFailurePolicyIgnore() { TestingModel processed = getProcessed(new MetadataFunctionPostProcessor<>( - String.class, "int", obj -> { throw new RuntimeException(); @@ -108,7 +102,6 @@ public void testFailurePolicyIgnore() { @Test public void testFailurePolicyRemove() { TestingModel processed = getProcessed(new MetadataFunctionPostProcessor<>( - String.class, "int", obj -> { throw new RuntimeException(); @@ -122,7 +115,6 @@ public void testFailurePolicyRemove() { @Test public void testFailurePolicySkip() { assertThrows(IgnorableException.class, () -> new MetadataFunctionPostProcessor<>( - String.class, "int", obj -> { throw new RuntimeException(); @@ -135,7 +127,6 @@ public void testFailurePolicySkip() { @Test public void testFailurePolicyError() { assertThrows(GenerationFailedException.class, () -> new MetadataFunctionPostProcessor<>( - String.class, "int", obj -> { throw new RuntimeException(); diff --git a/src/test/java/com/ignfab/minalac/generator/tasks/ModelTaskTest.java b/src/test/java/com/ignfab/minalac/generator/tasks/ModelTaskTest.java index 667b30581..96c333ce7 100644 --- a/src/test/java/com/ignfab/minalac/generator/tasks/ModelTaskTest.java +++ b/src/test/java/com/ignfab/minalac/generator/tasks/ModelTaskTest.java @@ -11,6 +11,7 @@ import com.ignfab.minalac.generator.generation.TestingGenerationTile; import com.ignfab.minalac.generator.models.ModelImpl; import com.ignfab.minalac.generator.models.ModelSelection; +import com.ignfab.minalac.generator.utils.coordinates.TestingConverter; import com.ignfab.minalac.generator.utils.world3d.WorldBBox3d; import static org.junit.jupiter.api.Assertions.*; @@ -64,6 +65,7 @@ private static class ModelImplTester extends ModelImpl { private final char type; ModelImplTester(char type) { + super(TestingConverter.UNUSED); this.type = type; } diff --git a/src/test/java/com/ignfab/minalac/generator/tasks/PopulateHeightmapTaskTest.java b/src/test/java/com/ignfab/minalac/generator/tasks/PopulateHeightmapTaskTest.java index e0e3c89f1..2cc5971e4 100644 --- a/src/test/java/com/ignfab/minalac/generator/tasks/PopulateHeightmapTaskTest.java +++ b/src/test/java/com/ignfab/minalac/generator/tasks/PopulateHeightmapTaskTest.java @@ -1,8 +1,6 @@ package com.ignfab.minalac.generator.tasks; -import org.geotools.referencing.operation.transform.IdentityTransform; import org.junit.jupiter.api.Test; -import org.locationtech.jts.geom.util.AffineTransformation; import com.ignfab.minalac.generator.exceptions.TransformException; import com.ignfab.minalac.generator.generation.TestingGenerationTile; @@ -12,7 +10,7 @@ import com.ignfab.minalac.generator.inputs.FloatGeographicDataMatrix2d; import com.ignfab.minalac.generator.models.FloatMatrixModel; import com.ignfab.minalac.generator.models.ModelSelection; -import com.ignfab.minalac.generator.utils.coordinates.MapToWorldConverter; +import com.ignfab.minalac.generator.utils.coordinates.TestingConverter; import com.ignfab.minalac.generator.utils.world3d.WorldBBox3d; import static org.junit.jupiter.api.Assertions.*; @@ -23,7 +21,6 @@ public void test() throws TransformException { TestingGenerationTile tile = new TestingGenerationTile(new WorldBBox3d(-1, -2, 0, 3, 3, 1)); TestingHeightmap heightmap = tile.newStoredHeightmap("heightmap", 0); - MapToWorldConverter converter = new MapToWorldConverter(IdentityTransform.create(2), new AffineTransformation()); // Beware, Y is upside down in this matrix float[] values = { 9.0f, 10.0f, 11.0f, @@ -33,7 +30,7 @@ public void test() throws TransformException { }; FloatGeographicDataMatrix2d data = new FloatArrayGeographicDataMatrix2d(values, 3, 4, 0, -1, 1.0, 1.0); - FloatMatrixModel model = new FloatMatrixModel(data, converter); + FloatMatrixModel model = new FloatMatrixModel(data, TestingConverter.IDENTITY); tile.models().add("matrix", model); ModelSelection selection = new ModelSelection("matrix", null); diff --git a/src/test/java/com/ignfab/minalac/generator/utils/coordinates/MapToMapConverterTest.java b/src/test/java/com/ignfab/minalac/generator/utils/coordinates/MapToMapConverterTest.java deleted file mode 100644 index 21fcdef78..000000000 --- a/src/test/java/com/ignfab/minalac/generator/utils/coordinates/MapToMapConverterTest.java +++ /dev/null @@ -1,188 +0,0 @@ -package com.ignfab.minalac.generator.utils.coordinates; - -import org.geotools.api.referencing.FactoryException; -import org.geotools.api.referencing.operation.MathTransform; -import org.geotools.referencing.CRS; -import org.geotools.referencing.operation.transform.IdentityTransform; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.locationtech.jts.geom.Coordinate; -import org.locationtech.jts.geom.Geometry; -import org.locationtech.jts.geom.GeometryFactory; -import org.locationtech.jts.geom.util.AffineTransformation; - -import com.ignfab.minalac.generator.exceptions.TransformException; - -import static org.junit.jupiter.api.Assertions.*; - -public class MapToMapConverterTest { - - private static final GeometryFactory FACTORY = new GeometryFactory(); - private static final AffineTransformation ATID = new AffineTransformation(); - - @Test - @DisplayName("Post transformation tests") - public void testPointPostTransform() throws TransformException { - Geometry result; - MathTransform mtid = IdentityTransform.create(2); - - // Verify identity transformation - result = new Converter(ATID, mtid, ATID) - .convert(FACTORY.createPoint(new Coordinate(1.0, -2.0))); - - assertEquals(result.getCoordinate().getX(), 1.0, 0.0001); - assertEquals(result.getCoordinate().getY(), -2.0, 0.0001); - - // Verify 180° rotation transformation - result = new Converter(ATID, mtid, AffineTransformation.rotationInstance(Math.PI)) - .convert(FACTORY.createPoint(new Coordinate(1.0, -2.0))); - - assertEquals(result.getCoordinate().getX(), -1.0, 0.0001); - assertEquals(result.getCoordinate().getY(), 2.0, 0.0001); - - // Verify 90° rotation transformation - result = new Converter(ATID, mtid, AffineTransformation.rotationInstance(Math.PI / 2)) - .convert(FACTORY.createPoint(new Coordinate(1.0, -2.0))); - - assertEquals(result.getCoordinate().getX(), 2.0, 0.0001); - assertEquals(result.getCoordinate().getY(), 1.0, 0.0001); - - // Verify translation transformation - result = new Converter(ATID, mtid, AffineTransformation.translationInstance(-2.0, 1.0)) - .convert(FACTORY.createPoint(new Coordinate(1.0, -2.0))); - - assertEquals(result.getCoordinate().getX(), -1.0, 0.0001); - assertEquals(result.getCoordinate().getY(), -1.0, 0.0001); - } - - @Test - @DisplayName("Pre transformation tests") - public void testPointPreTransform() throws TransformException { - Geometry result; - MathTransform mtid = IdentityTransform.create(2); - - // Verify 180° rotation transformation - result = new Converter(AffineTransformation.rotationInstance(Math.PI), mtid, ATID) - .convert(FACTORY.createPoint(new Coordinate(1.0, -2.0))); - - assertEquals(-1.0, result.getCoordinate().getX(), 0.0001); - assertEquals(2.0, result.getCoordinate().getY(), 0.0001); - - // Verify 90° rotation transformation - result = new Converter(AffineTransformation.rotationInstance(Math.PI / 2), mtid, ATID) - .convert(FACTORY.createPoint(new Coordinate(1.0, -2.0))); - - assertEquals(2.0, result.getCoordinate().getX(), 0.0001); - assertEquals(1.0, result.getCoordinate().getY(), 0.0001); - - // Verify translation transformation - result = new Converter(AffineTransformation.translationInstance(-2.0, 1.0), mtid, ATID) - .convert(FACTORY.createPoint(new Coordinate(1.0, -2.0))); - - assertEquals(-1.0, result.getCoordinate().getX(), 0.0001); - assertEquals(-1.0, result.getCoordinate().getY(), 0.0001); - } - - @Test - @DisplayName("Tests transformation works as expected on multipoint shapes") - public void testShapeAffineTransformation() throws TransformException { - Geometry result; - - Coordinate[] coords = {new Coordinate(-2.0, -2.0), new Coordinate(-1.0, 2.0), new Coordinate(2.0, 0.0)}; - - result = new Converter(new AffineTransformation(), IdentityTransform.create(2), AffineTransformation.rotationInstance(Math.PI / 2)) - .convert(FACTORY.createLineString(coords)); - - coords = result.getCoordinates(); - - // We assume that coords order has not changed but this is not sure at all. - assertEquals(2.0, coords[0].getX(), 0.0001); - assertEquals(-2.0, coords[0].getY(), 0.0001); - assertEquals(-2.0, coords[1].getX(), 0.0001); - assertEquals(-1.0, coords[1].getY(), 0.0001); - assertEquals(0.0, coords[2].getX(), 0.0001); - assertEquals(2.0, coords[2].getY(), 0.0001); - } - - @Test - @DisplayName("Testing projection works as expected, combined or not with affine transformations") - public void testProjection() throws TransformException, FactoryException { - Geometry result; - - // Check simple geographic conversion from WSG 84 to LAMBERT 93 - // IGN leveling mark https://geodesie.ign.fr/fiches/pdf/P.C.K3L3-20b_534424.pdf - MathTransform crsTransform = CRS.findMathTransform(CRS.decode("EPSG:4326"), CRS.decode("EPSG:2154")); - result = new Converter(ATID, crsTransform, ATID) - .convert(FACTORY.createPoint(new Coordinate(48.8452222, 2.4247222))); - - assertEquals(657780.87, result.getCoordinate().getX(), 0.1); - assertEquals(6860728.96, result.getCoordinate().getY(), 0.1); - - // Combine it with a translation - result = new Converter(ATID, crsTransform, AffineTransformation.translationInstance(-657780.87, -6860728.96)) - .convert(FACTORY.createPoint(new Coordinate(48.8452222, 2.4247222))); - - assertEquals(0.0, result.getCoordinate().getX(), 0.1); - assertEquals(0.0, result.getCoordinate().getY(), 0.1); - - // Combine with translation & rotation (rotation center should be given point) - result = new Converter(ATID, crsTransform, AffineTransformation.translationInstance(-657780.87, -6860728.96).rotate(1.0)) - .convert(FACTORY.createPoint(new Coordinate(48.8452222, 2.4247222))); - - assertEquals(0.0, result.getCoordinate().getX(), 0.1); - assertEquals(0.0, result.getCoordinate().getY(), 0.1); - - // Pre and post transformations - result = new Converter( - AffineTransformation.translationInstance(48.8452222, 2.4247222), - crsTransform, - AffineTransformation.translationInstance(-657780.87, -6860728.96)) - .convert(FACTORY.createPoint(new Coordinate(0, 0))); - - assertEquals(0.0, result.getCoordinate().getX(), 0.1); - assertEquals(0.0, result.getCoordinate().getY(), 0.1); - } - - @Test - @DisplayName("Testing reverse conversions") - public void testReverseConversions() throws TransformException, FactoryException { - MapCoordinates coords; - - Converter converter; - - converter = new Converter( - AffineTransformation.translationInstance(0.01, -0.02), - CRS.findMathTransform(CRS.decode("EPSG:4326"), CRS.decode("EPSG:2154")), - AffineTransformation.translationInstance(-657780.0, -6860730.0).rotate(-3.0)); - - Converter inverse = converter.inverse(); - - coords = converter.convert(new MapCoordinates(48.0, 2.0)); - - assertEquals(20441.0, coords.x(), 1.0); - assertEquals(96352.0, coords.y(), 1.0); - - coords = inverse.convert(coords); - - assertEquals(48.0, coords.x(), 0.001); // We expect a 1m precision ~ 0,001 degree - assertEquals(2.0, coords.y(), 0.001); - } - - @Test - @DisplayName("Testing prepend transformation on a converter") - public void testPrependTransformation() throws TransformException, FactoryException { - Converter converter = new Converter( - ATID, - CRS.findMathTransform(CRS.decode("EPSG:4326"), CRS.decode("EPSG:2154")), - AffineTransformation.translationInstance(-657780, -6860728) - ); - - converter = new Converter(AffineTransformation.translationInstance(48.8452222, 0.0), converter); - converter = new Converter(AffineTransformation.translationInstance(0.0, 2.4247222), converter); - - MapCoordinates coords = converter.convert(new MapCoordinates(0.0, 0.0)); - assertEquals(0.0, coords.x(), 1.0); - assertEquals(0.0, coords.y(), 1.0); - } -} - diff --git a/src/test/java/com/ignfab/minalac/generator/utils/coordinates/MapToWorldConverterTest.java b/src/test/java/com/ignfab/minalac/generator/utils/coordinates/MapToWorldConverterTest.java new file mode 100644 index 000000000..36e826905 --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/utils/coordinates/MapToWorldConverterTest.java @@ -0,0 +1,411 @@ +package com.ignfab.minalac.generator.utils.coordinates; + +import org.geotools.api.referencing.FactoryException; +import org.geotools.referencing.CRS; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; + +import com.ignfab.minalac.generator.exceptions.TransformException; +import com.ignfab.minalac.generator.utils.world2d.WorldCoords2d; +import com.ignfab.minalac.generator.utils.world3d.WorldCoords3d; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class MapToWorldConverterTest { + + @Test + public void testConvertAffineTransformationOnly() throws FactoryException, TransformException { + // Verify translation + WorldCoords2d result = new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 10, + -20, + 1.0, + 1.0, + 0, + 0 + ).convert(new MapCoordinates2d(-10, 20)); + assertEquals(0, result.x()); + assertEquals(0, result.y()); + + // Verify 180° rotation + result = new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 1.0, + 1.0, + Math.PI, + 0 + ).convert(new MapCoordinates2d(-1, 2)); + assertEquals(1, result.x()); + assertEquals(-2, result.y()); + + // Verify 90° rotation + result = new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 1.0, + 1.0, + Math.PI / 2.0, + 0 + ).convert(new MapCoordinates2d(1, -2)); + assertEquals(2, result.x()); + assertEquals(1, result.y()); + + // Verify scale transformation + result = new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 2.0, + 1.0, + 0, + 0 + ).convert(new MapCoordinates2d(1.5, -5)); + assertEquals(3, result.x()); + assertEquals(-10, result.y()); + + // verticalScale should not have any influence on 2d coordinates + result = new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 1.0, + 10.0, + 0, + 0 + ).convert(new MapCoordinates2d(2, 3)); + assertEquals(2, result.x()); + assertEquals(3, result.y()); + } + + @Test + @DisplayName("Conversion tests on 3D coordinates") + public void testConvert3D() throws FactoryException, TransformException { + // Verify translation + WorldCoords3d result = new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 10, + -20, + 1.0, + 1.0, + 0, + 0 + ).convert(new MapCoordinates3d(-10, 20, 7)); + assertEquals(0, result.x()); + assertEquals(0, result.y()); + assertEquals(7, result.z()); + + // Verify 180° rotation + result = new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 1.0, + 1.0, + Math.PI, + 0 + ).convert(new MapCoordinates3d(-1, 2, 7)); + assertEquals(1, result.x()); + assertEquals(-2, result.y()); + assertEquals(7, result.z()); + + // Verify 90° rotation + result = new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 1.0, + 1.0, + Math.PI / 2.0, + 0 + ).convert(new MapCoordinates3d(1, -2, 7)); + assertEquals(2, result.x()); + assertEquals(1, result.y()); + assertEquals(7, result.z()); + + // Verify horizontal scaling transformation + result = new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 2.0, + 1.0, + 0, + 0 + ).convert(new MapCoordinates3d(1.5, -5, 7)); + assertEquals(3, result.x()); + assertEquals(-10, result.y()); + assertEquals(7, result.z()); + + // Verify vertical scaling transformation + result = new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 1.0, + 10.0, + 0, + 0 + ).convert(new MapCoordinates3d(2, 3, 7.7)); + assertEquals(2, result.x()); + assertEquals(3, result.y()); + assertEquals(77, result.z()); + } + + @Test + @DisplayName("Conversion tests on 3D coordinates") + public void testConvertOnGeometry() throws TransformException, FactoryException { + Geometry result; + + Coordinate[] coords = {new Coordinate(-2.0, -2.0), new Coordinate(-1.0, 2.0), new Coordinate(2.0, 0.0)}; + + result = new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 1.0, + 1.0, + Math.PI / 2.0, + 0 + ).convert(new GeometryFactory().createLineString(coords)); + + coords = result.getCoordinates(); + + // We assume that coords order has not changed but this is not sure at all. + assertEquals(2.0, coords[0].getX(), 0.0001); + assertEquals(-2.0, coords[0].getY(), 0.0001); + assertEquals(-2.0, coords[1].getX(), 0.0001); + assertEquals(-1.0, coords[1].getY(), 0.0001); + assertEquals(0.0, coords[2].getX(), 0.0001); + assertEquals(2.0, coords[2].getY(), 0.0001); + } + + @Test + public void testConvertWithProjection() throws FactoryException, TransformException { + // Check simple geographic conversion from WSG 84 to LAMBERT 93 + // IGN leveling mark https://geodesie.ign.fr/fiches/pdf/P.C.K3L3-20b_534424.pdf + WorldCoords2d result = new MapToWorldConverter( + CRS.decode("EPSG:4326"), + CRS.decode("EPSG:2154"), + 0, + 0, + 1.0, + 1.0, + 0, + 0 + ).convert(new MapCoordinates2d(48.8452222, 2.4247222)); + assertEquals(657_780, result.x()); + assertEquals(6_860_728, result.y()); + + // Combine it with a translation + result = new MapToWorldConverter( + CRS.decode("EPSG:4326"), + CRS.decode("EPSG:2154"), + -657_780, + -6_860_728, + 1.0, + 1.0, + 0, + 0 + ).convert(new MapCoordinates2d(48.8452222, 2.4247222)); + assertEquals(0, result.x()); + assertEquals(0, result.y()); + } + + @Test + public void testInverse() throws FactoryException, TransformException { + // With projection only + MapToWorldConverter mapToWorld = new MapToWorldConverter( + CRS.decode("EPSG:4326"), + CRS.decode("EPSG:2154"), + 0, + 0, + 1.0, + 1.0, + 0, + 0 + ); + WorldToMapConverter worldToMap = mapToWorld.inverse(); + + WorldCoords2d gameCoords = mapToWorld.convert(new MapCoordinates2d(48.84, 2.42)); + MapCoordinates2d realCoords = worldToMap.convert(gameCoords); + assertEquals(48.84, realCoords.x(), 0.05); // Due to round-down precision is lost + assertEquals(2.42, realCoords.y(), 0.05); + + // With projection and affine transformation + mapToWorld = new MapToWorldConverter( + CRS.decode("EPSG:4326"), + CRS.decode("EPSG:2154"), + 5_000, + -2_300, + 1.5, + 0.5, + 30, + 0 + ); + worldToMap = mapToWorld.inverse(); + + gameCoords = mapToWorld.convert(new MapCoordinates2d(48.84, 2.42)); + realCoords = worldToMap.convert(gameCoords); + assertEquals(48.84, realCoords.x(), 0.05); + assertEquals(2.42, realCoords.y(), 0.05); + } + + @Test + public void testConvertAltitude() throws FactoryException { + // Identity: verticalScale = 1.0, altitudeOffset = 0.0 + assertEquals(5, (new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 1.0, + 1.0, + 0, + 0 + )).convertAltitude(5)); + + // verticalScale = 2.0, altitudeOffset = 0.0 + assertEquals(10, (new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 1.0, + 2.0, + 0, + 0 + ).convertAltitude(5))); + + // verticalScale = 2.0, altitudeOffset = 0.5 + assertEquals(11, (new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 1.0, + 2.0, + 0, + 0.5 + )).convertAltitude(5)); + + // verticalScale = 2.0, altitudeOffset = -0.5 + assertEquals(9, (new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 1.0, + 2.0, + 0, + -0.5 + )).convertAltitude(5)); + } + + @Test + public void testConvertHorizontalDistance() throws FactoryException { + // Identity: horizontalScale = 1.0 + assertEquals(5, (new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 1.0, + 1.0, + 0, + 0 + )).convertHorizontalDistance(5)); + + // horizontalScale = 2.0 + assertEquals(10, (new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 2.0, + 1.0, + 0, + 0 + )).convertHorizontalDistance(5)); + + // horizontalScale = 0.5 + assertEquals(3, (new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 0.5, + 1.0, + 0, + 0 + )).convertHorizontalDistance(6)); + } + + @Test + public void testConvertVerticalDistance() throws FactoryException { + // Identity: verticalScale = 1.0 + assertEquals(5, (new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 1.0, + 1.0, + 0, + 0 + )).convertVerticalDistance(5)); + + // verticalScale = 2.0 + assertEquals(10, (new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 1.0, + 2.0, + 0, + 0 + )).convertVerticalDistance(5)); + + // verticalScale = 0.5 + assertEquals(3, (new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 1.0, + 0.5, + 0, + 0 + )).convertVerticalDistance(6)); + + // altitudeOffset should not have any influence. verticalScale = 0.5 altitudeOffset = 100 + assertEquals(3, (new MapToWorldConverter( + CRS.decode("EPSG:2154"), + CRS.decode("EPSG:2154"), + 0, + 0, + 1.0, + 0.5, + 0, + 100 + )).convertVerticalDistance(6)); + } +} diff --git a/src/test/java/com/ignfab/minalac/generator/utils/coordinates/TestingConverter.java b/src/test/java/com/ignfab/minalac/generator/utils/coordinates/TestingConverter.java index 3af34bf9f..1077565e2 100644 --- a/src/test/java/com/ignfab/minalac/generator/utils/coordinates/TestingConverter.java +++ b/src/test/java/com/ignfab/minalac/generator/utils/coordinates/TestingConverter.java @@ -1,8 +1,6 @@ package com.ignfab.minalac.generator.utils.coordinates; -import org.geotools.api.referencing.operation.MathTransform; -import org.geotools.referencing.operation.transform.IdentityTransform; -import org.locationtech.jts.geom.util.AffineTransformation; +import org.geotools.referencing.crs.DefaultGeographicCRS; /** * A {@link MapToWorldConverter} for testing purposes. @@ -11,9 +9,22 @@ public final class TestingConverter extends MapToWorldConverter { /** * A testing {@code MapToWorldConverter} that does no transformation. */ - public static final TestingConverter IDENTITY = new TestingConverter(IdentityTransform.create(2), new AffineTransformation()); + public static final TestingConverter IDENTITY = new TestingConverter(); + /** + * An unused testing {@code MapToWorldConverter}. + */ + public static final TestingConverter UNUSED = null; - private TestingConverter(MathTransform crsTransform, AffineTransformation postTransform) { - super(crsTransform, postTransform); + private TestingConverter() { + super( + DefaultGeographicCRS.WGS84, + DefaultGeographicCRS.WGS84, + 0, + 0, + 1.0, + 1.0, + 0, + 0 + ); } } diff --git a/src/test/java/com/ignfab/minalac/generator/utils/coordinates/WorldToMapConverterTest.java b/src/test/java/com/ignfab/minalac/generator/utils/coordinates/WorldToMapConverterTest.java new file mode 100644 index 000000000..2d5f9dac9 --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/utils/coordinates/WorldToMapConverterTest.java @@ -0,0 +1,25 @@ +package com.ignfab.minalac.generator.utils.coordinates; + + +import java.awt.geom.AffineTransform; + +import org.geotools.referencing.operation.transform.AffineTransform2D; +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; + +import com.ignfab.minalac.generator.exceptions.TransformException; +import com.ignfab.minalac.generator.utils.world2d.WorldCoords2d; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class WorldToMapConverterTest { + + @Test + public void testConvert() throws TransformException { + // Simple since this converter is made via the constructor. + // Testing of the proper inversion of MapToWorldConverter into WorldToMapConverter is done in MapToWorldConverterTest + WorldToMapConverter converter = new WorldToMapConverter(new AffineTransform2D(AffineTransform.getRotateInstance(Math.PI / 2))); + assertEquals(new MapCoordinates2d(2, 1), converter.convert(new WorldCoords2d(1, -2))); + assertEquals(new Coordinate(1.5, 1), converter.convert(new Coordinate(1, -1.5))); + } +}