From 71ea9a955e38ab164d4b8a168771dd8a17b8159d Mon Sep 17 00:00:00 2001 From: naulan-chrzaszcz Date: Mon, 4 May 2026 16:23:33 +0200 Subject: [PATCH 1/2] feat(deserializer): add color deserializer --- .../core/parameters/ColorDeserializer.java | 44 +++++++++++++++++ .../core/parameters/ParamsParser.java | 4 ++ .../parameters/ColorDeserializerTest.java | 49 +++++++++++++++++++ .../core/parameters/ParamsTester.java | 2 + 4 files changed, 99 insertions(+) create mode 100644 src/main/java/fr/ign/voxatile/core/parameters/ColorDeserializer.java create mode 100644 src/test/java/fr/ign/voxatile/core/parameters/ColorDeserializerTest.java diff --git a/src/main/java/fr/ign/voxatile/core/parameters/ColorDeserializer.java b/src/main/java/fr/ign/voxatile/core/parameters/ColorDeserializer.java new file mode 100644 index 000000000..155a2ae73 --- /dev/null +++ b/src/main/java/fr/ign/voxatile/core/parameters/ColorDeserializer.java @@ -0,0 +1,44 @@ +package fr.ign.voxatile.core.parameters; + +import java.awt.Color; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ValueDeserializer; + +/** + * A custom deserializer for {@code Color}s. + */ +public class ColorDeserializer extends ValueDeserializer { + + @Override + public Color deserialize(JsonParser parser, DeserializationContext context) throws JacksonException { + JsonNode node = parser.readValueAsTree(); + + if (node.isString()) { + String string = node.asString(); + try { + return Color.decode(string); + } catch (NumberFormatException e) { + return (Color) context.handleWeirdStringValue(Color.class, string, "The color %s cannot be interpreted", string); + } + } + + if (node.isArray()) { + int size = node.size(); + if (size < 3 || size > 4) + return (Color) context.reportInputMismatch(Color.class, "Color array must have 3 or 4 components, got %d.", size); + + int red = node.get(0).asInt(); + int green = node.get(1).asInt(); + int blue = node.get(2).asInt(); + int alpha = (size == 4) ? node.get(3).asInt() : 255; + + return new Color(red, green, blue, alpha); + } + + return (Color) context.reportInputMismatch(Color.class, "Expected a hex String or an RGB(A) Array, but got %s.", node.getNodeType()); + } +} diff --git a/src/main/java/fr/ign/voxatile/core/parameters/ParamsParser.java b/src/main/java/fr/ign/voxatile/core/parameters/ParamsParser.java index 32afdc75f..6816405e7 100644 --- a/src/main/java/fr/ign/voxatile/core/parameters/ParamsParser.java +++ b/src/main/java/fr/ign/voxatile/core/parameters/ParamsParser.java @@ -1,5 +1,7 @@ package fr.ign.voxatile.core.parameters; +import java.awt.Color; + import tools.jackson.core.JacksonException; import tools.jackson.core.StreamReadFeature; import tools.jackson.databind.DeserializationFeature; @@ -43,6 +45,8 @@ public GenerationParams parse(String serialized) throws ParseException { VoxatileParserModule module = new VoxatileParserModule(); // TODO OutputFormat handling might be relocated to the VoxatileParserModule (not sure) module.addDeserializer(OutputFormat.class, formatDeserializer); + module.addDeserializer(Color.class, new ColorDeserializer()); + module.setDeserializerModifier(new JsonDelegateDeserialize.BeanModifier()); mapperBuilder.addModule(module); YAMLMapper mapper = mapperBuilder.build(); diff --git a/src/test/java/fr/ign/voxatile/core/parameters/ColorDeserializerTest.java b/src/test/java/fr/ign/voxatile/core/parameters/ColorDeserializerTest.java new file mode 100644 index 000000000..58d41637d --- /dev/null +++ b/src/test/java/fr/ign/voxatile/core/parameters/ColorDeserializerTest.java @@ -0,0 +1,49 @@ +package fr.ign.voxatile.core.parameters; + +import java.awt.Color; + +import org.junit.jupiter.api.Test; +import tools.jackson.databind.exc.InvalidFormatException; +import tools.jackson.databind.exc.MismatchedInputException; + +import static org.junit.jupiter.api.Assertions.*; + +public class ColorDeserializerTest { + + @Test + public void testDeserialize() { + Color color = assertDoesNotThrow(() -> ParamsTester.deserialize(Color.class, "\"#FF0000\"")); + assertEquals(255, color.getRed()); + assertEquals(0, color.getGreen()); + assertEquals(0, color.getBlue()); + + color = assertDoesNotThrow(() -> ParamsTester.deserialize(Color.class, "[0, 255, 0]")); + assertEquals(0, color.getRed()); + assertEquals(255, color.getGreen()); + assertEquals(0, color.getBlue()); + + color = assertDoesNotThrow(() -> ParamsTester.deserialize(Color.class, "[0, 0, 255, 128]")); + assertEquals(255, color.getBlue()); + assertEquals(128, color.getAlpha()); + + assertThrows(IllegalArgumentException.class, () -> + ParamsTester.deserialize(Color.class, "[0, 0, -1]") + ); + + assertThrows(IllegalArgumentException.class, () -> + ParamsTester.deserialize(Color.class, "[256, 0, 0]") + ); + + assertThrows(InvalidFormatException.class, () -> + ParamsTester.deserialize(Color.class, "\"not-a-color\"") + ); + + assertThrows(MismatchedInputException.class, () -> + ParamsTester.deserialize(Color.class, "[255, 255]") + ); + + assertThrows(MismatchedInputException.class, () -> + ParamsTester.deserialize(Color.class, "{ r: 255, g: 0, b: 0 }") + ); + } +} diff --git a/src/test/java/fr/ign/voxatile/core/parameters/ParamsTester.java b/src/test/java/fr/ign/voxatile/core/parameters/ParamsTester.java index 96bfb79f7..2e775046d 100644 --- a/src/test/java/fr/ign/voxatile/core/parameters/ParamsTester.java +++ b/src/test/java/fr/ign/voxatile/core/parameters/ParamsTester.java @@ -1,5 +1,7 @@ package fr.ign.voxatile.core.parameters; +import java.awt.Color; + import tools.jackson.core.JacksonException; import tools.jackson.core.StreamReadFeature; import tools.jackson.databind.DeserializationFeature; From 7bd4ac2fc0ccf7292a9c8993653069252457edb5 Mon Sep 17 00:00:00 2001 From: naulan-chrzaszcz Date: Wed, 26 Nov 2025 17:20:01 +0100 Subject: [PATCH 2/2] feat(minimap): Render a minimap of the world - MINALAC-86 --- docs/usage/parameters/Minimap.md | 24 + docs/usage/parameters/Parameters.md | 7 + docs/usage/parameters/Tasks.md | 53 ++ docs/usage/parameters/TileTasks.md | 26 + examples/formats/minecraft.yaml | 15 + examples/formats/minetest.yaml | 16 + examples/places/meije.yaml | 10 + examples/processes/full.yaml | 892 +++++++++--------- .../java/fr/ign/voxatile/core/Voxatile.java | 10 +- .../voxatile/core/generation/Generation.java | 9 + .../core/generation/minimaps/Minimap.java | 150 +++ .../core/generation/minimaps/MinimapCell.java | 122 +++ .../generation/minimaps/MinimapStore.java | 37 + .../core/parameters/ColorDeserializer.java | 8 +- .../core/parameters/GenerationParams.java | 16 + .../core/parameters/MinimapParams.java | 39 + .../core/parameters/ParamsParser.java | 3 +- .../tasks/ApplyShadingMinimapTaskParams.java | 70 ++ .../tasks/PopulateMinimapTaskParams.java | 53 ++ .../tasks/SaveMinimapTaskParams.java | 91 ++ .../core/tasks/ApplyShadingMinimapTask.java | 94 ++ .../core/tasks/PopulateMinimapTask.java | 88 ++ .../voxatile/core/tasks/SaveMinimapTask.java | 62 ++ .../ign/voxatile/core/world/PlacedVoxel.java | 3 +- .../fr/ign/voxatile/core/world/Voxel.java | 15 + .../core/world/VoxelColumnIterator.java | 3 +- .../fr/ign/voxatile/core/world/VoxelTile.java | 5 +- .../ign/voxatile/core/world/VoxelWorld.java | 15 +- .../voxatile/modules/luanti/LuantiVoxel.java | 11 +- .../modules/luanti/LuantiVoxelTile.java | 6 +- .../modules/luanti/LuantiVoxelWorld.java | 4 +- .../modules/minecraft/MinecraftVoxel.java | 11 +- .../modules/minecraft/MinecraftVoxelTile.java | 6 +- .../minecraft/MinecraftVoxelWorld.java | 4 +- .../generation/minimaps/MinimapStoreTest.java | 39 + .../generation/minimaps/MinimapTest.java | 115 +++ .../core/generation/GenerationTest.java | 2 +- .../core/parameters/GenerationParamsTest.java | 2 + .../core/parameters/MinimapParamsTest.java | 35 + .../core/parameters/ParamsTester.java | 2 - .../ApplyShadingMinimapTaskParamsTest.java | 34 + .../tasks/PopulateMinimapTaskParamsTest.java | 52 + .../tasks/SaveMinimapTaskParamsTest.java | 54 ++ .../ign/voxatile/core/world/TestingVoxel.java | 7 +- .../voxatile/core/world/TestingVoxelTile.java | 5 +- .../core/world/TestingVoxelWorld.java | 12 +- .../voxatile/core/world/VoxelWorldTest.java | 8 +- 47 files changed, 1863 insertions(+), 482 deletions(-) create mode 100644 docs/usage/parameters/Minimap.md create mode 100644 examples/places/meije.yaml create mode 100644 src/main/java/fr/ign/voxatile/core/generation/minimaps/Minimap.java create mode 100644 src/main/java/fr/ign/voxatile/core/generation/minimaps/MinimapCell.java create mode 100644 src/main/java/fr/ign/voxatile/core/generation/minimaps/MinimapStore.java create mode 100644 src/main/java/fr/ign/voxatile/core/parameters/MinimapParams.java create mode 100644 src/main/java/fr/ign/voxatile/core/parameters/tasks/ApplyShadingMinimapTaskParams.java create mode 100644 src/main/java/fr/ign/voxatile/core/parameters/tasks/PopulateMinimapTaskParams.java create mode 100644 src/main/java/fr/ign/voxatile/core/parameters/tasks/SaveMinimapTaskParams.java create mode 100644 src/main/java/fr/ign/voxatile/core/tasks/ApplyShadingMinimapTask.java create mode 100644 src/main/java/fr/ign/voxatile/core/tasks/PopulateMinimapTask.java create mode 100644 src/main/java/fr/ign/voxatile/core/tasks/SaveMinimapTask.java create mode 100644 src/main/java/fr/ign/voxatile/core/world/Voxel.java create mode 100644 src/test/java/com/ignfab/minalac/generator/generation/minimaps/MinimapStoreTest.java create mode 100644 src/test/java/com/ignfab/minalac/generator/generation/minimaps/MinimapTest.java create mode 100644 src/test/java/fr/ign/voxatile/core/parameters/MinimapParamsTest.java create mode 100644 src/test/java/fr/ign/voxatile/core/parameters/tasks/ApplyShadingMinimapTaskParamsTest.java create mode 100644 src/test/java/fr/ign/voxatile/core/parameters/tasks/PopulateMinimapTaskParamsTest.java create mode 100644 src/test/java/fr/ign/voxatile/core/parameters/tasks/SaveMinimapTaskParamsTest.java diff --git a/docs/usage/parameters/Minimap.md b/docs/usage/parameters/Minimap.md new file mode 100644 index 000000000..36e775993 --- /dev/null +++ b/docs/usage/parameters/Minimap.md @@ -0,0 +1,24 @@ +# Minimap + +A minimap is a scaled 2D representation of the generated 3D world. + +## Stored minimap + +Minimaps are created at the beginning of the generation process, remain available throughout the generation, and can be accessed by their names. + +You can generate multiple minimaps at the same time by declaring them with different names. + +In the example below, two distinct minimaps named overworld and onlyWater are declared: + +```yaml +minimaps: + # A minimap named 'overworld' using the default settings + overworld: {} + + # A minimap named 'onlyWater' with a custom maximum size + onlyWater: + size: 200 +``` + +Fields: +- `size` : Maximum size in pixel of the longest side of the minimap. By default, this field is set to `1000`. diff --git a/docs/usage/parameters/Parameters.md b/docs/usage/parameters/Parameters.md index d7aa40d8e..658f1a7b3 100644 --- a/docs/usage/parameters/Parameters.md +++ b/docs/usage/parameters/Parameters.md @@ -14,10 +14,12 @@ Voxatile makes a heavy use of parameters. Generation is described in Yaml (or Js Only root Yaml parameters are described here. More specific aspects could be found in these other files: * [FetchDataTask.md](FetchDataTask.md): Description of tasks for fetching data (see [TileTasks.md](TileTasks.md)). +* [Minimap.md](Minimap.md). * [Heightmaps.md](Heightmaps.md): Declaration and usage of heightmaps. * [ModelSelection.md](ModelSelection.md): Selection of models (see [TileTasks.md](TileTasks.md)). * [ModelValues.md](ModelValues.md): Dynamic numeric values computed from models (see [TileTasks.md](TileTasks.md)). * [Placeables.md](Placeables.md): Description of things that can be placed in resulting world (see [TileTasks.md](TileTasks.md)). +* [Tasks.md](Tasks.md): Tasks that are executes anywhere. * [TileTasks.md](TileTasks.md): Tasks that can be run on a generation tile. ## Example @@ -34,6 +36,10 @@ references: at: [ 0, 0, 0 ] - put: default:dirt at: [ 0, 0, -10..-1 ] + +minimaps: + world: {} + area: center: latitude: 48.845 @@ -98,6 +104,7 @@ forEachTile: - `worldName`: World name (text, default `Voxatile`) - `references`: Ignored field where references (or other content) can be put in +- `minimaps`: [Minimaps](Minimap.md) to be created and accessible throughout the generation - `area`: Area to be rendered - `center`: Coordinates of the area's center point, expressed in the commonly used coordinate system (EPSG:4326) - `latitude`: latitude in decimal degrees diff --git a/docs/usage/parameters/Tasks.md b/docs/usage/parameters/Tasks.md index 3d12aea02..eec7e2923 100644 --- a/docs/usage/parameters/Tasks.md +++ b/docs/usage/parameters/Tasks.md @@ -8,6 +8,14 @@ Generic schedules such as the `afterAllTiles` schedule can only execute generic Each task has a `type`, optional dependencies on other tasks (using `after`), and specific parameters depending on its type. +## Table of contents + +* [Dependencies](#dependencies) + * [Scope constraints](#scope-constraints) +* [Tasks operating on minimaps](#tasks-operating-on-minimaps) + * [`applyShadingMinimap`](#applyshadingminimap) + * [`saveMinimap`](#saveminimap) + ## Dependencies Dependencies allow to control execution order of tasks. Optional `after` field lists names of every other tasks that should run before the task it belongs to. @@ -59,3 +67,48 @@ afterAllTiles: after: a # Error: 'a' is not defined in this scope type: noOperation ``` + +## Tasks operating on minimaps + +### `applyShadingMinimap` + +Applies post-processing shading to a minimap using its heightmap. + +This task simulates sunlight by calculating the directional terrain slope, adjusting pixel brightness according to how the terrain is oriented relative to a light source. +This enhances the visual perception of elevation changes. + +#### Extra parameters + +- `minimap` (required): Name of the minimap to shade. +- `shadowIntensity` (optional, default is `0.5`): The intensity of the shading effect (should be between 0.0 and 1.0). +- `sunDirection` (optional, default is `90`): The direction of the simulated light source, expressed in degrees. + +#### Example + +```yaml +type: applyShadingMinimap +minimap: overworld +shadowIntensity: 0.8 +sunDirection: 45 +``` + +### `saveMinimap` + +Export the Minimap to an image file. + +The `saveMinimap` task requires the `populateMinimap` task to be executed on each tile beforehand. `populateMinimap` generates the actual map data; without it, the saved image will be empty. + +#### Extra parameters + +- `minimap` (required): name of the minimap to save. +- `destination` (required): relative path where the rendered minimap image will be saved. This path is relative to the world's root directory. +- `format` (optional): format of the minimap to render (ex: `jpeg`, `png`, `gif`, ...). + +#### Exemple + +```yaml +type: saveMinimap +minimap: overworld +destination: overworld-minimap.png +format: png +``` diff --git a/docs/usage/parameters/TileTasks.md b/docs/usage/parameters/TileTasks.md index f2cf21340..1382ae436 100644 --- a/docs/usage/parameters/TileTasks.md +++ b/docs/usage/parameters/TileTasks.md @@ -25,6 +25,8 @@ Each task has a `type`, optional dependencies to other tasks (in `after`), and o * [`populateHeightmap`](#populateheightmap) * [`copyHeightmap`](#copyheightmap) * [`computeHeightmapStats`](#computeheightmapstats) +* [Tasks operating on minimaps](#tasks-operating-on-minimaps) + * [`populateMinimap`](#populateminimap) * [Tool tasks](#tool-tasks) * [`buildLayout`](#buildlayout) @@ -378,6 +380,30 @@ compute: minimum: minimum-ground-altitude ``` +## Tasks operating on minimaps + +### `populateMinimap` + +Populates a minimap from each tile. + +This task must be executed for all tiles before calling `saveMinimap`. `populateMinimap` gathers the necessary voxel data into the minimap structure; without this step, `saveMinimap` will produce an empty image. + +#### Extra parameters + +- `minimap` (required): Name of the minimap to populate. +- `colors` (required): Mapping of voxel type identifiers to their display colors (RGBA). + +#### Example + +```yaml +type: populateMinimap +minimap: overworld +colors: + water: [64, 64, 255, 180] + white_wool: [255, 255, 255] + black_wool: [25, 25, 25] +``` + ## Tool tasks These tasks are not intended to be used to create worlds from geographical data but rather to help parameter files development. diff --git a/examples/formats/minecraft.yaml b/examples/formats/minecraft.yaml index d18f7bb8e..3e1e00fc3 100644 --- a/examples/formats/minecraft.yaml +++ b/examples/formats/minecraft.yaml @@ -55,3 +55,18 @@ references: - &modern-materials "O": *modern-wall "*": *modern-glass + - &minimap-color-palette + # https://minecraft.wiki/w/Map_item_format#Color_table + minecraft:stone: [112, 112, 112] + minecraft:dirt: [151, 109, 77] + minecraft:cobblestone: [112, 112, 112] + minecraft:stone_bricks: [112, 112, 112] + minecraft:grass_block: [127, 178, 56] + minecraft:podzol: [129, 86, 49] + minecraft:dirt_path: [151, 109, 77] + minecraft:fern: [0, 124, 0] + minecraft:oak_leaves: [0, 124, 0] + minecraft:birch_leaves: [0, 124, 0] + minecraft:water: [64, 64, 255, 180] + minecraft:white_wool: [255, 255, 255] + minecraft:black_wool: [25, 25, 25] diff --git a/examples/formats/minetest.yaml b/examples/formats/minetest.yaml index ea2b765de..3767c0b63 100644 --- a/examples/formats/minetest.yaml +++ b/examples/formats/minetest.yaml @@ -49,3 +49,19 @@ references: - &modern-materials "O": *modern-wall "*": *modern-glass + - &minimap-color-palette + # https://github.com/luanti-org/minetestmapper/blob/master/colors.txt + default:stone: [97, 94, 93] + default:dirt: [97, 67, 43] + default:cobble: [89, 86, 84] + default:stonebrick: [102, 99, 98] + default:dirt_with_grass: [64, 111, 26] + default:dirt_with_rainforest_litter: [76, 39, 10] + default:dirt_with_coniferous_litter: [109, 90, 71] + default:junglegrass: [67, 110, 28] + default:leaves: [36, 55, 29] + default:aspen_leaves: [72, 105, 29] + default:water_source: [39, 66, 106, 128] + wool:white: [220, 220, 220] + wool:black: [30, 30, 30] + default:snowblock: [225, 225, 238] diff --git a/examples/places/meije.yaml b/examples/places/meije.yaml new file mode 100644 index 000000000..031742ead --- /dev/null +++ b/examples/places/meije.yaml @@ -0,0 +1,10 @@ +# A mountaineering center & a resort offering extreme skiing distinguish this snow-capped mountain. +worldName: Meije +verticalScale: 1.0 +horizontalScale: 1.0 +area: + center: + latitude: 45.005 + longitude: 6.307 + extentX: 2459 + extentY: 2145 diff --git a/examples/processes/full.yaml b/examples/processes/full.yaml index 57ee5951f..0f845e532 100644 --- a/examples/processes/full.yaml +++ b/examples/processes/full.yaml @@ -4,485 +4,509 @@ heightmaps: water: default: 0 +minimaps: + overworld: {} + forEachTile: - altitude: - type: sequence + generateWorld: + type: schedule do: - - type: fetchData - modelType: altitude - provider: - type: wmsFloat - url: https://data.geopf.fr/wms-r/wms - layer: RGEALTI-MNT_PYR-ZIP_FXX_LAMB93_WMS - - type: populateHeightmap - models: - type: altitude - heightmap: ground - - type: setSpawn - heightmap: - sum: [ground, 1] - x: 0 - y: 0 + altitude: + type: sequence + do: + - type: fetchData + modelType: altitude + provider: + type: wmsFloat + url: https://data.geopf.fr/wms-r/wms + layer: RGEALTI-MNT_PYR-ZIP_FXX_LAMB93_WMS + - type: populateHeightmap + models: + type: altitude + heightmap: ground + - type: setSpawn + heightmap: + sum: [ground, 1] + x: 0 + y: 0 - fetchBuildings: - type: fetchData - modelType: buildings - provider: - type: wfs - url: https://data.geopf.fr/wfs/wfs - features: BDTOPO_V3:batiment - maxFeaturesPerQuery: 500 - postProcessing: - - type: copy - metadata: hauteur - to: height - - type: parse - metadata: height - as: decimal - ifMissing: ignore - ifNotParsable: removeMetadata - - type: truncate - metadata: height - method: round - ifMissing: ignore - - type: default - metadata: height - value: 5 - as: integer - - fetchWater: - type: fetchData - modelType: water - provider: - type: wfs - url: https://data.geopf.fr/wfs/wfs - features: BDTOPO_V3:surface_hydrographique + fetchBuildings: + type: fetchData + modelType: buildings + provider: + type: wfs + url: https://data.geopf.fr/wfs/wfs + features: BDTOPO_V3:batiment + maxFeaturesPerQuery: 500 + postProcessing: + - type: copy + metadata: hauteur + to: height + - type: parse + metadata: height + as: decimal + ifMissing: ignore + ifNotParsable: removeMetadata + - type: truncate + metadata: height + method: round + ifMissing: ignore + - type: default + metadata: height + value: 5 + as: integer - fetchRoads: - type: fetchData - modelType: road - provider: - type: wfs - url: https://data.geopf.fr/wfs/wfs - features: BDTOPO_V3:troncon_de_route + fetchWater: + type: fetchData + modelType: water + provider: + type: wfs + url: https://data.geopf.fr/wfs/wfs + features: BDTOPO_V3:surface_hydrographique - flattenWater: - after: - - fetchWater - - altitude - type: copyHeightmap - models: - type: water - from: - localMin: ground - range: 9 - to: ground + fetchRoads: + type: fetchData + modelType: road + provider: + type: wfs + url: https://data.geopf.fr/wfs/wfs + features: BDTOPO_V3:troncon_de_route - renderGround: - after: - - altitude - - flattenWater - type: renderHeightmap - at: ground - place: - structure: - - place: *voxel-grass - at: [0, 0, 0] - - place: *voxel-dirt - at: [0, 0, -3..-1] - - place: *voxel-stone - at: [0, 0, -20..-4] + flattenWater: + after: + - fetchWater + - altitude + type: copyHeightmap + models: + type: water + from: + localMin: ground + range: 9 + to: ground - computeBuildingsAltitude: - after: - - fetchBuildings - - renderGround - type: computeHeightmapStats - models: - type: buildings - heightmap: ground - compute: - minimum: minimum-ground-altitude - maximum: ground-floor-altitude + renderGround: + after: + - altitude + - flattenWater + type: renderHeightmap + at: ground + place: + structure: + - place: *voxel-grass + at: [0, 0, 0] + - place: *voxel-dirt + at: [0, 0, -3..-1] + - place: *voxel-stone + at: [0, 0, -20..-4] - flattenGroundBuildings: - after: - - computeBuildingsAltitude - type: fillBetweenHeightmapAndValue - models: - type: buildings - heightmap: ground - altitudeValue: - metadata: ground-floor-altitude - placeAbove: *voxel-air - placeBelow: *voxel-building-ground + computeBuildingsAltitude: + after: + - fetchBuildings + - renderGround + type: computeHeightmapStats + models: + type: buildings + heightmap: ground + compute: + minimum: minimum-ground-altitude + maximum: ground-floor-altitude - # Hacky way to render roofs. Should be removed. - # Would have been nice to have a fillBetweenMetadatas - renderRoofs: - after: flattenGroundBuildings - type: renderBuildings - models: - type: buildings - filter: - value: - metadata: height - greaterThan: 0 - roof: *voxel-cobble - wall: *voxel-air - window: *voxel-air + flattenGroundBuildings: + after: + - computeBuildingsAltitude + type: fillBetweenHeightmapAndValue + models: + type: buildings + heightmap: ground + altitudeValue: + metadata: ground-floor-altitude + placeAbove: *voxel-air + placeBelow: *voxel-building-ground - forest: - type: sequence - using: renderGround - do: - - type: fetchData - modelType: vegetation - provider: - type: wfs - url: https://data.geopf.fr/wfs/wfs - features: BDTOPO_V3:zone_de_vegetation - - type: renderSurfaces - after: renderGround + # Hacky way to render roofs. Should be removed. + # Would have been nice to have a fillBetweenMetadatas + renderRoofs: + after: flattenGroundBuildings + type: renderBuildings models: - type: vegetation + type: buildings filter: - metadata: nature - in: - - "Bois" - - "Forêt fermée de conifères" - - "Forêt fermée de feuillus" - - "Forêt fermée mixte" - - "Forêt ouverte" - - "Zone arborée" - heightmap: ground - place: - - pattern: - seed: forest-grass - chance: 0.1 - place: - structure: - - at: [0, 0, 1] - place: *forest-grass - - *voxel-forest-ground - - pattern: - seed: forest-tree1 - chance: 0.01 - place: *forest-tree1 - - pattern: - seed: tree2 - chance: 0.003 - place: *forest-tree2 + value: + metadata: height + greaterThan: 0 + roof: *voxel-cobble + wall: *voxel-air + window: *voxel-air - populateWaterPresence: - after: fetchWater - type: copyHeightmap - models: - type: water - from: 1 - to: water + forest: + type: sequence + using: renderGround + do: + - type: fetchData + modelType: vegetation + provider: + type: wfs + url: https://data.geopf.fr/wfs/wfs + features: BDTOPO_V3:zone_de_vegetation + - type: renderSurfaces + after: renderGround + models: + type: vegetation + filter: + metadata: nature + in: + - "Bois" + - "Forêt fermée de conifères" + - "Forêt fermée de feuillus" + - "Forêt fermée mixte" + - "Forêt ouverte" + - "Zone arborée" + heightmap: ground + place: + - pattern: + seed: forest-grass + chance: 0.1 + place: + structure: + - at: [0, 0, 1] + place: *forest-grass + - *voxel-forest-ground + - pattern: + seed: forest-tree1 + chance: 0.01 + place: *forest-tree1 + - pattern: + seed: tree2 + chance: 0.003 + place: *forest-tree2 - computeWaterDepth: - after: populateWaterPresence - type: copyHeightmap - models: - type: water - from: - remap: - manhattan: water - maximumDistance: 12 - targetValue: 0 - mapping: - 0: 0 - 1: 1 - 2: 8 - 3..3: 10 - 4..6: 11 - 7..12: 12 - to: water + populateWaterPresence: + after: fetchWater + type: copyHeightmap + models: + type: water + from: 1 + to: water - renderWater: - after: - - renderGround - - computeWaterDepth - # Vegetation and water vector data overlap in certain areas. (Because of vegetation representing canopy) - # The dependency and air voxel below should be removed when cropping is implemented on vector data. - - forest - type: renderHeightmap - minimum: - sum: - - ground - - 1 - - product: [ -1, water ] - maximum: ground - place: - structure: - - place: *voxel-water - at: [ 0, 0, 0 ] - # This a dirt fix to remove vegetation on water - - place: *voxel-air - at: [ 0, 0, 1..12 ] + computeWaterDepth: + after: populateWaterPresence + type: copyHeightmap + models: + type: water + from: + remap: + manhattan: water + maximumDistance: 12 + targetValue: 0 + mapping: + 0: 0 + 1: 1 + 2: 8 + 3..3: 10 + 4..6: 11 + 7..12: 12 + to: water - path: - after: - - fetchRoads - - renderGround - - forest - type: renderLines - models: - type: road - filter: - metadata: nature - in: [ "Chemin", "Sentier", "Route empierrée" ] - structure: - - place: *path - at: [ 0, -1..1, 0] - - place: *voxel-air - at: [ 0, -1..1, 1..2 ] + renderWater: + after: + - renderGround + - computeWaterDepth + # Vegetation and water vector data overlap in certain areas. (Because of vegetation representing canopy) + # The dependency and air voxel below should be removed when cropping is implemented on vector data. + - forest + type: renderHeightmap + minimum: + sum: + - ground + - 1 + - product: [ -1, water ] + maximum: ground + place: + structure: + - place: *voxel-water + at: [ 0, 0, 0 ] + # This a dirt fix to remove vegetation on water + - place: *voxel-air + at: [ 0, 0, 1..12 ] - renderBridges: - after: path - type: renderLines - models: - type: road - filter: - metadata: nature - in: [ "Bretelle", "Rond-point", "Route à 1 chaussée", "Route à 2 chaussées", "Type autoroutier"] - renderOnlyWhenAbove: - sum: - - ground - - 1 - structure: - # Half bridge structure (sectional view) - axes: [ z, y ] - blueprint: - - "▒▒ ▒▒" - - "▒▒▒▒▒▒▒▒▒▒▒▒▒" - with: - "▒": *voxel-stone - zOffset: -1 + path: + after: + - fetchRoads + - renderGround + - forest + type: renderLines + models: + type: road + filter: + metadata: nature + in: [ "Chemin", "Sentier", "Route empierrée" ] + structure: + - place: *path + at: [ 0, -1..1, 0] + - place: *voxel-air + at: [ 0, -1..1, 1..2 ] - renderRoadsPass1: - after: renderBridges - type: renderLines - models: - type: road - filter: - metadata: nature - in: [ "Bretelle", "Rond-point", "Route à 1 chaussée", "Route à 2 chaussées", "Type autoroutier"] - structure: - # Road border lines and air to clean eventual voxels (sectional view) - axes: [ z, y ] - blueprint: - - "·········" - - "·········" - - "█·······█" - with: - "█": *road-paint - "·": *voxel-air + renderBridges: + after: path + type: renderLines + models: + type: road + filter: + metadata: nature + in: [ "Bretelle", "Rond-point", "Route à 1 chaussée", "Route à 2 chaussées", "Type autoroutier"] + renderOnlyWhenAbove: + sum: + - ground + - 1 + structure: + # Half bridge structure (sectional view) + axes: [ z, y ] + blueprint: + - "▒▒ ▒▒" + - "▒▒▒▒▒▒▒▒▒▒▒▒▒" + with: + "▒": *voxel-stone + zOffset: -1 - renderRoadsPass2: - after: renderRoadsPass1 - type: renderLines - models: - type: road - filter: - metadata: nature - in: [ "Bretelle", "Rond-point", "Route à 1 chaussée", "Route à 2 chaussées", "Type autoroutier"] - structure: - # Road central line and tar (view from above) - axes: [ x, y ] - blueprint: - - "░░░█░░░" - - "░░░█░░░" - - "░░░█░░░" - - "░░░░░░░" - - "░░░░░░░" - with: - "█": *road-paint - "░": *road-tar + renderRoadsPass1: + after: renderBridges + type: renderLines + models: + type: road + filter: + metadata: nature + in: [ "Bretelle", "Rond-point", "Route à 1 chaussée", "Route à 2 chaussées", "Type autoroutier"] + structure: + # Road border lines and air to clean eventual voxels (sectional view) + axes: [ z, y ] + blueprint: + - "·········" + - "·········" + - "█·······█" + with: + "█": *road-paint + "·": *voxel-air - renderResidentialBuildings: - after: - - flattenGroundBuildings - - renderRoofs - type: renderFacades - models: - type: buildings - filter: - metadata: usage_1 - equals: Résidentiel - height: height - altitude: ground-floor-altitude - # TODO: Layout should probably be defined on a common.yaml file in format. - build: - - along: x - concatenate: - # Left corner - - &corner - structure: - at: [ 0, -1..0, 0 ] - place: *corner-material - stretchableAlongX: { at: 0 } - stretchableAlongZ: { at: 0 } - priority: -1 - - along: z + renderRoadsPass2: + after: renderRoadsPass1 + type: renderLines + models: + type: road + filter: + metadata: nature + in: [ "Bretelle", "Rond-point", "Route à 1 chaussée", "Route à 2 chaussées", "Type autoroutier"] + structure: + # Road central line and tar (view from above) + axes: [ x, y ] + blueprint: + - "░░░█░░░" + - "░░░█░░░" + - "░░░█░░░" + - "░░░░░░░" + - "░░░░░░░" + with: + "█": *road-paint + "░": *road-tar + + renderResidentialBuildings: + after: + - flattenGroundBuildings + - renderRoofs + type: renderFacades + models: + type: buildings + filter: + metadata: usage_1 + equals: Résidentiel + height: height + altitude: ground-floor-altitude + # TODO: Layout should probably be defined on a common.yaml file in format. + build: + - along: x concatenate: - # Ground floor - - priority: 1 - along: x + # Left corner + - &corner + structure: + at: [ 0, -1..0, 0 ] + place: *corner-material + stretchableAlongX: { at: 0 } + stretchableAlongZ: { at: 0 } + priority: -1 + - along: z concatenate: - - atLeast: 0 + # Ground floor + - priority: 1 along: x - repeat: - structure: - axes: [ z, x ] - blueprint: [ "-O==O", "-O**O", "-O**O", "-O**O", "-O==O", "-O--O" ] - with: *facade-residential-materials - stretchableAlongZ: { at: 0 } - # Door + concatenate: + - atLeast: 0 + along: x + repeat: + structure: + axes: [ z, x ] + blueprint: [ "-O==O", "-O**O", "-O**O", "-O**O", "-O==O", "-O--O" ] + with: *facade-residential-materials + stretchableAlongZ: { at: 0 } + # Door + - structure: + axes: [ z, x ] + blueprint: [ "-O==O-", "-O O-", "-O O-", "-O O-", "-O O-", "-O O-" ] + with: *facade-residential-materials + stretchableAlongZ: { at: 0 } + # Right ground windows + - along: x + atLeast: 0 + repeat: + structure: + axes: [ z, x ] + blueprint: [ "O==O-", "O**O-", "O**O-", "O**O-", "O==O-", "O--O-" ] + with: *facade-residential-materials + stretchableAlongZ: { at: 0 } + # Cornice - structure: - axes: [ z, x ] - blueprint: [ "-O==O-", "-O O-", "-O O-", "-O O-", "-O O-", "-O O-" ] - with: *facade-residential-materials - stretchableAlongZ: { at: 0 } - # Right ground windows - - along: x + at: [ 0, 0, 0 ] + place: *cornice-material + stretchableAlongX: { at: 0 } + # Upper floors + - priority: 2 + along: z atLeast: 0 repeat: - structure: - axes: [ z, x ] - blueprint: [ "O==O-", "O**O-", "O**O-", "O**O-", "O==O-", "O--O-" ] - with: *facade-residential-materials - stretchableAlongZ: { at: 0 } - # Cornice - - structure: - at: [ 0, 0, 0 ] - place: *cornice-material - stretchableAlongX: { at: 0 } - # Upper floors - - priority: 2 - along: z - atLeast: 0 - repeat: - along: x - concatenate: - - structure: - at: [ 0, 0, 0..5 ] - place: *other-material - - along: x - repeat: - structure: - axes: [ y, z, x ] - blueprint: - - [ "O==O+", "O**O+", "O**O+", "O**O+", "O==O+", "O--O+" ] - - [ " ", " ", " ", "|||| ", "==== ", "O O " ] - with: *facade-residential-materials - yOffset: -1 - # Upper cornice - - structure: - at: [ 0, 0, 0 ] - place: *cornice-material - stretchableAlongX: { at: 0 } - stretchableAlongZ: { at: 0 } - # Right corner - - *corner - - structure: - at: [ 0, 0, 0 ] - place: *corner-material - stretchableAlongX: { at: 0 } - stretchableAlongZ: { at: 0 } - - renderOfficeBuildings: - after: - - flattenGroundBuildings - - renderRoofs - type: renderFacades - models: - type: buildings - filter: - not: - metadata: usage_1 - equals: Résidentiel - height: height - altitude: ground-floor-altitude - # TODO: Layout should probably be defined on a common.yaml file in format. - build: - - along: x - concatenate: - # Left corner + along: x + concatenate: + - structure: + at: [ 0, 0, 0..5 ] + place: *other-material + - along: x + repeat: + structure: + axes: [ y, z, x ] + blueprint: + - [ "O==O+", "O**O+", "O**O+", "O**O+", "O==O+", "O--O+" ] + - [ " ", " ", " ", "|||| ", "==== ", "O O " ] + with: *facade-residential-materials + yOffset: -1 + # Upper cornice + - structure: + at: [ 0, 0, 0 ] + place: *cornice-material + stretchableAlongX: { at: 0 } + stretchableAlongZ: { at: 0 } + # Right corner + - *corner - structure: - place: *modern-wall - at: [0, 0, 0] - stretchableAlongX: - at: 0 - stretchableAlongZ: - at: 0 - # Facade - - priority: 1 - along: z + at: [ 0, 0, 0 ] + place: *corner-material + stretchableAlongX: { at: 0 } + stretchableAlongZ: { at: 0 } + + renderOfficeBuildings: + after: + - flattenGroundBuildings + - renderRoofs + type: renderFacades + models: + type: buildings + filter: + not: + metadata: usage_1 + equals: Résidentiel + height: height + altitude: ground-floor-altitude + # TODO: Layout should probably be defined on a common.yaml file in format. + build: + - along: x concatenate: - # Ground floor - - along: x - concatenate: - # Left - - along: x - repeat: - structure: - with: *modern-materials - axes: [z, x] - blueprint: [ "O**", "O**", "O**", "OOO", "OOO"] - # Door - - structure: - with: *modern-materials - axes: [z, x] - blueprint: [ "OOOO", "O O", "O O", "O O", "O O" ] - # Right - - along: x - repeat: - structure: - with: *modern-materials - axes: [z, x] - blueprint: [ "**O", "**O", "**O", "OOO", "OOO" ] - # Upper floors + # Left corner + - structure: + place: *modern-wall + at: [0, 0, 0] + stretchableAlongX: + at: 0 + stretchableAlongZ: + at: 0 + # Facade - priority: 1 - along: x + along: z concatenate: + # Ground floor + - along: x + concatenate: + # Left + - along: x + repeat: + structure: + with: *modern-materials + axes: [z, x] + blueprint: [ "O**", "O**", "O**", "OOO", "OOO"] + # Door + - structure: + with: *modern-materials + axes: [z, x] + blueprint: [ "OOOO", "O O", "O O", "O O", "O O" ] + # Right + - along: x + repeat: + structure: + with: *modern-materials + axes: [z, x] + blueprint: [ "**O", "**O", "**O", "OOO", "OOO" ] + # Upper floors + - priority: 1 + along: x + concatenate: + - structure: + place: *modern-wall + at: [0, 0, 0 ] + stretchableAlongZ: + at: 0 + - along: x + repeat: + structure: + with: *modern-materials + axes: x + blueprint: "**O" + stretchableAlongZ: + at: 0 + # Top - structure: place: *modern-wall at: [0, 0, 0 ] + stretchableAlongX: + at: 0 stretchableAlongZ: at: 0 - - along: x - repeat: - structure: - with: *modern-materials - axes: x - blueprint: "**O" - stretchableAlongZ: - at: 0 - # Top + # Left corner - structure: place: *modern-wall - at: [0, 0, 0 ] + at: [0, 0, 0] stretchableAlongX: at: 0 stretchableAlongZ: at: 0 - # Left corner + # Default - structure: + at: [ 0, 0, 0 ] place: *modern-wall - at: [0, 0, 0] stretchableAlongX: at: 0 stretchableAlongZ: at: 0 - # Default - - structure: - at: [ 0, 0, 0 ] - place: *modern-wall - stretchableAlongX: - at: 0 - stretchableAlongZ: - at: 0 + + populateOverworldMinimap: + after: generateWorld + type: populateMinimap + minimap: overworld + colors: *minimap-color-palette + +afterAllTiles: + minimaps: + type: sequence + do: + - type: applyShadingMinimap + minimap: overworld + - type: saveMinimap + minimap: overworld + destination: overworld-minimap.png + format: png + background: [0, 0, 0, 0] diff --git a/src/main/java/fr/ign/voxatile/core/Voxatile.java b/src/main/java/fr/ign/voxatile/core/Voxatile.java index 9b30b8251..26a4d395f 100644 --- a/src/main/java/fr/ign/voxatile/core/Voxatile.java +++ b/src/main/java/fr/ign/voxatile/core/Voxatile.java @@ -34,6 +34,7 @@ import fr.ign.voxatile.core.parameters.providers.ShapefileProviderParams; import fr.ign.voxatile.core.parameters.providers.WFSProviderParams; import fr.ign.voxatile.core.parameters.providers.WMSFloatBilProviderParams; +import fr.ign.voxatile.core.parameters.tasks.ApplyShadingMinimapTaskParams; import fr.ign.voxatile.core.parameters.tasks.BuildLayoutTaskParams; import fr.ign.voxatile.core.parameters.tasks.CopyHeightmapTaskParams; import fr.ign.voxatile.core.parameters.tasks.FetchDataTaskParams; @@ -41,6 +42,7 @@ import fr.ign.voxatile.core.parameters.tasks.HeightmapStatsTaskParams; import fr.ign.voxatile.core.parameters.tasks.NoOperationTaskParams; import fr.ign.voxatile.core.parameters.tasks.PopulateHeightmapTaskParams; +import fr.ign.voxatile.core.parameters.tasks.PopulateMinimapTaskParams; import fr.ign.voxatile.core.parameters.tasks.RenderBuildingsTaskParams; import fr.ign.voxatile.core.parameters.tasks.RenderFacadesTaskParams; import fr.ign.voxatile.core.parameters.tasks.RenderHeightmapTaskParams; @@ -49,6 +51,7 @@ import fr.ign.voxatile.core.parameters.tasks.RenderPoints2dTaskParams; import fr.ign.voxatile.core.parameters.tasks.RenderPointsTaskParams; import fr.ign.voxatile.core.parameters.tasks.RenderSurfacesTaskParams; +import fr.ign.voxatile.core.parameters.tasks.SaveMinimapTaskParams; import fr.ign.voxatile.core.parameters.tasks.ScheduleTaskParams; import fr.ign.voxatile.core.parameters.tasks.SequenceTaskParams; import fr.ign.voxatile.core.parameters.tasks.SetSpawnTaskParams; @@ -120,6 +123,8 @@ public static void main(String[] args) throws FactoryException, InterruptedExcep // TODO: Static method that provides a ParamsParser with all default renderers // If those name values are modified, update the documentation accordingly + parser.registerParams("applyShadingMinimap", ApplyShadingMinimapTaskParams.class); + parser.registerParams("buildLayout", BuildLayoutTaskParams.class); parser.registerParams("noOperation", NoOperationTaskParams.class); parser.registerParams("sequence", SequenceTaskParams.class); parser.registerParams("schedule", ScheduleTaskParams.class); @@ -128,6 +133,7 @@ public static void main(String[] args) throws FactoryException, InterruptedExcep parser.registerParams("fetchData", FetchDataTaskParams.class); parser.registerParams("fillBetweenHeightmapAndValue", FillBetweenHeightmapAndValueTaskParams.class); parser.registerParams("populateHeightmap", PopulateHeightmapTaskParams.class); + parser.registerParams("populateMinimap", PopulateMinimapTaskParams.class); parser.registerParams("renderBuildings", RenderBuildingsTaskParams.class); parser.registerParams("renderHeightmap", RenderHeightmapTaskParams.class); parser.registerParams("renderSurfaces", RenderSurfacesTaskParams.class); @@ -135,9 +141,9 @@ public static void main(String[] args) throws FactoryException, InterruptedExcep parser.registerParams("renderLines2d", RenderLines2dTaskParams.class); parser.registerParams("renderPoints", RenderPointsTaskParams.class); parser.registerParams("renderPoints2d", RenderPoints2dTaskParams.class); - parser.registerParams("setSpawn", SetSpawnTaskParams.class); parser.registerParams("renderFacades", RenderFacadesTaskParams.class); - parser.registerParams("buildLayout", BuildLayoutTaskParams.class); + parser.registerParams("saveMinimap", SaveMinimapTaskParams.class); + parser.registerParams("setSpawn", SetSpawnTaskParams.class); parser.registerParams("wfs", WFSProviderParams.class); parser.registerParams("gpkg", GeoPackageProviderParams.class); diff --git a/src/main/java/fr/ign/voxatile/core/generation/Generation.java b/src/main/java/fr/ign/voxatile/core/generation/Generation.java index fc4fc4c13..f13a205c7 100644 --- a/src/main/java/fr/ign/voxatile/core/generation/Generation.java +++ b/src/main/java/fr/ign/voxatile/core/generation/Generation.java @@ -14,6 +14,7 @@ import fr.ign.voxatile.core.exceptions.TransformException; import fr.ign.voxatile.core.generation.heightmaps.HeightmapDeclarationStore; +import fr.ign.voxatile.core.generation.minimaps.MinimapStore; import fr.ign.voxatile.core.utils.coordinates.MapToWorldConverter; import fr.ign.voxatile.core.utils.coordinates.WorldToMapConverter; import fr.ign.voxatile.core.utils.execution.Scheduler; @@ -42,6 +43,7 @@ public class Generation { private final AffineTransformation voxelToCrs; private final VoxelWorld world; + private final MinimapStore minimaps = new MinimapStore(); private final HeightmapDeclarationStore heightmaps = new HeightmapDeclarationStore(); private final Scheduler forEachTileScheduler = new Scheduler(); @@ -125,6 +127,13 @@ public VoxelWorld world() { return world; } + /** + * {@return the minimaps store} + */ + public MinimapStore minimaps() { + return minimaps; + } + /** * Returns the {@link HeightmapDeclarationStore} for the stored heightmaps. * diff --git a/src/main/java/fr/ign/voxatile/core/generation/minimaps/Minimap.java b/src/main/java/fr/ign/voxatile/core/generation/minimaps/Minimap.java new file mode 100644 index 000000000..93a9110ea --- /dev/null +++ b/src/main/java/fr/ign/voxatile/core/generation/minimaps/Minimap.java @@ -0,0 +1,150 @@ +package fr.ign.voxatile.core.generation.minimaps; + +import java.awt.Color; + +import fr.ign.voxatile.core.tasks.ApplyShadingMinimapTask; +import fr.ign.voxatile.core.tasks.PopulateMinimapTask; +import fr.ign.voxatile.core.tasks.SaveMinimapTask; +import fr.ign.voxatile.core.utils.world2d.Bounded2d; +import fr.ign.voxatile.core.utils.world3d.WorldCoords3d; +import fr.ign.voxatile.core.world.Voxel; +import fr.ign.voxatile.core.world.VoxelWorld; + +/** + * Builds a scaled 2D representation of a {@link VoxelWorld} for use as an in-game minimap. + * + *

+ * Although the stored data is 2D, this {@code Minimap} is designed to support 2.5D effects + * through height-based tasks (e.g. shading, lighting). + * + *

+ * This class is purely a data structure. Population, processing, and rendering + * are performed by dedicated external tasks. + * + * @see PopulateMinimapTask + * @see ApplyShadingMinimapTask + * @see SaveMinimapTask + */ +public class Minimap { + + // Cells composing the minimap, stored in row-major order. + private final MinimapCell[] cells; + + private final Bounded2d bbox; + private final double samplingRate; + + // Size in pixels of the minimap. + private final int width; + private final int height; + + /** + * Creates a {@code Minimap} covering the specified world bounding box. + * + * @param bbox the world bounds + * @param size the maximum size of the longest side of the minimap in pixels + */ + public Minimap(Bounded2d bbox, int size) { + this.bbox = bbox; + + samplingRate = Math.min((double) size / bbox.bbox().sizeX(), (double) size / bbox.bbox().sizeY()); + + width = (int) Math.ceil(samplingRate * bbox.bbox().sizeX()); + height = (int) Math.ceil(samplingRate * bbox.bbox().sizeY()); + + cells = new MinimapCell[width * height]; + } + + // Returns the index of a cell at the specified projected coordinates + private int index(int x, int y) { + return y * width + x; + } + + /** + * Projects a {@link Voxel} onto the {@code Minimap} using a box blur algorithm. + * + *

+ * The {@link VoxelWorld} and the {@code Minimap} usually do not share the same + * size, so a voxel rarely maps cleanly to a single {@link MinimapCell}. + * A direct one-to-one mapping would therefore produce visual artifacts on the + * resulting {@code Minimap}. + * + *

+ * To avoid this, each {@link Voxel} contribution is distributed + * across all overlapping {@link MinimapCell}s. The weight assigned to each + * cell corresponds to the area of intersection between the voxel's projected + * footprint and the cell itself. + * + * @param color the voxel color + * @param coords the voxel world coordinates + */ + public void add(Color color, WorldCoords3d coords) { + // Project world coordinates into minimap coordinates. + double x = (coords.x() - bbox.bbox().minX()) * samplingRate; + double y = (coords.y() - bbox.bbox().minY()) * samplingRate; + double xEnd = x + samplingRate; + double yEnd = y + samplingRate; + + // Compute the range of cells that may be covered by the sample. + int xMin = Math.max(0, (int) Math.floor(x)); + int yMin = Math.max(0, (int) Math.floor(y)); + int xMax = Math.min(width - 1, (int) Math.floor(xEnd)); + int yMax = Math.min(height - 1, (int) Math.floor(yEnd)); + + for (int py = yMin; py <= yMax; py++) { + // Compute how much the sample overlaps this cell vertically. + double overlapY = Math.max(0.0, + Math.min(yEnd, py + 1.0) - Math.max(y, py)); + + for (int px = xMin; px <= xMax; px++) { + // Compute how much the sample overlaps this cell horizontally. + double overlapX = Math.max(0.0, + Math.min(xEnd, px + 1.0) - Math.max(x, px)); + + // The contribution of the sample is proportional + // to the overlapping area with the current cell. + double weight = overlapX * overlapY; + if (weight > 0.0) { + int index = index(px, py); + if (cells[index] == null) + cells[index] = new MinimapCell(); + cells[index].add( + color.getRed(), + color.getGreen(), + color.getBlue(), + color.getAlpha(), + coords.z(), + weight + ); + } + } + } + } + + /** + * Returns the {@link MinimapCell} at the specified {@code Minimap} coordinates. + * + * @param x the cell x-coordinate + * @param y the cell y-coordinate + * @return the {@link MinimapCell}, or {@code null} if the coordinates are outside the {@code Minimap} + * or if no voxel has been projected onto this cell. + */ + public MinimapCell get(int x, int y) { + if (x < 0 || y < 0 || x >= width || y >= height) + return null; + return cells[index(x, y)]; + } + + /** + * {@return the width of the {@code Minimap}} + */ + public int getWidth() { + return width; + } + + /** + * {@return the height of the {@code Minimap}} + */ + public int getHeight() { + return height; + } +} diff --git a/src/main/java/fr/ign/voxatile/core/generation/minimaps/MinimapCell.java b/src/main/java/fr/ign/voxatile/core/generation/minimaps/MinimapCell.java new file mode 100644 index 000000000..5ccaea677 --- /dev/null +++ b/src/main/java/fr/ign/voxatile/core/generation/minimaps/MinimapCell.java @@ -0,0 +1,122 @@ +package fr.ign.voxatile.core.generation.minimaps; + +import fr.ign.voxatile.core.world.Voxel; + +/** + * Represents a single cell in the minimap 2D grid. + * + *

+ * A {@code MinimapCell} acts as a weighted accumulator for multiple {@link Voxel} + * contributions projected onto the same grid position. + * It stores aggregated color (RGBA) and height data before averaging. + * + *

+ * Each contribution is accumulated using a weight, allowing smooth blending between + * multiple voxels mapped to the same cell. + * + *

+ * Final values are obtained as weighted averages computed using the sum of all + * accumulated weights. + * + *

+ * This class does not represent a final pixel but an intermediate aggregation state + * used during minimap data population. + */ +public class MinimapCell { + + // Accumulated weighted color components. + private double red = 0d; + private double green = 0d; + private double blue = 0d; + private double alpha = 0d; + // Accumulated weighted height value. + private double height = 0d; + // Sum of all contribution weights, used to compute weighted averages. + private double totalWeight = 0d; + + private double computeAverage(double accumulatedValue) { + return totalWeight == 0 ? 0 : accumulatedValue / totalWeight; + } + + /** + * Adds a weighted contribution to this cell. + * + * @param red the red color component + * @param green the green color component + * @param blue the blue color component + * @param alpha the alpha color component + * @param height the height value + * @param weight the influence of this contribution + */ + public void add(double red, double green, double blue, double alpha, double height, double weight) { + this.red += red * weight; + this.green += green * weight; + this.blue += blue * weight; + this.alpha += alpha * weight; + this.height += height * weight; + this.totalWeight += weight; + } + + /** + * Sets this cell to a single explicit value, replacing any previous contributions. + * + * @param red the red color component + * @param green the green color component + * @param blue the blue color component + * @param alpha the alpha color component + * @param height the height value + */ + public void set(double red, double green, double blue, double alpha, double height) { + this.red = red; + this.green = green; + this.blue = blue; + this.alpha = alpha; + this.height = height; + totalWeight = 1; + } + + /** + * {@return the red component} + */ + public double getRed() { + return computeAverage(red); + } + + /** + * {@return the green component} + */ + public double getGreen() { + return computeAverage(green); + } + + /** + * {@return the blue component} + */ + public double getBlue() { + return computeAverage(blue); + } + + /** + * {@return the alpha component} + */ + public double getAlpha() { + return computeAverage(alpha); + } + + /** + * {@return the RGB value} + */ + public int getRGB() { + return Math.max(0, Math.min(255, (int) getAlpha())) << 24 + | (Math.max(0, Math.min(255, (int) getRed())) << 16) + | (Math.max(0, Math.min(255, (int) getGreen())) << 8) + | (Math.max(0, Math.min(255, (int) getBlue())) << 0); + } + + /** + * {@return the height value} + */ + public double getHeight() { + return computeAverage(height); + } +} diff --git a/src/main/java/fr/ign/voxatile/core/generation/minimaps/MinimapStore.java b/src/main/java/fr/ign/voxatile/core/generation/minimaps/MinimapStore.java new file mode 100644 index 000000000..82b6ce16e --- /dev/null +++ b/src/main/java/fr/ign/voxatile/core/generation/minimaps/MinimapStore.java @@ -0,0 +1,37 @@ +package fr.ign.voxatile.core.generation.minimaps; + +import java.util.HashMap; +import java.util.Map; + +/** + * A store of {@link Minimap}, indexed by their name. + */ +public class MinimapStore { + + private final Map store = new HashMap<>(); + + /** + * Adds a minimap to the store with the specified name. + * + * @param name the name of the minimap to add + * @param minimap the minimap to add + * @throws IllegalArgumentException if the name is {@code null} or already exists in the store + */ + public void add(String name, Minimap minimap) { + if (name == null) + throw new IllegalArgumentException("Cannot add a minimap with null name"); + if (store.containsKey(name)) + throw new IllegalArgumentException("A minimap named \"%s\" is already in store".formatted(name)); + store.put(name, minimap); + } + + /** + * Retrieves a {@link Minimap} corresponding to the specified name. + * + * @param name the name of the minimap to retrieve + * @return resulting {@link Minimap}, or {@code null} if not found. + */ + public Minimap get(String name) { + return store.get(name); + } +} diff --git a/src/main/java/fr/ign/voxatile/core/parameters/ColorDeserializer.java b/src/main/java/fr/ign/voxatile/core/parameters/ColorDeserializer.java index 155a2ae73..e3ea1dc3f 100644 --- a/src/main/java/fr/ign/voxatile/core/parameters/ColorDeserializer.java +++ b/src/main/java/fr/ign/voxatile/core/parameters/ColorDeserializer.java @@ -15,21 +15,23 @@ public class ColorDeserializer extends ValueDeserializer { @Override public Color deserialize(JsonParser parser, DeserializationContext context) throws JacksonException { - JsonNode node = parser.readValueAsTree(); + JsonNode node = parser.readValueAsTree(); if (node.isString()) { String string = node.asString(); try { return Color.decode(string); } catch (NumberFormatException e) { - return (Color) context.handleWeirdStringValue(Color.class, string, "The color %s cannot be interpreted", string); + return (Color) context.handleWeirdStringValue(Color.class, string, "String value %s cannot be interpreted as a color", string); } } if (node.isArray()) { int size = node.size(); if (size < 3 || size > 4) - return (Color) context.reportInputMismatch(Color.class, "Color array must have 3 or 4 components, got %d.", size); + return context.reportInputMismatch(Color.class, "Color array must have 3 or 4 components, got %d.", size); + if (!node.get(0).isInt() || !node.get(1).isInt() || !node.get(2).isInt() || (size == 4 && !node.get(3).isInt())) + return context.reportInputMismatch(Color.class, "Color array must contain only integer values."); int red = node.get(0).asInt(); int green = node.get(1).asInt(); diff --git a/src/main/java/fr/ign/voxatile/core/parameters/GenerationParams.java b/src/main/java/fr/ign/voxatile/core/parameters/GenerationParams.java index c0c83241a..c9cf4be70 100644 --- a/src/main/java/fr/ign/voxatile/core/parameters/GenerationParams.java +++ b/src/main/java/fr/ign/voxatile/core/parameters/GenerationParams.java @@ -92,6 +92,15 @@ public class GenerationParams { @JsonSetter(nulls = Nulls.SKIP, contentNulls = Nulls.FAIL) public Map heightmaps = new LinkedHashMap<>(); + /** + * Minimaps of the world. + */ + @JsonSetter( + nulls = Nulls.SKIP, + contentNulls = Nulls.FAIL + ) + public Map minimaps = new LinkedHashMap<>(); + /** * Description of the schedule that will run for each tile. */ @@ -141,6 +150,9 @@ public void validate() throws IllegalArgumentException { for (HeightmapDeclarationParams params : heightmaps.values()) params.validate(); + for (MinimapParams params : minimaps.values()) + params.validate(); + forEachTile.validate(); afterAllTiles.validate(); } @@ -184,6 +196,10 @@ public Generation create(File destination, Integer maxTileSize) { maxTileSize == null || maxTileSize <= 0 ? Math.max(area.extentX, area.extentY) : maxTileSize ); + minimaps.forEach((name, minimapParams) -> + generation.minimaps().add(name, minimapParams.create(world.limits().to2d())) + ); + heightmaps.forEach((name, heightmapParams) -> generation.heightmaps().add(heightmapParams.create(name)) ); diff --git a/src/main/java/fr/ign/voxatile/core/parameters/MinimapParams.java b/src/main/java/fr/ign/voxatile/core/parameters/MinimapParams.java new file mode 100644 index 000000000..7ac70e2c8 --- /dev/null +++ b/src/main/java/fr/ign/voxatile/core/parameters/MinimapParams.java @@ -0,0 +1,39 @@ +package fr.ign.voxatile.core.parameters; + +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; + +import fr.ign.voxatile.core.generation.minimaps.Minimap; +import fr.ign.voxatile.core.utils.world2d.WorldBBox2d; + +/** + * Params for {@link Minimap}. + */ +public class MinimapParams { + + /** + * Maximum size of the minimap in pixels (both width and height). + */ + @JsonSetter(nulls = Nulls.SKIP) + public int size = 1000; + + /** + * Checks if there are any blatantly invalid parameters. + * + * @throws IllegalArgumentException is any of the parameters is invalid. + */ + public void validate() throws IllegalArgumentException { + if (size <= 0) + throw new IllegalArgumentException("'size' must be greater than zero"); + } + + /** + * Create the {@link Minimap}. + * + * @param worldLimits limits of the world + * @return {@link Minimap} instance + */ + public Minimap create(WorldBBox2d worldLimits) { + return new Minimap(worldLimits, size); + } +} diff --git a/src/main/java/fr/ign/voxatile/core/parameters/ParamsParser.java b/src/main/java/fr/ign/voxatile/core/parameters/ParamsParser.java index 6816405e7..2c9e5c2ca 100644 --- a/src/main/java/fr/ign/voxatile/core/parameters/ParamsParser.java +++ b/src/main/java/fr/ign/voxatile/core/parameters/ParamsParser.java @@ -45,8 +45,6 @@ public GenerationParams parse(String serialized) throws ParseException { VoxatileParserModule module = new VoxatileParserModule(); // TODO OutputFormat handling might be relocated to the VoxatileParserModule (not sure) module.addDeserializer(OutputFormat.class, formatDeserializer); - module.addDeserializer(Color.class, new ColorDeserializer()); - module.setDeserializerModifier(new JsonDelegateDeserialize.BeanModifier()); mapperBuilder.addModule(module); YAMLMapper mapper = mapperBuilder.build(); @@ -118,6 +116,7 @@ public VoxatileParserModule() { @Override public void setupModule(SetupContext context) { + addDeserializer(Color.class, new ColorDeserializer()); super.setupModule(context); context.addDeserializerModifier(new JsonDelegateDeserialize.BeanModifier()); context.addDeserializerModifier(new JsonWrapper.BeanModifier()); diff --git a/src/main/java/fr/ign/voxatile/core/parameters/tasks/ApplyShadingMinimapTaskParams.java b/src/main/java/fr/ign/voxatile/core/parameters/tasks/ApplyShadingMinimapTaskParams.java new file mode 100644 index 000000000..f95cfa8a6 --- /dev/null +++ b/src/main/java/fr/ign/voxatile/core/parameters/tasks/ApplyShadingMinimapTaskParams.java @@ -0,0 +1,70 @@ +package fr.ign.voxatile.core.parameters.tasks; + +import java.beans.ConstructorProperties; + +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; + +import fr.ign.voxatile.core.generation.Generation; +import fr.ign.voxatile.core.tasks.ApplyShadingMinimapTask; +import fr.ign.voxatile.core.utils.execution.Task; + +/** + * Parameters for {@link ApplyShadingMinimapTask}. + */ +public class ApplyShadingMinimapTaskParams extends TaskParams { + + /** + * Name of the minimap to apply shading to (required). + */ + @JsonSetter(nulls = Nulls.FAIL) + public String minimap; + + /** + * Shadow intensity factor (optional, default is 0.5). + * + *

+ * A higher value will result in darker shadows. The value should be between 0 and 1. + */ + @JsonSetter(nulls = Nulls.SKIP) + public double shadowIntensity = 0.5; + + /** + * Sun azimuth in degrees (optional, default is 90). + * + *

    + *
  • 0 degrees is to the right (east)
  • + *
  • 90 degrees is up (north)
  • + *
  • 180 degrees is to the left (west)
  • + *
  • 270 degrees is down (south)
  • + *
+ */ + @JsonSetter(nulls = Nulls.SKIP) + public double sunAzimuth = 90; + + /** + * Constructor used to ensure that the required fields are present during + * deserialization. + * + * @param minimap the name of the minimap to apply shading to + */ + @ConstructorProperties({"minimap"}) + public ApplyShadingMinimapTaskParams(String minimap) { + this.minimap = minimap; + } + + @Override + public void validate() { + if (minimap.isEmpty() || minimap.isBlank()) + throw new IllegalArgumentException("Minimap name cannot be empty, or blank"); + if (shadowIntensity < 0 || shadowIntensity > 1) + throw new IllegalArgumentException("Shadow intensity must be between 0 and 1"); + if (sunAzimuth < 0 || sunAzimuth > 360) + throw new IllegalArgumentException("Sun azimuth must be between 0 and 360 degrees"); + } + + @Override + public Task create(Generation generation) { + return new ApplyShadingMinimapTask(generation.minimaps().get(minimap), shadowIntensity, Math.toRadians(sunAzimuth)); + } +} diff --git a/src/main/java/fr/ign/voxatile/core/parameters/tasks/PopulateMinimapTaskParams.java b/src/main/java/fr/ign/voxatile/core/parameters/tasks/PopulateMinimapTaskParams.java new file mode 100644 index 000000000..4962631bb --- /dev/null +++ b/src/main/java/fr/ign/voxatile/core/parameters/tasks/PopulateMinimapTaskParams.java @@ -0,0 +1,53 @@ +package fr.ign.voxatile.core.parameters.tasks; + +import java.awt.Color; +import java.beans.ConstructorProperties; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; + +import fr.ign.voxatile.core.generation.Generation; +import fr.ign.voxatile.core.tasks.PopulateMinimapTask; +import fr.ign.voxatile.core.tasks.TileTask; + +/** + * Parameters for {@link PopulateMinimapTask}. + */ +public class PopulateMinimapTaskParams extends TaskParams { + /** + * Name of the minimap to populate (required). + */ + @JsonSetter(nulls = Nulls.FAIL) + public String minimap; + + /** + * Mapping of voxel identifiers to their display colors (required). + */ + @JsonSetter(nulls = Nulls.FAIL, contentNulls = Nulls.FAIL) + public Map colors; + + /** + * Constructor used to ensure that the required fields are present during + * deserialization. + * + * @param minimap name of the minimap to populate + * @param colors mapping of voxel identifiers to their display colors + */ + @ConstructorProperties({"minimap", "colors"}) + public PopulateMinimapTaskParams(String minimap, Map colors) { + this.minimap = minimap; + this.colors = colors; + } + + @Override + public void validate() throws IllegalArgumentException { + if (minimap.isBlank()) + throw new IllegalArgumentException("Minimap name cannot be empty or blank"); + } + + @Override + public TileTask create(Generation generation) { + return new PopulateMinimapTask(generation.minimaps().get(minimap), colors); + } +} diff --git a/src/main/java/fr/ign/voxatile/core/parameters/tasks/SaveMinimapTaskParams.java b/src/main/java/fr/ign/voxatile/core/parameters/tasks/SaveMinimapTaskParams.java new file mode 100644 index 000000000..80bc35bbe --- /dev/null +++ b/src/main/java/fr/ign/voxatile/core/parameters/tasks/SaveMinimapTaskParams.java @@ -0,0 +1,91 @@ +package fr.ign.voxatile.core.parameters.tasks; + +import java.awt.Color; +import java.beans.ConstructorProperties; +import java.io.File; +import java.nio.file.Path; +import javax.imageio.ImageIO; + +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; + +import fr.ign.voxatile.core.generation.Generation; +import fr.ign.voxatile.core.tasks.NoOperationTask; +import fr.ign.voxatile.core.tasks.SaveMinimapTask; +import fr.ign.voxatile.core.utils.execution.Task; + +/** + * Parameters for {@link SaveMinimapTask}. + */ +public class SaveMinimapTaskParams extends TaskParams { + + /** + * Name of the minimap to save (required). + */ + @JsonSetter(nulls = Nulls.FAIL) + public String minimap; + + /** + * Relative path where the rendered minimap image will be saved (required). + * + *

+ * This path is relative to the world's root directory. + */ + @JsonSetter(nulls = Nulls.FAIL) + public File destination; + + /** + * Format of the minimap to render (required). + */ + @JsonSetter(nulls = Nulls.FAIL) + public String format; + + /** + * Default background color for the minimap (optional, default is black). + */ + @JsonSetter(nulls = Nulls.SKIP) + public Color background = Color.BLACK; + + /** + * Constructor used to ensure that the required fields are present during deserialization. + * + * @param minimap minimap to render + * @param destination image destination relative to the world directory + * @param format image format to save + */ + @ConstructorProperties({ "minimap", "destination", "format" }) + public SaveMinimapTaskParams(String minimap, File destination, String format) { + this.minimap = minimap; + this.destination = destination; + this.format = format; + } + + @Override + public void validate() throws IllegalArgumentException { + if (minimap.isBlank()) + throw new IllegalArgumentException("Minimap name cannot be empty or blank"); + + if (format.isBlank()) + throw new IllegalArgumentException("Format cannot be empty or blank"); + if (!ImageIO.getImageWritersByFormatName(format).hasNext()) + throw new IllegalArgumentException("The format '" + format + "' is not supported"); + if (!destination.getName().toLowerCase().contains(format.toLowerCase())) + throw new IllegalArgumentException("Destination must contain the format '" + format + "' extension"); + + if (destination.getName().isBlank()) + throw new IllegalArgumentException("Destination name cannot be empty or blank"); + Path destinationPath = destination.toPath(); + if (destinationPath.isAbsolute()) + throw new IllegalArgumentException("Destination must be a relative path"); + } + + @Override + public Task create(Generation generation) { + // If the world has no destination, means the save is disabled. + if (generation.world().destination() == null) + return NoOperationTask.INSTANCE; + + File destination = generation.world().destination().toPath().resolve(this.destination.toPath()).toFile(); + return new SaveMinimapTask(generation.minimaps().get(minimap), destination, format, background); + } +} diff --git a/src/main/java/fr/ign/voxatile/core/tasks/ApplyShadingMinimapTask.java b/src/main/java/fr/ign/voxatile/core/tasks/ApplyShadingMinimapTask.java new file mode 100644 index 000000000..54800ee63 --- /dev/null +++ b/src/main/java/fr/ign/voxatile/core/tasks/ApplyShadingMinimapTask.java @@ -0,0 +1,94 @@ +package fr.ign.voxatile.core.tasks; + +import fr.ign.voxatile.core.generation.minimaps.Minimap; +import fr.ign.voxatile.core.generation.minimaps.MinimapCell; +import fr.ign.voxatile.core.utils.execution.Task; + +/** + * Applies shading to a minimap based on its heightmap data. + * + *

+ * Shading simulates the effect of sunlight on terrain by adjusting pixel brightness + * according to the slope and orientation of the terrain relative to a light source. + * This post-processing effect enhances the visual perception of elevation changes. + * + *

+ * Note: The visual output is determined by the configured sun azimuth + * and shadow intensity properties. + */ +public class ApplyShadingMinimapTask implements Task { + private final Minimap minimap; + private final double shadowIntensity; + private final double sunDirectionX; + private final double sunDirectionY; + + /** + * Creates a new {@code ApplyShadingMinimapTask}. + * + * @param minimap the minimap to apply shading to + * @param shadowIntensity the intensity of the shadows (0 to 1) + * @param sunAzimuth the azimuth of the sun in radians + */ + public ApplyShadingMinimapTask(Minimap minimap, double shadowIntensity, double sunAzimuth) { + this.minimap = minimap; + this.shadowIntensity = shadowIntensity; + this.sunDirectionX = Math.cos(sunAzimuth); + this.sunDirectionY = Math.sin(sunAzimuth); + } + + /** + * Computes the terrain slope projected onto the light direction. + * + *

+ * The local terrain gradient is estimated using central differences on the + * heightmap. This gradient is then projected onto the light direction defined + * by {@code sunDirectionX} and {@code sunDirectionY}. The returned value + * represents how strongly the terrain faces the light source. + * + *

+ * Positive values indicate slopes facing the light, while negative values + * indicate slopes facing away from it. + * + * @param x pixel x-coordinate + * @param y pixel y-coordinate + * @return the directional terrain slope. Returns {@code 1.0} for border + * pixels where the gradient cannot be computed. + * + * @see Image gradient + */ + private double computeDirectionalSlope(int x, int y) { + if (x <= 0 || y <= 0 || x >= minimap.getWidth() - 1 || y >= minimap.getHeight() - 1) + return 1.0; + MinimapCell nextXCell = minimap.get(x + 1, y); + MinimapCell nextYCell = minimap.get(x, y + 1); + if (nextXCell == null || nextYCell == null) + return 1.0; + + // TODO: Should be to parameterize the distance used for gradient computation (3 pixels and 2 pixels). + double gx = minimap.get(x, y).getHeight() - nextXCell.getHeight(); + double gy = minimap.get(x, y).getHeight() - nextYCell.getHeight(); + return (gx * sunDirectionX) + (gy * sunDirectionY); + } + + @Override + public void run() { + for (int x = 0; x < minimap.getWidth(); x++) { + for (int y = 0; y < minimap.getHeight(); y++) { + MinimapCell cell = minimap.get(x, y); + if (cell == null) continue; + + // Math.atan compresses extreme values and avoids harsh contrast + double slopeFactor = 1.0 + Math.atan(computeDirectionalSlope(x, y) * shadowIntensity) / Math.PI; + + // TODO: this way is dirty, as it directly modifies the cell's color based on the slope factor. Implements minimap layers + cell.set( + cell.getRed() * slopeFactor, + cell.getGreen() * slopeFactor, + cell.getBlue() * slopeFactor, + cell.getAlpha(), + cell.getHeight() + ); + } + } + } +} diff --git a/src/main/java/fr/ign/voxatile/core/tasks/PopulateMinimapTask.java b/src/main/java/fr/ign/voxatile/core/tasks/PopulateMinimapTask.java new file mode 100644 index 000000000..439259143 --- /dev/null +++ b/src/main/java/fr/ign/voxatile/core/tasks/PopulateMinimapTask.java @@ -0,0 +1,88 @@ +package fr.ign.voxatile.core.tasks; + +import java.awt.Color; +import java.util.Map; + +import fr.ign.voxatile.core.generation.GenerationTile; +import fr.ign.voxatile.core.generation.minimaps.Minimap; +import fr.ign.voxatile.core.utils.world2d.WorldCoords2d; +import fr.ign.voxatile.core.utils.world3d.WorldCoords3d; +import fr.ign.voxatile.core.world.PlacedVoxel; +import fr.ign.voxatile.core.world.VoxelTile; + +/** + * A {@link TileTask} that populates a {@link Minimap}. + * +*

+ * For each (x, y) world coordinate, {@link PlacedVoxel}s are processed from top to bottom, + * get the corresponding color of the {@link PlacedVoxel}, and composite them using source-over + * alpha compositing until a fully opaque color is reached. + */ +public class PopulateMinimapTask implements TileTask { + + private final Minimap minimap; + private final Map colors; + + /** + * Creates a new {@code PopulateMinimapTask}. + * + * @param minimap minimap to populate + * @param colors mapping of voxel identifiers to their display colors + */ + public PopulateMinimapTask(Minimap minimap, Map colors) { + this.minimap = minimap; + this.colors = colors; + } + + /** + * Performs source-over alpha compositing between two colors. + * + *

+ * This method blends semi-transparent voxel layers (e.g., water, glass) + * into a single 2D pixel color. By accumulating these layers, it enables the + * representation of depth (such as water transparency or varying terrain details). + * + * @param previousColor destination (background) + * @param currentColor source (foreground) + * @return blended color using alpha compositing + */ + private Color blend(Color previousColor, Color currentColor) { + double cAlpha = currentColor.getAlpha() / 255d; + double pAlpha = previousColor.getAlpha() / 255d; + + // Alpha compositing formula + double red = pAlpha * (previousColor.getRed() / 255d) + (1 - pAlpha) * cAlpha * (currentColor.getRed() / 255d); + double green = pAlpha * (previousColor.getGreen() / 255d) + (1 - pAlpha) * cAlpha * (currentColor.getGreen() / 255d); + double blue = pAlpha * (previousColor.getBlue() / 255d) + (1 - pAlpha) * cAlpha * (currentColor.getBlue() / 255d); + double alpha = pAlpha + (1 - pAlpha) * cAlpha; + + return new Color( + (int) (red * 255), + (int) (green * 255), + (int) (blue * 255), + (int) (alpha * 255) + ); + } + + @Override + public void run(GenerationTile tile) { + VoxelTile voxels = tile.voxels(); + for (WorldCoords2d pos : tile.limits().intersection(voxels.limits()).to2d()) { + WorldCoords3d coords = null; + Color computedColor = new Color(0, 0, 0, 0); + for (PlacedVoxel voxel : voxels.voxels(pos.x(), pos.y())) { + Color voxelColor = colors.get(voxel.voxel().getTypeIdentifier()); + if (voxelColor == null || voxelColor.getAlpha() == 0) continue; + + coords = voxel.coords(); + computedColor = blend(computedColor, voxelColor); + + // Stop once the accumulated color is fully opaque. + if (computedColor.getAlpha() == 255) break; + } + + if (coords != null) + minimap.add(computedColor, coords); + } + } +} diff --git a/src/main/java/fr/ign/voxatile/core/tasks/SaveMinimapTask.java b/src/main/java/fr/ign/voxatile/core/tasks/SaveMinimapTask.java new file mode 100644 index 000000000..654e30157 --- /dev/null +++ b/src/main/java/fr/ign/voxatile/core/tasks/SaveMinimapTask.java @@ -0,0 +1,62 @@ +package fr.ign.voxatile.core.tasks; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import javax.imageio.ImageIO; +import javax.imageio.ImageTypeSpecifier; + +import fr.ign.voxatile.core.generation.minimaps.Minimap; +import fr.ign.voxatile.core.utils.execution.Task; + +/** + * A {@code Task} used for saving a {@link Minimap} into specified file format. + * + *

+ * The {@code Minimap} is converted into a {@link BufferedImage}. + */ +public class SaveMinimapTask implements Task { + private final Minimap minimap; + private final String format; + private final File destination; + private final Color background; + + /** + * Creates a new {@code SaveMinimapTask}. + * + * @param minimap minimap to save + * @param destination minimap output destination relative to the world directory + * @param format format to save the minimap in a format supported by {@link ImageIO#write}, e.g. "png", "jpg", "bmp", "gif" + * @param background default background color for the minimap + */ + public SaveMinimapTask(Minimap minimap, File destination, String format, Color background) { + this.minimap = minimap; + this.format = format.toLowerCase(); + this.destination = destination; + this.background = background; + } + + @Override + public void run() { + boolean supportsAlpha = ImageIO.getImageWriters( + ImageTypeSpecifier.createFromBufferedImageType(BufferedImage.TYPE_INT_ARGB), + format + ).hasNext(); + + BufferedImage image = new BufferedImage(minimap.getWidth(), minimap.getHeight(), supportsAlpha ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB); + for (int x = 0; x < minimap.getWidth(); x++) + for (int y = 0; y < minimap.getHeight(); y++) { + if (minimap.get(x, y) == null) + image.setRGB(x, minimap.getHeight() - 1 - y, background.getRGB()); + else + image.setRGB(x, minimap.getHeight() - 1 - y, minimap.get(x, y).getRGB()); + } + + try { + ImageIO.write(image, format, destination); + } catch (IOException e) { + throw new RuntimeException("Unable to save minimap to " + destination, e); + } + } +} diff --git a/src/main/java/fr/ign/voxatile/core/world/PlacedVoxel.java b/src/main/java/fr/ign/voxatile/core/world/PlacedVoxel.java index bb1e2d2b7..af4345666 100644 --- a/src/main/java/fr/ign/voxatile/core/world/PlacedVoxel.java +++ b/src/main/java/fr/ign/voxatile/core/world/PlacedVoxel.java @@ -1,6 +1,5 @@ package fr.ign.voxatile.core.world; -import fr.ign.voxatile.core.placeables.Placeable; import fr.ign.voxatile.core.utils.world3d.WorldCoords3d; /** @@ -9,4 +8,4 @@ * @param voxel a new instance of the placed voxel. * @param coords the {@link WorldCoords3d} where the voxel is placed. */ -public record PlacedVoxel(Placeable voxel, WorldCoords3d coords) {} +public record PlacedVoxel(Voxel voxel, WorldCoords3d coords) {} diff --git a/src/main/java/fr/ign/voxatile/core/world/Voxel.java b/src/main/java/fr/ign/voxatile/core/world/Voxel.java new file mode 100644 index 000000000..98d6a7faf --- /dev/null +++ b/src/main/java/fr/ign/voxatile/core/world/Voxel.java @@ -0,0 +1,15 @@ +package fr.ign.voxatile.core.world; + +import fr.ign.voxatile.core.placeables.Placeable; + +/** + * The {@code Voxel} interface represents a {@link Placeable} that can be identified in the voxel world. + */ +public interface Voxel extends Placeable { + /** + * {@return the type identifier of the voxel} + * It is a unique and persistent identifier for each type of voxel. + * Only the type matters, any additional property of the voxel has no impact on this identifier. + */ + String getTypeIdentifier(); +} diff --git a/src/main/java/fr/ign/voxatile/core/world/VoxelColumnIterator.java b/src/main/java/fr/ign/voxatile/core/world/VoxelColumnIterator.java index 3593bb7e8..dc9e1c4ea 100644 --- a/src/main/java/fr/ign/voxatile/core/world/VoxelColumnIterator.java +++ b/src/main/java/fr/ign/voxatile/core/world/VoxelColumnIterator.java @@ -2,7 +2,6 @@ import java.util.Iterator; -import fr.ign.voxatile.core.placeables.Placeable; import fr.ign.voxatile.core.utils.world3d.WorldCoords3d; /** @@ -15,7 +14,7 @@ public class VoxelColumnIterator implements Iterator { private final int y; private final int zMin; private int currentZ; - private Placeable currentVoxel; + private Voxel currentVoxel; /** * Constructs a new {@code VoxelColumnIterator}. diff --git a/src/main/java/fr/ign/voxatile/core/world/VoxelTile.java b/src/main/java/fr/ign/voxatile/core/world/VoxelTile.java index be7bd3505..d077933e0 100644 --- a/src/main/java/fr/ign/voxatile/core/world/VoxelTile.java +++ b/src/main/java/fr/ign/voxatile/core/world/VoxelTile.java @@ -5,7 +5,6 @@ import fr.ign.voxatile.core.generation.heightmaps.Heightmap; import fr.ign.voxatile.core.generation.heightmaps.ReadableHeightmap; -import fr.ign.voxatile.core.placeables.Placeable; import fr.ign.voxatile.core.utils.world3d.WorldBBox3d; /** @@ -54,14 +53,14 @@ public WorldBBox3d limits() { public abstract void save() throws MapWriteException; /** - * Returns the voxel located at the given coordinates as a new {@code Placeable}. + * Returns the voxel located at the given coordinates as a new {@link Voxel}. * * @param x x-coordinate * @param y y-coordinate * @param z z-coordinate * @return the corresponding voxel */ - public abstract Placeable getVoxel(int x, int y, int z); + public abstract Voxel getVoxel(int x, int y, int z); /** * Returns a descending iterator over the voxels and associated coordinates of a column of this world. diff --git a/src/main/java/fr/ign/voxatile/core/world/VoxelWorld.java b/src/main/java/fr/ign/voxatile/core/world/VoxelWorld.java index cd18496a2..4a12d556c 100644 --- a/src/main/java/fr/ign/voxatile/core/world/VoxelWorld.java +++ b/src/main/java/fr/ign/voxatile/core/world/VoxelWorld.java @@ -1,5 +1,6 @@ package fr.ign.voxatile.core.world; +import java.io.File; import java.util.Collection; import fr.ign.voxatile.core.utils.world2d.WorldBBox2d; @@ -19,10 +20,15 @@ public abstract class VoxelWorld { * The metadata of the world. */ protected final VoxelWorldMetadata metadata; + /** + * The directory where to save data to. If null nothing is saved. + */ + protected final File destination; - protected VoxelWorld(VoxelWorldMetadata metadata) { + protected VoxelWorld(VoxelWorldMetadata metadata, File destination) { limits = WorldBBox3d.EMPTY; this.metadata = metadata; + this.destination = destination; } /** @@ -61,6 +67,13 @@ public VoxelWorldMetadata getMetadata() { return metadata; } + /** + * {@return the directory where to save data to. If null nothing is saved} + */ + public File destination() { + return this.destination; + } + /** * Creates a new tile for this world (a smaller cubic part). * World has to be initialized and not finalized. diff --git a/src/main/java/fr/ign/voxatile/modules/luanti/LuantiVoxel.java b/src/main/java/fr/ign/voxatile/modules/luanti/LuantiVoxel.java index 9c951d9dc..80b0cb834 100644 --- a/src/main/java/fr/ign/voxatile/modules/luanti/LuantiVoxel.java +++ b/src/main/java/fr/ign/voxatile/modules/luanti/LuantiVoxel.java @@ -2,15 +2,15 @@ import java.util.Objects; -import fr.ign.voxatile.core.placeables.Placeable; +import fr.ign.voxatile.core.world.Voxel; import fr.ign.voxatile.core.world.VoxelTile; /** - * {@code LuantiVoxel} class implements a {@link Placeable} voxel for Luanti. + * {@code LuantiVoxel} class implements a {@link Voxel} for Luanti. * A voxel in Luanti, known as node, consists of three parameters: type, param1, param2. * @see Luanti's MapNode class for more information about the node's parameters */ -public class LuantiVoxel implements Placeable { +public class LuantiVoxel implements Voxel { /** * Default voxel used on map initialization. @@ -75,6 +75,11 @@ public String getType() { return type; } + @Override + public String getTypeIdentifier() { + return type; + } + /** * {@return the param1 data of the node} * This parameter usually contains information about the node's light intensity. diff --git a/src/main/java/fr/ign/voxatile/modules/luanti/LuantiVoxelTile.java b/src/main/java/fr/ign/voxatile/modules/luanti/LuantiVoxelTile.java index 210614f05..77e3a5356 100644 --- a/src/main/java/fr/ign/voxatile/modules/luanti/LuantiVoxelTile.java +++ b/src/main/java/fr/ign/voxatile/modules/luanti/LuantiVoxelTile.java @@ -4,9 +4,9 @@ import it.unimi.dsi.fastutil.longs.Long2ObjectMaps; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; -import fr.ign.voxatile.core.placeables.Placeable; import fr.ign.voxatile.core.utils.world3d.WorldBBox3d; import fr.ign.voxatile.core.world.MapWriteException; +import fr.ign.voxatile.core.world.Voxel; import fr.ign.voxatile.core.world.VoxelTile; import fr.ign.voxatile.modules.luanti.utils.SQLiteMapWriter; @@ -79,13 +79,13 @@ public void save() throws MapWriteException { /** * {@inheritDoc} - * The returned voxel is not necessarily one placed using {@link Placeable#place}. + * The returned voxel is not necessarily one placed using {@link Voxel#place}. * It may be an air node created when the world is initialized. *

* If you try to get a voxel outside the tile limits, it will return {@link LuantiVoxel#DEFAULT_VOXEL}. */ @Override - public Placeable getVoxel(int x, int y, int z) { + public Voxel getVoxel(int x, int y, int z) { // X/Y/Z => X/Z/Y Block block = getBlock(x >> 4, z >> 4, y >> 4); diff --git a/src/main/java/fr/ign/voxatile/modules/luanti/LuantiVoxelWorld.java b/src/main/java/fr/ign/voxatile/modules/luanti/LuantiVoxelWorld.java index bf0612c04..873839d68 100644 --- a/src/main/java/fr/ign/voxatile/modules/luanti/LuantiVoxelWorld.java +++ b/src/main/java/fr/ign/voxatile/modules/luanti/LuantiVoxelWorld.java @@ -24,7 +24,6 @@ public class LuantiVoxelWorld extends VoxelWorld { new WorldCoords3d(31_007, 31_007, 31_007) ); - private final File destination; private SQLiteMapWriter mapWriter; /** @@ -34,8 +33,7 @@ public class LuantiVoxelWorld extends VoxelWorld { * @param destination Directory where to save data to. If null nothing is saved. */ public LuantiVoxelWorld(File destination) { - super(new VoxelWorldMetadata()); - this.destination = destination; + super(new VoxelWorldMetadata(), destination); } @Override diff --git a/src/main/java/fr/ign/voxatile/modules/minecraft/MinecraftVoxel.java b/src/main/java/fr/ign/voxatile/modules/minecraft/MinecraftVoxel.java index 3e1d9bfd0..7802bd4a7 100644 --- a/src/main/java/fr/ign/voxatile/modules/minecraft/MinecraftVoxel.java +++ b/src/main/java/fr/ign/voxatile/modules/minecraft/MinecraftVoxel.java @@ -7,14 +7,14 @@ import io.github.ensgijs.nbt.tag.CompoundTag; import io.github.ensgijs.nbt.tag.StringTag; -import fr.ign.voxatile.core.placeables.Placeable; +import fr.ign.voxatile.core.world.Voxel; import fr.ign.voxatile.core.world.VoxelTile; /** - * {@code MinecraftVoxel} class implements a {@link Placeable} voxel for Minecraft. + * {@code MinecraftVoxel} class implements a {@link Voxel} for Minecraft. * A voxel in Minecraft, known as block, consists of two parameters: type and state properties. */ -public class MinecraftVoxel implements Placeable { +public class MinecraftVoxel implements Voxel { private final String type; private final Map properties; @@ -61,6 +61,11 @@ public Map properties() { return properties; } + @Override + public String getTypeIdentifier() { + return type; + } + @Override public void place(VoxelTile tile, int x, int y, int z) { if (tile instanceof MinecraftVoxelTile mcTile) { diff --git a/src/main/java/fr/ign/voxatile/modules/minecraft/MinecraftVoxelTile.java b/src/main/java/fr/ign/voxatile/modules/minecraft/MinecraftVoxelTile.java index 583fae1f4..28c5628e2 100644 --- a/src/main/java/fr/ign/voxatile/modules/minecraft/MinecraftVoxelTile.java +++ b/src/main/java/fr/ign/voxatile/modules/minecraft/MinecraftVoxelTile.java @@ -11,9 +11,9 @@ import it.unimi.dsi.fastutil.ints.Int2ObjectMaps; import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; -import fr.ign.voxatile.core.placeables.Placeable; import fr.ign.voxatile.core.utils.world3d.WorldBBox3d; import fr.ign.voxatile.core.world.MapWriteException; +import fr.ign.voxatile.core.world.Voxel; import fr.ign.voxatile.core.world.VoxelTile; /** @@ -129,13 +129,13 @@ private Region getOrCreateRegion(int blockX, int blockZ) { /** * {@inheritDoc} - * The returned voxel is not necessarily one placed using {@link Placeable#place}. + * The returned voxel is not necessarily one placed using {@link Voxel#place}. * It may be an air block created when the world is initialized. *

* If you try to get a voxel outside the tile limits, it will return {@link MinecraftVoxel#DEFAULT_VOXEL}. */ @Override - public Placeable getVoxel(int x, int y, int z) { + public Voxel getVoxel(int x, int y, int z) { // (World coords to In-Game coords) X/Y/Z => X/Z/-Y-1 int blockX = x; int blockY = z; diff --git a/src/main/java/fr/ign/voxatile/modules/minecraft/MinecraftVoxelWorld.java b/src/main/java/fr/ign/voxatile/modules/minecraft/MinecraftVoxelWorld.java index d255088a0..deab38c41 100644 --- a/src/main/java/fr/ign/voxatile/modules/minecraft/MinecraftVoxelWorld.java +++ b/src/main/java/fr/ign/voxatile/modules/minecraft/MinecraftVoxelWorld.java @@ -37,7 +37,6 @@ public class MinecraftVoxelWorld extends VoxelWorld { new WorldCoords3d(30_000_000, 30_000_000, 319) ); - private final File destination; private final File regionDirectory; /** @@ -47,8 +46,7 @@ public class MinecraftVoxelWorld extends VoxelWorld { * @param destination Directory where to save data to. If null nothing is saved. */ public MinecraftVoxelWorld(File destination) { - super(new VoxelWorldMetadata()); - this.destination = destination; + super(new VoxelWorldMetadata(), destination); regionDirectory = destination == null ? null : new File(destination, "region"); } diff --git a/src/test/java/com/ignfab/minalac/generator/generation/minimaps/MinimapStoreTest.java b/src/test/java/com/ignfab/minalac/generator/generation/minimaps/MinimapStoreTest.java new file mode 100644 index 000000000..8e477ab22 --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/generation/minimaps/MinimapStoreTest.java @@ -0,0 +1,39 @@ +package com.ignfab.minalac.generator.generation.minimaps; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.ignfab.minalac.generator.utils.world2d.WorldBBox2d; + +import static org.junit.jupiter.api.Assertions.*; + +public class MinimapStoreTest { + private MinimapStore store; + + @BeforeEach + void setUp() { + store = new MinimapStore(); + } + + @Test + void testAdd() { + assertDoesNotThrow(() -> store.add("test", new Minimap( + new WorldBBox2d(0, 0, 1, 1), 1 + ))); + + assertThrows(IllegalArgumentException.class, () -> store.add(null, new Minimap( + new WorldBBox2d(0, 0, 1, 1), 1 + ))); + + assertThrows(IllegalArgumentException.class, () -> store.add("test", new Minimap( + new WorldBBox2d(0, 0, 1, 1), 1 + ))); + } + + @Test + void testGet() { + Minimap expected = new Minimap(new WorldBBox2d(0, 0, 1, 1), 1); + store.add("getTest", expected); + assertEquals(expected, assertDoesNotThrow(() -> store.get("getTest"))); + } +} diff --git a/src/test/java/com/ignfab/minalac/generator/generation/minimaps/MinimapTest.java b/src/test/java/com/ignfab/minalac/generator/generation/minimaps/MinimapTest.java new file mode 100644 index 000000000..3074fe5f6 --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/generation/minimaps/MinimapTest.java @@ -0,0 +1,115 @@ +package com.ignfab.minalac.generator.generation.minimaps; + +import java.awt.Color; + +import org.junit.jupiter.api.Test; + +import com.ignfab.minalac.generator.utils.world2d.WorldBBox2d; +import com.ignfab.minalac.generator.utils.world3d.WorldCoords3d; + +import static org.junit.jupiter.api.Assertions.*; + +public class MinimapTest { + @Test + void testConstructor() { + assertDoesNotThrow(() -> new Minimap(new WorldBBox2d(0, 0, 1, 1), 1000)); + } + + @Test + void testSize() { + Minimap minimap = assertDoesNotThrow(() -> new Minimap(new WorldBBox2d(0, 0, 2, 2), 2)); + assertEquals(2, minimap.getWidth()); + assertEquals(2, minimap.getHeight()); + + minimap = assertDoesNotThrow(() -> new Minimap(new WorldBBox2d(0, 0, 20, 22), 2)); + assertEquals(2, minimap.getWidth()); + assertEquals(2, minimap.getHeight()); + + minimap = assertDoesNotThrow(() -> new Minimap(new WorldBBox2d(0, 0, 20, 40), 2)); + assertEquals(1, minimap.getWidth()); + assertEquals(2, minimap.getHeight()); + + minimap = assertDoesNotThrow(() -> new Minimap(new WorldBBox2d(0, 0, 1, 1), 2)); + assertEquals(2, minimap.getWidth()); + assertEquals(2, minimap.getHeight()); + + minimap = assertDoesNotThrow(() -> new Minimap(new WorldBBox2d(0, 0, 1, 1), 0)); + assertEquals(0, minimap.getWidth()); + assertEquals(0, minimap.getHeight()); + + minimap = assertDoesNotThrow(() -> new Minimap(new WorldBBox2d(0, 0, 0, 0), 0)); + assertEquals(0, minimap.getWidth()); + assertEquals(0, minimap.getHeight()); + } + + @Test + void testAveragingSamePixel() { + Minimap minimap = new Minimap( + new WorldBBox2d(0, 0, 1, 1), + 1 + ); + + minimap.add(new Color(255, 0, 0), new WorldCoords3d(0, 0, 0)); + minimap.add(new Color(0, 255, 0), new WorldCoords3d(0, 0, 0)); + minimap.add(new Color(0, 0, 255), new WorldCoords3d(0, 0, 0)); + + MinimapCell actual = minimap.get(0, 0); + assertEquals(85, actual.getRed(), 0.01); + assertEquals(85, actual.getGreen(), 0.01); + assertEquals(85, actual.getBlue(), 0.01); + + minimap = new Minimap( + new WorldBBox2d(0, 0, 2, 2), + 1 + ); + + minimap.add(new Color(255, 255, 255), new WorldCoords3d(0, 0, 0)); + minimap.add(new Color(0, 0, 0), new WorldCoords3d(1, 0, 0)); + minimap.add(new Color(0, 0, 0), new WorldCoords3d(0, 1, 0)); + minimap.add(new Color(0, 0, 0), new WorldCoords3d(1, 1, 0)); + + assertEquals(63.75, minimap.get(0, 0).getRed(), 0.01); + assertEquals(63.75, minimap.get(0, 0).getGreen(), 0.01); + assertEquals(63.75, minimap.get(0, 0).getBlue(), 0.01); + } + + @Test + void testInterpolatedWeighting() { + Minimap minimap = new Minimap(new WorldBBox2d(0, 0, 3, 2), 2); + minimap.add(new Color(255, 255, 255), new WorldCoords3d(1, 0, 0)); + assertEquals(255, minimap.get(0, 0).getRed()); + assertEquals(255, minimap.get(1, 0).getRed()); + + minimap = new Minimap(new WorldBBox2d(0, 0, 3, 2), 2); + minimap.add(new Color(0, 0, 0), new WorldCoords3d(0, 0, 0)); + minimap.add(new Color(255, 255, 255), new WorldCoords3d(1, 0, 0)); + assertEquals(85, minimap.get(0, 0).getRed(), 0.01); + assertEquals(85, minimap.get(0, 0).getGreen(), 0.01); + assertEquals(85, minimap.get(0, 0).getBlue(), 0.01); + } + + @Test + void testSingleVoxelNoSampling() { + Minimap minimap = new Minimap(new WorldBBox2d(0, 0, 2, 2), 2); + minimap.add(new Color(255, 0, 0), new WorldCoords3d(0, 0, 10)); + minimap.add(new Color(0, 0, 255), new WorldCoords3d(1, 0, 8)); + minimap.add(new Color(0, 255, 0), new WorldCoords3d(0, 1, 15)); + minimap.add(new Color(0, 0, 0), new WorldCoords3d(1, 1, 6)); + assertEquals(255, minimap.get(0, 0).getRed(), 0.01); + assertEquals(0, minimap.get(0, 0).getGreen(), 0.01); + assertEquals(0, minimap.get(0, 0).getBlue(), 0.01); + assertEquals(10.0, minimap.get(0, 0).getHeight(), 0.01); + assertEquals(0, minimap.get(1, 0).getRed(), 0.01); + assertEquals(0, minimap.get(1, 0).getGreen(), 0.01); + assertEquals(255, minimap.get(1, 0).getBlue(), 0.01); + assertEquals(8.0, minimap.get(1, 0).getHeight(), 0.01); + assertEquals(0, minimap.get(0, 1).getRed(), 0.01); + assertEquals(255, minimap.get(0, 1).getGreen(), 0.01); + assertEquals(0, minimap.get(0, 1).getBlue(), 0.01); + assertEquals(15.0, minimap.get(0, 1).getHeight(), 0.01); + assertEquals(0, minimap.get(1, 1).getRed(), 0.01); + assertEquals(0, minimap.get(1, 1).getGreen(), 0.01); + assertEquals(0, minimap.get(1, 1).getBlue(), 0.01); + assertEquals(6.0, minimap.get(1, 1).getHeight(), 0.01); + } +} diff --git a/src/test/java/fr/ign/voxatile/core/generation/GenerationTest.java b/src/test/java/fr/ign/voxatile/core/generation/GenerationTest.java index 1c789e16d..ca07b07fe 100644 --- a/src/test/java/fr/ign/voxatile/core/generation/GenerationTest.java +++ b/src/test/java/fr/ign/voxatile/core/generation/GenerationTest.java @@ -98,7 +98,7 @@ private static class EmptyVoxelWorld extends VoxelWorld { private final int extentY; protected EmptyVoxelWorld(int extentX, int extentY) { - super(new VoxelWorldMetadata()); + super(new VoxelWorldMetadata(), null); this.extentX = extentX; this.extentY = extentY; } diff --git a/src/test/java/fr/ign/voxatile/core/parameters/GenerationParamsTest.java b/src/test/java/fr/ign/voxatile/core/parameters/GenerationParamsTest.java index e6d9341a5..069b73f5a 100644 --- a/src/test/java/fr/ign/voxatile/core/parameters/GenerationParamsTest.java +++ b/src/test/java/fr/ign/voxatile/core/parameters/GenerationParamsTest.java @@ -1,6 +1,7 @@ package fr.ign.voxatile.core.parameters; import java.util.HashMap; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -110,6 +111,7 @@ public void testValidateWorldName() { @Test public void testCreate() throws ParseException { params.worldName = "test"; + params.minimaps = Map.of("test", new MinimapParams()); params.verticalScale = 3.0; params.horizontalScale = 4.0; params.crs = "EPSG:5643"; diff --git a/src/test/java/fr/ign/voxatile/core/parameters/MinimapParamsTest.java b/src/test/java/fr/ign/voxatile/core/parameters/MinimapParamsTest.java new file mode 100644 index 000000000..68b3ed37e --- /dev/null +++ b/src/test/java/fr/ign/voxatile/core/parameters/MinimapParamsTest.java @@ -0,0 +1,35 @@ +package fr.ign.voxatile.core.parameters; + +import org.junit.jupiter.api.Test; + +import fr.ign.voxatile.core.utils.world2d.WorldBBox2d; + +import static org.junit.jupiter.api.Assertions.*; + +public class MinimapParamsTest { + @Test + void testDeserialize() { + assertDoesNotThrow(() -> ParamsTester.deserialize( + MinimapParams.class, + """ + size: 1234 + """ + )); + } + + @Test + void testValidate() { + MinimapParams params = new MinimapParams(); + params.size = -8; + assertThrows(IllegalArgumentException.class, params::validate); + + params.size = 1234; + assertDoesNotThrow(params::validate); + } + + @Test + void testCreate() { + MinimapParams params = new MinimapParams(); + assertDoesNotThrow(() -> params.create(new WorldBBox2d(0, 0, 1, 1))); + } +} diff --git a/src/test/java/fr/ign/voxatile/core/parameters/ParamsTester.java b/src/test/java/fr/ign/voxatile/core/parameters/ParamsTester.java index 2e775046d..96bfb79f7 100644 --- a/src/test/java/fr/ign/voxatile/core/parameters/ParamsTester.java +++ b/src/test/java/fr/ign/voxatile/core/parameters/ParamsTester.java @@ -1,7 +1,5 @@ package fr.ign.voxatile.core.parameters; -import java.awt.Color; - import tools.jackson.core.JacksonException; import tools.jackson.core.StreamReadFeature; import tools.jackson.databind.DeserializationFeature; diff --git a/src/test/java/fr/ign/voxatile/core/parameters/tasks/ApplyShadingMinimapTaskParamsTest.java b/src/test/java/fr/ign/voxatile/core/parameters/tasks/ApplyShadingMinimapTaskParamsTest.java new file mode 100644 index 000000000..c96959a2f --- /dev/null +++ b/src/test/java/fr/ign/voxatile/core/parameters/tasks/ApplyShadingMinimapTaskParamsTest.java @@ -0,0 +1,34 @@ +package fr.ign.voxatile.core.parameters.tasks; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class ApplyShadingMinimapTaskParamsTest { + + @Test + public void testValidate() { + ApplyShadingMinimapTaskParams params; + + params = assertDoesNotThrow(() -> new ApplyShadingMinimapTaskParams("")); + assertThrows(IllegalArgumentException.class, params::validate); + + params = assertDoesNotThrow(() -> new ApplyShadingMinimapTaskParams(" \t ")); + assertThrows(IllegalArgumentException.class, params::validate); + + params = assertDoesNotThrow(() -> new ApplyShadingMinimapTaskParams("test")); + assertDoesNotThrow(params::validate); + + params.shadowIntensity = -0.1; + assertThrows(IllegalArgumentException.class, params::validate); + + params.shadowIntensity = 1.1; + assertThrows(IllegalArgumentException.class, params::validate); + + params.sunAzimuth = -10; + assertThrows(IllegalArgumentException.class, params::validate); + + params.sunAzimuth = 370; + assertThrows(IllegalArgumentException.class, params::validate); + } +} diff --git a/src/test/java/fr/ign/voxatile/core/parameters/tasks/PopulateMinimapTaskParamsTest.java b/src/test/java/fr/ign/voxatile/core/parameters/tasks/PopulateMinimapTaskParamsTest.java new file mode 100644 index 000000000..9118852ad --- /dev/null +++ b/src/test/java/fr/ign/voxatile/core/parameters/tasks/PopulateMinimapTaskParamsTest.java @@ -0,0 +1,52 @@ +package fr.ign.voxatile.core.parameters.tasks; + +import java.awt.Color; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import tools.jackson.databind.cfg.MapperBuilder; + +import fr.ign.voxatile.core.generation.Generation; +import fr.ign.voxatile.core.parameters.ParamsTester; +import fr.ign.voxatile.core.utils.random.TestingSeed; +import fr.ign.voxatile.core.world.TestingVoxelWorld; + +import static org.junit.jupiter.api.Assertions.*; + +public class PopulateMinimapTaskParamsTest { + @Test + void testDeserialize() { + MapperBuilder builder = ParamsTester.mapperBuilderWithParams("populateMinimap", PopulateMinimapTaskParams.class); + + PopulateMinimapTaskParams params = assertDoesNotThrow(() -> ParamsTester.deserialize( + PopulateMinimapTaskParams.class, + """ + type: populateMinimap + minimap: test + colors: + a: "#FFFFFF" + b: [128, 182, 10, 128] + c: [255, 0, 0] + """, + builder + )); + assertEquals("test", params.minimap); + } + + @Test + void testValidate() { + PopulateMinimapTaskParams params = new PopulateMinimapTaskParams(" ", Map.of("voxel", Color.BLACK)); + assertThrows(IllegalArgumentException.class, params::validate); + + params = new PopulateMinimapTaskParams("minimap", Map.of("voxel", Color.BLACK)); + assertDoesNotThrow(params::validate); + } + + @Test + void testCreate() { + PopulateMinimapTaskParams params = new PopulateMinimapTaskParams("minimap", Map.of("voxel", Color.BLACK)); + assertDoesNotThrow(() -> params.create( + new Generation(new TestingVoxelWorld(), TestingSeed.UNUSED, null, 0, 0, 1, 1, 1.0, 1.0, 0.0, 100) + )); + } +} diff --git a/src/test/java/fr/ign/voxatile/core/parameters/tasks/SaveMinimapTaskParamsTest.java b/src/test/java/fr/ign/voxatile/core/parameters/tasks/SaveMinimapTaskParamsTest.java new file mode 100644 index 000000000..05ae63af5 --- /dev/null +++ b/src/test/java/fr/ign/voxatile/core/parameters/tasks/SaveMinimapTaskParamsTest.java @@ -0,0 +1,54 @@ +package fr.ign.voxatile.core.parameters.tasks; + +import java.io.File; + +import org.junit.jupiter.api.Test; + +import fr.ign.voxatile.core.generation.Generation; +import fr.ign.voxatile.core.parameters.ParamsTester; +import fr.ign.voxatile.core.utils.random.TestingSeed; +import fr.ign.voxatile.core.world.TestingVoxelWorld; + +import static org.junit.jupiter.api.Assertions.*; + +public class SaveMinimapTaskParamsTest { + @Test + void testDeserialize() { + assertDoesNotThrow(() -> ParamsTester.deserialize( + SaveMinimapTaskParams.class, + """ + type: saveMinimap + minimap: test + format: jpeg + destination: testFile.png + background: [1, 2, 3, 4] + """, + ParamsTester.mapperBuilderWithParams("saveMinimap", SaveMinimapTaskParams.class) + )); + } + + @Test + void testValidate() { + SaveMinimapTaskParams params = new SaveMinimapTaskParams(" ", new File("test.png"), "png"); + assertThrows(IllegalArgumentException.class, params::validate); + params = new SaveMinimapTaskParams("minimap", new File(" "), "png"); + assertThrows(IllegalArgumentException.class, params::validate); + params = new SaveMinimapTaskParams("minimap", new File("test.unknown"), "unknown"); + assertThrows(IllegalArgumentException.class, params::validate); + params = new SaveMinimapTaskParams("minimap", new File("testFile.png"), "jpeg"); + assertThrows(IllegalArgumentException.class, params::validate); + + params = new SaveMinimapTaskParams("minimap", new File("testFile.pNG"), "png"); + assertDoesNotThrow(params::validate); + params = new SaveMinimapTaskParams("minimap", new File("testFile.png"), "png"); + assertDoesNotThrow(params::validate); + } + + @Test + void testCreate() { + SaveMinimapTaskParams params = new SaveMinimapTaskParams("minimap", new File("testFile.png"), "png"); + assertDoesNotThrow(() -> params.create( + new Generation(new TestingVoxelWorld(new File("testing")), TestingSeed.UNUSED, null, 0, 0, 1, 1, 1.0, 1.0, 0.0, 100) + )); + } +} diff --git a/src/test/java/fr/ign/voxatile/core/world/TestingVoxel.java b/src/test/java/fr/ign/voxatile/core/world/TestingVoxel.java index 4619b8382..8424ff483 100644 --- a/src/test/java/fr/ign/voxatile/core/world/TestingVoxel.java +++ b/src/test/java/fr/ign/voxatile/core/world/TestingVoxel.java @@ -2,13 +2,11 @@ import java.util.Objects; -import fr.ign.voxatile.core.placeables.Placeable; - /** * A dummy voxel for {@code TestingVoxelWorld} and {@code TestingVoxelTile}. * All testing voxels are represented by simple strings. */ -public class TestingVoxel implements Placeable { +public class TestingVoxel implements Voxel { /** * Name of the voxel. */ @@ -40,7 +38,8 @@ public void place(VoxelTile tile, int x, int y, int z) { * * @return Voxel as string */ - protected String getName() { + @Override + public String getTypeIdentifier() { return name; } diff --git a/src/test/java/fr/ign/voxatile/core/world/TestingVoxelTile.java b/src/test/java/fr/ign/voxatile/core/world/TestingVoxelTile.java index 6c9cd63b5..82b4b34da 100644 --- a/src/test/java/fr/ign/voxatile/core/world/TestingVoxelTile.java +++ b/src/test/java/fr/ign/voxatile/core/world/TestingVoxelTile.java @@ -1,7 +1,6 @@ package fr.ign.voxatile.core.world; -import fr.ign.voxatile.core.placeables.Placeable; import fr.ign.voxatile.core.utils.world3d.WorldBBox3d; import fr.ign.voxatile.core.utils.world3d.WorldCoords3d; @@ -42,7 +41,7 @@ private int index(int x, int y, int z) { } protected void set(int x, int y, int z, TestingVoxel voxel) { - set(x, y, z, voxel.getName()); + set(x, y, z, voxel.getTypeIdentifier()); } /** @@ -61,7 +60,7 @@ public void set(int x, int y, int z, String value) { } @Override - public Placeable getVoxel(int x, int y, int z) { + public Voxel getVoxel(int x, int y, int z) { if (!limits().contains(x, y, z)) return null; String name = voxels[index(x, y, z)]; return name == null ? null : new TestingVoxel(name); diff --git a/src/test/java/fr/ign/voxatile/core/world/TestingVoxelWorld.java b/src/test/java/fr/ign/voxatile/core/world/TestingVoxelWorld.java index d4199bf7f..b437135d6 100644 --- a/src/test/java/fr/ign/voxatile/core/world/TestingVoxelWorld.java +++ b/src/test/java/fr/ign/voxatile/core/world/TestingVoxelWorld.java @@ -1,5 +1,6 @@ package fr.ign.voxatile.core.world; +import java.io.File; import java.util.Collection; import java.util.Collections; @@ -25,7 +26,16 @@ public class TestingVoxelWorld extends VoxelWorld { * Creates a new TestingVoxelWorld. */ public TestingVoxelWorld() { - super(new VoxelWorldMetadata()); + super(new VoxelWorldMetadata(), null); + } + + /** + * Creates a new {@code TestingVoxelWorld}. + * + * @param destination destination folder for the world + */ + public TestingVoxelWorld(File destination) { + super(new VoxelWorldMetadata(), destination); } @Override diff --git a/src/test/java/fr/ign/voxatile/core/world/VoxelWorldTest.java b/src/test/java/fr/ign/voxatile/core/world/VoxelWorldTest.java index b1ab2d4f7..bdfff8af9 100644 --- a/src/test/java/fr/ign/voxatile/core/world/VoxelWorldTest.java +++ b/src/test/java/fr/ign/voxatile/core/world/VoxelWorldTest.java @@ -39,9 +39,9 @@ public void testSetLimits() { @Test public void testVoxelIterator() { TestingVoxelTile tile = new TestingVoxelTile(new WorldBBox3d(new WorldCoords3d(-1, -2, -5), new WorldCoords3d(2, 3, 6))); - Placeable a = new TestingVoxel("aa"); - Placeable b = new TestingVoxel("b"); - Placeable c = new TestingVoxel("c"); + Voxel a = new TestingVoxel("aa"); + Voxel b = new TestingVoxel("b"); + Voxel c = new TestingVoxel("c"); a.place(tile, -1, 2, 6); a.place(tile, -1, 2, -4); @@ -80,7 +80,7 @@ public void testVoxelIterator() { private static class VoxelWorldMock extends VoxelWorld { protected VoxelWorldMock() { - super(new VoxelWorldMetadata()); + super(new VoxelWorldMetadata(), null); } @Override