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 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/com/ignfab/minalac/generator/modules/luanti/LuantiVoxel.java b/src/main/java/com/ignfab/minalac/generator/modules/luanti/LuantiVoxel.java index 9f44b70c1..ccee81f44 100644 --- a/src/main/java/com/ignfab/minalac/generator/modules/luanti/LuantiVoxel.java +++ b/src/main/java/com/ignfab/minalac/generator/modules/luanti/LuantiVoxel.java @@ -2,15 +2,15 @@ import java.util.Objects; -import com.ignfab.minalac.generator.placeables.Placeable; +import com.ignfab.minalac.generator.world.Voxel; import com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/modules/luanti/LuantiVoxelTile.java b/src/main/java/com/ignfab/minalac/generator/modules/luanti/LuantiVoxelTile.java index 40ac8a3c3..326f28e18 100644 --- a/src/main/java/com/ignfab/minalac/generator/modules/luanti/LuantiVoxelTile.java +++ b/src/main/java/com/ignfab/minalac/generator/modules/luanti/LuantiVoxelTile.java @@ -5,9 +5,9 @@ import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import com.ignfab.minalac.generator.modules.luanti.utils.SQLiteMapWriter; -import com.ignfab.minalac.generator.placeables.Placeable; import com.ignfab.minalac.generator.utils.world3d.WorldBBox3d; import com.ignfab.minalac.generator.world.MapWriteException; +import com.ignfab.minalac.generator.world.Voxel; import com.ignfab.minalac.generator.world.VoxelTile; /** @@ -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/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 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/com/ignfab/minalac/generator/modules/minecraft/MinecraftVoxelTile.java b/src/main/java/com/ignfab/minalac/generator/modules/minecraft/MinecraftVoxelTile.java index 785519b42..151309bb6 100644 --- a/src/main/java/com/ignfab/minalac/generator/modules/minecraft/MinecraftVoxelTile.java +++ b/src/main/java/com/ignfab/minalac/generator/modules/minecraft/MinecraftVoxelTile.java @@ -11,9 +11,9 @@ import it.unimi.dsi.fastutil.ints.Int2ObjectMaps; import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; -import com.ignfab.minalac.generator.placeables.Placeable; import com.ignfab.minalac.generator.utils.world3d.WorldBBox3d; import com.ignfab.minalac.generator.world.MapWriteException; +import com.ignfab.minalac.generator.world.Voxel; import com.ignfab.minalac.generator.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/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 { + + @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, "String value %s cannot be interpreted as a color", string); + } + } + + if (node.isArray()) { + int size = node.size(); + if (size < 3 || size > 4) + 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(); + 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/com/ignfab/minalac/generator/parameters/GenerationParams.java b/src/main/java/com/ignfab/minalac/generator/parameters/GenerationParams.java index 1a1a0b8c6..bf6122544 100644 --- a/src/main/java/com/ignfab/minalac/generator/parameters/GenerationParams.java +++ b/src/main/java/com/ignfab/minalac/generator/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/com/ignfab/minalac/generator/parameters/MinimapParams.java b/src/main/java/com/ignfab/minalac/generator/parameters/MinimapParams.java new file mode 100644 index 000000000..04e38b5c0 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/parameters/MinimapParams.java @@ -0,0 +1,39 @@ +package com.ignfab.minalac.generator.parameters; + +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; + +import com.ignfab.minalac.generator.generation.minimaps.Minimap; +import com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/parameters/ParamsParser.java b/src/main/java/com/ignfab/minalac/generator/parameters/ParamsParser.java index 88f70642f..7a162a232 100644 --- a/src/main/java/com/ignfab/minalac/generator/parameters/ParamsParser.java +++ b/src/main/java/com/ignfab/minalac/generator/parameters/ParamsParser.java @@ -1,5 +1,7 @@ package com.ignfab.minalac.generator.parameters; +import java.awt.Color; + import tools.jackson.core.JacksonException; import tools.jackson.core.StreamReadFeature; import tools.jackson.databind.DeserializationFeature; @@ -114,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/com/ignfab/minalac/generator/parameters/tasks/ApplyShadingMinimapTaskParams.java b/src/main/java/com/ignfab/minalac/generator/parameters/tasks/ApplyShadingMinimapTaskParams.java new file mode 100644 index 000000000..e7dfd7288 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/parameters/tasks/ApplyShadingMinimapTaskParams.java @@ -0,0 +1,70 @@ +package com.ignfab.minalac.generator.parameters.tasks; + +import java.beans.ConstructorProperties; + +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.ApplyShadingMinimapTask; +import com.ignfab.minalac.generator.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). + * + *

+ */ + @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 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/com/ignfab/minalac/generator/parameters/tasks/SaveMinimapTaskParams.java b/src/main/java/com/ignfab/minalac/generator/parameters/tasks/SaveMinimapTaskParams.java new file mode 100644 index 000000000..e37aafb0c --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/parameters/tasks/SaveMinimapTaskParams.java @@ -0,0 +1,91 @@ +package com.ignfab.minalac.generator.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 com.ignfab.minalac.generator.generation.Generation; +import com.ignfab.minalac.generator.tasks.NoOperationTask; +import com.ignfab.minalac.generator.tasks.SaveMinimapTask; +import com.ignfab.minalac.generator.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/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 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/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 { 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/com/ignfab/minalac/generator/world/VoxelTile.java b/src/main/java/com/ignfab/minalac/generator/world/VoxelTile.java index df2bc2179..ce41b8a45 100644 --- a/src/main/java/com/ignfab/minalac/generator/world/VoxelTile.java +++ b/src/main/java/com/ignfab/minalac/generator/world/VoxelTile.java @@ -5,7 +5,6 @@ import com.ignfab.minalac.generator.generation.heightmaps.Heightmap; import com.ignfab.minalac.generator.generation.heightmaps.ReadableHeightmap; -import com.ignfab.minalac.generator.placeables.Placeable; import com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/world/VoxelWorld.java b/src/main/java/com/ignfab/minalac/generator/world/VoxelWorld.java index 34ac0cf65..ad9d573dd 100644 --- a/src/main/java/com/ignfab/minalac/generator/world/VoxelWorld.java +++ b/src/main/java/com/ignfab/minalac/generator/world/VoxelWorld.java @@ -1,5 +1,6 @@ package com.ignfab.minalac.generator.world; +import java.io.File; import java.util.Collection; import com.ignfab.minalac.generator.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/test/java/com/ignfab/minalac/generator/generation/GenerationTest.java b/src/test/java/com/ignfab/minalac/generator/generation/GenerationTest.java index ee0d3ac3e..b86a49fc0 100644 --- a/src/test/java/com/ignfab/minalac/generator/generation/GenerationTest.java +++ b/src/test/java/com/ignfab/minalac/generator/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/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/com/ignfab/minalac/generator/parameters/ColorDeserializerTest.java b/src/test/java/com/ignfab/minalac/generator/parameters/ColorDeserializerTest.java new file mode 100644 index 000000000..a5095a289 --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/parameters/ColorDeserializerTest.java @@ -0,0 +1,49 @@ +package com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/parameters/GenerationParamsTest.java b/src/test/java/com/ignfab/minalac/generator/parameters/GenerationParamsTest.java index 8e85866d9..a8b2e3bee 100644 --- a/src/test/java/com/ignfab/minalac/generator/parameters/GenerationParamsTest.java +++ b/src/test/java/com/ignfab/minalac/generator/parameters/GenerationParamsTest.java @@ -1,6 +1,7 @@ package com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/parameters/MinimapParamsTest.java b/src/test/java/com/ignfab/minalac/generator/parameters/MinimapParamsTest.java new file mode 100644 index 000000000..876d384cc --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/parameters/MinimapParamsTest.java @@ -0,0 +1,35 @@ +package com.ignfab.minalac.generator.parameters; + +import org.junit.jupiter.api.Test; + +import com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/parameters/tasks/ApplyShadingMinimapTaskParamsTest.java b/src/test/java/com/ignfab/minalac/generator/parameters/tasks/ApplyShadingMinimapTaskParamsTest.java new file mode 100644 index 000000000..12a2b745b --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/parameters/tasks/ApplyShadingMinimapTaskParamsTest.java @@ -0,0 +1,34 @@ +package com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/parameters/tasks/PopulateMinimapTaskParamsTest.java b/src/test/java/com/ignfab/minalac/generator/parameters/tasks/PopulateMinimapTaskParamsTest.java new file mode 100644 index 000000000..9256c1986 --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/parameters/tasks/PopulateMinimapTaskParamsTest.java @@ -0,0 +1,52 @@ +package com.ignfab.minalac.generator.parameters.tasks; + +import java.awt.Color; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import tools.jackson.databind.cfg.MapperBuilder; + +import com.ignfab.minalac.generator.generation.Generation; +import com.ignfab.minalac.generator.parameters.ParamsTester; +import com.ignfab.minalac.generator.utils.random.TestingSeed; +import com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/parameters/tasks/SaveMinimapTaskParamsTest.java b/src/test/java/com/ignfab/minalac/generator/parameters/tasks/SaveMinimapTaskParamsTest.java new file mode 100644 index 000000000..ef632f7d5 --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/parameters/tasks/SaveMinimapTaskParamsTest.java @@ -0,0 +1,54 @@ +package com.ignfab.minalac.generator.parameters.tasks; + +import java.io.File; + +import org.junit.jupiter.api.Test; + +import com.ignfab.minalac.generator.generation.Generation; +import com.ignfab.minalac.generator.parameters.ParamsTester; +import com.ignfab.minalac.generator.utils.random.TestingSeed; +import com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/world/TestingVoxel.java b/src/test/java/com/ignfab/minalac/generator/world/TestingVoxel.java index 70f4cb2b8..08ee50fa6 100644 --- a/src/test/java/com/ignfab/minalac/generator/world/TestingVoxel.java +++ b/src/test/java/com/ignfab/minalac/generator/world/TestingVoxel.java @@ -2,13 +2,11 @@ import java.util.Objects; -import com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/world/TestingVoxelTile.java b/src/test/java/com/ignfab/minalac/generator/world/TestingVoxelTile.java index 92dc481e0..066af1d1c 100644 --- a/src/test/java/com/ignfab/minalac/generator/world/TestingVoxelTile.java +++ b/src/test/java/com/ignfab/minalac/generator/world/TestingVoxelTile.java @@ -1,7 +1,6 @@ package com.ignfab.minalac.generator.world; -import com.ignfab.minalac.generator.placeables.Placeable; import com.ignfab.minalac.generator.utils.world3d.WorldBBox3d; import com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/world/TestingVoxelWorld.java b/src/test/java/com/ignfab/minalac/generator/world/TestingVoxelWorld.java index 8e26c65ed..e27343395 100644 --- a/src/test/java/com/ignfab/minalac/generator/world/TestingVoxelWorld.java +++ b/src/test/java/com/ignfab/minalac/generator/world/TestingVoxelWorld.java @@ -1,5 +1,6 @@ package com.ignfab.minalac.generator.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/com/ignfab/minalac/generator/world/VoxelWorldTest.java b/src/test/java/com/ignfab/minalac/generator/world/VoxelWorldTest.java index e2795b5be..08c3382c8 100644 --- a/src/test/java/com/ignfab/minalac/generator/world/VoxelWorldTest.java +++ b/src/test/java/com/ignfab/minalac/generator/world/VoxelWorldTest.java @@ -6,7 +6,6 @@ import org.junit.jupiter.api.Test; -import com.ignfab.minalac.generator.placeables.Placeable; import com.ignfab.minalac.generator.utils.world2d.WorldBBox2d; import com.ignfab.minalac.generator.utils.world3d.WorldBBox3d; import com.ignfab.minalac.generator.utils.world3d.WorldCoords3d; @@ -39,9 +38,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 +79,7 @@ public void testVoxelIterator() { private static class VoxelWorldMock extends VoxelWorld { protected VoxelWorldMock() { - super(new VoxelWorldMetadata()); + super(new VoxelWorldMetadata(), null); } @Override