diff --git a/docs/usage/parameters/Layouts.md b/docs/usage/parameters/Layouts.md new file mode 100644 index 000000000..316cc1948 --- /dev/null +++ b/docs/usage/parameters/Layouts.md @@ -0,0 +1,88 @@ + +# Layouts + +Layouts are a kind of structure that can adapt itself to fit a requested space. + +## Table of contents + +* [Stretchable Layout](#stretchable-layout) +* [Repeat Layout](#repeat-layout) +* [Concatenate Layout](#concatenate-layout) + +## Stretchable Layout + +Makes a structure stretchable by having one band per axis (row, column or layer) that gets repeated or omitted to fit the requested size. + +```yaml +structure: + axes: x + blueprint: "rgb" + with: + "r": wool:red + "g": wool:green + "b": wool:blue +stretchableAlongX: + at: 2 + atLeast: 2 + atMost: 5 +stretchableAlongY: + at: 1 +``` + +Fields: + - `structure` (required): The structure to make stretchable + - `stretchableAlongX`/`Y`/`Z` (each optional): Strech parameters along x/y/z-axis. Omit to keep that fixed (non-stretchable). + - `at` (required): coordinate of the band to stretch + - `atLeast` (optional, default `1`): Minimum repetition of the band. `0` allows squeezing. + - `atMost` (optional, default `infinite`): Maximum repetition of the band. + +## Repeat Layout + +Repeats a layout along a given axis. If the repeated layout is resizable on that axis, +it may be stretched to fill the requested size. + +```yaml +repeat: otherLayout +along: x +atLeast: 1 +atMost: 2 +``` + +Fields: + - `repeat` (required): The layout to repeat. + - `along` (required): Axis along which the layout is repeated (`x`, `y` or `z`) + - `atLeast` (optional, default `1`): Minimum repetitions of the layout. `0` allows the layout to be empty. + - `atMost` (optional, default `infinite`): Maximum repetitions of the layout. + +## Concatenate Layout + +Places several layouts side by side on a given axis, with priorities. + +It proceeds that way: +1. All layouts gets their minimal required space (minimum size). +1. Higher priority layouts (higher number) start first and get as much remaining space as possible. +1. If space still remains, continue with lower priorities until no space left. + +If serveral layouts have the same priority, the available space is distributed between them as evenly as possible. + +```yaml +concatenate: + - priority: 1 + otherLayout + - priority: 2 + anotherLayout +along: y +zPolicy: keep +``` + +Fields: +- `concatenate` (required): List of layouts to place side by side. Each layout definition may have an extra `priority` field (default `0`) to set its order of priority (higher values get space first). +- `along` (required): Axis along which the layouts are placed (`x`, `y` or `z`) +- `x`/`y`/`zPolicy` (optional, default `inherit`): Policy for the x/y/z-axis (only for axes other than `along` axis). + +Policies control how other axes are sized: +| Policy | Constraint on concerned axis | +|:----------|:--------------------------------------------------------| +| `inherit` | Use parent policy (layout or task). | +| `adjust` | Force same size for all child layouts. | +| `keep` | Keep child layouts sizes (different sizes are allowed). | diff --git a/docs/usage/parameters/TileTasks.md b/docs/usage/parameters/TileTasks.md index 389658d55..f2cf21340 100644 --- a/docs/usage/parameters/TileTasks.md +++ b/docs/usage/parameters/TileTasks.md @@ -20,10 +20,13 @@ Each task has a `type`, optional dependencies to other tasks (in `after`), and o * [`fillBetweenHeightmapAndValue`](#fillbetweenheightmapandvalue) * [`renderBuildings`](#renderbuildings) * [`setSpawn`](#setspawn) + * [`renderFacades`](#renderfacades) * [Tasks operating on heightmaps](#tasks-operating-on-heightmaps) * [`populateHeightmap`](#populateheightmap) * [`copyHeightmap`](#copyheightmap) * [`computeHeightmapStats`](#computeheightmapstats) +* [Tool tasks](#tool-tasks) + * [`buildLayout`](#buildlayout) ## Organizational tasks @@ -162,7 +165,7 @@ The blueprint shows five successive vertical slices of the structure. This will ### `renderPoints` -Renders 3d points as a placeable. +Renders 3d points as a placeable. #### Extra parameters @@ -278,6 +281,37 @@ x: 2 y: -1 ``` +### `renderFacades` + +Renders facades using [layouts](Layouts.md) from 2D shapes. +Layouts are tried in order, the first one that can fit the requested space is used. + +#### Extra parameters + +- `models`: [Selection of models](ModelSelection.md) to render (required, models must be convertible to 2d shapes) +- `height` (string): Name of the metadata containing the desired height. (required) +- `altitude` (string): Name of the metadata containing the altitude. (required) +- `build`: List of [layouts](Layouts.md) to try. Default axis policies are `ADJUST` on `X` and `Z`, `KEEP` on `Y`. + +See [Layouts](Layouts.md) for more information about layouts + +#### Example + +```yaml +type: renderFacades +models: buildings +height: height +altitude: ground-floor-altitude +build: + - structure: + at: [ 0, -1..0, 0 ] + put: stone + stretchableAlongX: + at: 0 + stretchableAlongZ: + at: 0 +``` + ## Tasks operating on heightmaps ### `populateHeightmap` @@ -343,3 +377,36 @@ compute: maximum: maximum-ground-altitude minimum: minimum-ground-altitude ``` + +## Tool tasks + +These tasks are not intended to be used to create worlds from geographical data but rather to help parameter files development. + +### `buildLayout` + +This task builds a structure of a given size from a layout (or a fallback list of layouts) and places it into the world at a given position. + +#### Extra parameters + +- `build` (required) : [Layouts](Layouts.md) or list of layouts to build (first buildable will be used). +- `at` (required): Where to place the built layout. +- `size` (optional): wanted size: + - `x` / `y` / `z` (optional, defaults to minimum layout size): wanted size in each dimension +- `policies` (optional): size policies to apply in each three dimensions: + - `x` / `y` / `z` (optional, default `adjust`): `keep` means layout sizes are kept as is, `adjust` meands layout sizes should adjust to content. + +See [Layouts](Layouts.md) for more information about layouts and policies. + +#### Example +```yaml +type: buildLayout +build: + - ... # First layout candidate + - ... # Second layout candidate (fallback) + - ... +at: [ 10, 20, 0 ] +size: + x: 4 +policies: + x: keep +``` \ No newline at end of file diff --git a/examples/formats/minecraft.yaml b/examples/formats/minecraft.yaml index b42a7cf44..d18f7bb8e 100644 --- a/examples/formats/minecraft.yaml +++ b/examples/formats/minecraft.yaml @@ -3,7 +3,6 @@ references: - &voxel-stone minecraft:stone - &voxel-dirt minecraft:dirt - &voxel-cobble minecraft:cobblestone - - &voxel-empty minecraft:air - &voxel-building-ground minecraft:stone_bricks - &voxel-grass minecraft:grass_block - &voxel-glass minecraft:glass @@ -38,3 +37,21 @@ references: at: [ 0, 0, 0..5 ] - &voxel-water minecraft:water - &voxel-air minecraft:air + - &corner-material minecraft:smooth_red_sandstone + - &base-material minecraft:sandstone + - &other-material minecraft:red_sandstone + - &borders-material minecraft:smooth_red_sandstone + - &cornice-material minecraft:smooth_red_sandstone + - &lintel-material minecraft:cut_sandstone + - &facade-residential-materials + "-": *base-material + "+": *other-material + "O": *borders-material + "*": minecraft:tinted_glass + "=": *lintel-material + "|": minecraft:jungle_fence + - &modern-wall minecraft:clay + - &modern-glass minecraft:tinted_glass + - &modern-materials + "O": *modern-wall + "*": *modern-glass diff --git a/examples/formats/minetest.yaml b/examples/formats/minetest.yaml index e92abee28..ea2b765de 100644 --- a/examples/formats/minetest.yaml +++ b/examples/formats/minetest.yaml @@ -3,7 +3,6 @@ references: - &voxel-stone default:stone - &voxel-dirt default:dirt - &voxel-cobble default:cobble - - &voxel-empty default:air - &voxel-building-ground default:stonebrick - &voxel-grass default:dirt_with_grass - &voxel-glass default:glass @@ -32,3 +31,21 @@ references: at: [ 0, 0, 0..5 ] - &voxel-water default:water_source - &voxel-air air + - &corner-material default:desert_sandstone_block + - &base-material default:sandstone + - &other-material default:desert_stone + - &borders-material default:desert_sandstone_block + - &cornice-material default:desert_sandstone_block + - &lintel-material default:sandstonebrick + - &facade-residential-materials + "-": *base-material + "+": *other-material + "O": *borders-material + "*": default:obsidian_glass + "=": *lintel-material + "|": default:fence_junglewood + - &modern-wall default:clay + - &modern-glass default:obsidian_glass + - &modern-materials + "O": *modern-wall + "*": *modern-glass diff --git a/examples/processes/full.yaml b/examples/processes/full.yaml index 2c81a264b..57ee5951f 100644 --- a/examples/processes/full.yaml +++ b/examples/processes/full.yaml @@ -115,10 +115,12 @@ forEachTile: heightmap: ground altitudeValue: metadata: ground-floor-altitude - placeAbove: *voxel-empty + placeAbove: *voxel-air placeBelow: *voxel-building-ground - renderBuildings: + # Hacky way to render roofs. Should be removed. + # Would have been nice to have a fillBetweenMetadatas + renderRoofs: after: flattenGroundBuildings type: renderBuildings models: @@ -128,8 +130,8 @@ forEachTile: metadata: height greaterThan: 0 roof: *voxel-cobble - wall: *voxel-stone - window: *voxel-glass + wall: *voxel-air + window: *voxel-air forest: type: sequence @@ -300,3 +302,187 @@ forEachTile: with: "█": *road-paint "░": *road-tar + + renderResidentialBuildings: + after: + - flattenGroundBuildings + - renderRoofs + type: renderFacades + models: + type: buildings + filter: + metadata: usage_1 + equals: Résidentiel + height: height + altitude: ground-floor-altitude + # TODO: Layout should probably be defined on a common.yaml file in format. + build: + - along: x + concatenate: + # Left corner + - &corner + structure: + at: [ 0, -1..0, 0 ] + place: *corner-material + stretchableAlongX: { at: 0 } + stretchableAlongZ: { at: 0 } + priority: -1 + - along: z + concatenate: + # Ground floor + - priority: 1 + along: x + concatenate: + - atLeast: 0 + along: x + repeat: + structure: + axes: [ z, x ] + blueprint: [ "-O==O", "-O**O", "-O**O", "-O**O", "-O==O", "-O--O" ] + with: *facade-residential-materials + stretchableAlongZ: { at: 0 } + # Door + - structure: + axes: [ z, x ] + blueprint: [ "-O==O-", "-O O-", "-O O-", "-O O-", "-O O-", "-O O-" ] + with: *facade-residential-materials + stretchableAlongZ: { at: 0 } + # Right ground windows + - along: x + atLeast: 0 + repeat: + structure: + axes: [ z, x ] + blueprint: [ "O==O-", "O**O-", "O**O-", "O**O-", "O==O-", "O--O-" ] + with: *facade-residential-materials + stretchableAlongZ: { at: 0 } + # Cornice + - structure: + at: [ 0, 0, 0 ] + place: *cornice-material + stretchableAlongX: { at: 0 } + # Upper floors + - priority: 2 + along: z + atLeast: 0 + repeat: + along: x + concatenate: + - structure: + at: [ 0, 0, 0..5 ] + place: *other-material + - along: x + repeat: + structure: + axes: [ y, z, x ] + blueprint: + - [ "O==O+", "O**O+", "O**O+", "O**O+", "O==O+", "O--O+" ] + - [ " ", " ", " ", "|||| ", "==== ", "O O " ] + with: *facade-residential-materials + yOffset: -1 + # Upper cornice + - structure: + at: [ 0, 0, 0 ] + place: *cornice-material + stretchableAlongX: { at: 0 } + stretchableAlongZ: { at: 0 } + # Right corner + - *corner + - structure: + at: [ 0, 0, 0 ] + place: *corner-material + stretchableAlongX: { at: 0 } + stretchableAlongZ: { at: 0 } + + renderOfficeBuildings: + after: + - flattenGroundBuildings + - renderRoofs + type: renderFacades + models: + type: buildings + filter: + not: + metadata: usage_1 + equals: Résidentiel + height: height + altitude: ground-floor-altitude + # TODO: Layout should probably be defined on a common.yaml file in format. + build: + - along: x + concatenate: + # Left corner + - structure: + place: *modern-wall + at: [0, 0, 0] + stretchableAlongX: + at: 0 + stretchableAlongZ: + at: 0 + # Facade + - priority: 1 + along: z + concatenate: + # Ground floor + - along: x + concatenate: + # Left + - along: x + repeat: + structure: + with: *modern-materials + axes: [z, x] + blueprint: [ "O**", "O**", "O**", "OOO", "OOO"] + # Door + - structure: + with: *modern-materials + axes: [z, x] + blueprint: [ "OOOO", "O O", "O O", "O O", "O O" ] + # Right + - along: x + repeat: + structure: + with: *modern-materials + axes: [z, x] + blueprint: [ "**O", "**O", "**O", "OOO", "OOO" ] + # Upper floors + - priority: 1 + along: x + concatenate: + - structure: + place: *modern-wall + at: [0, 0, 0 ] + stretchableAlongZ: + at: 0 + - along: x + repeat: + structure: + with: *modern-materials + axes: x + blueprint: "**O" + stretchableAlongZ: + at: 0 + # Top + - structure: + place: *modern-wall + at: [0, 0, 0 ] + stretchableAlongX: + at: 0 + stretchableAlongZ: + at: 0 + # Left corner + - structure: + place: *modern-wall + at: [0, 0, 0] + stretchableAlongX: + at: 0 + stretchableAlongZ: + at: 0 + # Default + - structure: + at: [ 0, 0, 0 ] + place: *modern-wall + stretchableAlongX: + at: 0 + stretchableAlongZ: + at: 0 diff --git a/src/main/java/com/ignfab/minalac/generator/Voxatile.java b/src/main/java/com/ignfab/minalac/generator/Voxatile.java index e9bcf9fa5..6a418d68a 100644 --- a/src/main/java/com/ignfab/minalac/generator/Voxatile.java +++ b/src/main/java/com/ignfab/minalac/generator/Voxatile.java @@ -36,6 +36,7 @@ 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.BuildLayoutTaskParams; import com.ignfab.minalac.generator.parameters.tasks.CopyHeightmapTaskParams; import com.ignfab.minalac.generator.parameters.tasks.FetchDataTaskParams; import com.ignfab.minalac.generator.parameters.tasks.FillBetweenHeightmapAndValueTaskParams; @@ -43,6 +44,7 @@ import com.ignfab.minalac.generator.parameters.tasks.NoOperationTaskParams; import com.ignfab.minalac.generator.parameters.tasks.PopulateHeightmapTaskParams; import com.ignfab.minalac.generator.parameters.tasks.RenderBuildingsTaskParams; +import com.ignfab.minalac.generator.parameters.tasks.RenderFacadesTaskParams; import com.ignfab.minalac.generator.parameters.tasks.RenderHeightmapTaskParams; import com.ignfab.minalac.generator.parameters.tasks.RenderLines2dTaskParams; import com.ignfab.minalac.generator.parameters.tasks.RenderLinesTaskParams; @@ -134,6 +136,8 @@ 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("renderFacades", RenderFacadesTaskParams.class); + parser.registerParams("buildLayout", BuildLayoutTaskParams.class); parser.registerParams("wfs", WFSProviderParams.class); parser.registerParams("gpkg", GeoPackageProviderParams.class); diff --git a/src/main/java/com/ignfab/minalac/generator/parameters/placeables/layouts/ConcatenateLayoutBuilderParams.java b/src/main/java/com/ignfab/minalac/generator/parameters/placeables/layouts/ConcatenateLayoutBuilderParams.java new file mode 100644 index 000000000..7c3f1fcdb --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/parameters/placeables/layouts/ConcatenateLayoutBuilderParams.java @@ -0,0 +1,146 @@ +package com.ignfab.minalac.generator.parameters.placeables.layouts; + +import java.beans.ConstructorProperties; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonParser; +import tools.jackson.core.exc.InputCoercionException; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.annotation.JsonDeserialize; +import tools.jackson.databind.node.ObjectNode; + +import com.ignfab.minalac.generator.parameters.utils.AxisParams; +import com.ignfab.minalac.generator.placeables.layouts.DefaultLayoutBuilder; +import com.ignfab.minalac.generator.placeables.layouts.LayoutBuilder; +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; +import com.ignfab.minalac.generator.utils.random.Seed; + +/** + * Parameters for a {@link LayoutBuilder} that concatenates different layouts along an axis. + */ +public class ConcatenateLayoutBuilderParams implements LayoutBuilderParams { + /** + * List of layouts to concatenate. + */ + @JsonSetter(nulls = Nulls.FAIL) + public List concatenate; + + /** + * Axis along which layouts are concatenated. + */ + @JsonSetter(nulls = Nulls.FAIL) + public AxisParams along; + + /** + * Adjust policy along X-axis (default: inherit). + */ + @JsonSetter(nulls = Nulls.SKIP) + public AxisPolicy xPolicy = AxisPolicy.INHERIT; + + /** + * Adjust policy along Y-axis (default: inherit). + */ + @JsonSetter(nulls = Nulls.SKIP) + public AxisPolicy yPolicy = AxisPolicy.INHERIT; + + /** + * Adjust policy along Z-axis (default: inherit). + */ + @JsonSetter(nulls = Nulls.SKIP) + public AxisPolicy zPolicy = AxisPolicy.INHERIT; + + /** + * Creates a new {@code ConcatenateLayoutBuilderParams} out of mandatory parameters. + * @param concatenate list of layouts to concatenate + * @param along axis along which concatenate layouts + */ + @ConstructorProperties({ "concatenate", "along" }) + public ConcatenateLayoutBuilderParams(List concatenate, AxisParams along) { + this.concatenate = concatenate; + this.along = along; + } + + @Override + public void validate() { + if (concatenate.isEmpty()) + throw new IllegalArgumentException("'concatenate' list cannot be empty"); + concatenate.forEach(ConcatenateParams::validate); + } + + @Override + public LayoutBuilder createBuilder(Seed seed, AxesPolicies policies) throws UnbuildableLayoutException { + LayoutBuilder[] builders = new LayoutBuilder[concatenate.size()]; + int[] priorities = new int[concatenate.size()]; + for (int i = 0; i < concatenate.size(); i++) { + builders[i] = concatenate.get(i).layout.createBuilder(seed, policies); + priorities[i] = concatenate.get(i).priority; + } + + return DefaultLayoutBuilder.concat(builders, along.create(), priorities, + xPolicy.inheriting(policies.x()) == AxisPolicy.ADJUST, + yPolicy.inheriting(policies.y()) == AxisPolicy.ADJUST, + zPolicy.inheriting(policies.z()) == AxisPolicy.ADJUST + ); + } + + /** + * Parameters for each concatenated layout. + *

