diff --git a/src/gdpc/block.py b/src/gdpc/block.py index cf9bc26..c735714 100644 --- a/src/gdpc/block.py +++ b/src/gdpc/block.py @@ -13,6 +13,7 @@ from .nbt_tools import nbtToSnbt from .block_state_tools import transformAxis, transformFacing, transformRotation +BlockName = str @dataclass class Block: @@ -38,7 +39,7 @@ class Block: # - type="bottom"/"top" (e.g. slabs) (note that slabs can also have type="double"!) # - half="bottom"/"top" (e.g. stairs) ("half" is also used for other purposes, see e.g. doors) - id: Optional[str] = "minecraft:stone" + id: Optional[BlockName] = "minecraft:stone" states: Dict[str, str] = field(default_factory=dict) data: Optional[str] = None @@ -65,13 +66,11 @@ def transformed(self, rotation: int = 0, flip: Vec3bLike = bvec3()): def stateString(self): """Returns a string containing the block states of this block, including the outer brackets.""" stateString = ",".join([f"{key}={value}" for key, value in self.states.items()]) - return "" if stateString == "" else f"[{stateString}]" + return f"[{stateString}]" if stateString else "" def __str__(self): - if not self.id: - return "" - return self.id + self.stateString() + (self.data if self.data else "") + return self.id + self.stateString() + (self.data or "") if self.id else "" def __repr__(self): diff --git a/src/gdpc/editor.py b/src/gdpc/editor.py index 58e6fdc..97643dc 100644 --- a/src/gdpc/editor.py +++ b/src/gdpc/editor.py @@ -2,29 +2,73 @@ world through the GDMC HTTP interface""" -from typing import Dict, Sequence, Union, Optional, List, Iterable -from numbers import Integral +from concurrent import futures from contextlib import contextmanager from copy import copy, deepcopy -import random -from concurrent import futures +from glm import ivec3 import logging +from numbers import Integral +import random +from typing import Dict, Iterable, List, Optional, Sequence, Union + +from typing_extensions import Protocol import numpy as np -from glm import ivec3 -from .utils import eagerAll, OrderedByLookupDict -from .vector_tools import Vec3iLike, Rect, Box, addY, dropY -from .transform import Transform, TransformLike, toTransform -from .block import Block, transformedBlockOrPalette from . import interface +from .block import Block, BlockName, transformedBlockOrPalette +from .model import Model +from .transform import Transform, TransformLike, toTransform +from .utils import OrderedByLookupDict, eagerAll +from .vector_tools import ZERO_3D, Box, Rect, Vec3iLike, dropY from .world_slice import WorldSlice logger = logging.getLogger(__name__) -class Editor: +class BlockGetterMixin(Protocol): + + @property + def size(self) -> ivec3 :... + + def getBlock(self, position: Vec3iLike) -> Optional[Block]: ... + + def getBlocks(self, box: Box): + """Returns a Model containing a cuboid of blocks, as defined by box.""" + if box.getOriginDiagonal > self.size: + # FIXME: Out-of-bounds handling + raise NotImplementedError() + + return Model(box.size, [self.getBlock(v) for v in box.inner]) + +class BlockPlacerMixin(Protocol): + def placeBlock(self, + position: Union[Vec3iLike, Iterable[Vec3iLike]], + block: Union[Block, Sequence[Block]], + replace: Optional[Union[BlockName, List[BlockName]]] = None + ): ... + + def placeBlocks(self, + source: BlockGetterMixin, + destination_target: Box = None, # if unspecified, assumes equal to source + source_target: Box = None, # if unspecified, assumes equal to destination + ): + if destination_target is None: + destination_target = Box(size=source.size) + + if source_target is None: + source_target = destination_target + + if destination_target.size < source_target.size or source.size < source_target.getOriginDiagonal(): + # FIXME: Out-of-bounds handling + raise NotImplementedError() + + for source_v, destination_v in zip(source_target.inner, destination_target.inner): + self.placeBlock(destination_v, source.getBlock(source_v)) + + +class Editor(BlockGetterMixin, BlockPlacerMixin): """Provides a high-level functions to interact with the Minecraft world through the GDMC HTTP interface. @@ -57,11 +101,11 @@ def __init__( self._buffering = buffering self._bufferLimit = bufferLimit - self._buffer: Dict[ivec3,Block] = {} + self._buffer: Dict[Vec3iLike,Block] = {} self._commandBuffer: List[str] = [] self._caching = caching - self._cache = OrderedByLookupDict[ivec3,Block](cacheLimit) + self._cache = OrderedByLookupDict[Vec3iLike,Block](cacheLimit) self._multithreading = False self._multithreadingWorkers = multithreadingWorkers @@ -88,6 +132,25 @@ def __del__(self): # actually shut down yet. For safety, the last buffer flush must be done on the main thread. self.flushBuffer() + @property + def offset(self): + """An alias for the Editor's translation.""" + return self.transform.translation + + @offset.setter + def offset(self, value: Vec3iLike): + self.transform.translation = value + + @property + def size(self): + """An alias for the size of the build area.""" + return self.getBuildArea().size + + @size.setter + def size(self, size: Vec3iLike): + build_area = self.getBuildArea() + build_area.size = size + self.setBuildArea(build_area) @property def transform(self): @@ -95,7 +158,7 @@ def transform(self): return self._transform @transform.setter - def transform(self, value: Union[Transform, ivec3]): + def transform(self, value: Union[Transform, Vec3iLike]): self._transform = toTransform(value) @property @@ -336,7 +399,7 @@ def getBlock(self, position: Vec3iLike): def getBlockGlobal(self, position: Vec3iLike): """Returns the block at [position], ignoring self.transform.\n If the given coordinates are invalid, returns Block("minecraft:void_air").""" - _position = ivec3(*position) + _position = Vec3iLike(*position) if self.caching: block = self._cache.get(_position) @@ -376,7 +439,7 @@ def getBiomeGlobal(self, position: Vec3iLike): if ( self._worldSlice is not None and self._worldSlice.box.contains(position) and - not self._worldSliceDecay[tuple(ivec3(position) - self._worldSlice.box.offset)] + not self._worldSliceDecay[tuple(Vec3iLike(position) - self._worldSlice.box.offset)] ): return self._worldSlice.getBiomeGlobal(position) @@ -387,7 +450,7 @@ def placeBlock( self, position: Union[Vec3iLike, Iterable[Vec3iLike]], block: Union[Block, Sequence[Block]], - replace: Optional[Union[str, List[str]]] = None + replace: Optional[Union[BlockName, List[BlockName]]] = None ): """Places at .\n is interpreted as local to the coordinate system defined by self.transform.\n @@ -405,7 +468,7 @@ def placeBlockGlobal( self, position: Union[Vec3iLike, Iterable[Vec3iLike]], block: Union[Block, Sequence[Block]], - replace: Optional[Union[str, Iterable[str]]] = None + replace: Optional[Union[BlockName, Iterable[BlockName]]] = None ): """Places at , ignoring self.transform.\n If is iterable (e.g. a list), is placed at all positions. @@ -418,16 +481,16 @@ def placeBlockGlobal( oldBuffering = self.buffering self.buffering = True - success = eagerAll(self._placeSingleBlockGlobal(ivec3(*pos), block, replace) for pos in position) + success = eagerAll(self._placeSingleBlockGlobal(Vec3iLike(*pos), block, replace) for pos in position) self.buffering = oldBuffering return success def _placeSingleBlockGlobal( self, - position: ivec3, + position: Vec3iLike, block: Union[Block, Sequence[Block]], - replace: Optional[Union[str, Iterable[str]]] = None + replace: Optional[Union[BlockName, Iterable[BlockName]]] = None ): """Places at , ignoring self.transform.\n If is a sequence (e.g. a list), blocks are sampled randomly.\n @@ -435,7 +498,7 @@ def _placeSingleBlockGlobal( # Check replace condition if replace is not None: - if isinstance(replace, str): + if isinstance(replace, BlockName): replace = [replace] if self.getBlockGlobal(position).id not in replace: return True @@ -463,7 +526,7 @@ def _placeSingleBlockGlobal( return True - def _placeSingleBlockGlobalDirect(self, position: ivec3, block: Block): + def _placeSingleBlockGlobalDirect(self, position: Vec3iLike, block: Block): """Place a single block in the world directly.\n Returns whether the placement succeeded.""" result = interface.placeBlocks([(position, block)], dimension=self.dimension, doBlockUpdates=self.doBlockUpdates, spawnDrops=self.spawnDrops, retries=self.retries, timeout=self.timeout, host=self.host) @@ -473,7 +536,7 @@ def _placeSingleBlockGlobalDirect(self, position: ivec3, block: Block): return True - def _placeSingleBlockGlobalBuffered(self, position: ivec3, block: Block): + def _placeSingleBlockGlobalBuffered(self, position: Vec3iLike, block: Block): """Place a block in the buffer and send once limit is exceeded.\n Returns whether placement succeeded.""" if len(self._buffer) >= self.bufferLimit: @@ -488,7 +551,7 @@ def flushBuffer(self): If multithreaded buffer flushing is enabled, the worker threads can be awaited with awaitBufferFlushes().""" - def flush(blockBuffer: Dict[ivec3, Block], commandBuffer: List[str]): + def flush(blockBuffer: Dict[Vec3iLike, Block], commandBuffer: List[str]): # Flush block buffer if blockBuffer: response = interface.placeBlocks(blockBuffer.items(), dimension=self.dimension, doBlockUpdates=self._bufferDoBlockUpdates, spawnDrops=self.spawnDrops, retries=self.retries, timeout=self.timeout, host=self.host) @@ -594,3 +657,9 @@ def pushTransform(self, transformLike: Optional[TransformLike] = None): yield finally: self.transform = originalTransform + + def toBox(self): + return Box(self.offset, self.size) + + def toModel(self): + return self.getBlocks(Box(ZERO_3D, self.size)) diff --git a/src/gdpc/lookup.py b/src/gdpc/lookup.py index 17bc0c2..4fa200e 100644 --- a/src/gdpc/lookup.py +++ b/src/gdpc/lookup.py @@ -290,13 +290,15 @@ def variate( # soils SPREADING_DIRTS = {"minecraft:mycelium", "minecraft:grass_block", } DIRTS = {"minecraft:coarse_dirt", "minecraft:dirt", - "minecraft:grass_path", "minecraft:farmland", "minecraft:podzol", } \ + "minecraft:farmland", "minecraft:podzol", } \ | SPREADING_DIRTS +FERTILE_SOILS = DIRTS | {"minecraft:rooted_dirt", "minecraft:moss_block", "minecraft:mud", "minecraft:muddy_mangrove_roots"} SANDS = variate(SAND_TYPES, "sand") GRANULARS = {"minecraft:gravel", } | SANDS RIVERBED_SOILS = {"minecraft:dirt", "minecraft:clay", "minecraft:sand", "minecraft:gravel", } -OVERWORLD_SOILS = DIRTS | GRANULARS | RIVERBED_SOILS +OVERWORLD_SOILS = FERTILE_SOILS | GRANULARS | RIVERBED_SOILS + NYLIUMS = variate(FUNGUS_TYPES, "nylium") NETHERRACKS = {"minecraft:netherrack", } | NYLIUMS | NETHERRACK_ORES diff --git a/src/gdpc/model.py b/src/gdpc/model.py index 9058fe7..5ff2012 100644 --- a/src/gdpc/model.py +++ b/src/gdpc/model.py @@ -7,11 +7,11 @@ from .vector_tools import Vec3iLike, Box from .transform import TransformLike -from .editor import Editor +from .editor import BlockGetterMixin, BlockPlacerMixin from .block import Block -class Model: +class Model(BlockGetterMixin, BlockPlacerMixin): """A 3D model of Minecraft blocks. Can be used to store a structure in memory, allowing it to be built under different @@ -22,13 +22,14 @@ def __init__(self, size: Vec3iLike, blocks: Optional[List[Optional[Block]]] = No """Constructs a Model of size [size], optionally filled with [blocks].""" self._size = ivec3(*size) volume = self._size.x * self._size.y * self._size.z - if blocks is not None: - if len(blocks) != volume: - raise ValueError("The number of blocks should be equal to size[0] * size[1] * size[2]") - self._blocks = copy(blocks) - else: + if blocks is None: self._blocks = [None] * volume + elif len(blocks) != volume: + raise ValueError("The number of blocks should be equal to size[0] * size[1] * size[2]") + else: + self._blocks = copy(blocks) + @property def size(self): @@ -45,14 +46,14 @@ def getBlock(self, position: Vec3iLike): """Returns the block at [vec]""" return self._blocks[(position[0] * self._size.y + position[1]) * self._size.z + position[2]] - def setBlock(self, position: Vec3iLike, block: Optional[Block]): + def placeBlock(self, position: Vec3iLike, block: Optional[Block]): """Sets the block at [vec] to [block]""" self._blocks[(position[0] * self._size.y + position[1]) * self._size.z + position[2]] = block def build( self, - editor: Editor, + editor: BlockPlacerMixin, transformLike: Optional[TransformLike] = None, substitutions: Optional[Dict[str, str]] = None, replace: Optional[Union[str, List[str]]] = None diff --git a/src/gdpc/transform.py b/src/gdpc/transform.py index d179067..c2f9bf1 100644 --- a/src/gdpc/transform.py +++ b/src/gdpc/transform.py @@ -181,12 +181,15 @@ def rotatedBoxTransform(box: Box, rotation: int): """Returns a transform that maps the box ((0,0,0), size) to [box] under [rotation], where size == vector_tools.rotateSize3D([box].size, [rotation]).""" return Transform( - translation = box.offset + ivec3( - box.size.x - 1 if rotation in [1, 2] else 0, - 0, - box.size.z - 1 if rotation in [2, 3] else 0, + translation=( + box.offset + + ivec3( + box.size.x - 1 if rotation in {1, 2} else 0, + 0, + box.size.z - 1 if rotation in {2, 3} else 0, + ) ), - rotation = rotation + rotation=rotation, ) diff --git a/src/gdpc/vector_tools.py b/src/gdpc/vector_tools.py index 9d99167..50d60ae 100644 --- a/src/gdpc/vector_tools.py +++ b/src/gdpc/vector_tools.py @@ -515,6 +515,11 @@ def getDimensionality(corner1: Union[Vec2iLike, Vec3iLike], corner2: Union[Vec2i return int(len(corner1) - np.sum(flatSides)), list(flatSides) +def anyComponentSmaller(v1: Union[ivec2, ivec3, vec2, vec3], v2: Union[ivec2, ivec3, vec2, vec3]) -> bool: + """Checks if any component of v1 is smaller than the corresponding component in v2.""" + return glm.any(glm.smallerThan(v1, v2)) + + # ================================================================================================== # Rect and Box # ================================================================================================== @@ -540,6 +545,11 @@ def __hash__(self) -> int: def __repr__(self) -> str: return f"Rect({tuple(self._offset)}, {tuple(self._size)})" + def __iter__(self) -> Generator[ivec2, None, None]: + dx, dy = self.size.x, self.size.y + for x, y in itertools.product(range(dx), range(dy)): + yield self.begin + ivec2(x, y) + @property def offset(self) -> ivec2: """This Rect's offset""" @@ -722,6 +732,11 @@ def __hash__(self) -> int: def __repr__(self) -> str: return f"Box({tuple(self._offset)}, {tuple(self._size)})" + def __iter__(self) -> Generator[ivec3, None, None]: + dx, dy, dz = self.size.x, self.size.y, self.size.z + for x, y in itertools.product(range(dx), range(dy), range(dz)): + yield self.begin + ivec3(x, y) + @property def offset(self) -> ivec3: """This Box's offset""" @@ -981,6 +996,13 @@ def wireframe(self) -> Generator[ivec3, Any, None]: ivec3(first.x, last.y - 1, last.z) + 1, ) + def getDiagonal(self): + """Returns the diagonal vector of this Box, representing its size.""" + return self.size + + def getOriginDiagonal(self): + """Returns the diagonal vector from the origin to the end of the box.""" + return self.end def rectSlice(array: np.ndarray, rect: Rect) -> np.ndarray: """Returns the slice from [array] defined by [rect]""" diff --git a/src/gdpc/world_slice.py b/src/gdpc/world_slice.py index a9938d4..fbc4cbd 100644 --- a/src/gdpc/world_slice.py +++ b/src/gdpc/world_slice.py @@ -1,17 +1,18 @@ """Provides the WorldSlice class""" - -from typing import Dict, Iterable, Optional +import contextlib from dataclasses import dataclass from io import BytesIO -from math import floor, ceil, log2 +from math import ceil, floor, log2 +from typing import Dict, Iterable, Optional from glm import ivec2, ivec3 -from nbt import nbt import numpy as np +from nbt import nbt -from .vector_tools import Vec3iLike, addY, loop2D, loop3D, trueMod2D, Rect -from .block import Block from . import interface +from .block import Block +from .editor import BlockGetterMixin +from .vector_tools import Rect, Vec3iLike, addY, loop2D, loop3D, trueMod2D # Chunk format information: @@ -75,11 +76,12 @@ def getBiomeAtIndex(self, index) -> nbt.TAG_String: return self.biomesPalette[self.biomesBitArray[index]] -class WorldSlice: +class WorldSlice(BlockGetterMixin): """Contains information on a slice of the world.""" def __init__(self, rect: Rect, dimension: Optional[str] = None, heightmapTypes: Optional[Iterable[str]] = None, retries=0, timeout=None, host=interface.DEFAULT_HOST): """Initialise WorldSlice with region and heightmap.""" + # TODO: Really needs a refactor # To protect from calling this with a Box, which can lead to very confusing bugs. if not isinstance(rect, Rect): @@ -131,20 +133,20 @@ def __init__(self, rect: Rect, dimension: Optional[str] = None, heightmapTypes: hmBitArray = _BitArray(hmBitsPerEntry, 16*16, hmRaw) heightmap = self._heightmaps[hmName] for inChunkPos in loop2D(ivec2(16,16)): - try: + with contextlib.suppress(IndexError): # In the heightmap data, the lowest point is encoded as 0, while since # Minecraft 1.18 the actual lowest y position is below zero. We subtract # yBegin from the heightmap value to compensate for this difference. hmPos = -inChunkRectOffset + chunkPos * 16 + inChunkPos # pylint: disable=invalid-unary-operand-type heightmap[hmPos.x, hmPos.y] = hmBitArray[inChunkPos.y * 16 + inChunkPos.x] + self._yBegin - except IndexError: - pass - # Read chunk sections for sectionTag in chunkTag['sections']: y = int(sectionTag['Y'].value) - if (not ('block_states' in sectionTag) or len(sectionTag['block_states']) == 0): + if ( + 'block_states' not in sectionTag + or len(sectionTag['block_states']) == 0 + ): continue blockPalette = sectionTag['block_states']['palette'] @@ -304,7 +306,7 @@ def getBiomeCountsInChunkGlobal(self, position: Vec3iLike): chunkSection = self._getChunkSectionGlobal(position) if chunkSection is None: return None - biomeCounts: Dict[str, int] = dict() + biomeCounts: Dict[str, int] = {} for biomePos in loop3D(ivec3(4,4,4)): biomeIndex = (biomePos.y << 4) | (biomePos.z << 2) | biomePos.x biome = str(chunkSection.getBiomeAtIndex(biomeIndex).value)