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 0279a233a..5de497ffa 100644 --- a/docs/usage/parameters/Parameters.md +++ b/docs/usage/parameters/Parameters.md @@ -14,10 +14,12 @@ Generator makes a heavy use of parameters. Generation is described in Yaml (or J 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 `Minalac`) - `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 389658d55..52597a49e 100644 --- a/docs/usage/parameters/TileTasks.md +++ b/docs/usage/parameters/TileTasks.md @@ -24,6 +24,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) ## Organizational tasks @@ -343,3 +345,27 @@ compute: maximum: maximum-ground-altitude 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] +``` diff --git a/examples/formats/minecraft.yaml b/examples/formats/minecraft.yaml index b42a7cf44..b623b3db7 100644 --- a/examples/formats/minecraft.yaml +++ b/examples/formats/minecraft.yaml @@ -38,3 +38,18 @@ references: at: [ 0, 0, 0..5 ] - &voxel-water minecraft:water - &voxel-air minecraft:air + - &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 e92abee28..02d279ba2 100644 --- a/examples/formats/minetest.yaml +++ b/examples/formats/minetest.yaml @@ -32,3 +32,19 @@ references: at: [ 0, 0, 0..5 ] - &voxel-water default:water_source - &voxel-air air + - &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 2c81a264b..a495845c5 100644 --- a/examples/processes/full.yaml +++ b/examples/processes/full.yaml @@ -4,299 +4,324 @@ 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 - - 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 + 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 - 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-empty - placeBelow: *voxel-building-ground + computeBuildingsAltitude: + after: + - fetchBuildings + - renderGround + type: computeHeightmapStats + models: + type: buildings + heightmap: ground + compute: + minimum: minimum-ground-altitude + maximum: ground-floor-altitude - renderBuildings: - after: flattenGroundBuildings - type: renderBuildings - models: - type: buildings - filter: - value: - metadata: height - greaterThan: 0 - roof: *voxel-cobble - wall: *voxel-stone - window: *voxel-glass + flattenGroundBuildings: + after: + - computeBuildingsAltitude + type: fillBetweenHeightmapAndValue + models: + type: buildings + heightmap: ground + altitudeValue: + metadata: ground-floor-altitude + placeAbove: *voxel-empty + 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 + renderBuildings: + 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 + value: + metadata: height + greaterThan: 0 + roof: *voxel-cobble + wall: *voxel-stone + window: *voxel-glass + + 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 + + populateWaterPresence: + after: fetchWater + type: copyHeightmap + models: + type: water + from: 1 + to: water + + 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 + + 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: - - 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 + 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 ] - populateWaterPresence: - after: fetchWater - type: copyHeightmap - models: - type: water - from: 1 - 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 ] - 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 + 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 - 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 ] + 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 - 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 ] + 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 - 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 + populateOverworldMinimap: + after: generateWorld + type: populateMinimap + minimap: overworld + colors: *minimap-color-palette - 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 +afterAllTiles: - 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 + 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/com/ignfab/minalac/generator/Voxatile.java b/src/main/java/com/ignfab/minalac/generator/Voxatile.java index e9bcf9fa5..cb5801e3f 100644 --- a/src/main/java/com/ignfab/minalac/generator/Voxatile.java +++ b/src/main/java/com/ignfab/minalac/generator/Voxatile.java @@ -36,12 +36,14 @@ import com.ignfab.minalac.generator.parameters.providers.ShapefileProviderParams; import com.ignfab.minalac.generator.parameters.providers.WFSProviderParams; import com.ignfab.minalac.generator.parameters.providers.WMSFloatBilProviderParams; +import com.ignfab.minalac.generator.parameters.tasks.ApplyShadingMinimapTaskParams; import com.ignfab.minalac.generator.parameters.tasks.CopyHeightmapTaskParams; import com.ignfab.minalac.generator.parameters.tasks.FetchDataTaskParams; import com.ignfab.minalac.generator.parameters.tasks.FillBetweenHeightmapAndValueTaskParams; import com.ignfab.minalac.generator.parameters.tasks.HeightmapStatsTaskParams; import com.ignfab.minalac.generator.parameters.tasks.NoOperationTaskParams; import com.ignfab.minalac.generator.parameters.tasks.PopulateHeightmapTaskParams; +import com.ignfab.minalac.generator.parameters.tasks.PopulateMinimapTaskParams; import com.ignfab.minalac.generator.parameters.tasks.RenderBuildingsTaskParams; import com.ignfab.minalac.generator.parameters.tasks.RenderHeightmapTaskParams; import com.ignfab.minalac.generator.parameters.tasks.RenderLines2dTaskParams; @@ -49,6 +51,7 @@ import com.ignfab.minalac.generator.parameters.tasks.RenderPoints2dTaskParams; import com.ignfab.minalac.generator.parameters.tasks.RenderPointsTaskParams; import com.ignfab.minalac.generator.parameters.tasks.RenderSurfacesTaskParams; +import com.ignfab.minalac.generator.parameters.tasks.SaveMinimapTaskParams; import com.ignfab.minalac.generator.parameters.tasks.ScheduleTaskParams; import com.ignfab.minalac.generator.parameters.tasks.SequenceTaskParams; import com.ignfab.minalac.generator.parameters.tasks.SetSpawnTaskParams; @@ -118,6 +121,7 @@ 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("noOperation", NoOperationTaskParams.class); parser.registerParams("sequence", SequenceTaskParams.class); parser.registerParams("schedule", ScheduleTaskParams.class); @@ -126,6 +130,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); @@ -134,6 +139,7 @@ public static void main(String[] args) throws FactoryException, InterruptedExcep parser.registerParams("renderPoints", RenderPointsTaskParams.class); parser.registerParams("renderPoints2d", RenderPoints2dTaskParams.class); parser.registerParams("setSpawn", SetSpawnTaskParams.class); + parser.registerParams("saveMinimap", SaveMinimapTaskParams.class); parser.registerParams("wfs", WFSProviderParams.class); parser.registerParams("gpkg", GeoPackageProviderParams.class); diff --git a/src/main/java/com/ignfab/minalac/generator/generation/Generation.java b/src/main/java/com/ignfab/minalac/generator/generation/Generation.java index 6b02617a0..576087f7d 100644 --- a/src/main/java/com/ignfab/minalac/generator/generation/Generation.java +++ b/src/main/java/com/ignfab/minalac/generator/generation/Generation.java @@ -14,6 +14,7 @@ import com.ignfab.minalac.generator.exceptions.TransformException; import com.ignfab.minalac.generator.generation.heightmaps.HeightmapDeclarationStore; +import com.ignfab.minalac.generator.generation.minimaps.MinimapStore; import com.ignfab.minalac.generator.utils.coordinates.MapToWorldConverter; import com.ignfab.minalac.generator.utils.coordinates.WorldToMapConverter; import com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/generation/minimaps/Minimap.java b/src/main/java/com/ignfab/minalac/generator/generation/minimaps/Minimap.java new file mode 100644 index 000000000..c1c559fcc --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/generation/minimaps/Minimap.java @@ -0,0 +1,150 @@ +package com.ignfab.minalac.generator.generation.minimaps; + +import java.awt.Color; + +import com.ignfab.minalac.generator.tasks.ApplyShadingMinimapTask; +import com.ignfab.minalac.generator.tasks.PopulateMinimapTask; +import com.ignfab.minalac.generator.tasks.SaveMinimapTask; +import com.ignfab.minalac.generator.utils.world2d.Bounded2d; +import com.ignfab.minalac.generator.utils.world3d.WorldCoords3d; +import com.ignfab.minalac.generator.world.Voxel; +import com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/generation/minimaps/MinimapCell.java b/src/main/java/com/ignfab/minalac/generator/generation/minimaps/MinimapCell.java new file mode 100644 index 000000000..f63bb268c --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/generation/minimaps/MinimapCell.java @@ -0,0 +1,122 @@ +package com.ignfab.minalac.generator.generation.minimaps; + +import com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/generation/minimaps/MinimapStore.java b/src/main/java/com/ignfab/minalac/generator/generation/minimaps/MinimapStore.java
new file mode 100644
index 000000000..5403df29e
--- /dev/null
+++ b/src/main/java/com/ignfab/minalac/generator/generation/minimaps/MinimapStore.java
@@ -0,0 +1,37 @@
+package com.ignfab.minalac.generator.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
* 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/com/ignfab/minalac/generator/modules/luanti/LuantiVoxelWorld.java b/src/main/java/com/ignfab/minalac/generator/modules/luanti/LuantiVoxelWorld.java
index 84530c384..0f1b35584 100644
--- a/src/main/java/com/ignfab/minalac/generator/modules/luanti/LuantiVoxelWorld.java
+++ b/src/main/java/com/ignfab/minalac/generator/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/com/ignfab/minalac/generator/modules/minecraft/MinecraftVoxel.java b/src/main/java/com/ignfab/minalac/generator/modules/minecraft/MinecraftVoxel.java
index 06e37d5ca..c2069831f 100644
--- a/src/main/java/com/ignfab/minalac/generator/modules/minecraft/MinecraftVoxel.java
+++ b/src/main/java/com/ignfab/minalac/generator/modules/minecraft/MinecraftVoxel.java
@@ -7,14 +7,14 @@
import io.github.ensgijs.nbt.tag.CompoundTag;
import io.github.ensgijs.nbt.tag.StringTag;
-import com.ignfab.minalac.generator.placeables.Placeable;
+import com.ignfab.minalac.generator.world.Voxel;
import com.ignfab.minalac.generator.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
* 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/com/ignfab/minalac/generator/modules/minecraft/MinecraftVoxelWorld.java b/src/main/java/com/ignfab/minalac/generator/modules/minecraft/MinecraftVoxelWorld.java
index 2b9acaecb..852374c7e 100644
--- a/src/main/java/com/ignfab/minalac/generator/modules/minecraft/MinecraftVoxelWorld.java
+++ b/src/main/java/com/ignfab/minalac/generator/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/main/java/com/ignfab/minalac/generator/parameters/ColorDeserializer.java b/src/main/java/com/ignfab/minalac/generator/parameters/ColorDeserializer.java
new file mode 100644
index 000000000..674812989
--- /dev/null
+++ b/src/main/java/com/ignfab/minalac/generator/parameters/ColorDeserializer.java
@@ -0,0 +1,46 @@
+package com.ignfab.minalac.generator.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
+ * 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).
+ *
+ *
+ * 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/com/ignfab/minalac/generator/tasks/ApplyShadingMinimapTask.java b/src/main/java/com/ignfab/minalac/generator/tasks/ApplyShadingMinimapTask.java
new file mode 100644
index 000000000..43318968d
--- /dev/null
+++ b/src/main/java/com/ignfab/minalac/generator/tasks/ApplyShadingMinimapTask.java
@@ -0,0 +1,94 @@
+package com.ignfab.minalac.generator.tasks;
+
+import com.ignfab.minalac.generator.generation.minimaps.Minimap;
+import com.ignfab.minalac.generator.generation.minimaps.MinimapCell;
+import com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/tasks/PopulateMinimapTask.java b/src/main/java/com/ignfab/minalac/generator/tasks/PopulateMinimapTask.java
new file mode 100644
index 000000000..20ff89302
--- /dev/null
+++ b/src/main/java/com/ignfab/minalac/generator/tasks/PopulateMinimapTask.java
@@ -0,0 +1,88 @@
+package com.ignfab.minalac.generator.tasks;
+
+import java.awt.Color;
+import java.util.Map;
+
+import com.ignfab.minalac.generator.generation.GenerationTile;
+import com.ignfab.minalac.generator.generation.minimaps.Minimap;
+import com.ignfab.minalac.generator.utils.world2d.WorldCoords2d;
+import com.ignfab.minalac.generator.utils.world3d.WorldCoords3d;
+import com.ignfab.minalac.generator.world.PlacedVoxel;
+import com.ignfab.minalac.generator.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
+ * 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/com/ignfab/minalac/generator/tasks/SaveMinimapTask.java b/src/main/java/com/ignfab/minalac/generator/tasks/SaveMinimapTask.java
new file mode 100644
index 000000000..5ba185247
--- /dev/null
+++ b/src/main/java/com/ignfab/minalac/generator/tasks/SaveMinimapTask.java
@@ -0,0 +1,62 @@
+package com.ignfab.minalac.generator.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 com.ignfab.minalac.generator.generation.minimaps.Minimap;
+import com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/world/PlacedVoxel.java b/src/main/java/com/ignfab/minalac/generator/world/PlacedVoxel.java
index c44ba8df7..7d3b573e5 100644
--- a/src/main/java/com/ignfab/minalac/generator/world/PlacedVoxel.java
+++ b/src/main/java/com/ignfab/minalac/generator/world/PlacedVoxel.java
@@ -1,6 +1,5 @@
package com.ignfab.minalac.generator.world;
-import com.ignfab.minalac.generator.placeables.Placeable;
import com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/world/Voxel.java b/src/main/java/com/ignfab/minalac/generator/world/Voxel.java
new file mode 100644
index 000000000..96062aed3
--- /dev/null
+++ b/src/main/java/com/ignfab/minalac/generator/world/Voxel.java
@@ -0,0 +1,15 @@
+package com.ignfab.minalac.generator.world;
+
+import com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/world/VoxelColumnIterator.java b/src/main/java/com/ignfab/minalac/generator/world/VoxelColumnIterator.java
index e4fd4ad9c..7e7cc19e7 100644
--- a/src/main/java/com/ignfab/minalac/generator/world/VoxelColumnIterator.java
+++ b/src/main/java/com/ignfab/minalac/generator/world/VoxelColumnIterator.java
@@ -2,7 +2,6 @@
import java.util.Iterator;
-import com.ignfab.minalac.generator.placeables.Placeable;
import com.ignfab.minalac.generator.utils.world3d.WorldCoords3d;
/**
@@ -15,7 +14,7 @@ public class VoxelColumnIterator implements Iterator
+ *
+ */
+ @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/com/ignfab/minalac/generator/parameters/tasks/PopulateMinimapTaskParams.java b/src/main/java/com/ignfab/minalac/generator/parameters/tasks/PopulateMinimapTaskParams.java
new file mode 100644
index 000000000..5c3e3dd6c
--- /dev/null
+++ b/src/main/java/com/ignfab/minalac/generator/parameters/tasks/PopulateMinimapTaskParams.java
@@ -0,0 +1,53 @@
+package com.ignfab.minalac.generator.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 com.ignfab.minalac.generator.generation.Generation;
+import com.ignfab.minalac.generator.tasks.PopulateMinimapTask;
+import com.ignfab.minalac.generator.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