+ * This is basically a {@link LayoutBuilderParams} with an eventual extra {@code priority} field. + */ + @JsonDeserialize(using = ConcatenateParams.Deserializer.class) + public static final class ConcatenateParams { + /** + * Concatenated {@link LayoutBuilderParams}. + */ + @JsonSetter(nulls = Nulls.FAIL) + public LayoutBuilderParams layout; + + /** + * Priority for space repartition. + */ + @JsonSetter(nulls = Nulls.SKIP) + public int priority = 0; + + /** + * Validates parameters. + */ + public void validate() { + layout.validate(); + } + + /** + * Custom deserializer for {@code ConcatenateParams}. + *

+ * This deserializer will take care of {@code priority} field and use all other fields to deserialize into {@code layout}. + */ + public static class Deserializer extends ValueDeserializer { + + @Override + public ConcatenateParams deserialize(JsonParser parser, DeserializationContext context) throws JacksonException { + JsonNode node = parser.readValueAsTree(); + + if (node instanceof ObjectNode objectNode) { + ConcatenateParams params = new ConcatenateParams(); + + JsonNode priorityNode = objectNode.get("priority"); + if (priorityNode != null) { + if (!priorityNode.isIntegralNumber()) + throw new InputCoercionException(parser, "Priority should be a integer number", node.asToken(), Integer.class); + params.priority = priorityNode.asInt(); + objectNode.remove("priority"); + } + params.layout = context.readTreeAsValue(objectNode, LayoutBuilderParams.class); + + return params; + } else + throw new InputCoercionException(parser, "Placed layouts should be layouts", node.asToken(), LayoutBuilderParams.class); + } + } + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/parameters/placeables/layouts/LayoutBuilderParams.java b/src/main/java/com/ignfab/minalac/generator/parameters/placeables/layouts/LayoutBuilderParams.java new file mode 100644 index 000000000..b7b0fe5f6 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/parameters/placeables/layouts/LayoutBuilderParams.java @@ -0,0 +1,78 @@ +package com.ignfab.minalac.generator.parameters.placeables.layouts; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import com.ignfab.minalac.generator.placeables.layouts.LayoutBuilder; +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; +import com.ignfab.minalac.generator.utils.random.Seed; + +/** + * Interface for {@link LayoutBuilder} params. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION, defaultImpl = StructureLayoutBuilderParams.class) +@JsonSubTypes({ + @JsonSubTypes.Type(ConcatenateLayoutBuilderParams.class), + @JsonSubTypes.Type(RepeatLayoutBuilderParams.class), + @JsonSubTypes.Type(StructureLayoutBuilderParams.class), +}) +public interface LayoutBuilderParams { + + /** + * Validates layout builder. + * + * @throws IllegalArgumentException if layout is invalid + */ + default void validate() {} + + /** + * Creates {@link LayoutBuilder} from parameters. + * + * @param seed The random seed + * @param policies Default axis adjustment policies + * @return created layout builder + * @throws UnbuildableLayoutException + */ + LayoutBuilder createBuilder(Seed seed, AxesPolicies policies) throws UnbuildableLayoutException; + + /** + * Axis adjustment policeis. + */ + enum AxisPolicy { + /** + * Inherit policy: use default value. + */ + @JsonProperty("inherit") + INHERIT, + + /** + * Keep policy: keep axis as is with no modification. + */ + @JsonProperty("keep") + KEEP, + + /** + * Adjust policy: Adjust axis so it starts at 0 and has the desired size. + */ + @JsonProperty("adjust") + ADJUST; + + /** + * {@return this if different from INHERIT else policy given as argument} + * + * @param policy parent policy + */ + public AxisPolicy inheriting(AxisPolicy policy) { + return this == INHERIT ? policy : this; + } + } + + /** + * Ajust policies for the three axes. + * @param x x-axis adjustment policy + * @param y y-axis adjustment policy + * @param z z-axis adjustment policy + */ + record AxesPolicies(AxisPolicy x, AxisPolicy y, AxisPolicy z) {} +} diff --git a/src/main/java/com/ignfab/minalac/generator/parameters/placeables/layouts/RepeatLayoutBuilderParams.java b/src/main/java/com/ignfab/minalac/generator/parameters/placeables/layouts/RepeatLayoutBuilderParams.java new file mode 100644 index 000000000..5d7047ae0 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/parameters/placeables/layouts/RepeatLayoutBuilderParams.java @@ -0,0 +1,71 @@ +package com.ignfab.minalac.generator.parameters.placeables.layouts; + +import java.beans.ConstructorProperties; + +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; + +import com.ignfab.minalac.generator.parameters.utils.AxisParams; +import com.ignfab.minalac.generator.placeables.layouts.DefaultLayoutBuilder; +import com.ignfab.minalac.generator.placeables.layouts.LayoutBuilder; +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; +import com.ignfab.minalac.generator.utils.random.Seed; + +/** + * Parameters for a {@link LayoutBuilder} that repeats a layout along an axis. + */ +public class RepeatLayoutBuilderParams implements LayoutBuilderParams { + /** + * {@link LayoutBuilderParams} to repeat. + */ + @JsonSetter(nulls = Nulls.FAIL) + public LayoutBuilderParams repeat; + + /** + * Axis along which layout is repeated. + */ + @JsonSetter(nulls = Nulls.FAIL) + public AxisParams along; + + /** + * Minimum number of repetitions (default 1). + */ + @JsonSetter(nulls = Nulls.SKIP) + public int atLeast = 1; + + /** + * Maximum number of repetitions (default infinite). + */ + @JsonSetter(nulls = Nulls.SKIP) + public int atMost = Integer.MAX_VALUE; + + /** + * Creates a new {@code RepeatLayoutBuilderParams} out of mandatory parameters. + * @param repeat layout to repeat + * @param along axis along which layout is repeated + */ + @ConstructorProperties({ "repeat", "along" }) + public RepeatLayoutBuilderParams(LayoutBuilderParams repeat, AxisParams along) { + this.repeat = repeat; + this.along = along; + } + + @Override + public void validate() { + if (atLeast < 0) + throw new IllegalArgumentException("'atLeast' must be a positive integer"); + if (atMost < atLeast) + throw new IllegalArgumentException("'atMost' must be greater than or equals to 'atLeast'"); + repeat.validate(); + } + + @Override + public LayoutBuilder createBuilder(Seed seed, AxesPolicies policies) throws UnbuildableLayoutException { + return DefaultLayoutBuilder.repeat( + repeat.createBuilder(seed, policies), + along.create(), + atLeast, + atMost + ); + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/parameters/placeables/layouts/StructureLayoutBuilderParams.java b/src/main/java/com/ignfab/minalac/generator/parameters/placeables/layouts/StructureLayoutBuilderParams.java new file mode 100644 index 000000000..0df9c6446 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/parameters/placeables/layouts/StructureLayoutBuilderParams.java @@ -0,0 +1,128 @@ +package com.ignfab.minalac.generator.parameters.placeables.layouts; + +import java.beans.ConstructorProperties; + +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; + +import com.ignfab.minalac.generator.parameters.placeables.structures.PlaceableStructureParams; +import com.ignfab.minalac.generator.placeables.PlaceableStructure; +import com.ignfab.minalac.generator.placeables.layouts.LayoutBuilder; +import com.ignfab.minalac.generator.placeables.layouts.StructureLayoutBuilder; +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; +import com.ignfab.minalac.generator.utils.axis.Axis; +import com.ignfab.minalac.generator.utils.random.Seed; + +/** + * Parameters for making a {@link PlaceableStructure} resizable by allowing at most one stretchable band of the structure per axis (e.g. a column at x = 2 and a row at z = 1), effectively making it a Layout Builder. + */ +public class StructureLayoutBuilderParams implements LayoutBuilderParams { + /** + * The {@link PlaceableStructure} to transform into a LayoutBuilder. + */ + @JsonSetter(nulls = Nulls.FAIL) + public PlaceableStructureParams structure; + /** + * Parameters for defining the band along x-axis stretchable. (Optional) + */ + @JsonSetter(nulls = Nulls.SKIP) + public StretchAxisParams stretchableAlongX = new NonStretchableAxisParams(); + /** + * Parameters for defining the band along y-axis stretchable. (Optional) + */ + @JsonSetter(nulls = Nulls.SKIP) + public StretchAxisParams stretchableAlongY = new NonStretchableAxisParams(); + /** + * Parameters for defining the band along z-axis stretchable. (Optional) + */ + @JsonSetter(nulls = Nulls.SKIP) + public StretchAxisParams stretchableAlongZ = new NonStretchableAxisParams(); + + /** + * Creates a new {@code StructureLayoutBuilderParams} out of mandatory parameters. + * + * @param structure the {@link PlaceableStructureParams} to transform. + */ + @ConstructorProperties({ "structure" }) + public StructureLayoutBuilderParams(PlaceableStructureParams structure) { + this.structure = structure; + } + + @Override + public void validate() { + structure.validate(); + stretchableAlongX.validate(); + stretchableAlongY.validate(); + stretchableAlongZ.validate(); + } + + @Override + public LayoutBuilder createBuilder(Seed seed, AxesPolicies policies) throws UnbuildableLayoutException { + PlaceableStructure structure = this.structure.create(seed); + + return new StructureLayoutBuilder( + structure, + stretchableAlongX.create(Axis.X), + stretchableAlongY.create(Axis.Y), + stretchableAlongZ.create(Axis.Z) + ); + } + + /** + * Stretch params along a given axis. + */ + public static class StretchAxisParams { + /** + * Position to use for stretching. This position will be repeated (or omitted) according to wanted size. + */ + @JsonSetter(nulls = Nulls.FAIL) + public int at; + /** + * Minimum number of repetitions (default 1). + */ + @JsonSetter(nulls = Nulls.SKIP) + public int atLeast = 1; + /** + * Maximum number of repetitions (default infinite). + */ + @JsonSetter(nulls = Nulls.SKIP) + public int atMost = Integer.MAX_VALUE; + + /** + * Validates params. + * + * @throws IllegalArgumentException if params are invalid + */ + public void validate() { + if (at < 0) + throw new IllegalArgumentException("Stretch position must be positive"); + if (atLeast < 0) + throw new IllegalArgumentException("Stretch at least must be positive"); + if (atMost < atLeast) + throw new IllegalArgumentException("Stretch at most must be greater or equals to stretch at least"); + } + + /** + * Creates corresponding {@link StructureLayoutBuilder.StretchAxis}. + * + * @param axis axis to create + * @return created {@link StructureLayoutBuilder.StretchAxis} + */ + public StructureLayoutBuilder.StretchAxis create(Axis axis) { + return new StructureLayoutBuilder.StretchAxis(axis, at, atLeast, atMost); + } + } + + /** + * Non stretchable stretch axis params. + */ + public static class NonStretchableAxisParams extends StretchAxisParams { + @Override + public void validate() {} + + @Override + public StructureLayoutBuilder.StretchAxis create(Axis axis) { + return null; + } + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/parameters/tasks/BuildLayoutTaskParams.java b/src/main/java/com/ignfab/minalac/generator/parameters/tasks/BuildLayoutTaskParams.java new file mode 100644 index 000000000..9fc356ab3 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/parameters/tasks/BuildLayoutTaskParams.java @@ -0,0 +1,130 @@ +package com.ignfab.minalac.generator.parameters.tasks; + +import java.beans.ConstructorProperties; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; + +import com.ignfab.minalac.generator.generation.Generation; +import com.ignfab.minalac.generator.parameters.placeables.layouts.LayoutBuilderParams; +import com.ignfab.minalac.generator.parameters.placeables.layouts.LayoutBuilderParams.AxisPolicy; +import com.ignfab.minalac.generator.parameters.utils.WorldCoords3dParams; +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; +import com.ignfab.minalac.generator.tasks.BuildLayoutTask; +import com.ignfab.minalac.generator.tasks.TileTask; + +/** + * Parameters for a {@code BuildLayoutTask}. + */ +public class BuildLayoutTaskParams extends TaskParams { + /** + * List of builders to use to construct structure. + * Builders will be tried in list order. First succeeding will be used. + */ + @JsonSetter(nulls = Nulls.FAIL, contentNulls = Nulls.FAIL) + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + public List build; + + /** + * Where to place resulting structure. + */ + @JsonSetter(nulls = Nulls.FAIL) + public WorldCoords3dParams at; + + /** + * Desired resulting size. + */ + @JsonSetter(nulls = Nulls.SKIP) + public SizeParams size = new SizeParams(); + + /** + * Desired policies. + */ + @JsonSetter(nulls = Nulls.SKIP) + public PoliciesParams policies = new PoliciesParams(); + + /** + * Creates a new {@code BuildLayoutTaskParams} out of mandatory parameters. + * @param build list of builders to use + * @param at position where to place resulting structure + */ + @ConstructorProperties({"build", "at"}) + public BuildLayoutTaskParams(List build, WorldCoords3dParams at) { + this.build = build; + this.at = at; + } + + @Override + public void validate() { + at.validate(); + build.forEach(LayoutBuilderParams::validate); + } + + @Override + public TileTask create(Generation generation) { + try { + return new BuildLayoutTask( + build.stream().map(builder -> { + try { + return builder.createBuilder( + generation.seed(), + new LayoutBuilderParams.AxesPolicies(policies.x, policies.y, policies.z) + ); + } catch (UnbuildableLayoutException e) { + throw new IllegalArgumentException(e); + } + }).toList(), + at.create(), + size.x, size.y, size.z + ); + } catch (UnbuildableLayoutException e) { + throw new IllegalArgumentException(e); + } + } + + /** + * Params for placed structure wanted size. + *

+ * Size component may be omitted. Omitting a component means leaving the builder decide size on corresponding axis. + */ + public static class SizeParams { + /** + * X-axis component of the size. + */ + @JsonSetter(nulls = Nulls.SKIP) + public Integer x; + /** + * Y-axis component of the size. + */ + @JsonSetter(nulls = Nulls.SKIP) + public Integer y; + /** + * Z-axis component of the size. + */ + @JsonSetter(nulls = Nulls.SKIP) + public Integer z; + } + + /** + * Params for layout builder policies. + */ + public static class PoliciesParams { + /** + * X-axis policy. + */ + @JsonSetter(nulls = Nulls.SKIP) + public AxisPolicy x = AxisPolicy.ADJUST; + /** + * Y-axis policy. + */ + @JsonSetter(nulls = Nulls.SKIP) + public AxisPolicy y = AxisPolicy.ADJUST; + /** + * Z-axis policy. + */ + @JsonSetter(nulls = Nulls.SKIP) + public AxisPolicy z = AxisPolicy.ADJUST; + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/parameters/tasks/RenderFacadesTaskParams.java b/src/main/java/com/ignfab/minalac/generator/parameters/tasks/RenderFacadesTaskParams.java new file mode 100644 index 000000000..c8b33e558 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/parameters/tasks/RenderFacadesTaskParams.java @@ -0,0 +1,90 @@ +package com.ignfab.minalac.generator.parameters.tasks; + +import java.beans.ConstructorProperties; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; + +import com.ignfab.minalac.generator.generation.Generation; +import com.ignfab.minalac.generator.parameters.placeables.layouts.LayoutBuilderParams; +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; +import com.ignfab.minalac.generator.tasks.RenderFacadesTask; +import com.ignfab.minalac.generator.tasks.TileTask; + +/** + * Parameters for a {@link RenderFacadesTask}. + */ +public class RenderFacadesTaskParams extends ModelTaskParams { + /** + * List of builders to try out. + *

+ * Builders will be tried in list order. + * If a builder fails, next one is tried. + * If it succeeds, facade is built and next builders won't be used. + * If all builders fail, nothing will be built. + */ + @JsonSetter(nulls = Nulls.FAIL, contentNulls = Nulls.FAIL) + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + public List build; + + /** + * Name of metadata containing building height (from wall bottom to wall top). + */ + @JsonSetter(nulls = Nulls.FAIL) + public String height; + + /** + * Name of metadata containing building altitude (altitude of wall bottom). + */ + @JsonSetter(nulls = Nulls.FAIL) + public String altitude; + + /** + * Creates a new {@code RenderFacadeTaskParams} out of mandatory parameters. + * @param build builders to try, in order + * @param height name of metadata containing building height + * @param altitude name of metadata containing building altitude + */ + @ConstructorProperties({ "build", "height", "altitude"}) + public RenderFacadesTaskParams( + List build, + String height, + String altitude + ) { + this.build = build; + this.height = height; + this.altitude = altitude; + } + + @Override + public void validate() { + super.validate(); + if (height.isBlank()) + throw new IllegalArgumentException("Height metadata cannot be blank"); + if (altitude.isBlank()) + throw new IllegalArgumentException("Altitude metadata cannot be blank"); + build.forEach(LayoutBuilderParams::validate); + } + + @Override + public TileTask create(Generation generation) { + return new RenderFacadesTask( + models.create(generation), + build.stream().map(builder -> { + try { + return builder.createBuilder(generation.seed(), new LayoutBuilderParams.AxesPolicies( + LayoutBuilderParams.AxisPolicy.ADJUST, + LayoutBuilderParams.AxisPolicy.KEEP, + LayoutBuilderParams.AxisPolicy.ADJUST + )); + } catch (UnbuildableLayoutException e) { + throw new IllegalArgumentException(e); + } + }).toList(), + height, + altitude + ); + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/parameters/utils/AxisParams.java b/src/main/java/com/ignfab/minalac/generator/parameters/utils/AxisParams.java new file mode 100644 index 000000000..0bece5620 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/parameters/utils/AxisParams.java @@ -0,0 +1,42 @@ +package com.ignfab.minalac.generator.parameters.utils; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import com.ignfab.minalac.generator.utils.axis.Axis; + +/** + * Parameters for a three dimensional axis. + */ +public enum AxisParams { + /** + * X-axis. + */ + @JsonProperty("x") + X(Axis.X), + /** + * Y-axis. + */ + @JsonProperty("y") + Y(Axis.Y), + /** + * Z-axis. + */ + @JsonProperty("z") + Z(Axis.Z); + + /** + * Actual {@link Axis} associated to parameter. + */ + private final Axis axis; + + AxisParams(Axis axis) { + this.axis = axis; + } + + /** + * {@return the corresponding {@link Axis}} + */ + public Axis create() { + return axis; + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/parameters/utils/WorldCoords3dParams.java b/src/main/java/com/ignfab/minalac/generator/parameters/utils/WorldCoords3dParams.java new file mode 100644 index 000000000..352c48874 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/parameters/utils/WorldCoords3dParams.java @@ -0,0 +1,38 @@ +package com.ignfab.minalac.generator.parameters.utils; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; + +import com.ignfab.minalac.generator.parameters.JsonWrapper; +import com.ignfab.minalac.generator.utils.world3d.WorldCoords3d; + +/** + * Parameters describing a 3d position as {@link WorldCoords3d}. + */ +@JsonWrapper +public class WorldCoords3dParams { + /** + * The three coordinates (required). + */ + @JsonSetter(nulls = Nulls.FAIL, contentNulls = Nulls.FAIL) + public List coordinates; + + /** + * Validates parameters. + */ + public void validate() { + if (coordinates.size() != 3) + throw new IllegalArgumentException("3d position should have three coordinates"); + } + + /** + * Creates a new {@link WorldCoords3d} out of parameters. + * + * @return created {@link WorldCoords3d} + */ + public WorldCoords3d create() { + return new WorldCoords3d(coordinates.get(0), coordinates.get(1), coordinates.get(2)); + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/placeables/LayoutStructure.java b/src/main/java/com/ignfab/minalac/generator/placeables/LayoutStructure.java new file mode 100644 index 000000000..942e95826 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/placeables/LayoutStructure.java @@ -0,0 +1,109 @@ +package com.ignfab.minalac.generator.placeables; + +import java.util.List; + +import com.ignfab.minalac.generator.utils.axis.Axis; +import com.ignfab.minalac.generator.utils.axis.mappers.AxisMapper; +import com.ignfab.minalac.generator.utils.axis.mappers.IdentityAxisMapper; +import com.ignfab.minalac.generator.utils.axis.mappers.SizesAxisMapper; +import com.ignfab.minalac.generator.utils.world3d.WorldBBox3d; +import com.ignfab.minalac.generator.utils.world3d.WorldCoords3d; +import com.ignfab.minalac.generator.utils.world3d.WorldSize3d; +import com.ignfab.minalac.generator.world.VoxelTile; + +/** + * A placeable structure made of several structures. + *

+ * {@link AxisMapper}s are used to map structure coordinate to substructures and in-substructures coordinates. + */ +public class LayoutStructure implements Structure { + private final Structure[][][] structures; + private final AxisMapper axisX; + private final AxisMapper axisY; + private final AxisMapper axisZ; + private final WorldBBox3d limits; + + /** + * Creates a new {@code LayoutStructure}. + * + * @param structures Three dimensional array of structures. Array dimensions must correspond to AxisMappers sizes. + * @param axisX axis mapper for X-axis + * @param axisY axis mapper for Y-axis + * @param axisZ axis mapper for Z-axis + */ + public LayoutStructure(Structure[][][] structures, AxisMapper axisX, AxisMapper axisY, AxisMapper axisZ) { + this.structures = structures; + this.axisX = axisX; + this.axisY = axisY; + this.axisZ = axisZ; + + limits = new WorldBBox3d( + new WorldCoords3d(axisX.minimum(), axisY.minimum(), axisZ.minimum()), + new WorldSize3d(axisX.size(), axisY.size(), axisZ.size()) + ); + } + + @Override + public Placeable get(int x, int y, int z) { + if (axisX.contains(x) && axisY.contains(y) && axisZ.contains(z)) { + AxisMapper.Mapped aX = axisX.map(x); + AxisMapper.Mapped aY = axisY.map(y); + AxisMapper.Mapped aZ = axisZ.map(z); + return structures[aX.index()][aY.index()][aZ.index()] + .get(aX.position(), aY.position(), aZ.position()); + } + + // Return Nothing instead of raising an exception to be able to render unadjusted stuff + return Nothing.INSTANCE; + } + + @Override + public WorldBBox3d limits() { + return limits; + } + + @Override + public void place(VoxelTile tile, int x, int y, int z) { + for (WorldCoords3d c : limits.bbox()) + get(c.x(), c.y(), c.z()).place(tile, c.x() + x, c.y() + y, c.z() + z); + } + + /** + * Creates a layout concatenating structures along given axis. + * + * @param structures list of structures to concatenate + * @param axis axis along which structures will be placed + * @return a {@link LayoutStructure} concatenating structures + */ + public static LayoutStructure concatenate(List structures, Axis axis) { + WorldBBox3d limits = WorldBBox3d.surrounding(structures.stream().map(Structure::limits).toList()); + + AxisMapper axisX = axis == Axis.X + ? new SizesAxisMapper(structures.stream().mapToInt(s -> s.limits().sizeX()).toArray()) + : new IdentityAxisMapper(limits.minX(), limits.sizeX()); + + AxisMapper axisY = axis == Axis.Y + ? new SizesAxisMapper(structures.stream().mapToInt(s -> s.limits().sizeY()).toArray()) + : new IdentityAxisMapper(limits.minY(), limits.sizeY()); + + AxisMapper axisZ = axis == Axis.Z + ? new SizesAxisMapper(structures.stream().mapToInt(s -> s.limits().sizeZ()).toArray()) + : new IdentityAxisMapper(limits.minZ(), limits.sizeZ()); + + Structure[][][] structArray = new Structure + [axis == Axis.X ? structures.size() : 1] + [axis == Axis.Y ? structures.size() : 1] + [axis == Axis.Z ? structures.size() : 1]; + + int index = 0; + for (Structure structure : structures) { + structArray + [axis == Axis.X ? index : 0] + [axis == Axis.Y ? index : 0] + [axis == Axis.Z ? index : 0] = structure; + index++; + } + + return new LayoutStructure(structArray, axisX, axisY, axisZ); + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/placeables/PlaceableStructure.java b/src/main/java/com/ignfab/minalac/generator/placeables/PlaceableStructure.java index 3a6950b47..d2f778458 100644 --- a/src/main/java/com/ignfab/minalac/generator/placeables/PlaceableStructure.java +++ b/src/main/java/com/ignfab/minalac/generator/placeables/PlaceableStructure.java @@ -11,7 +11,7 @@ * {@code PlaceableStructure} is a {@link Placeable} consisting of placeables at given coordinate offsets. * The structure itself is immutable, but can be defined by using the {@link #builder()}. */ -public final class PlaceableStructure implements Placeable { +public final class PlaceableStructure implements Structure { private final Map placeables; private final WorldBBox3d limits; @@ -52,29 +52,12 @@ public Placeable get(WorldCoords3d coords) { return placeables.getOrDefault(coords, Nothing.INSTANCE); } - /** - * Returns the placeable at the specified coordinates. - * - * @param x structure relative x-coordinate - * @param y structure relative y-coordinate - * @param z structure relative z-coordinate - * - * @return placeable at relative structure coordinates or {@link Nothing#INSTANCE} if none - */ + @Override public Placeable get(int x, int y, int z) { return get(new WorldCoords3d(x, y, z)); } - /** - * {@return the limits of this structure in relative coordinates} - *

- * This is not the bounding box of all that would be placed. - * Limits will only contain origin coordinates of contained placeables. - *

- * In other words, limits is the smallest bounding box containing every position - * for which {@link #get} returns something other than {@link Nothing#INSTANCE}. - * This may be used to know how to repeat this structure. - */ + @Override public WorldBBox3d limits() { return limits; } diff --git a/src/main/java/com/ignfab/minalac/generator/placeables/Structure.java b/src/main/java/com/ignfab/minalac/generator/placeables/Structure.java new file mode 100644 index 000000000..895f0c416 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/placeables/Structure.java @@ -0,0 +1,36 @@ +package com.ignfab.minalac.generator.placeables; + +import com.ignfab.minalac.generator.utils.world3d.WorldBBox3d; + +/** + * A placeable made of other placeables. + *

+ * Each underlying placeable has a unique position. + * There is a finite number of underlying placeables. + */ +public interface Structure extends Placeable { + /** + * Gets placeable at the given position in the structure. + *

+ * (0, 0, 0) is the point that will be placed in the world at {@link Placeable#place} position. + * + * @param x x-component of position + * @param y y-component of position + * @param z z-component of position + * + * @return placeable at that position or {@link Nothing} if none. + */ + Placeable get(int x, int y, int z); + + /** + * Limits of this structure. + *

+ * Outside this limit, {@link Structure#get(int, int, int)} will always return {@link Nothing}. + *

+ * Limits are distinct from bounding box as limits only includes direct underlying placeables. + * A hierarchy of structures could place voxels beyond these limits. + * + * @return limits of the placeable + */ + WorldBBox3d limits(); +} diff --git a/src/main/java/com/ignfab/minalac/generator/placeables/layouts/DefaultLayoutBuilder.java b/src/main/java/com/ignfab/minalac/generator/placeables/layouts/DefaultLayoutBuilder.java new file mode 100644 index 000000000..852829097 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/placeables/layouts/DefaultLayoutBuilder.java @@ -0,0 +1,146 @@ +package com.ignfab.minalac.generator.placeables.layouts; + +import com.ignfab.minalac.generator.placeables.LayoutStructure; +import com.ignfab.minalac.generator.placeables.PlaceableStructure; +import com.ignfab.minalac.generator.placeables.Structure; +import com.ignfab.minalac.generator.utils.axis.Axis; +import com.ignfab.minalac.generator.utils.axis.mappers.AxisMapper; +import com.ignfab.minalac.generator.utils.axis.mappers.builders.AdjustAxisMapperBuilder; +import com.ignfab.minalac.generator.utils.axis.mappers.builders.AxisMapperBuilder; +import com.ignfab.minalac.generator.utils.axis.mappers.builders.DelegateAxisMapperBuilder; +import com.ignfab.minalac.generator.utils.axis.mappers.builders.KeepAxisMapperBuilder; +import com.ignfab.minalac.generator.utils.axis.mappers.builders.PriorityRepartitionAxisMapperBuilder; +import com.ignfab.minalac.generator.utils.axis.mappers.builders.RepeatAxisMapperBuilder; + +/** + * A versatile implementation of {@link LayoutBuilder} based on a layout builder provider. + */ +public class DefaultLayoutBuilder extends LayoutBuilder { + private final LayoutBuilderProvider provider; + + /** + * Creates a new {@code DefaultLayoutBuilder} with only one, eventually repeated, structure builder. + * + * @param builder underlying unique builder + * @param xAxisBuilder axis builder for x-axis + * @param yAxisBuilder axis builder for y-axis + * @param zAxisBuilder axis builder for z-axis + */ + public DefaultLayoutBuilder(LayoutBuilder builder, AxisMapperBuilder xAxisBuilder, AxisMapperBuilder yAxisBuilder, AxisMapperBuilder zAxisBuilder) { + // Could be optimized if builder is a DefaultLayoutBuilder, we could simply do: + // this.provider = builder.provider + // this.(x/y/z)AxisBuilder = combine (x/y/z)AxisBuilder over builder.(x/y/z)AxisBuilder (need a new method) + this((x, y, z) -> builder, xAxisBuilder, yAxisBuilder, zAxisBuilder); + } + + private DefaultLayoutBuilder(LayoutBuilderProvider provider, AxisMapperBuilder xAxisBuilder, AxisMapperBuilder yAxisBuilder, AxisMapperBuilder zAxisBuilder) { + super(xAxisBuilder, yAxisBuilder, zAxisBuilder); + this.provider = provider; + } + + @Override + public Structure build(int sizeX, int sizeY, int sizeZ) throws UnbuildableLayoutException { + AxisMapper axisX = xAxis().build(sizeX); + AxisMapper axisY = yAxis().build(sizeY); + AxisMapper axisZ = zAxis().build(sizeZ); + + int[] intervalsX = axisX.intervals(); + int[] intervalsY = axisY.intervals(); + int[] intervalsZ = axisZ.intervals(); + + if (intervalsX.length == 0 || intervalsY.length == 0 || intervalsZ.length == 0) + return PlaceableStructure.EMPTY; + + Structure[][][] structures = new Structure + [intervalsX.length] + [intervalsY.length] + [intervalsZ.length]; + + for (int iX = 0; iX < intervalsX.length; iX++) { + for (int iY = 0; iY < intervalsY.length; iY++) { + for (int iZ = 0; iZ < intervalsZ.length; iZ++) { + LayoutBuilder b = provider.get(iX, iY, iZ); + structures[iX][iY][iZ] = b.build(intervalsX[iX], intervalsY[iY], intervalsZ[iZ]); + } + } + } + + return new LayoutStructure(structures, axisX, axisY, axisZ); + } + + /** + * Creates a new {@code LayoutBuilder} repeating an {@link LayoutBuilder} along an axis. + * + * @param builder builder to repeat + * @param axis axis of repetition + * @param minimum minimal number of repetitions (could be 0) + * @param maximum maximal number of repetitions + * @return created {@link LayoutBuilder} + * @throws UnbuildableLayoutException if layout builder cannot be created + */ + public static LayoutBuilder repeat(LayoutBuilder builder, Axis axis, int minimum, int maximum) throws UnbuildableLayoutException { + if (minimum < 0) + throw new IllegalArgumentException("minimum must be positive or zero"); + if (minimum > maximum) + throw new IllegalArgumentException("minimum must be less than maximum"); + + return new DefaultLayoutBuilder( + builder, + // RepeatAxisMapperBuilder for chosen axis, DelegateAxisMapperBuilder for others + axis == Axis.X ? new RepeatAxisMapperBuilder(builder.xAxis(), minimum, maximum) : new DelegateAxisMapperBuilder(builder.xAxis()), + axis == Axis.Y ? new RepeatAxisMapperBuilder(builder.yAxis(), minimum, maximum) : new DelegateAxisMapperBuilder(builder.yAxis()), + axis == Axis.Z ? new RepeatAxisMapperBuilder(builder.zAxis(), minimum, maximum) : new DelegateAxisMapperBuilder(builder.zAxis()) + ); + } + + /** + * Creates a new {@code LayoutBuilder} concatenating {@link LayoutBuilder}s along an axis, with priorities for repartition. + * + * @param builders builders to concatenate + * @param axis axis of concatenation + * @param priorities priorities for each builders (must have same length as {@code builders}) + * @param adjustX whether X-axis should be adjusted or not. + * @param adjustY whether Y-axis should be adjusted or not. + * @param adjustZ whether Z-axis should be adjusted or not. + * @return created {@link LayoutBuilder} + * @throws UnbuildableLayoutException if layout builder cannot be created + */ + public static LayoutBuilder concat(LayoutBuilder[] builders, Axis axis, int[] priorities, boolean adjustX, boolean adjustY, boolean adjustZ) throws UnbuildableLayoutException { + if (builders.length == 0 || builders.length != priorities.length) + throw new IllegalArgumentException("array length do not match"); + + // Separate in three axis arrays the axes from each builder. + AxisMapperBuilder[] arrayX = new AxisMapperBuilder[builders.length]; + AxisMapperBuilder[] arrayY = new AxisMapperBuilder[builders.length]; + AxisMapperBuilder[] arrayZ = new AxisMapperBuilder[builders.length]; + + for (int i = 0; i < builders.length; i++) { + arrayX[i] = builders[i].xAxis(); + arrayY[i] = builders[i].yAxis(); + arrayZ[i] = builders[i].zAxis(); + } + + LayoutBuilderProvider provider = switch (axis) { + case X -> (x, y, z) -> builders[x]; + case Y -> (x, y, z) -> builders[y]; + case Z -> (x, y, z) -> builders[z]; + }; + + return new DefaultLayoutBuilder( + // A LayoutBuilderProvider that maps `builders` argument array to the chosen axis: + provider, + // PriorityRepartitionAxisMapperBuilder for chosen axis, KeepAxisMapperBuilder or AdjustAxisMapperBuilder for others + axis == Axis.X ? new PriorityRepartitionAxisMapperBuilder(arrayX, priorities) + : adjustX ? new AdjustAxisMapperBuilder(arrayX) : new KeepAxisMapperBuilder(arrayX), + axis == Axis.Y ? new PriorityRepartitionAxisMapperBuilder(arrayY, priorities) + : adjustY ? new AdjustAxisMapperBuilder(arrayY) : new KeepAxisMapperBuilder(arrayY), + axis == Axis.Z ? new PriorityRepartitionAxisMapperBuilder(arrayZ, priorities) + : adjustZ ? new AdjustAxisMapperBuilder(arrayZ) : new KeepAxisMapperBuilder(arrayZ) + ); + } + + @FunctionalInterface + private interface LayoutBuilderProvider { + LayoutBuilder get(int ix, int iy, int iz); + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/placeables/layouts/LayoutBuilder.java b/src/main/java/com/ignfab/minalac/generator/placeables/layouts/LayoutBuilder.java new file mode 100644 index 000000000..80e7d0686 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/placeables/layouts/LayoutBuilder.java @@ -0,0 +1,64 @@ +package com.ignfab.minalac.generator.placeables.layouts; + +import com.ignfab.minalac.generator.placeables.Structure; +import com.ignfab.minalac.generator.utils.axis.mappers.builders.AxisMapperBuilder; + +/** + * A structure layout builder based on axis mappers. + */ +public abstract class LayoutBuilder { + private final AxisMapperBuilder xAxis; + private final AxisMapperBuilder yAxis; + private final AxisMapperBuilder zAxis; + + /** + * Creates a new {@code LayoutBuilder}. + * + * @param xAxis axis builder for x-axis + * @param yAxis axis builder for y-axis + * @param zAxis axis builder for z-axis + */ + protected LayoutBuilder( + AxisMapperBuilder xAxis, + AxisMapperBuilder yAxis, + AxisMapperBuilder zAxis + ) { + this.xAxis = xAxis; + this.yAxis = yAxis; + this.zAxis = zAxis; + } + + /** + * {@return X-axis mapper builder} + */ + public AxisMapperBuilder xAxis() { + return xAxis; + } + + /** + * {@return Y-axis mapper builder} + */ + public AxisMapperBuilder yAxis() { + return yAxis; + } + + /** + * {@return Z-axis mapper builder} + */ + public AxisMapperBuilder zAxis() { + return zAxis; + } + + /** + * Builds a structure of given size. + * + * @param sizeX Size along x-axis + * @param sizeY Size along y-axis + * @param sizeZ Size along z-axis + * + * @return built structure + * + * @throws UnbuildableLayoutException if structure cannot be built + */ + public abstract Structure build(int sizeX, int sizeY, int sizeZ) throws UnbuildableLayoutException; +} diff --git a/src/main/java/com/ignfab/minalac/generator/placeables/layouts/StructureLayoutBuilder.java b/src/main/java/com/ignfab/minalac/generator/placeables/layouts/StructureLayoutBuilder.java new file mode 100644 index 000000000..2740cf9d5 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/placeables/layouts/StructureLayoutBuilder.java @@ -0,0 +1,81 @@ +package com.ignfab.minalac.generator.placeables.layouts; + +import com.ignfab.minalac.generator.placeables.LayoutStructure; +import com.ignfab.minalac.generator.placeables.PlaceableStructure; +import com.ignfab.minalac.generator.placeables.Structure; +import com.ignfab.minalac.generator.utils.axis.Axis; +import com.ignfab.minalac.generator.utils.axis.mappers.AxisMapper; +import com.ignfab.minalac.generator.utils.axis.mappers.builders.AxisMapperBuilder; +import com.ignfab.minalac.generator.utils.axis.mappers.builders.ConstantAxisMapperBuilder; +import com.ignfab.minalac.generator.utils.axis.mappers.builders.DelegateAxisMapperBuilder; +import com.ignfab.minalac.generator.utils.axis.mappers.builders.StretcherAxisMapperBuilder; + +/** + * This makes a {@link PlaceableStructure} resizable by allowing at most one stretchable band of the structure per axis (e.g. a column at x = 2 and a row at z = 1), effectively making it a Layout Builder. + */ +public class StructureLayoutBuilder extends LayoutBuilder { + private final Structure structure; + + /** + * Creates a new {@link StructureLayoutBuilder}. + * + * @param structure the {@link PlaceableStructure} to make resizable. + * @param axisX the band along x-axis to make stretchable. A {@code null} value makes it not stretchable along x-axis. + * @param axisY the band along y-axis to make stretchable. A {@code null} value makes it not stretchable along y-axis. + * @param axisZ the band along z-axis to make stretchable. A {@code null} value makes it not stretchable along z-axis. + * @throws UnbuildableLayoutException if unable to create with provided arguments. + */ + public StructureLayoutBuilder(Structure structure, StretchAxis axisX, StretchAxis axisY, StretchAxis axisZ) throws UnbuildableLayoutException { + super( + createAndCheck(structure.limits().minX(), structure.limits().maxX(), axisX), + createAndCheck(structure.limits().minY(), structure.limits().maxY(), axisY), + createAndCheck(structure.limits().minZ(), structure.limits().maxZ(), axisZ) + ); + this.structure = structure; + } + + private static AxisMapperBuilder createAndCheck(int min, int max, StretchAxis axis) throws UnbuildableLayoutException { + ConstantAxisMapperBuilder base = new ConstantAxisMapperBuilder(max - min + 1, min); + + if (axis == null) + return new DelegateAxisMapperBuilder(base); + + if (axis.stretchPosition > max || axis.stretchPosition < min) + throw new UnbuildableLayoutException( + "\"at\" value (%d) is outside structure (%d to %d) for axis %s".formatted( + axis.stretchPosition, min, max, axis.axis + ) + ); + + return new StretcherAxisMapperBuilder(base, axis.stretchPosition, axis.minStretch, axis.maxStretch); + } + + @Override + public Structure build(int sizeX, int sizeY, int sizeZ) throws UnbuildableLayoutException { + // There is redundancy with DefaultLayoutStructure. + // Fow now, it is accepted as DefaultLayoutStructure is builder to builder and this class structure to builder + AxisMapper axisX = xAxis().build(sizeX); + AxisMapper axisY = yAxis().build(sizeY); + AxisMapper axisZ = zAxis().build(sizeZ); + + + // StretcherAxisMapperBuilder can have a size of zero. (Stretchable, length of 1, asked 0) + if (axisX.intervals().length == 0 || axisY.intervals().length == 0 || axisZ.intervals().length == 0) + return PlaceableStructure.EMPTY; + + Structure[][][] structures = new Structure[1][1][1]; + structures[0][0][0] = structure; + + return new LayoutStructure(structures, axisX, axisY, axisZ); + } + + /** + * Defines a stretchable band along a given axis. + * + * @param axis the axis which the band is defined. + * @param stretchPosition the coordinate of the band to stretch. Must be within structure limits. + * @param minStretch minimum repetitions. May be 0 if the structure size along the given axis is greater than 0. On that particular case, the structure may be squeezed. + * @param maxStretch maximum repetitions. + */ + public record StretchAxis(Axis axis, int stretchPosition, int minStretch, int maxStretch) {} +} diff --git a/src/main/java/com/ignfab/minalac/generator/placeables/layouts/UnbuildableLayoutException.java b/src/main/java/com/ignfab/minalac/generator/placeables/layouts/UnbuildableLayoutException.java new file mode 100644 index 000000000..60c38d42c --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/placeables/layouts/UnbuildableLayoutException.java @@ -0,0 +1,35 @@ +package com.ignfab.minalac.generator.placeables.layouts; + +/** + * An exception thrown when a layout builder cannot build. + *

+ * This may happen when building or as soon as the situation is detected. + */ +public class UnbuildableLayoutException extends Exception { + + /** + * Creates a new unbuildable exception. + * @param message the error message + */ + public UnbuildableLayoutException(String message) { + super(message); + } + + /** + * Creates a new unbuildable exception. + * @param cause the cause of this error + */ + public UnbuildableLayoutException(Throwable cause) { + super(cause); + } + + /** + * Creates a new unbuildable exception. + * @param message the error message + * @param cause the cause of this error + */ + public UnbuildableLayoutException(String message, Throwable cause) { + super(message, cause); + } +} + diff --git a/src/main/java/com/ignfab/minalac/generator/tasks/BuildLayoutTask.java b/src/main/java/com/ignfab/minalac/generator/tasks/BuildLayoutTask.java new file mode 100644 index 000000000..5dc4239f8 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/tasks/BuildLayoutTask.java @@ -0,0 +1,71 @@ +package com.ignfab.minalac.generator.tasks; + +import java.util.List; + +import com.ignfab.minalac.generator.generation.GenerationTile; +import com.ignfab.minalac.generator.placeables.Structure; +import com.ignfab.minalac.generator.placeables.layouts.LayoutBuilder; +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; +import com.ignfab.minalac.generator.utils.world3d.WorldCoords3d; + +/** + * A task building a structure from a layout builder and placing it in the world. + *

+ * It is not intended to be used to create worlds from geographical data but rather to visualize how layout builders behave. + */ +public class BuildLayoutTask implements TileTask { + private final List builders; + private final WorldCoords3d position; + private final Integer sizeX; + private final Integer sizeY; + private final Integer sizeZ; + + /** + * Creates a new {@code BuildLayoutTask}. + * @param builders list of builders to use for construction (first succeeding will be used) + * @param position where to place built structure in world + * @param sizeX x-axis component of wanted resulting size or null + * @param sizeY y-axis component of wanted resulting size or null + * @param sizeZ z-axis component of wanted resulting size or null + */ + public BuildLayoutTask(List builders, WorldCoords3d position, Integer sizeX, Integer sizeY, Integer sizeZ) throws UnbuildableLayoutException { + this.builders = builders; + this.position = position; + this.sizeX = sizeX; + this.sizeY = sizeY; + this.sizeZ = sizeZ; + } + + @Override + public void run(GenerationTile tile) { + Structure structure = null; + int number = 0; + + String prefix = String.format("(%d, %d, %d)", position.x(), position.y(), position.z()); + + for (LayoutBuilder builder : builders) { + number++; + int x = sizeX != null ? sizeX : builder.xAxis().minimumSize(); + int y = sizeY != null ? sizeY : builder.yAxis().minimumSize(); + int z = sizeZ != null ? sizeZ : builder.zAxis().minimumSize(); + System.out.printf("%s: Try builder #%d with size (x=%d, y=%d, z=%d)%n", prefix, number, x, y, z); + System.out.printf("%s: On x-axis: minimum=%d max-under=%d%n", prefix, builder.xAxis().minimumSize(), builder.xAxis().maxSizeFittingUnder(x)); + System.out.printf("%s: On y-axis: minimum=%d max-under=%d%n", prefix, builder.yAxis().minimumSize(), builder.yAxis().maxSizeFittingUnder(y)); + System.out.printf("%s: On z-axis: minimum=%d max-under=%d%n", prefix, builder.zAxis().minimumSize(), builder.zAxis().maxSizeFittingUnder(z)); + try { + structure = builder.build(x, y, z); + break; + } catch (UnbuildableLayoutException e) { + System.out.printf("%s: Failed:%n", prefix); + e.printStackTrace(); + } + } + + if (structure == null) { + System.out.printf("%s: No builder worked%n".formatted(prefix)); + return; + } + System.out.printf("%s: Resulting structure size: (x=%d, y=%d, z=%d)%n", prefix, structure.limits().size().x(), structure.limits().size().y(), structure.limits().size().z()); + structure.place(tile.voxels(), position.x(), position.y(), position.z()); + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/tasks/RenderFacadesTask.java b/src/main/java/com/ignfab/minalac/generator/tasks/RenderFacadesTask.java new file mode 100644 index 000000000..b8feb76b7 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/tasks/RenderFacadesTask.java @@ -0,0 +1,123 @@ +package com.ignfab.minalac.generator.tasks; + +import java.util.LinkedList; +import java.util.List; + +import com.ignfab.minalac.generator.generation.GenerationTile; +import com.ignfab.minalac.generator.models.ModelSelection; +import com.ignfab.minalac.generator.models.Shape2dConvertibleModel; +import com.ignfab.minalac.generator.placeables.LayoutStructure; +import com.ignfab.minalac.generator.placeables.Structure; +import com.ignfab.minalac.generator.placeables.layouts.LayoutBuilder; +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; +import com.ignfab.minalac.generator.utils.axis.Axis; +import com.ignfab.minalac.generator.voxelization.shape2d.LineString2d; +import com.ignfab.minalac.generator.voxelization.shape2d.Segment2d; +import com.ignfab.minalac.generator.voxelization.shape2d.iterator.IndexedPosition2d; +import com.ignfab.minalac.generator.voxelization.shape2d.voxelizer.ThickLinearIndexedVoxelizer2d; + +/** + * A {@link ModelTask} rendering facades from 2d shapes, using {@link LayoutBuilder}s. + */ +public class RenderFacadesTask extends ModelTask { + + private final List builders; + private final String heightMetadata; + private final String baseAltitudeMetadata; + private final ThickLinearIndexedVoxelizer2d voxelizer; + + /** + * Creates a new {@code RenderFacadesTask}. + * + * @param selection Selection of models to render + * @param builders Layout builder to use to render facades + * @param heightMetadata Name of metadata holding building height + * @param baseAltitudeMetadata Name of metadata holding building base altitude (altitude of walls bottom) + */ + public RenderFacadesTask( + ModelSelection selection, + List builders, + String heightMetadata, + String baseAltitudeMetadata + ) { + super(Shape2dConvertibleModel.class, selection); + this.builders = builders; + this.heightMetadata = heightMetadata; + this.baseAltitudeMetadata = baseAltitudeMetadata; + + int thickness = 0; + for (LayoutBuilder builder : builders) + thickness = Math.max(thickness, builder.yAxis().minimumSize()); + + voxelizer = new ThickLinearIndexedVoxelizer2d(thickness); + } + + @Override + protected void run(Shape2dConvertibleModel model, GenerationTile tile) { + + // Process metadata + Integer height = model.getMetadata(heightMetadata); + Integer baseAltitude = model.getMetadata(baseAltitudeMetadata); + if (height == null || baseAltitude == null || height <= 0) + return; + + // Process each lineString (actually linearRing) separately + for (LineString2d lineString : model.toShape2d().lineStrings()) { + + // Determine facade parts lengths + List lengths = new LinkedList<>(); + double remain = 0.0; + + Segment2d previousSegment = null; + for (Segment2d segment : lineString.segments()) { + + if (segment.length() > 2.0 && previousSegment != null + && previousSegment.direction().dot(segment.direction()) < 0.5 + ) { + int length = (int) Math.ceil(remain); + remain -= length; + lengths.add(length); + } + remain += segment.length(); + previousSegment = segment; + } + + // Add last remaining part + if (remain > 0.0) + lengths.add((int) Math.ceil(remain)); + + // Prepare final structure + List structures = new LinkedList<>(); + for (int length : lengths) { + Structure structure = null; + + for (LayoutBuilder builder : builders) { + try { + structure = builder.build(length, builder.yAxis().minimumSize(), height); + break; + } catch (UnbuildableLayoutException ignored) {} + } + + if (structure == null) { + System.out.println("Could not build facade structure"); + return; + } + structures.add(structure); + } + + Structure structure = LayoutStructure.concatenate(structures, Axis.X); + + // Draw structure along linestring + int x; + int y; + + for (IndexedPosition2d index : voxelizer.voxelize(lineString)) { + x = (int) Math.round(index.index()); + y = (int) Math.round(index.distance()); + for (int z = 0; z < height; z++) + if (structure.limits().contains(x, y, z)) + structure.get(x, y, z).place(tile.voxels(), index.coords().x(), index.coords().y(), z + baseAltitude); + } + } + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/utils/axis/Axis.java b/src/main/java/com/ignfab/minalac/generator/utils/axis/Axis.java new file mode 100644 index 000000000..62065a2ed --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/utils/axis/Axis.java @@ -0,0 +1,21 @@ +package com.ignfab.minalac.generator.utils.axis; + +/** + * Existing axes in 3 dimensions. + */ +public enum Axis { + /** + * X axis. + */ + X, + + /** + * Y axis. + */ + Y, + + /** + * Z axis (vertical axis in generator). + */ + Z +} diff --git a/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/AxisMapper.java b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/AxisMapper.java new file mode 100644 index 000000000..3bb096a87 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/AxisMapper.java @@ -0,0 +1,88 @@ +package com.ignfab.minalac.generator.utils.axis.mappers; + +/** + * An axis mapper maps a mono dimensional position in a composite structure into a substructure index and position in substructure. + *

+ * It also provides a list of intervals with their sizes. This could represent a list of dimension, on the given axis, of repeated or placed structures. + */ +public abstract class AxisMapper { + private final int minimum; + private final int size; + private final int[] intervals; + + + protected AxisMapper(int minimum, int size, int[] intervals) { + if (size < 0) + throw new IllegalArgumentException("length can not be negative"); + + this.minimum = minimum; + this.size = size; + this.intervals = intervals; + } + + /** + * Maps given position on axis to an underlying interval. + * + * @param position position to map + * @return {@link Mapped} combining the interval index and in-interval position. + */ + public Mapped map(int position) { + if (!contains(position)) + throw new IndexOutOfBoundsException("%s Provided position is out of bounds (position %d, min %d, size %d)".formatted(this, position, minimum(), size())); + return mapUnchecked(position); + } + + /** + * Same as {@link #map} but does not check position is in axis mapper. + * + * @param position position to map + * @return {@link Mapped} combining the interval index and in-interval position. + */ + protected abstract Mapped mapUnchecked(int position); + + /** + * {@return list of underlying intervals sizes} + */ + public int[] intervals() { + return intervals; + } + + /** + * {@return size of the axis mapper} + */ + public int size() { + return size; + } + + /** + * {@return minimum valid position for this axis mapper} + */ + public int minimum() { + return minimum; + } + + /** + * {@return maximum valid position for this axis mapper} + */ + public int maximum() { + return minimum + size - 1; + } + + /** + * Tells if position could be mapped. + * + * @param position position to test + * @return true if position could be mapped + */ + public boolean contains(int position) { + return position >= minimum && position <= maximum(); + } + + /** + * A mapped index. + * + * @param index Structure index (telling which structure index is mapped to) + * @param position In structure position + */ + public record Mapped(int index, int position) {} +} diff --git a/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/IdentityAxisMapper.java b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/IdentityAxisMapper.java new file mode 100644 index 000000000..00c3df79d --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/IdentityAxisMapper.java @@ -0,0 +1,24 @@ +package com.ignfab.minalac.generator.utils.axis.mappers; + +/** + * An {@link AxisMapper} that does an identity mapping. + *

+ * It maps any position into interval number 0, at the same position (it keeps same origin as underlying interval) + */ +public class IdentityAxisMapper extends AxisMapper { + + /** + * Creates a new {@code IdentityAxisMapper}. + * + * @param minimum Start position of the underlying interval + * @param size Size of the underlying (and so mapper) interval + */ + public IdentityAxisMapper(int minimum, int size) { + super(minimum, size, (size == 0) ? new int[0] : new int[] { size }); + } + + @Override + protected Mapped mapUnchecked(int position) { + return new Mapped(0, position); + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/SizesAxisMapper.java b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/SizesAxisMapper.java new file mode 100644 index 000000000..3f9b48ca7 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/SizesAxisMapper.java @@ -0,0 +1,43 @@ +package com.ignfab.minalac.generator.utils.axis.mappers; + +/** + * An {@link AxisMapper} that maps a position into a concatenation of intervals with given sizes. + *

+ * {@code SizesAxisMapper} does not manage origins of underlying intervals. + * They would be moved anyway. So origin of this {@link AxisMapper} is always 0. + */ +public class SizesAxisMapper extends AxisMapper { + /** + * Creates a new {@code SizesAxisMapper}. + *

+ * A {@code SizesAxisMapper} maps position into a succession of intervals of various sizes. + * + * @param sizes list of sizes of intervals composing the axis. + */ + public SizesAxisMapper(int... sizes) { + super(0, computeSize(sizes), sizes); + } + + private static int computeSize(int[] intervals) { + int size = 0; + for (int index = 0; index < intervals.length; index++) { + if (intervals[index] < 0) + throw new IllegalArgumentException("length can not be negative"); + size += intervals[index]; + } + return size; + } + + @Override + protected Mapped mapUnchecked(int position) { + for (int index = 0; index < intervals().length; index++) { + int size = intervals()[index]; + if (position < size) + return new Mapped(index, position); + position -= size; + } + + // Will never be reached + throw new IndexOutOfBoundsException("This is a bug"); + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/StretcherAxisMapper.java b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/StretcherAxisMapper.java new file mode 100644 index 000000000..4326e82ec --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/StretcherAxisMapper.java @@ -0,0 +1,38 @@ +package com.ignfab.minalac.generator.utils.axis.mappers; + +/** + * An {@link AxisMapper} that maps position into one stretched interval. + */ +public class StretcherAxisMapper extends AxisMapper { + private final int stretchablePosition; + private final int stretchSize; + + /** + * Creates a new {@code StretcherAxisMapper}. + * + * @param origin the origin of this mapper + * @param stretchablePosition Position where the underlying interval is stretched (must be in the underlying interval) + * @param baseSize Size of the underlying interval + * @param size Stretched size (size of this mapper) + */ + public StretcherAxisMapper(int origin, int stretchablePosition, int baseSize, int size) { + super(origin, size, new int[] { baseSize }); + + if (baseSize <= 0) + throw new IllegalArgumentException("Base size can not be negative or zero"); + if (size - baseSize < -1) + throw new IllegalArgumentException("Can not be squeezed more than 1"); + if (stretchablePosition < origin || stretchablePosition >= origin + baseSize) + throw new IllegalArgumentException("Stretchable coordinate out of base interval"); + + this.stretchablePosition = stretchablePosition; + this.stretchSize = size - baseSize; + } + + @Override + protected Mapped mapUnchecked(int position) { + return new Mapped(0, + position < stretchablePosition ? position : Math.max(stretchablePosition, position - stretchSize) + ); + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/AdjustAxisMapperBuilder.java b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/AdjustAxisMapperBuilder.java new file mode 100644 index 000000000..c6ef4c067 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/AdjustAxisMapperBuilder.java @@ -0,0 +1,85 @@ +package com.ignfab.minalac.generator.utils.axis.mappers.builders; + +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; +import com.ignfab.minalac.generator.utils.axis.mappers.AxisMapper; +import com.ignfab.minalac.generator.utils.axis.mappers.IdentityAxisMapper; + +/** + * An {@link AxisMapperBuilder} made of multiple {@link AxisMapperBuilder}s having the same dimensions. + *

+ * Underlying builders must all have the same origin or {@code AdjustAxisMapperBuilder} will not be + * able to adjust them as we have no stretcher able to change origin yet. + */ +public class AdjustAxisMapperBuilder implements AxisMapperBuilder { + private final AxisMapperBuilder[] builders; + private final int minimumSize; + private final int origin; + + /** + * Creates a new {@code AdjustAxisMapperBuilder}. + * @param builders underlying {@link AxisMapperBuilder} to adjust + * @throws UnbuildableLayoutException if underlying builders can't adjust to each other + */ + public AdjustAxisMapperBuilder(AxisMapperBuilder... builders) throws UnbuildableLayoutException { + this.builders = builders; + + if (builders.length == 0) { + origin = 0; + minimumSize = 0; + return; + } + + int origin = builders[0].origin(); + int minimumSize = 0; + + for (AxisMapperBuilder builder : builders) { + if (origin != builder.origin()) + throw new UnbuildableLayoutException("All origins must be the same"); + minimumSize = Math.max(minimumSize, builder.minimumSize()); + } + + // Check + minimumSize = maxSizeFittingUnder(minimumSize); + + if (minimumSize < 0) + throw new UnbuildableLayoutException("Unable to adjust dimensions (builders don't agree)"); + + this.minimumSize = minimumSize; + this.origin = origin; + } + + @Override + public AxisMapper build(int size) throws UnbuildableLayoutException { + if (size < 0) + throw new IllegalArgumentException("Size must be positive or zero"); + + int possible = maxSizeFittingUnder(size); + if (size != possible) + throw new UnbuildableLayoutException("Impossible to build for this size (%d, possible %d)".formatted(size, possible)); + + return new IdentityAxisMapper(origin, size); + } + + @Override + public int maxSizeFittingUnder(int threshold) { + if (threshold < 0) + return threshold; + int minCandidate = threshold; + for (AxisMapperBuilder builder : builders) + minCandidate = Math.min(minCandidate, builder.maxSizeFittingUnder(threshold)); + if (minCandidate < 0 || minCandidate == threshold) + return minCandidate; + + return maxSizeFittingUnder(minCandidate); + } + + @Override + public int minimumSize() { + return minimumSize; + } + + @Override + public int origin() { + return origin; + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/AxisMapperBuilder.java b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/AxisMapperBuilder.java new file mode 100644 index 000000000..185642cf2 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/AxisMapperBuilder.java @@ -0,0 +1,43 @@ +package com.ignfab.minalac.generator.utils.axis.mappers.builders; + +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; +import com.ignfab.minalac.generator.utils.axis.mappers.AxisMapper; + +/** + * A builder for {@link AxisMapper}s. + *

+ * An {@code AxisMapperBuilder} builds {@link AxisMapper}s and gives information about their possible sizes. + */ +public interface AxisMapperBuilder { + /** + * Creates an {@link AxisMapper} for the given size. + *

+ * Resulting {@link AxisMapper} will not be offset (it will start at position 0) + * + * @param size wanted size for {@link AxisMapper} + * @return built {@link AxisMapper} + * @throws UnbuildableLayoutException if not able to build for this size. + */ + AxisMapper build(int size) throws UnbuildableLayoutException; + + /** + * Returns the greatest buildable size fitting under given threshold. + *

+ * Not all sizes are suitable for building. + * This method will provide information about what is possible. + * + * @param threshold Testing size + * @return Greatest buildable size lower than or equals to testing size or {@code -1} if no suitable size found. + */ + int maxSizeFittingUnder(int threshold); + + /** + * {@return the minimal buildable size} + */ + int minimumSize(); + + /** + * {@return the starting point (usually 0 but may be different)} + */ + int origin(); +} diff --git a/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/ConstantAxisMapperBuilder.java b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/ConstantAxisMapperBuilder.java new file mode 100644 index 000000000..ff70c5016 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/ConstantAxisMapperBuilder.java @@ -0,0 +1,46 @@ +package com.ignfab.minalac.generator.utils.axis.mappers.builders; + +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; +import com.ignfab.minalac.generator.utils.axis.mappers.AxisMapper; +import com.ignfab.minalac.generator.utils.axis.mappers.IdentityAxisMapper; + +/** + * An {@link AxisMapperBuilder} for a constant size. + */ +public class ConstantAxisMapperBuilder implements AxisMapperBuilder { + private final int size; + private final int origin; + + /** + * Creates a new {@code ConstantAxisMapperBuilder}. + * + * @param size Size of the built {@link AxisMapper}. + * @param origin Starting position of underlying interval. + */ + public ConstantAxisMapperBuilder(int size, int origin) { + this.size = size; + this.origin = origin; + } + + @Override + public AxisMapper build(int size) throws UnbuildableLayoutException { + if (size != this.size) + throw new UnbuildableLayoutException("Requested size isn't equal to the intrinsic size"); + return new IdentityAxisMapper(origin, size); + } + + @Override + public int maxSizeFittingUnder(int threshold) { + return threshold < size ? -1 : size; + } + + @Override + public int minimumSize() { + return size; + } + + @Override + public int origin() { + return origin; + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/DelegateAxisMapperBuilder.java b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/DelegateAxisMapperBuilder.java new file mode 100644 index 000000000..f6ba923a4 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/DelegateAxisMapperBuilder.java @@ -0,0 +1,43 @@ +package com.ignfab.minalac.generator.utils.axis.mappers.builders; + +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; +import com.ignfab.minalac.generator.utils.axis.mappers.AxisMapper; +import com.ignfab.minalac.generator.utils.axis.mappers.IdentityAxisMapper; + +/** + * An {@link AxisMapperBuilder} inheriting size constraints from another {@link AxisMapperBuilder}. + */ +public class DelegateAxisMapperBuilder implements AxisMapperBuilder { + private final AxisMapperBuilder delegatee; + + /** + * Creates a new {@code DelegateAxisMapperBuilder} for a given {@link AxisMapperBuilder}. + * + * @param delegatee an {@link AxisMapperBuilder} from which size constraints are inherited + */ + public DelegateAxisMapperBuilder(AxisMapperBuilder delegatee) { + this.delegatee = delegatee; + } + + @Override + public AxisMapper build(int size) throws UnbuildableLayoutException { + if (maxSizeFittingUnder(size) > size) + throw new UnbuildableLayoutException("Builder could not fit size=%d (Builder is %s)".formatted(size, delegatee)); + return new IdentityAxisMapper(origin(), size); + } + + @Override + public int maxSizeFittingUnder(int threshold) { + return delegatee.maxSizeFittingUnder(threshold); + } + + @Override + public int minimumSize() { + return delegatee.minimumSize(); + } + + @Override + public int origin() { + return delegatee.origin(); + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/KeepAxisMapperBuilder.java b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/KeepAxisMapperBuilder.java new file mode 100644 index 000000000..8e8d50aa0 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/KeepAxisMapperBuilder.java @@ -0,0 +1,64 @@ +package com.ignfab.minalac.generator.utils.axis.mappers.builders; + +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; +import com.ignfab.minalac.generator.utils.axis.mappers.AxisMapper; +import com.ignfab.minalac.generator.utils.axis.mappers.IdentityAxisMapper; + +/** + * An {@link AxisMapperBuilder} made of multiple unchanged and overlayed {@link AxisMapperBuilder}s. + */ +public class KeepAxisMapperBuilder implements AxisMapperBuilder { + private final int minimumSize; + private final int origin; + + /** + * Creates a new {@code KeepAxisMapperBuilder}. + *

+ * {@code KeepAxisMapperBuilder} keeps underlying {@link AxisMapperBuilder} size and offset unchanged. + * This could be used for facade depth (we want to keep structure placement and size on the depth axis). + * + * @param builders underlying {@link AxisMapperBuilder} + */ + public KeepAxisMapperBuilder(AxisMapperBuilder... builders) { + if (builders.length == 0) { + origin = 0; + minimumSize = 0; + } else { + int minimum = Integer.MAX_VALUE; + int maximum = Integer.MIN_VALUE; + + for (AxisMapperBuilder builder : builders) { + minimum = Math.min(minimum, builder.origin()); + maximum = Math.max(maximum, builder.origin() + builder.minimumSize()); + } + origin = minimum; + minimumSize = maximum - minimum; + } + } + + @Override + public AxisMapper build(int size) throws UnbuildableLayoutException { + if (size < 0) + throw new IllegalArgumentException("Size must be positive or zero"); + // Must be quite permissive or won't be able to render stuff with various sizes + if (size < minimumSize) + throw new UnbuildableLayoutException("Impossible to build for this size (%d, must be at least %d)".formatted(size, minimumSize)); + + return new IdentityAxisMapper(origin, size); + } + + @Override + public int maxSizeFittingUnder(int threshold) { + return threshold < minimumSize ? -1 : threshold; + } + + @Override + public int minimumSize() { + return minimumSize; + } + + @Override + public int origin() { + return origin; + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/PriorityRepartitionAxisMapperBuilder.java b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/PriorityRepartitionAxisMapperBuilder.java new file mode 100644 index 000000000..6e813dc49 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/PriorityRepartitionAxisMapperBuilder.java @@ -0,0 +1,183 @@ +package com.ignfab.minalac.generator.utils.axis.mappers.builders; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; +import com.ignfab.minalac.generator.utils.axis.mappers.AxisMapper; +import com.ignfab.minalac.generator.utils.axis.mappers.SizesAxisMapper; + +/** + * An {@link AxisMapperBuilder} that distributes space to undelying {@link AxisMapperBuilder} with priorities. + *

+ * Origins of {@link AxisMapperBuilder} are ignored as they are placed according to {@code PriorityRepartitionAxisMapperBuilder} computations. + */ +public class PriorityRepartitionAxisMapperBuilder implements AxisMapperBuilder { + private final AxisMapperBuilder[] builders; + private final TreeMap> priorities = new TreeMap<>(Collections.reverseOrder()); + private final int[] minLengths; + private final int minimalSize; + + /** + * Creates a new {@code PriorityRepartitionAxisMapperBuilder}. + *

+ * Underlying builders will be placed side by side. To fill total size, they will be resized according to priorities. + * Higher priority gets extra size first. If they can't fill the size, remaining will be given to lower priorities. + * + * @param builders underlying builders, must have the same length as {@code priorities} + * @param priorities corresponding priorities, must have the same length as {@code builders} + */ + public PriorityRepartitionAxisMapperBuilder(AxisMapperBuilder[] builders, int[] priorities) { + if (builders.length != priorities.length) + throw new IllegalArgumentException("Provided arrays must have the same length"); + + int sum = 0; + minLengths = new int[builders.length]; + this.builders = builders; + + for (int i = 0; i < builders.length; i++) { + // Sort builder indexes by priorities + this.priorities.computeIfAbsent(priorities[i], k -> new ArrayList<>()).add(i); + + minLengths[i] = builders[i].minimumSize(); + sum += minLengths[i]; + } + minimalSize = sum; + } + + @Override + public AxisMapper build(int size) throws UnbuildableLayoutException { + if (size < minimalSize) + throw new UnbuildableLayoutException("Requested size is not enough"); + + DistributionResult result = compute(size); + + if (result.remainder != 0) + throw new UnbuildableLayoutException("Could not distribute remainder"); + + return new SizesAxisMapper(result.lengths); + } + + @Override + public int maxSizeFittingUnder(int threshold) { + if (threshold < minimalSize) + return -1; + int remainder = compute(threshold).remainder; + return threshold - remainder; + } + + @Override + public int minimumSize() { + return minimalSize; + } + + @Override + public int origin() { + return 0; + } + + private DistributionResult compute(int size) { + int remaining = size - minimalSize; + if (remaining == 0) return new DistributionResult(minLengths, 0); + int[] lengths = minLengths.clone(); + + // Distribute remaining by priority group + for (Map.Entry> priority : priorities.entrySet()) { + List candidates = new ArrayList<>(priority.getValue()); + boolean firstIteration = true; + + // Remaining size repartition happens in two phases. + // Phase 1: We try to assign an equal share of that size to each builder. + // Once a builder reached its final length (unable to grow more while still fitting under the total size), it is no longer considered for further iterations. + // When a builder is unable to grow more while fitting under that equal share, but has not reached its final length, it is considered starved, and is passed on to phase 2. + // Phase 2: We try to grow each starved builder progressively until some of them eat. + while (remaining > 0 && !candidates.isEmpty()) { + int lastRemaining = remaining; + int lastCount = candidates.size(); + candidates.sort(Comparator.comparingInt(i -> lengths[i])); + // Ceil division + int fairShare = (remaining + candidates.size() - 1) / candidates.size(); + + List starved = new ArrayList<>(); + + // This is phase 1 : Fast distribution + Iterator candidateIterator = candidates.iterator(); + while (candidateIterator.hasNext()) { + int index = candidateIterator.next(); + // Builders with a minimumSize already ate during allocation. + // First iteration is different for them to prevent potential starvation of other candidates. + if (firstIteration && lengths[index] > 0) { + int toEat = builders[index].maxSizeFittingUnder(fairShare); + // Should eat if its allocated minimal size is inferior to what it could have eaten with no min size. + if (toEat > lengths[index]) { + remaining -= toEat - lengths[index]; + lengths[index] = toEat; + } + continue; + } + int toEat = builders[index].maxSizeFittingUnder(lengths[index] + Math.min(remaining, fairShare)); + if (toEat > lengths[index]) { + remaining -= toEat - lengths[index]; + lengths[index] = toEat; + } else if (builders[index].maxSizeFittingUnder(lengths[index] + remaining) == lengths[index]) { + // Can not eat, it is no longer a candidate + candidateIterator.remove(); + } else { + // Can potentially eat, will go on phase 2 + starved.add(index); + } + } + + // Phase 2 gives each starved candidate a successively larger share until one or more eat it. + // All candidates that end up on starved list can eat something (others got removed during phase 1). + // They may not all eat, but at least one starved will eat. + while (!starved.isEmpty() && remaining > 0) { + int lastStarvedRemaining = remaining; + + for (int count = lastCount - 1; count > 0; count--) { + int share = (remaining + count - 1) / count; + + Iterator starvedIterator = starved.iterator(); + while (starvedIterator.hasNext()) { + int currentIndex = starvedIterator.next(); + int toEat = builders[currentIndex].maxSizeFittingUnder(lengths[currentIndex] + Math.min(remaining, share)); + + if (toEat > lengths[currentIndex]) { + remaining -= (toEat - lengths[currentIndex]); + lengths[currentIndex] = toEat; + starvedIterator.remove(); + break; + } + } + } + + // We give up phase 2. Full candidates will be removed on phase 1. + if (lastStarvedRemaining == remaining) + break; + } + + // No progress at all we give up. It should not be necessary. + + // At the end of phase 1. Candidate is either : full (and removed), hasEaten or isStarved (canEat and on starvedList). + // Worst scenario of no progress is : all in starvedList. + // On phase 2, each starved candidate is given successively larger share until reaching remaining. + // Worst scenario is that only one among the starved ate. + // If other starved can't eat, phase 2 will break and those starved will be eventually be removed. + + // Since I can't be 100% sure on edges cases, I have put this test as a safeguard. + if (lastRemaining == remaining && lastCount == candidates.size() && !firstIteration) + break; + + firstIteration = false; + } + } + return new DistributionResult(lengths, remaining); + } + + private record DistributionResult(int[] lengths, int remainder) {} +} diff --git a/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/RepeatAxisMapperBuilder.java b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/RepeatAxisMapperBuilder.java new file mode 100644 index 000000000..b077f5603 --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/RepeatAxisMapperBuilder.java @@ -0,0 +1,95 @@ +package com.ignfab.minalac.generator.utils.axis.mappers.builders; + +import java.util.Arrays; + +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; +import com.ignfab.minalac.generator.utils.axis.mappers.AxisMapper; +import com.ignfab.minalac.generator.utils.axis.mappers.SizesAxisMapper; + +/** + * An {@link AxisMapperBuilder} that repeats underlying {@link AxisMapperBuilder} as many times as possible. + */ +public class RepeatAxisMapperBuilder implements AxisMapperBuilder { + private final AxisMapperBuilder underlying; + private final int minSize; + private final int maxOccur; + + /** + * Creates a new {@code RepeatAxisMapperBuilder}. + * + * @param underlying Undelying {@link AxisMapperBuilder} to repeat + * @param minOccur Minimum number of occurrences + * @param maxOccur Maximum number of occurrences + * @throws UnbuildableLayoutException if underlying mapper builder cannot be repeated (0 sized) + */ + public RepeatAxisMapperBuilder(AxisMapperBuilder underlying, int minOccur, int maxOccur) throws UnbuildableLayoutException { + // Another way to do it to allow it is to instantiate a SizesAxisMapper with a length of 0. + // If changed, StretcherAxisMapperBuilder should have same behavior + if (underlying.minimumSize() <= 0) + throw new UnbuildableLayoutException("Underlying has a minimum size of zero. Can not be repeated"); + this.maxOccur = maxOccur; + this.underlying = underlying; + minSize = underlying.minimumSize() * minOccur; + } + + @Override + public AxisMapper build(int size) throws UnbuildableLayoutException { + if (size < minSize) + throw new UnbuildableLayoutException("Requested size is not enough"); + + DistributionResult result = compute(size, underlying.minimumSize()); + int remainder = result.remainder; + + if (remainder != 0) + throw new UnbuildableLayoutException("Requested size is either not enough or too large. Failed to distribute remainder"); + + return new SizesAxisMapper(result.lengths); + } + + @Override + public int maxSizeFittingUnder(int threshold) { + if (threshold < minSize) + return -1; + + int underlyingMin = underlying.minimumSize(); + if (underlyingMin == 0) return 0; + + DistributionResult result = compute(threshold, underlyingMin); + return threshold - result.remainder; + } + + + @Override + public int minimumSize() { + return minSize; + } + + @Override + public int origin() { + return 0; + } + + private DistributionResult compute(int size, int underlyingMin) { + if (underlyingMin <= 0) + throw new UnsupportedOperationException("Can not repeat a layout that has a minimal size equal or below zero"); + int count = Math.min(size / underlyingMin, maxOccur); + // Modulo not used since maxOccur can be below (size / underlyingMin) + int remaining = size - count * underlyingMin; + + int[] lengths = new int[count]; + Arrays.fill(lengths, underlyingMin); + + for (int index = 0; index < lengths.length; index++) { + // Math.ceilDiv not available in Java 17 + int possible = underlying.maxSizeFittingUnder(underlyingMin + (remaining + count - 1) / count); + // distributedRemaining is (lengths[index] - possible) + remaining += lengths[index] - possible; + lengths[index] = possible; + count--; + } + + return new DistributionResult(lengths, remaining); + } + + private record DistributionResult(int[] lengths, int remainder) {} +} diff --git a/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/StretcherAxisMapperBuilder.java b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/StretcherAxisMapperBuilder.java new file mode 100644 index 000000000..305fddaaa --- /dev/null +++ b/src/main/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/StretcherAxisMapperBuilder.java @@ -0,0 +1,65 @@ +package com.ignfab.minalac.generator.utils.axis.mappers.builders; + +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; +import com.ignfab.minalac.generator.utils.axis.mappers.AxisMapper; +import com.ignfab.minalac.generator.utils.axis.mappers.StretcherAxisMapper; + +/** + * Builder for an {@link AxisMapper} that stretches an interval at a given coordinate. + */ +public class StretcherAxisMapperBuilder implements AxisMapperBuilder { + // For now, the only AxisMapperBuilder that is passed is the ConstantAxisMapperBuilder. + // StretcherIndexMapper was functionally made to work with something constant. + // If something else is passed, the resulting mapping might be incorrect. + // The AxisMapperBuilder is kept for consistency. + private final AxisMapperBuilder underlying; + private final int stretchableCoord; + private final int minSize; + private final int maxSize; + + /** + * Creates a new {@code StretcherAxisMapperBuilder}. + * + * @param underlying {@link AxisMapperBuilder} to stretch + * @param stretchableCoord coordinate where to stretch {@code underlying} + * @param minRepetition minimum possible repetitions of stretchable coordinate + * @param maxRepetition maximum possible repetitions of stretchable coordinate + * @throws UnbuildableLayoutException if underlying builder is not adjustable + */ + public StretcherAxisMapperBuilder(AxisMapperBuilder underlying, int stretchableCoord, int minRepetition, int maxRepetition) throws UnbuildableLayoutException { + // If changed, RepeatAxisMapperBuilder should have same behavior + if (underlying.minimumSize() <= 0) + throw new UnbuildableLayoutException("Underlying has a minimum size of zero. Can not be stretched"); + this.underlying = underlying; + this.stretchableCoord = stretchableCoord; + int underlyingMin = underlying.minimumSize(); + minSize = underlyingMin + minRepetition - 1; + maxSize = (maxRepetition == Integer.MAX_VALUE) ? Integer.MAX_VALUE : underlying.minimumSize() + maxRepetition - 1; + } + + @Override + public AxisMapper build(int size) throws UnbuildableLayoutException { + if (size < minSize) + throw new UnbuildableLayoutException("Not enough space"); + if (size > maxSize) + throw new UnbuildableLayoutException("Requested size is too large"); + return new StretcherAxisMapper(underlying.origin(), stretchableCoord, underlying.minimumSize(), size); + } + + @Override + public int maxSizeFittingUnder(int threshold) { + if (threshold < minSize) + return -1; + return Math.min(threshold, maxSize); + } + + @Override + public int minimumSize() { + return minSize; + } + + @Override + public int origin() { + return underlying.origin(); + } +} diff --git a/src/main/java/com/ignfab/minalac/generator/utils/world2d/WorldCoords2d.java b/src/main/java/com/ignfab/minalac/generator/utils/world2d/WorldCoords2d.java index e5c385cfb..80b1c4491 100644 --- a/src/main/java/com/ignfab/minalac/generator/utils/world2d/WorldCoords2d.java +++ b/src/main/java/com/ignfab/minalac/generator/utils/world2d/WorldCoords2d.java @@ -1,5 +1,6 @@ package com.ignfab.minalac.generator.utils.world2d; +import com.ignfab.minalac.generator.utils.axis.Axis; import com.ignfab.minalac.generator.utils.world3d.Positioned3d; import com.ignfab.minalac.generator.utils.world3d.WorldCoords3d; @@ -79,6 +80,19 @@ public static WorldCoords2d ceil(double x, double y) { return new WorldCoords2d((int) Math.ceil(x), (int) Math.ceil(y)); } + /** + * Returns one of {@code WorldCoords2d} component. + * @param axis component axis + * @return component value + */ + public int coord(Axis axis) { + return switch (axis) { + case X -> x; + case Y -> y; + case Z -> throw new IllegalArgumentException("2d position has no Z coordinate."); + }; + } + @Override public WorldCoords2d coords() { return this; diff --git a/src/main/java/com/ignfab/minalac/generator/utils/world2d/WorldSize2d.java b/src/main/java/com/ignfab/minalac/generator/utils/world2d/WorldSize2d.java index f9b14f4e4..a683d348f 100644 --- a/src/main/java/com/ignfab/minalac/generator/utils/world2d/WorldSize2d.java +++ b/src/main/java/com/ignfab/minalac/generator/utils/world2d/WorldSize2d.java @@ -1,5 +1,6 @@ package com.ignfab.minalac.generator.utils.world2d; +import com.ignfab.minalac.generator.utils.axis.Axis; import com.ignfab.minalac.generator.utils.world3d.WorldSize3d; /** @@ -54,4 +55,17 @@ public int area() { public WorldSize3d to3d(int z) { return new WorldSize3d(this, z); } + + /** + * Returns one of {@code WorldSize2d} component. + * @param axis component axis + * @return component value + */ + public int coord(Axis axis) { + return switch (axis) { + case X -> x; + case Y -> y; + case Z -> throw new IllegalArgumentException("2d size has not Z coordinate."); + }; + } } diff --git a/src/main/java/com/ignfab/minalac/generator/utils/world3d/WorldCoords3d.java b/src/main/java/com/ignfab/minalac/generator/utils/world3d/WorldCoords3d.java index ae1a53908..260f4132e 100644 --- a/src/main/java/com/ignfab/minalac/generator/utils/world3d/WorldCoords3d.java +++ b/src/main/java/com/ignfab/minalac/generator/utils/world3d/WorldCoords3d.java @@ -1,5 +1,6 @@ package com.ignfab.minalac.generator.utils.world3d; +import com.ignfab.minalac.generator.utils.axis.Axis; import com.ignfab.minalac.generator.utils.world2d.Positioned2d; import com.ignfab.minalac.generator.utils.world2d.WorldCoords2d; @@ -93,6 +94,19 @@ public WorldCoords3d add(WorldCoords3d value) { return add(value.x, value.y, value.z); } + /** + * Returns one of {@code WorldCoords3d} component. + * @param axis component axis + * @return component value + */ + public int coord(Axis axis) { + return switch (axis) { + case X -> x; + case Y -> y; + case Z -> z; + }; + } + @Override public WorldCoords3d coords() { return this; diff --git a/src/main/java/com/ignfab/minalac/generator/utils/world3d/WorldSize3d.java b/src/main/java/com/ignfab/minalac/generator/utils/world3d/WorldSize3d.java index a261cdb27..20a80621e 100644 --- a/src/main/java/com/ignfab/minalac/generator/utils/world3d/WorldSize3d.java +++ b/src/main/java/com/ignfab/minalac/generator/utils/world3d/WorldSize3d.java @@ -1,5 +1,6 @@ package com.ignfab.minalac.generator.utils.world3d; +import com.ignfab.minalac.generator.utils.axis.Axis; import com.ignfab.minalac.generator.utils.world2d.WorldSize2d; /** @@ -56,4 +57,17 @@ public int volume() { public WorldSize2d to2d() { return new WorldSize2d(this); } + + /** + * Returns one of {@code WorldSize3d} component. + * @param axis component axis + * @return component value + */ + public int coord(Axis axis) { + return switch (axis) { + case X -> x; + case Y -> y; + case Z -> z; + }; + } } diff --git a/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/IdentityAxisMapperTest.java b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/IdentityAxisMapperTest.java new file mode 100644 index 000000000..f9ee47867 --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/IdentityAxisMapperTest.java @@ -0,0 +1,45 @@ +package com.ignfab.minalac.generator.utils.axis.mappers; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class IdentityAxisMapperTest { + @Test + public void testConstructor() { + assertThrows(IllegalArgumentException.class, () -> new IdentityAxisMapper(0, -1)); + } + + @Test + public void testMap() { + AxisMapper empty = assertDoesNotThrow(() -> new IdentityAxisMapper(0, 0)); + assertThrows(IndexOutOfBoundsException.class, () -> empty.map(0)); + + AxisMapper notEmpty = assertDoesNotThrow(() -> new IdentityAxisMapper(1, 3)); + assertThrows(IndexOutOfBoundsException.class, () -> notEmpty.map(0)); + assertThrows(IndexOutOfBoundsException.class, () -> notEmpty.map(4)); + + assertEquals(new AxisMapper.Mapped(0, 1), notEmpty.map(1)); + assertEquals(new AxisMapper.Mapped(0, 2), notEmpty.map(2)); + assertEquals(new AxisMapper.Mapped(0, 3), notEmpty.map(3)); + } + + @Test + public void testIntervals() { + int[] empty = assertDoesNotThrow(() -> (new IdentityAxisMapper(0, 0).intervals())); + assertEquals(0, empty.length); + + int[] notEmpty = assertDoesNotThrow(() -> (new IdentityAxisMapper(2, 7).intervals())); + assertEquals(1, notEmpty.length); + assertEquals(7, notEmpty[0]); + } + + @Test + public void testSize() { + AxisMapper empty = assertDoesNotThrow(() -> new IdentityAxisMapper(2, 0)); + assertEquals(0, empty.size()); + + AxisMapper notEmpty = assertDoesNotThrow(() -> new IdentityAxisMapper(3, 37)); + assertEquals(37, notEmpty.size()); + } +} diff --git a/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/SizesAxisMapperTest.java b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/SizesAxisMapperTest.java new file mode 100644 index 000000000..e659365a0 --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/SizesAxisMapperTest.java @@ -0,0 +1,65 @@ +package com.ignfab.minalac.generator.utils.axis.mappers; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class SizesAxisMapperTest { + @Test + public void testConstructor() { + assertThrows(IllegalArgumentException.class, () -> new SizesAxisMapper(2, 1, -5)); + assertThrows(IllegalArgumentException.class, () -> new SizesAxisMapper(-2, 1, 5)); + assertDoesNotThrow(() -> new SizesAxisMapper()); + assertDoesNotThrow(() -> new SizesAxisMapper(1, 2, 0)); + assertDoesNotThrow(() -> new SizesAxisMapper(1, 2, 7)); + } + + @Test + public void testMap() { + AxisMapper empty = assertDoesNotThrow(() -> new SizesAxisMapper(0)); + assertThrows(IndexOutOfBoundsException.class, () -> empty.map(0)); + + AxisMapper notEmpty = assertDoesNotThrow(() -> new SizesAxisMapper(3)); + assertThrows(IndexOutOfBoundsException.class, () -> notEmpty.map(-1)); + assertThrows(IndexOutOfBoundsException.class, () -> notEmpty.map(3)); + + assertEquals(new AxisMapper.Mapped(0, 0), notEmpty.map(0)); + assertEquals(new AxisMapper.Mapped(0, 1), notEmpty.map(1)); + assertEquals(new AxisMapper.Mapped(0, 2), notEmpty.map(2)); + + AxisMapper multiple = assertDoesNotThrow(() -> new SizesAxisMapper(3, 0, 1, 2)); + assertThrows(IndexOutOfBoundsException.class, () -> multiple.map(-1)); + assertThrows(IndexOutOfBoundsException.class, () -> multiple.map(6)); + + // First interval (length 3) + assertEquals(new AxisMapper.Mapped(0, 0), multiple.map(0)); + assertEquals(new AxisMapper.Mapped(0, 1), multiple.map(1)); + assertEquals(new AxisMapper.Mapped(0, 2), multiple.map(2)); + + // Third interval (length 1) + assertEquals(new AxisMapper.Mapped(2, 0), multiple.map(3)); + + // Fourth interval (length 0) + assertEquals(new AxisMapper.Mapped(3, 0), multiple.map(4)); + assertEquals(new AxisMapper.Mapped(3, 1), multiple.map(5)); + } + + @Test + public void testIntervals() { + assertArrayEquals(new int[] {}, new SizesAxisMapper().intervals()); + assertArrayEquals(new int[] { 3, 1, 2 }, new SizesAxisMapper(3, 1, 2).intervals()); + assertArrayEquals(new int[] { 0, 1, 2 }, new SizesAxisMapper(0, 1, 2).intervals()); + } + + @Test + public void testSize() { + AxisMapper empty = assertDoesNotThrow(() -> new SizesAxisMapper(0)); + assertEquals(0, empty.size()); + + AxisMapper notEmpty = assertDoesNotThrow(() -> new SizesAxisMapper(3, 1, 5)); + assertEquals(9, notEmpty.size()); + } +} diff --git a/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/StretcherAxisMapperTest.java b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/StretcherAxisMapperTest.java new file mode 100644 index 000000000..e31468333 --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/StretcherAxisMapperTest.java @@ -0,0 +1,74 @@ +package com.ignfab.minalac.generator.utils.axis.mappers; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class StretcherAxisMapperTest { + @Test + public void testConstructor() { + assertThrows(IllegalArgumentException.class, () -> new StretcherAxisMapper(0, 0, 2, -1), "negative length"); + assertThrows(IllegalArgumentException.class, () -> new StretcherAxisMapper(0, 0, -1, 1), "negative base length"); + assertThrows(IllegalArgumentException.class, () -> new StretcherAxisMapper(0, 0, 3, 1), "Squeezed more than once"); + assertThrows(IllegalArgumentException.class, () -> new StretcherAxisMapper(0, 4, 2, 2), "stretch out of size"); + } + + @Test + public void testMap() { + AxisMapper same = assertDoesNotThrow(() -> new StretcherAxisMapper(0, 1, 3, 3)); + assertEquals(new AxisMapper.Mapped(0, 0), same.map(0)); + assertEquals(new AxisMapper.Mapped(0, 1), same.map(1)); + assertEquals(new AxisMapper.Mapped(0, 2), same.map(2)); + + AxisMapper stretched = assertDoesNotThrow(() -> new StretcherAxisMapper(0, 1, 3, 5)); + assertEquals(new AxisMapper.Mapped(0, 0), stretched.map(0)); + assertEquals(new AxisMapper.Mapped(0, 1), stretched.map(1)); + assertEquals(new AxisMapper.Mapped(0, 1), stretched.map(2)); + assertEquals(new AxisMapper.Mapped(0, 1), stretched.map(3)); + assertEquals(new AxisMapper.Mapped(0, 2), stretched.map(4)); + + AxisMapper squeezedFirst = assertDoesNotThrow(() -> new StretcherAxisMapper(0, 0, 3, 2)); + assertEquals(new AxisMapper.Mapped(0, 1), squeezedFirst.map(0)); + assertEquals(new AxisMapper.Mapped(0, 2), squeezedFirst.map(1)); + + AxisMapper squeezedSecond = assertDoesNotThrow(() -> new StretcherAxisMapper(0, 1, 3, 2)); + assertEquals(new AxisMapper.Mapped(0, 0), squeezedSecond.map(0)); + assertEquals(new AxisMapper.Mapped(0, 2), squeezedSecond.map(1)); + + AxisMapper stretchedWithOffset = assertDoesNotThrow(() -> new StretcherAxisMapper(-2, -1, 3, 5)); + assertEquals(new AxisMapper.Mapped(0, -2), stretchedWithOffset.map(-2)); + assertEquals(new AxisMapper.Mapped(0, -1), stretchedWithOffset.map(-1)); + assertEquals(new AxisMapper.Mapped(0, -1), stretchedWithOffset.map(0)); + assertEquals(new AxisMapper.Mapped(0, -1), stretchedWithOffset.map(1)); + assertEquals(new AxisMapper.Mapped(0, 0), stretchedWithOffset.map(2)); + + AxisMapper squeezedSecondWithOffset = assertDoesNotThrow(() -> new StretcherAxisMapper(4, 5, 3, 2)); + assertEquals(new AxisMapper.Mapped(0, 4), squeezedSecondWithOffset.map(4)); + assertEquals(new AxisMapper.Mapped(0, 6), squeezedSecondWithOffset.map(5)); + + AxisMapper squeezedFirstWithOffset = assertDoesNotThrow(() -> new StretcherAxisMapper(4, 4, 3, 2)); + assertEquals(new AxisMapper.Mapped(0, 5), squeezedFirstWithOffset.map(4)); + assertEquals(new AxisMapper.Mapped(0, 6), squeezedFirstWithOffset.map(5)); + + AxisMapper zeroSize = assertDoesNotThrow(() -> new StretcherAxisMapper(0, 0, 1, 0)); + assertThrows(IndexOutOfBoundsException.class, () -> zeroSize.map(0)); + } + + @Test + public void testIntervalsAndSize() { + AxisMapper same = assertDoesNotThrow(() -> new StretcherAxisMapper(0, 0, 3, 3)); + assertArrayEquals(new int[] { 3 }, same.intervals()); + assertEquals(3, same.size()); + + AxisMapper stretched = assertDoesNotThrow(() -> new StretcherAxisMapper(0, 0, 3, 5)); + assertArrayEquals(new int[] { 3 }, stretched.intervals()); + assertEquals(5, stretched.size()); + + AxisMapper squeezed = assertDoesNotThrow(() -> new StretcherAxisMapper(0, 0, 3, 2)); + assertArrayEquals(new int[] { 3 }, squeezed.intervals()); + assertEquals(2, squeezed.size()); + } +} diff --git a/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/AdjustAxisMapperBuilderTest.java b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/AdjustAxisMapperBuilderTest.java new file mode 100644 index 000000000..28e4f82cf --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/AdjustAxisMapperBuilderTest.java @@ -0,0 +1,101 @@ +package com.ignfab.minalac.generator.utils.axis.mappers.builders; + +import org.junit.jupiter.api.Test; + +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; + +import static org.junit.jupiter.api.Assertions.*; + +public class AdjustAxisMapperBuilderTest { + @Test + public void testBuild() { + assertThrows( + UnbuildableLayoutException.class, + () -> new AdjustAxisMapperBuilder( + new RangeTestingAxisMapperBuilder(1, 5, 0), + new RangeTestingAxisMapperBuilder(1, 5, 1) + ), + "Different origin" + ); + + assertThrows( + UnbuildableLayoutException.class, + () -> new AdjustAxisMapperBuilder( + new RangeTestingAxisMapperBuilder(1, 5), + new RangeTestingAxisMapperBuilder(6, 9) + ), + "No min sizes" + ); + + assertDoesNotThrow( + () -> new AdjustAxisMapperBuilder( + new RangeTestingAxisMapperBuilder(0), + new RangeTestingAxisMapperBuilder(0, 9) + ) + ); + + assertDoesNotThrow( + () -> new AdjustAxisMapperBuilder( + new RangeTestingAxisMapperBuilder(1, 6), + new RangeTestingAxisMapperBuilder(4, 9) + ) + ); + } + + @Test + public void testMaxSizeFittingUnder() { + assertThrows( + UnbuildableLayoutException.class, + () -> new AdjustAxisMapperBuilder( + new RangeTestingAxisMapperBuilder(1, 5), + new RangeTestingAxisMapperBuilder(7, 8)) + ); + + AxisMapperBuilder builder = assertDoesNotThrow( + () -> new AdjustAxisMapperBuilder( + new RangeTestingAxisMapperBuilder(1, 7), + new RangeTestingAxisMapperBuilder(5, 10)) + ); + assertEquals(7, builder.maxSizeFittingUnder(10)); + assertEquals(7, builder.maxSizeFittingUnder(7)); + assertEquals(6, builder.maxSizeFittingUnder(6)); + assertEquals(5, builder.maxSizeFittingUnder(5)); + assertEquals(-1, builder.maxSizeFittingUnder(4)); + + AxisMapperBuilder canBeZero = assertDoesNotThrow( + () -> new AdjustAxisMapperBuilder( + new RangeTestingAxisMapperBuilder(0, 7), + new RangeTestingAxisMapperBuilder(0, 10)) + ); + assertEquals(7, canBeZero.maxSizeFittingUnder(10)); + assertEquals(0, canBeZero.maxSizeFittingUnder(0)); + } + + @Test + public void testMinimumSize() { + AxisMapperBuilder nonZeroMinSize = assertDoesNotThrow( + () -> new AdjustAxisMapperBuilder( + new RangeTestingAxisMapperBuilder(1, 7), + new RangeTestingAxisMapperBuilder(5, 10), + new RangeTestingAxisMapperBuilder(4, 6)) + ); + assertEquals(5, nonZeroMinSize.minimumSize()); + + AxisMapperBuilder zeroMinSize = assertDoesNotThrow( + () -> new AdjustAxisMapperBuilder( + new RangeTestingAxisMapperBuilder(0, 7), + new RangeTestingAxisMapperBuilder(0, 10) + )); + assertEquals(0, zeroMinSize.minimumSize()); + } + + @Test + public void testOrigin() { + AxisMapperBuilder builder = assertDoesNotThrow( + () -> new AdjustAxisMapperBuilder( + new RangeTestingAxisMapperBuilder(1, 7, -3), + new RangeTestingAxisMapperBuilder(5, 10, -3)) + ); + assertEquals(-3, builder.origin()); + } +} diff --git a/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/AllowlistTestingAxisMapperBuilder.java b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/AllowlistTestingAxisMapperBuilder.java new file mode 100644 index 000000000..a40afccd8 --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/AllowlistTestingAxisMapperBuilder.java @@ -0,0 +1,62 @@ +package com.ignfab.minalac.generator.utils.axis.mappers.builders; + +import java.util.Arrays; + +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; +import com.ignfab.minalac.generator.utils.axis.mappers.AxisMapper; + +/** + * A testing AxisMapperBuilder that only allows given sizes. + */ +public class AllowlistTestingAxisMapperBuilder implements AxisMapperBuilder { + private final int[] allowedLength; + private final int origin; + + /** + * Creates a new {@code AllowlistTestingAxisMapperBuilder}. + * + * @param allowedLength Array of only allowed sizes. + * @param origin Origin position + */ + public AllowlistTestingAxisMapperBuilder(int[] allowedLength, int origin) { + this.allowedLength = allowedLength; + this.origin = origin; + Arrays.sort(this.allowedLength); + if (this.allowedLength[0] < 0) + throw new RuntimeException("Can not contain negative length"); + } + + /** + * Creates a new {@code AllowlistTestingAxisMapperBuilder} with origin at 0. + * + * @param allowedLength Array of only allowed sizes. + */ + public AllowlistTestingAxisMapperBuilder(int[] allowedLength) { + this(allowedLength, 0); + } + + @Override + public AxisMapper build(int size) throws UnbuildableLayoutException { + throw new UnsupportedOperationException("Not implemented"); + } + + @Override + public int maxSizeFittingUnder(int threshold) { + if (threshold < allowedLength[0]) + return -1; + for (int i = allowedLength.length - 1; i >= 0; i--) + if (allowedLength[i] <= threshold) + return allowedLength[i]; + throw new RuntimeException("It should not happen"); + } + + @Override + public int minimumSize() { + return allowedLength[0]; + } + + @Override + public int origin() { + return origin; + } +} diff --git a/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/AllowlistTestingAxisMapperBuilderTest.java b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/AllowlistTestingAxisMapperBuilderTest.java new file mode 100644 index 000000000..612cd2531 --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/AllowlistTestingAxisMapperBuilderTest.java @@ -0,0 +1,25 @@ +package com.ignfab.minalac.generator.utils.axis.mappers.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class AllowlistTestingAxisMapperBuilderTest { + @Test + public void testMaxSizeUnder() { + AllowlistTestingAxisMapperBuilder allowlist = new AllowlistTestingAxisMapperBuilder(new int[]{2, 5, 50}); + assertEquals(50, allowlist.maxSizeFittingUnder(50)); + assertEquals(50, allowlist.maxSizeFittingUnder(60)); + assertEquals(5, allowlist.maxSizeFittingUnder(49)); + assertEquals(2, allowlist.maxSizeFittingUnder(3)); + assertEquals(2, allowlist.maxSizeFittingUnder(2)); + assertEquals(-1, allowlist.maxSizeFittingUnder(1)); + } + + @Test + public void testMinimumSize() { + assertEquals(1, new AllowlistTestingAxisMapperBuilder(new int[]{1, 5, 6}).minimumSize()); + assertEquals(2, new AllowlistTestingAxisMapperBuilder(new int[]{7, 2, 6, 3}).minimumSize()); + assertEquals(7, new AllowlistTestingAxisMapperBuilder(new int[]{7, 7, 7}).minimumSize()); + } +} diff --git a/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/ConstantAxisMapperBuilderTest.java b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/ConstantAxisMapperBuilderTest.java new file mode 100644 index 000000000..d5f06c816 --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/ConstantAxisMapperBuilderTest.java @@ -0,0 +1,44 @@ +package com.ignfab.minalac.generator.utils.axis.mappers.builders; + +import org.junit.jupiter.api.Test; + +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; + +import static org.junit.jupiter.api.Assertions.*; + +public class ConstantAxisMapperBuilderTest { + @Test + public void testBuild() { + assertThrows(UnbuildableLayoutException.class, () -> new ConstantAxisMapperBuilder(7, 0).build(8)); + assertThrows(UnbuildableLayoutException.class, () -> new ConstantAxisMapperBuilder(6, 0).build(8)); + assertThrows(UnbuildableLayoutException.class, () -> new ConstantAxisMapperBuilder(9, 0).build(8)); + + assertDoesNotThrow(() -> new ConstantAxisMapperBuilder(0, 0).build(0)); + assertDoesNotThrow(() -> new ConstantAxisMapperBuilder(6, 2).build(6)); + } + + @Test + public void testMaxSizeFittingUnder() { + assertEquals(-1, new ConstantAxisMapperBuilder(3, 0).maxSizeFittingUnder(-1)); + + assertEquals(3, new ConstantAxisMapperBuilder(3, 0).maxSizeFittingUnder(4)); + assertEquals(3, new ConstantAxisMapperBuilder(3, 0).maxSizeFittingUnder(3)); + assertEquals(-1, new ConstantAxisMapperBuilder(3, 0).maxSizeFittingUnder(2)); + + assertEquals(0, new ConstantAxisMapperBuilder(0, 0).maxSizeFittingUnder(0)); + assertEquals(0, new ConstantAxisMapperBuilder(0, 0).maxSizeFittingUnder(1)); + } + + @Test + public void testMinimumSize() { + assertEquals(7, new ConstantAxisMapperBuilder(7, 0).minimumSize()); + assertEquals(0, new ConstantAxisMapperBuilder(0, 0).minimumSize()); + } + + @Test + public void testOrigin() { + assertEquals(-5, (new ConstantAxisMapperBuilder(7, -5)).origin()); + assertEquals(0, (new ConstantAxisMapperBuilder(7, 0)).origin()); + assertEquals(3, (new ConstantAxisMapperBuilder(7, 3)).origin()); + } +} diff --git a/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/KeepAxisMapperBuilderTest.java b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/KeepAxisMapperBuilderTest.java new file mode 100644 index 000000000..1c4446a38 --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/KeepAxisMapperBuilderTest.java @@ -0,0 +1,55 @@ +package com.ignfab.minalac.generator.utils.axis.mappers.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class KeepAxisMapperBuilderTest { + + @Test + public void testMaxSizeFittingUnder() { + AxisMapperBuilder fixed = assertDoesNotThrow(() -> new KeepAxisMapperBuilder( + new RangeTestingAxisMapperBuilder(2), + new RangeTestingAxisMapperBuilder(7), + new RangeTestingAxisMapperBuilder(5) + )); + // Any size greater than or equal to min is fine + assertEquals(7, fixed.maxSizeFittingUnder(7)); + assertEquals(8, fixed.maxSizeFittingUnder(8)); + assertEquals(25, fixed.maxSizeFittingUnder(25)); + assertEquals(-1, fixed.maxSizeFittingUnder(6)); + } + + @Test + public void testMinimumSize() { + AxisMapperBuilder fixed = assertDoesNotThrow(() -> new KeepAxisMapperBuilder( + new RangeTestingAxisMapperBuilder(2), + new RangeTestingAxisMapperBuilder(7), + new RangeTestingAxisMapperBuilder(5) + )); + assertEquals(7, fixed.minimumSize()); + + AxisMapperBuilder moving = assertDoesNotThrow(() -> new KeepAxisMapperBuilder( + new RangeTestingAxisMapperBuilder(2, 12), + new RangeTestingAxisMapperBuilder(7, 9), + new RangeTestingAxisMapperBuilder(5, 10) + )); + assertEquals(7, moving.minimumSize()); + } + + @Test + public void testOrigin() { + AxisMapperBuilder different = assertDoesNotThrow(() -> new KeepAxisMapperBuilder( + new RangeTestingAxisMapperBuilder(1, 2, 5), + new RangeTestingAxisMapperBuilder(1, 2, 2), + new RangeTestingAxisMapperBuilder(1, 2, -7) + )); + assertEquals(-7, different.origin()); + + AxisMapperBuilder same = assertDoesNotThrow(() -> new KeepAxisMapperBuilder( + new RangeTestingAxisMapperBuilder(1, 2, 5), + new RangeTestingAxisMapperBuilder(1, 2, 5) + )); + assertEquals(5, same.origin()); + } +} diff --git a/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/PriorityRepartitionAxisMapperBuilderTest.java b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/PriorityRepartitionAxisMapperBuilderTest.java new file mode 100644 index 000000000..44a176288 --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/PriorityRepartitionAxisMapperBuilderTest.java @@ -0,0 +1,252 @@ +package com.ignfab.minalac.generator.utils.axis.mappers.builders; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; +import com.ignfab.minalac.generator.utils.axis.mappers.AxisMapper; + +import static com.ignfab.minalac.generator.utils.iterator.IteratorTester.assertBrowsesAllOnce; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class PriorityRepartitionAxisMapperBuilderTest { + + @Test + public void testMaxSizeFittingUnderDistribution() { + // The tests here are not exact as finding the optimal distribution is not something easy. + // The exact results are implementation dependant. + // However, what we want to test is that the PriorityRepartitionAxisMapperBuilder: + // - Tries to distribute to have an roughly equals distribution + // - Tries to leave no starved segments when possible + + // On this case, for an asked size of 48, there is a solution with no remainder which is 20-16-12. + // What we want to test is that the segments are roughly equals (even if there is remainder). + // The result is implementation dependant, so what is tested is that the results are acceptable. + AxisMapperBuilder fiveFourThreeBuilder = assertDoesNotThrow( + () -> new PriorityRepartitionAxisMapperBuilder( + new AxisMapperBuilder[] { + new AllowlistTestingAxisMapperBuilder(new int[] { 0, 5, 10, 15, 20 }), + new AllowlistTestingAxisMapperBuilder(new int[] { 0, 4, 8, 12, 16, 20 }), + new AllowlistTestingAxisMapperBuilder(new int[] { 0, 3, 6, 9, 12, 15 }) + }, + new int[] { 0, 0, 0 } + ) + ); + assertTrue(fiveFourThreeBuilder.maxSizeFittingUnder(48) >= 40); + int resultingSize = fiveFourThreeBuilder.maxSizeFittingUnder(48); + + int[] intervals = assertDoesNotThrow(() -> fiveFourThreeBuilder.build(resultingSize).intervals()); + assertTrue(14 <= intervals[0]); + assertTrue(14 <= intervals[1]); + assertTrue(14 <= intervals[2]); + + + // The middle builder has a minimal size, we test that it doesn't get greedy + // Basically the repartition should be fair despite minimal size + AxisMapperBuilder noGreedTwo = assertDoesNotThrow( + () -> new PriorityRepartitionAxisMapperBuilder( + new AxisMapperBuilder[] { + new AllowlistTestingAxisMapperBuilder(new int[] { 0, 3, 6, 9 }), + new AllowlistTestingAxisMapperBuilder(new int[] { 2, 4, 6 }), + new AllowlistTestingAxisMapperBuilder(new int[] { 0, 3, 6, 9 }) + }, + new int[] { 0, 0, 0 } + ) + ); + + assertEquals(8, noGreedTwo.maxSizeFittingUnder(8)); + AxisMapper noGreedTwoMapper = assertDoesNotThrow(() -> noGreedTwo.build(8)); + assertArrayEquals(new int[] { 3, 2, 3 }, noGreedTwoMapper.intervals()); + + // Three has min size + AxisMapperBuilder noGreedThree = assertDoesNotThrow( + () -> new PriorityRepartitionAxisMapperBuilder( + new AxisMapperBuilder[] { + new AllowlistTestingAxisMapperBuilder(new int[] { 3, 6, 9 }), + new AllowlistTestingAxisMapperBuilder(new int[] { 0, 2, 4, 6 }), + new AllowlistTestingAxisMapperBuilder(new int[] { 3, 6, 9 }) + }, + new int[] { 0, 0, 0 } + ) + ); + + assertEquals(8, noGreedThree.maxSizeFittingUnder(8)); + AxisMapper noGreedThreeMapper = assertDoesNotThrow(() -> noGreedThree.build(8)); + assertArrayEquals(new int[] { 3, 2, 3 }, noGreedThreeMapper.intervals()); + + // The tests below are almost the same as noGreed but with no minimal size + // Tested with various order + // 3 - 2 - 3 + AxisMapperBuilder threeTwoThree = assertDoesNotThrow( + () -> new PriorityRepartitionAxisMapperBuilder( + new AxisMapperBuilder[] { + new AllowlistTestingAxisMapperBuilder(new int[] { 0, 3, 6, 9 }), + new AllowlistTestingAxisMapperBuilder(new int[] { 0, 2, 4, 6 }), + new AllowlistTestingAxisMapperBuilder(new int[] { 0, 3, 6, 9 }) + }, + new int[] { 0, 0, 0 } + ) + ); + + assertEquals(8, threeTwoThree.maxSizeFittingUnder(8)); + AxisMapper threeTwoThreeMapper = assertDoesNotThrow(() -> threeTwoThree.build(8)); + assertArrayEquals(new int[] { 3, 2, 3 }, threeTwoThreeMapper.intervals()); + + // 3 - 3 - 2 + AxisMapperBuilder threeThreeTwo = assertDoesNotThrow( + () -> new PriorityRepartitionAxisMapperBuilder( + new AxisMapperBuilder[] { + new AllowlistTestingAxisMapperBuilder(new int[] { 0, 3, 6, 9 }), + new AllowlistTestingAxisMapperBuilder(new int[] { 0, 3, 6, 9 }), + new AllowlistTestingAxisMapperBuilder(new int[] { 0, 2, 4, 6 }) + }, + new int[] { 0, 0, 0 } + ) + ); + + assertEquals(8, threeThreeTwo.maxSizeFittingUnder(8)); + AxisMapper threeThreeTwoMapper = assertDoesNotThrow(() -> threeThreeTwo.build(8)); + assertArrayEquals(new int[] { 3, 3, 2 }, threeThreeTwoMapper.intervals()); + + // 2 - 3 - 3 + AxisMapperBuilder twoThreeThree = assertDoesNotThrow( + () -> new PriorityRepartitionAxisMapperBuilder( + new AxisMapperBuilder[] { + new AllowlistTestingAxisMapperBuilder(new int[] { 0, 2, 4, 6 }), + new AllowlistTestingAxisMapperBuilder(new int[] { 0, 3, 6, 9 }), + new AllowlistTestingAxisMapperBuilder(new int[] { 0, 3, 6, 9 }) + }, + new int[] { 0, 0, 0 } + ) + ); + + assertEquals(8, twoThreeThree.maxSizeFittingUnder(8)); + AxisMapper twoThreeThreeMapper = assertDoesNotThrow(() -> twoThreeThree.build(8)); + assertArrayEquals(new int[] {2, 3, 3}, twoThreeThreeMapper.intervals()); + } + + @Test + public void testMaxSizeFittingUnderBuild() { + AxisMapperBuilder empty = assertDoesNotThrow( + () -> new PriorityRepartitionAxisMapperBuilder( + new AxisMapperBuilder[] { + new RangeTestingAxisMapperBuilder(0), + new RangeTestingAxisMapperBuilder(0), + new RangeTestingAxisMapperBuilder(0) + }, + new int[] { 0, 0, 0 } + ) + ); + assertEquals(0, empty.maxSizeFittingUnder(10)); + AxisMapper emptyMapper = assertDoesNotThrow(() -> empty.build(0)); + assertBrowsesAllOnce(List.of(0, 0, 0), Arrays.stream(emptyMapper.intervals()).iterator()); + + AxisMapperBuilder minSizeError = assertDoesNotThrow( + () -> new PriorityRepartitionAxisMapperBuilder( + new AxisMapperBuilder[] { + new RangeTestingAxisMapperBuilder(2, 5), + new RangeTestingAxisMapperBuilder(0), + new RangeTestingAxisMapperBuilder(6, 7) + }, + new int[] { 0, 0, 0 } + ) + ); + assertEquals(-1, minSizeError.maxSizeFittingUnder(7)); + assertThrows(UnbuildableLayoutException.class, () -> minSizeError.build(7)); + + AxisMapperBuilder minZero = assertDoesNotThrow( + () -> new PriorityRepartitionAxisMapperBuilder( + new AxisMapperBuilder[] { + new RangeTestingAxisMapperBuilder(0, 4), + new RangeTestingAxisMapperBuilder(0, 4) + }, + new int[] { 0, 0 } + ) + ); + + assertEquals(5, minZero.maxSizeFittingUnder(5)); + AxisMapper withMinZero = assertDoesNotThrow(() -> minZero.build(5)); + // Unordered tests as there is no control on which one will have the most + assertBrowsesAllOnce(List.of(2, 3), Arrays.stream(withMinZero.intervals()).iterator()); + + // Same priority + AxisMapperBuilder samePrio = assertDoesNotThrow( + () -> new PriorityRepartitionAxisMapperBuilder( + new AxisMapperBuilder[] { + new RangeTestingAxisMapperBuilder(2), + new RangeTestingAxisMapperBuilder(3) + }, + new int[] { 0, 0 } + ) + ); + assertEquals(5, samePrio.maxSizeFittingUnder(6)); + AxisMapper withSamePrio = assertDoesNotThrow(() -> samePrio.build(5)); + assertArrayEquals(new int[] {2, 3}, withSamePrio.intervals()); + + // Priority should take all possible after distribution of min + AxisMapperBuilder builderWithOnePrio = assertDoesNotThrow( + () -> new PriorityRepartitionAxisMapperBuilder( + new AxisMapperBuilder[] { + new RangeTestingAxisMapperBuilder(0, 8), + new RangeTestingAxisMapperBuilder(2, 4) + }, + new int[] { 1, 0 } + ) + ); + + // No remainder for first one + assertEquals(2, builderWithOnePrio.maxSizeFittingUnder(2)); + AxisMapper withSize2 = assertDoesNotThrow(() -> builderWithOnePrio.build(2)); + assertArrayEquals(new int[] { 0, 2 }, withSize2.intervals()); + + // First takes all remainder + assertEquals(8, builderWithOnePrio.maxSizeFittingUnder(8)); + AxisMapper withSize8 = assertDoesNotThrow(() -> builderWithOnePrio.build(8)); + assertArrayEquals(new int[] { 6, 2 }, withSize8.intervals()); + + assertEquals(10, builderWithOnePrio.maxSizeFittingUnder(10)); + AxisMapper withSize10 = assertDoesNotThrow(() -> builderWithOnePrio.build(10)); + assertArrayEquals(new int[] { 8, 2 }, withSize10.intervals()); + + // First has taken all available remainder the rest goes for the second one + assertEquals(11, builderWithOnePrio.maxSizeFittingUnder(11)); + AxisMapper withSize11 = assertDoesNotThrow(() -> builderWithOnePrio.build(11)); + assertArrayEquals(new int[] { 8, 3 }, withSize11.intervals()); + } + + @Test + public void testMinimumSize() { + assertEquals( + 8, + assertDoesNotThrow( + () -> new PriorityRepartitionAxisMapperBuilder( + new AxisMapperBuilder[] { + new RangeTestingAxisMapperBuilder(2, 5), + new RangeTestingAxisMapperBuilder(0), new RangeTestingAxisMapperBuilder(6, 7) + }, + new int[] { 0, 1, 0 } + ) + ).minimumSize() + ); + + assertEquals( + 0, + assertDoesNotThrow( + () -> new PriorityRepartitionAxisMapperBuilder( + new AxisMapperBuilder[] { + new RangeTestingAxisMapperBuilder(0, 5), + new RangeTestingAxisMapperBuilder(0), + new RangeTestingAxisMapperBuilder(0, 4) + }, + new int[] { 1, 0, 0 } + ) + ).minimumSize() + ); + } +} diff --git a/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/RangeTestingAxisMapperBuilder.java b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/RangeTestingAxisMapperBuilder.java new file mode 100644 index 000000000..1f2a37327 --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/RangeTestingAxisMapperBuilder.java @@ -0,0 +1,49 @@ +package com.ignfab.minalac.generator.utils.axis.mappers.builders; + +import com.ignfab.minalac.generator.utils.axis.mappers.AxisMapper; + +public class RangeTestingAxisMapperBuilder implements AxisMapperBuilder { + private final int min; + private final int max; + private final int origin; + + public RangeTestingAxisMapperBuilder(int min, int max, int origin) { + if (max < min) + throw new IllegalArgumentException("max should be greater than min"); + this.min = min; + this.max = max; + this.origin = origin; + } + + public RangeTestingAxisMapperBuilder(int min, int max) { + this(min, max, 0); + } + + public RangeTestingAxisMapperBuilder(int size) { + min = size; + max = size; + origin = 0; + } + + @Override + public AxisMapper build(int size) { + throw new UnsupportedOperationException("Not implemented"); + } + + @Override + public int maxSizeFittingUnder(int threshold) { + if (threshold < min) + return -1; + return Math.min(threshold, max); + } + + @Override + public int minimumSize() { + return min; + } + + @Override + public int origin() { + return origin; + } +} diff --git a/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/RangeTestingAxisMapperBuilderTest.java b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/RangeTestingAxisMapperBuilderTest.java new file mode 100644 index 000000000..af93402b6 --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/RangeTestingAxisMapperBuilderTest.java @@ -0,0 +1,37 @@ +package com.ignfab.minalac.generator.utils.axis.mappers.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class RangeTestingAxisMapperBuilderTest { + @Test + public void testBuild() { + assertThrows(IllegalArgumentException.class, () -> new RangeTestingAxisMapperBuilder(5, 2, 0)); + assertDoesNotThrow(() -> new RangeTestingAxisMapperBuilder(2, 2, 0)); + } + + @Test + public void testMaxSizeUnder() { + assertEquals(0, new RangeTestingAxisMapperBuilder(0).maxSizeFittingUnder(1)); + assertEquals(1, new RangeTestingAxisMapperBuilder(0, 2).maxSizeFittingUnder(1)); + + assertEquals(-1, new RangeTestingAxisMapperBuilder(2, 5).maxSizeFittingUnder(1)); + + assertEquals(2, new RangeTestingAxisMapperBuilder(2, 5).maxSizeFittingUnder(2)); + assertEquals(5, new RangeTestingAxisMapperBuilder(2, 5).maxSizeFittingUnder(5)); + assertEquals(3, new RangeTestingAxisMapperBuilder(2, 5).maxSizeFittingUnder(3)); + assertEquals(5, new RangeTestingAxisMapperBuilder(2, 5).maxSizeFittingUnder(6)); + + assertEquals(2, new RangeTestingAxisMapperBuilder(2).maxSizeFittingUnder(3)); + assertEquals(2, new RangeTestingAxisMapperBuilder(2).maxSizeFittingUnder(2)); + assertEquals(-1, new RangeTestingAxisMapperBuilder(2).maxSizeFittingUnder(1)); + + } + + @Test + public void testMinimumSize() { + assertEquals(2, new RangeTestingAxisMapperBuilder(2, 5).minimumSize()); + assertEquals(2, new RangeTestingAxisMapperBuilder(2).minimumSize()); + } +} diff --git a/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/RepeatAxisMapperBuilderTest.java b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/RepeatAxisMapperBuilderTest.java new file mode 100644 index 000000000..d31110e3f --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/RepeatAxisMapperBuilderTest.java @@ -0,0 +1,81 @@ +package com.ignfab.minalac.generator.utils.axis.mappers.builders; + +import org.junit.jupiter.api.Test; + +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; + +import static org.junit.jupiter.api.Assertions.*; + +public class RepeatAxisMapperBuilderTest { + @Test + public void testBuild() { + assertThrows(UnbuildableLayoutException.class, () -> new RepeatAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 1, Integer.MAX_VALUE).build(5)); + assertThrows(UnbuildableLayoutException.class, () -> new RepeatAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 1, Integer.MAX_VALUE).build(7)); + } + + @Test + public void testMaxSizeFittingUnder() { + assertEquals(6, assertDoesNotThrow( + () -> new RepeatAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 2, Integer.MAX_VALUE).maxSizeFittingUnder(6)) + ); + + + assertEquals(-1, assertDoesNotThrow( + () -> new RepeatAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 2, Integer.MAX_VALUE).maxSizeFittingUnder(5)) + ); + + assertThrows( + UnbuildableLayoutException.class, + () -> new RepeatAxisMapperBuilder(new RangeTestingAxisMapperBuilder(0), 1, Integer.MAX_VALUE).maxSizeFittingUnder(0) + ); + assertThrows( + UnbuildableLayoutException.class, + () -> new RepeatAxisMapperBuilder(new RangeTestingAxisMapperBuilder(0, 2), 1, Integer.MAX_VALUE).maxSizeFittingUnder(5) + ); + + assertEquals(6, assertDoesNotThrow( + () -> new RepeatAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3, 4), 2, Integer.MAX_VALUE).maxSizeFittingUnder(6)) + ); + assertEquals(10, assertDoesNotThrow( + () -> new RepeatAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3, 4), 2, Integer.MAX_VALUE).maxSizeFittingUnder(10)) + ); + + // Unresizable underlying structure + // Max under 25 is 2 * 7 = 14 + assertEquals(14, assertDoesNotThrow( + () -> new RepeatAxisMapperBuilder(new RangeTestingAxisMapperBuilder(7), 2, 2).maxSizeFittingUnder(25)) + ); + // Max under 14 is 2 * 7 = 14 + assertEquals(14, assertDoesNotThrow( + () -> new RepeatAxisMapperBuilder(new RangeTestingAxisMapperBuilder(7), 2, 2).maxSizeFittingUnder(14)) + ); + + // Resizable underlying structure (Can take any size from 2 to 8) + // Max under 30 is max([2, 8]) * maxOccurences (3 here) -> 8 * 3 = 24 + assertEquals(24, assertDoesNotThrow( + () -> new RepeatAxisMapperBuilder(new RangeTestingAxisMapperBuilder(2, 8), 2, 3).maxSizeFittingUnder(30)) + ); + + // Three 8 + assertEquals(24, assertDoesNotThrow( + () -> new RepeatAxisMapperBuilder(new AllowlistTestingAxisMapperBuilder(new int[] { 6, 8 }), 2, 3).maxSizeFittingUnder(25)) + ); + // Two 8 and one 6 + assertEquals(22, assertDoesNotThrow( + () -> new RepeatAxisMapperBuilder(new AllowlistTestingAxisMapperBuilder(new int[] { 6, 8 }), 2, 3).maxSizeFittingUnder(23)) + ); + } + + @Test + public void testMinimumSize() { + assertEquals(0, assertDoesNotThrow( + () -> new RepeatAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 0, Integer.MAX_VALUE).minimumSize()) + ); + assertEquals(3, assertDoesNotThrow( + () -> new RepeatAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 1, Integer.MAX_VALUE).minimumSize()) + ); + assertEquals(6, assertDoesNotThrow( + () -> new RepeatAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 2, Integer.MAX_VALUE).minimumSize()) + ); + } +} diff --git a/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/StretcherAxisMapperBuilderTest.java b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/StretcherAxisMapperBuilderTest.java new file mode 100644 index 000000000..eb1c8842b --- /dev/null +++ b/src/test/java/com/ignfab/minalac/generator/utils/axis/mappers/builders/StretcherAxisMapperBuilderTest.java @@ -0,0 +1,44 @@ +package com.ignfab.minalac.generator.utils.axis.mappers.builders; + +import org.junit.jupiter.api.Test; + +import com.ignfab.minalac.generator.placeables.layouts.UnbuildableLayoutException; + +import static org.junit.jupiter.api.Assertions.*; + +public class StretcherAxisMapperBuilderTest { + @Test + public void testBuild() { + // Not enough space + assertThrows(UnbuildableLayoutException.class, () -> new StretcherAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 0, 1, 2).build(2)); + // Requested size too much + assertThrows(UnbuildableLayoutException.class, () -> new StretcherAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 0, 1, 2).build(5)); + + // Underlying builder with min size of 0 + assertThrows(UnbuildableLayoutException.class, () -> new StretcherAxisMapperBuilder(new RangeTestingAxisMapperBuilder(0, 3), 0, 0, 0)); + assertThrows(UnbuildableLayoutException.class, () -> new StretcherAxisMapperBuilder(new RangeTestingAxisMapperBuilder(0), 0, 0, 0)); + } + + @Test + public void testMaxSizeFittingUnder() { + assertEquals(-1, assertDoesNotThrow(() -> new StretcherAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 0, 1, Integer.MAX_VALUE)).maxSizeFittingUnder(2)); + assertEquals(3, assertDoesNotThrow(() -> new StretcherAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 0, 1, Integer.MAX_VALUE)).maxSizeFittingUnder(3)); + assertEquals(7, assertDoesNotThrow(() -> new StretcherAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 0, 1, Integer.MAX_VALUE)).maxSizeFittingUnder(7)); + + assertEquals(-1, assertDoesNotThrow(() -> new StretcherAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 0, 0, Integer.MAX_VALUE)).maxSizeFittingUnder(1)); + assertEquals(2, assertDoesNotThrow(() -> new StretcherAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 0, 0, Integer.MAX_VALUE)).maxSizeFittingUnder(2)); + assertEquals(3, assertDoesNotThrow(() -> new StretcherAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 0, 0, Integer.MAX_VALUE)).maxSizeFittingUnder(3)); + + assertEquals(4, assertDoesNotThrow(() -> new StretcherAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 0, 1, 2)).maxSizeFittingUnder(6)); + } + + @Test + public void testMinimumSize() { + assertEquals(2, assertDoesNotThrow(() -> new StretcherAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 0, 0, Integer.MAX_VALUE)).minimumSize()); + assertEquals(3, assertDoesNotThrow(() -> new StretcherAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 0, 1, Integer.MAX_VALUE)).minimumSize()); + assertEquals(7, assertDoesNotThrow(() -> new StretcherAxisMapperBuilder(new RangeTestingAxisMapperBuilder(3), 0, 5, Integer.MAX_VALUE)).minimumSize()); + + // Shrink to 0 + assertEquals(0, assertDoesNotThrow(() -> new StretcherAxisMapperBuilder(new RangeTestingAxisMapperBuilder(1), 0, 0, Integer.MAX_VALUE)).minimumSize()); + } +}