weavingspace.weave_unit
The WeaveUnit
subclass of weavingspace.tileable.Tileable
.
Implements tileable geometric patterns constructed by specifying 2- and 3-axial weaves.
Examples: Explain usage here...
1"""The `WeaveUnit` subclass of `weavingspace.tileable.Tileable`. 2 3Implements tileable geometric patterns constructed by specifying 2- and 3-axial 4weaves. 5 6Examples: 7 Explain usage here... 8 9""" 10 11from __future__ import annotations 12 13import itertools 14import logging 15from dataclasses import dataclass 16from typing import TYPE_CHECKING 17 18import geopandas as gpd 19import numpy as np 20import pandas as pd 21import shapely.affinity as affine 22import shapely.geometry as geom 23import shapely.ops 24 25from weavingspace import ( 26 Loom, 27 Tileable, 28 TileShape, 29 WeaveGrid, 30 tiling_utils, 31 weave_matrices, 32) 33 34if TYPE_CHECKING: 35 from collections.abc import Iterable 36 37 38@dataclass 39class WeaveUnit(Tileable): 40 """Extend Tileable to allow for tiles that appear like woven patterns.""" 41 42 weave_type:str = "plain" 43 """type of weave pattern, one of `plain`, `twill`, `basket`, `cube`, `hex` or 44 `this`. Defaults to `plain`.""" 45 aspect:float = 1. 46 """width of strands relative to the `spacing`. Defaults to 1.0.""" 47 n:int|tuple[int] = (2, 2) 48 """number of over-under strands in biaxial weaves. Only one item is 49 required in a plain weave. Twill and basket patterns expect an even number of 50 entries in the tuple.""" 51 strands:str = "a|b|c" 52 """specification of the strand labels along each axis. Defaults to `a|b|c`.""" 53 _tie_up:np.ndarray = None 54 """optional tie-up array to pass through for `this` weave type.""" 55 _tr:np.ndarray = None 56 """optional treadling array to pass through for `this` weave type.""" 57 _th:np.ndarray = None 58 """optional threading array to pass through for `this` weave type.""" 59 60 def __init__(self, **kwargs:float|str) -> None: 61 super().__init__(**kwargs) 62 63 64 def _setup_tiles(self) -> None: 65 """Set up weave unit.""" 66 if self.weave_type in ("hex", "cube"): 67 self.base_shape = TileShape.HEXAGON 68 self._setup_triaxial_weave_unit() 69 else: 70 self.base_shape = TileShape.RECTANGLE 71 self._setup_biaxial_weave_unit() 72 73 74 def _setup_regularised_prototile(self) -> None: 75 """Set up regularised prototile fully containing all tile elements. 76 77 The work is carried out by the two methods that follow _regularise_tiles() 78 and _merge_fragments(). 79 """ 80 self._regularise_tiles() 81 # it's prudent to do some cleanup given all the manipulation of geometries 82 # carried out to generate the regularised prototile. But note that the 83 # regularised prototile has no functional purpose, so it's OK if it has, 84 # for example, additional points along line segments. 85 self.regularised_prototile.geometry = tiling_utils.repair_polygon( 86 self.regularised_prototile.geometry) 87 88 89 def _setup_biaxial_weave_unit(self) -> None: 90 """Set up weave tiles GeoDataFrame and tile GeoDataFrame.""" 91 warp_threads, weft_threads, _ = \ 92 tiling_utils.get_strand_ids(self.strands) 93 if self.weave_type == "basket" and isinstance(self.n, (list, tuple)): 94 self.n = self.n[0] 95 p = weave_matrices.get_weave_pattern_matrix( 96 weave_type = self.weave_type, n = self.n, warp = warp_threads, 97 weft = weft_threads, tie_up = self._tie_up, tr = self._tr, 98 th = self._th) 99 self._make_shapes_from_coded_weave_matrix( 100 Loom(p), strand_labels = [weft_threads, warp_threads, []]) 101 bb = self.tiles.total_bounds 102 w = (bb[2] - bb[0]) // self.spacing * self.spacing 103 h = (bb[3] - bb[1]) // self.spacing * self.spacing 104 self.setup_vectors((0, h), (w, 0)) 105 106 107 def _get_triaxial_weave_matrices(self, 108 strands_1:list[str]|tuple[str] = ("a",), 109 strands_2:list[str]|tuple[str] = ("b",), 110 strands_3:list[str]|tuple[str] = ("c",), 111 ) -> Loom: 112 """Return encoded weave pattern matrix as Loom of three biaxial matrices. 113 114 Allowed weave_types: "cube" or "hex". 115 116 "hex" is not flexible and will fail with any strand label lists that are 117 not length 3 or include more than one non-blank "-" item. You can 118 generate the "hex" weave with the default settings in any case! 119 120 Strand lists should be length 3 or length 1. "cube" tolerates more 121 than "hex" for the items in the strand lists. 122 123 Defaults will produce 'mad weave'. 124 125 Args: 126 strands_1 (list[str]|tuple[str], optional): list of labels 127 for warp strands. Defaults to ["a"]. 128 strands_2 (list[str]|tuple[str], optional): list of labels 129 for weft strands. Defaults to ["b"]. 130 strands_3 (list[str]|tuple[str], optional): list of labels 131 for weft strands. Defaults to ["c"]. 132 133 Returns: 134 Loom: which combines the three biaxial weaves 12, 23 and 31 implied 135 by the strand label lists. 136 137 """ 138 if self.weave_type == "hex": 139 loom = Loom( 140 weave_matrices.get_weave_pattern_matrix( 141 weave_type = "this", tie_up = np.ones((6, 6)), 142 warp = strands_1, weft = strands_2), 143 weave_matrices.get_weave_pattern_matrix( 144 weave_type = "this", tie_up = np.ones((6, 6)), 145 warp = strands_2, weft = strands_3), 146 weave_matrices.get_weave_pattern_matrix( 147 weave_type = "this", tie_up = np.ones((6, 6)), 148 warp = strands_3, weft = strands_1)) 149 else: # "cube" 150 loom = Loom( 151 # Note n = (1,2,1,2) is required here to force 6x6 twill 152 weave_matrices.get_weave_pattern_matrix( 153 weave_type = "twill", n = (1, 2, 1, 2), 154 warp = strands_1, weft = strands_2), 155 weave_matrices.get_weave_pattern_matrix( 156 weave_type = "twill", n = (1, 2, 1, 2), 157 warp = strands_2, weft = strands_3), 158 weave_matrices.get_weave_pattern_matrix( 159 weave_type = "twill", n = (1, 2, 1, 2), 160 warp = strands_3, weft = strands_1)) 161 return loom 162 163 164 def _setup_triaxial_weave_unit(self) -> None: 165 """Return weave tiles GeoDataFrame and tile GeoDataFrame.""" 166 strands_1, strands_2, strands_3 = \ 167 tiling_utils.get_strand_ids(self.strands) 168 loom = self._get_triaxial_weave_matrices( 169 strands_1 = strands_1, strands_2 = strands_2, strands_3 = strands_3) 170 self._make_shapes_from_coded_weave_matrix( 171 loom, strand_labels = [strands_1, strands_2, strands_3]) 172 self.setup_vectors((0, 6 * self.spacing), 173 (3 * self.spacing * np.sqrt(3), 3 * self.spacing), 174 (3 * self.spacing * np.sqrt(3), -3 * self.spacing)) 175 176 177 def _make_shapes_from_coded_weave_matrix( 178 self, 179 loom:Loom, strand_labels:list[list[str]] = (["a"], ["b"], ["c"]), 180 ) -> None: 181 """Set up weave tiles and prototile GeoDataFrames in a dictionary. 182 183 Builds the geometries associated with a given weave supplied as 184 'loom' containing the coordinates in an appropriate grid (Cartesian or 185 triangular) and the orderings of the strands at each coordinate location 186 187 Args: 188 loom (Loom): matrix or stack of matrices representing the weave 189 pattern. 190 strand_labels (list[list[str]], optional): list of lists of labels 191 for strands in each direction. Defaults to [["a"], ["b"], ["c"]] 192 193 """ 194 grid = WeaveGrid(loom.n_axes, loom.orientations, self.spacing) 195 # expand the list of strand labels if needed in each direction 196 labels = [thread * int(np.ceil(dim // len(thread))) 197 for dim, thread in zip(loom.dimensions, strand_labels, 198 strict = False)] 199 weave_polys = [] 200 strand_ids = [] 201 cells = [] 202 for k, strand_order in zip(loom.indices, loom.orderings, strict = True): 203 IDs = [thread[coord] for coord, thread in zip(k, labels, strict = True)] 204 cells.append(grid.get_grid_cell_at(k)) 205 if strand_order is None: 206 continue # No strands present 207 if strand_order == "NA": 208 continue # Inconsistency in layer order 209 n_slices = [len(ID) for ID in IDs] 210 next_polys = grid.get_visible_cell_strands( 211 width = self.aspect, coords = k, 212 strand_order = strand_order, n_slices = n_slices) 213 weave_polys.extend(next_polys) 214 next_labels = [list(IDs[i]) for i in strand_order] # list of lists 215 next_labels = list(itertools.chain(*next_labels)) # flatten 216 strand_ids.extend(next_labels) 217 # sometimes empty polygons make it to here, so 218 # filter those out along with the associated IDs 219 real_polys = [not p.is_empty for p in weave_polys] 220 weave_polys = [ 221 p for p, b in zip(weave_polys, real_polys, strict = True) if b] 222 strand_ids = [ 223 ID for ID, b in zip(strand_ids, real_polys, strict = True) if b] 224 # note that the tile is important for the biaxial case, which makes it 225 # a little hard to understand why the behaviour is so different in 226 # the biaxial and triaxial cases; however below seems to work... 227 if loom.n_axes == 3: 228 tile = grid.get_tile_from_cells(cells) 229 shift = (0, 0) 230 else: 231 tile = tiling_utils.safe_union(gpd.GeoSeries(cells), as_polygon = True) 232 shift = (-tile.centroid.x, -tile.centroid.y) 233 tile = grid.get_tile_from_cells(tile) 234 self.tiles = self._get_weave_tiles_gdf(weave_polys, strand_ids, shift) 235 self.prototile = gpd.GeoDataFrame( 236 geometry = gpd.GeoSeries([tile]), crs = self.crs) 237 238 239 def _get_weave_tiles_gdf( 240 self, polys:list[geom.Polygon], strand_ids:list[str], 241 offset:tuple[float]) -> gpd.GeoDataFrame: 242 """Make a GeoDataFrame from weave tile polygons, labels, etc. 243 244 Args: 245 polys (list[Polygon | MultiPolygon]): list of weave tile 246 polygons. 247 strand_ids (list[str]): list of strand labels. 248 offset (tuple[float]): offset to centre the weave tiles on the 249 tile. 250 251 Returns: 252 geopandas.GeoDataFrame: GeoDataFrame clipped to the tile, with 253 margin applied. 254 255 """ 256 weave = gpd.GeoDataFrame( 257 data = {"tile_id": strand_ids}, 258 geometry = gpd.GeoSeries([affine.translate(p, offset[0], offset[1]) 259 for p in polys])) 260 weave = weave[weave.tile_id != "-"] 261 weave.geometry = gpd.GeoSeries( 262 [tiling_utils.get_clean_polygon(p) for p in weave.geometry]) 263 264 # some buffering is required if aspect is 1 to safely dissolve and 265 # explode weave unit tiles that meet at corners 266 if self.aspect == 1: 267 # grow for dissolve 268 weave.geometry = weave.geometry.buffer( 269 # self.spacing * tiling_utils.RESOLUTION, 270 tiling_utils.RESOLUTION, 271 join_style = "mitre", cap_style = "square") 272 weave = weave.dissolve(by = "tile_id", as_index = False) 273 # shrink by more to explode into separate polygons 274 weave.geometry = weave.geometry.buffer( 275 # -2 * self.spacing * tiling_utils.RESOLUTION, 276 -2 * tiling_utils.RESOLUTION, 277 join_style = "mitre", cap_style = "square") 278 weave = weave.explode(ignore_index = True) 279 weave.geometry = weave.geometry.buffer( 280 # self.spacing * tiling_utils.RESOLUTION, 281 tiling_utils.RESOLUTION, 282 join_style = "mitre", cap_style = "square") 283 else: # aspect < 1 is fine without buffering 284 weave = weave.dissolve(by = "tile_id", as_index = False) 285 weave = weave.explode(ignore_index = True) 286 weave.geometry = gpd.GeoSeries( 287 [tiling_utils.get_clean_polygon(p) for p in weave.geometry]) 288 return weave.set_crs(self.crs) 289 290 291 def _get_axis_from_label(self, label:str = "a", strands:str = None): 292 """Determine the axis of a tile_id from the strands spec string. 293 294 Args: 295 label (str, optional): the tile_id. Defaults to "a". 296 strands (str, optional): the strand spec. Defaults to the WeaveUnit 297 strands attribute. 298 299 Returns: 300 _type_: the axis in which the supplied tile is found. 301 302 """ 303 if strands == None: 304 strands = self.strands 305 index = strands.index(label) 306 return strands[:index].count("|") 307 308 309 def _get_legend_tiles(self) -> gpd.GeoDataFrame: 310 """Return tiles suitable for use in a legend representation. 311 312 One tile for each tile_id value will be chosen, close to the 313 centre of the prototile extent, and not among the smallest tiles present 314 (for example not a short length of strand mostly hidden by other 315 strands) 316 317 Returns: 318 gpd.GeoDataFrame: the chosen tiles. 319 320 """ 321 angles = ((0, 240, 120) 322 if self.weave_type in ("hex", "cube") 323 else (90, 0)) 324 tile_ids = pd.Series.unique(self.tiles.tile_id) 325 groups = self.tiles.groupby("tile_id") 326 tiles, rotations = [], [] 327 for id in tile_ids: 328 candidates = groups.get_group(id) 329 axis = self._get_axis_from_label(id, self.strands) 330 tiles.append( 331 self._get_most_central_large_tile(candidates, tiles)) 332 rotations.append(-angles[axis] + self.rotation) 333 return gpd.GeoDataFrame( 334 data = {"tile_id": tile_ids, "rotation": rotations}, 335 crs = self.crs, 336 geometry = gpd.GeoSeries(tiles)) 337 338 339 def _get_most_central_large_tile(self, tiles:gpd.GeoDataFrame, 340 other_tiles:list[geom.Polygon]) -> geom.Polygon: 341 """Get a large tile close to the centre of the WeaveUnit. 342 343 Args: 344 tiles (gpd.GeoDataFrame): the set of tiles to choose from. 345 346 Returns: 347 geom.Polygon: the chosen, large central tile. 348 349 """ 350 areas = [g.area for g in tiles.geometry] 351 min_area, max_area = min(areas), max(areas) 352 if min_area / max_area > 0.5: 353 geoms = list(tiles.geometry) 354 else: 355 mean_log_a = np.mean(np.log(areas)) 356 geoms = [g for g, a in zip(tiles.geometry, areas) 357 if np.log(a) > mean_log_a] 358 if len(other_tiles) == 0 or self.weave_type in ("cube", "hex"): 359 d = [g.centroid.distance(geom.Point(0, 0)) for g in geoms] 360 else: 361 c = geom.MultiPolygon(other_tiles).centroid 362 d = [geom.MultiPolygon([g] + other_tiles).centroid.distance(c) 363 for g in geoms] 364 return geoms[d.index(min(d))] 365 366 367 def _get_legend_key_shapes( 368 self, 369 polygon:geom.Polygon, 370 counts:Iterable = [1] * 25, 371 angle:float = 0, 372 radial:bool = False, 373 ) -> list[geom.Polygon]: 374 """Return polygons obtained by slicing polygon crosswise into n slices. 375 376 Orientation of the polygon is indicated by the angle. 377 378 The returned list of polygons can be used to form a colour ramp in a 379 legend. 380 381 Args: 382 polygon (geom.Polygon): the weave strand polygon to slice. 383 counts (Iterable, optional): an iterable list of the numbers of 384 slices in each category. Defaults to [1] * 25. 385 angle (float, optional): orientation of the polygon. Defaults to 0. 386 radial (bool, optional): ignored by WeaveUnit. 387 388 Returns: 389 list[geom.Polygon]: a list of polygons. 390 391 """ 392 c = polygon.centroid 393 g = affine.rotate(polygon, -angle, origin = c) 394 width, height, left, bottom = \ 395 tiling_utils.get_width_height_left_bottom(gpd.GeoSeries([g])) 396 margin = width / 50 397 total = sum(counts) 398 cuts = list(np.cumsum(counts)) 399 cuts = [0] + [c / total for c in cuts] 400 cuts = [left + c * width for c in cuts] 401 bottom = bottom - margin 402 top = bottom + height + 2 * margin 403 slices = [] 404 for l, r in zip(cuts[:-1], cuts[1:], strict = True): 405 # we add a margin to left and right so that they overplot; otherwise in 406 # rendering matplotlib leaves small gaps which give a washed out look 407 # to the fill colour! 408 slice = geom.Polygon([(l - margin, bottom), (r + margin, bottom), 409 (r + margin, top), (l - margin, top)]) 410 slices.append(slice.intersection(g)) 411 return [affine.rotate(s, angle, origin = c) for s in slices]
39@dataclass 40class WeaveUnit(Tileable): 41 """Extend Tileable to allow for tiles that appear like woven patterns.""" 42 43 weave_type:str = "plain" 44 """type of weave pattern, one of `plain`, `twill`, `basket`, `cube`, `hex` or 45 `this`. Defaults to `plain`.""" 46 aspect:float = 1. 47 """width of strands relative to the `spacing`. Defaults to 1.0.""" 48 n:int|tuple[int] = (2, 2) 49 """number of over-under strands in biaxial weaves. Only one item is 50 required in a plain weave. Twill and basket patterns expect an even number of 51 entries in the tuple.""" 52 strands:str = "a|b|c" 53 """specification of the strand labels along each axis. Defaults to `a|b|c`.""" 54 _tie_up:np.ndarray = None 55 """optional tie-up array to pass through for `this` weave type.""" 56 _tr:np.ndarray = None 57 """optional treadling array to pass through for `this` weave type.""" 58 _th:np.ndarray = None 59 """optional threading array to pass through for `this` weave type.""" 60 61 def __init__(self, **kwargs:float|str) -> None: 62 super().__init__(**kwargs) 63 64 65 def _setup_tiles(self) -> None: 66 """Set up weave unit.""" 67 if self.weave_type in ("hex", "cube"): 68 self.base_shape = TileShape.HEXAGON 69 self._setup_triaxial_weave_unit() 70 else: 71 self.base_shape = TileShape.RECTANGLE 72 self._setup_biaxial_weave_unit() 73 74 75 def _setup_regularised_prototile(self) -> None: 76 """Set up regularised prototile fully containing all tile elements. 77 78 The work is carried out by the two methods that follow _regularise_tiles() 79 and _merge_fragments(). 80 """ 81 self._regularise_tiles() 82 # it's prudent to do some cleanup given all the manipulation of geometries 83 # carried out to generate the regularised prototile. But note that the 84 # regularised prototile has no functional purpose, so it's OK if it has, 85 # for example, additional points along line segments. 86 self.regularised_prototile.geometry = tiling_utils.repair_polygon( 87 self.regularised_prototile.geometry) 88 89 90 def _setup_biaxial_weave_unit(self) -> None: 91 """Set up weave tiles GeoDataFrame and tile GeoDataFrame.""" 92 warp_threads, weft_threads, _ = \ 93 tiling_utils.get_strand_ids(self.strands) 94 if self.weave_type == "basket" and isinstance(self.n, (list, tuple)): 95 self.n = self.n[0] 96 p = weave_matrices.get_weave_pattern_matrix( 97 weave_type = self.weave_type, n = self.n, warp = warp_threads, 98 weft = weft_threads, tie_up = self._tie_up, tr = self._tr, 99 th = self._th) 100 self._make_shapes_from_coded_weave_matrix( 101 Loom(p), strand_labels = [weft_threads, warp_threads, []]) 102 bb = self.tiles.total_bounds 103 w = (bb[2] - bb[0]) // self.spacing * self.spacing 104 h = (bb[3] - bb[1]) // self.spacing * self.spacing 105 self.setup_vectors((0, h), (w, 0)) 106 107 108 def _get_triaxial_weave_matrices(self, 109 strands_1:list[str]|tuple[str] = ("a",), 110 strands_2:list[str]|tuple[str] = ("b",), 111 strands_3:list[str]|tuple[str] = ("c",), 112 ) -> Loom: 113 """Return encoded weave pattern matrix as Loom of three biaxial matrices. 114 115 Allowed weave_types: "cube" or "hex". 116 117 "hex" is not flexible and will fail with any strand label lists that are 118 not length 3 or include more than one non-blank "-" item. You can 119 generate the "hex" weave with the default settings in any case! 120 121 Strand lists should be length 3 or length 1. "cube" tolerates more 122 than "hex" for the items in the strand lists. 123 124 Defaults will produce 'mad weave'. 125 126 Args: 127 strands_1 (list[str]|tuple[str], optional): list of labels 128 for warp strands. Defaults to ["a"]. 129 strands_2 (list[str]|tuple[str], optional): list of labels 130 for weft strands. Defaults to ["b"]. 131 strands_3 (list[str]|tuple[str], optional): list of labels 132 for weft strands. Defaults to ["c"]. 133 134 Returns: 135 Loom: which combines the three biaxial weaves 12, 23 and 31 implied 136 by the strand label lists. 137 138 """ 139 if self.weave_type == "hex": 140 loom = Loom( 141 weave_matrices.get_weave_pattern_matrix( 142 weave_type = "this", tie_up = np.ones((6, 6)), 143 warp = strands_1, weft = strands_2), 144 weave_matrices.get_weave_pattern_matrix( 145 weave_type = "this", tie_up = np.ones((6, 6)), 146 warp = strands_2, weft = strands_3), 147 weave_matrices.get_weave_pattern_matrix( 148 weave_type = "this", tie_up = np.ones((6, 6)), 149 warp = strands_3, weft = strands_1)) 150 else: # "cube" 151 loom = Loom( 152 # Note n = (1,2,1,2) is required here to force 6x6 twill 153 weave_matrices.get_weave_pattern_matrix( 154 weave_type = "twill", n = (1, 2, 1, 2), 155 warp = strands_1, weft = strands_2), 156 weave_matrices.get_weave_pattern_matrix( 157 weave_type = "twill", n = (1, 2, 1, 2), 158 warp = strands_2, weft = strands_3), 159 weave_matrices.get_weave_pattern_matrix( 160 weave_type = "twill", n = (1, 2, 1, 2), 161 warp = strands_3, weft = strands_1)) 162 return loom 163 164 165 def _setup_triaxial_weave_unit(self) -> None: 166 """Return weave tiles GeoDataFrame and tile GeoDataFrame.""" 167 strands_1, strands_2, strands_3 = \ 168 tiling_utils.get_strand_ids(self.strands) 169 loom = self._get_triaxial_weave_matrices( 170 strands_1 = strands_1, strands_2 = strands_2, strands_3 = strands_3) 171 self._make_shapes_from_coded_weave_matrix( 172 loom, strand_labels = [strands_1, strands_2, strands_3]) 173 self.setup_vectors((0, 6 * self.spacing), 174 (3 * self.spacing * np.sqrt(3), 3 * self.spacing), 175 (3 * self.spacing * np.sqrt(3), -3 * self.spacing)) 176 177 178 def _make_shapes_from_coded_weave_matrix( 179 self, 180 loom:Loom, strand_labels:list[list[str]] = (["a"], ["b"], ["c"]), 181 ) -> None: 182 """Set up weave tiles and prototile GeoDataFrames in a dictionary. 183 184 Builds the geometries associated with a given weave supplied as 185 'loom' containing the coordinates in an appropriate grid (Cartesian or 186 triangular) and the orderings of the strands at each coordinate location 187 188 Args: 189 loom (Loom): matrix or stack of matrices representing the weave 190 pattern. 191 strand_labels (list[list[str]], optional): list of lists of labels 192 for strands in each direction. Defaults to [["a"], ["b"], ["c"]] 193 194 """ 195 grid = WeaveGrid(loom.n_axes, loom.orientations, self.spacing) 196 # expand the list of strand labels if needed in each direction 197 labels = [thread * int(np.ceil(dim // len(thread))) 198 for dim, thread in zip(loom.dimensions, strand_labels, 199 strict = False)] 200 weave_polys = [] 201 strand_ids = [] 202 cells = [] 203 for k, strand_order in zip(loom.indices, loom.orderings, strict = True): 204 IDs = [thread[coord] for coord, thread in zip(k, labels, strict = True)] 205 cells.append(grid.get_grid_cell_at(k)) 206 if strand_order is None: 207 continue # No strands present 208 if strand_order == "NA": 209 continue # Inconsistency in layer order 210 n_slices = [len(ID) for ID in IDs] 211 next_polys = grid.get_visible_cell_strands( 212 width = self.aspect, coords = k, 213 strand_order = strand_order, n_slices = n_slices) 214 weave_polys.extend(next_polys) 215 next_labels = [list(IDs[i]) for i in strand_order] # list of lists 216 next_labels = list(itertools.chain(*next_labels)) # flatten 217 strand_ids.extend(next_labels) 218 # sometimes empty polygons make it to here, so 219 # filter those out along with the associated IDs 220 real_polys = [not p.is_empty for p in weave_polys] 221 weave_polys = [ 222 p for p, b in zip(weave_polys, real_polys, strict = True) if b] 223 strand_ids = [ 224 ID for ID, b in zip(strand_ids, real_polys, strict = True) if b] 225 # note that the tile is important for the biaxial case, which makes it 226 # a little hard to understand why the behaviour is so different in 227 # the biaxial and triaxial cases; however below seems to work... 228 if loom.n_axes == 3: 229 tile = grid.get_tile_from_cells(cells) 230 shift = (0, 0) 231 else: 232 tile = tiling_utils.safe_union(gpd.GeoSeries(cells), as_polygon = True) 233 shift = (-tile.centroid.x, -tile.centroid.y) 234 tile = grid.get_tile_from_cells(tile) 235 self.tiles = self._get_weave_tiles_gdf(weave_polys, strand_ids, shift) 236 self.prototile = gpd.GeoDataFrame( 237 geometry = gpd.GeoSeries([tile]), crs = self.crs) 238 239 240 def _get_weave_tiles_gdf( 241 self, polys:list[geom.Polygon], strand_ids:list[str], 242 offset:tuple[float]) -> gpd.GeoDataFrame: 243 """Make a GeoDataFrame from weave tile polygons, labels, etc. 244 245 Args: 246 polys (list[Polygon | MultiPolygon]): list of weave tile 247 polygons. 248 strand_ids (list[str]): list of strand labels. 249 offset (tuple[float]): offset to centre the weave tiles on the 250 tile. 251 252 Returns: 253 geopandas.GeoDataFrame: GeoDataFrame clipped to the tile, with 254 margin applied. 255 256 """ 257 weave = gpd.GeoDataFrame( 258 data = {"tile_id": strand_ids}, 259 geometry = gpd.GeoSeries([affine.translate(p, offset[0], offset[1]) 260 for p in polys])) 261 weave = weave[weave.tile_id != "-"] 262 weave.geometry = gpd.GeoSeries( 263 [tiling_utils.get_clean_polygon(p) for p in weave.geometry]) 264 265 # some buffering is required if aspect is 1 to safely dissolve and 266 # explode weave unit tiles that meet at corners 267 if self.aspect == 1: 268 # grow for dissolve 269 weave.geometry = weave.geometry.buffer( 270 # self.spacing * tiling_utils.RESOLUTION, 271 tiling_utils.RESOLUTION, 272 join_style = "mitre", cap_style = "square") 273 weave = weave.dissolve(by = "tile_id", as_index = False) 274 # shrink by more to explode into separate polygons 275 weave.geometry = weave.geometry.buffer( 276 # -2 * self.spacing * tiling_utils.RESOLUTION, 277 -2 * tiling_utils.RESOLUTION, 278 join_style = "mitre", cap_style = "square") 279 weave = weave.explode(ignore_index = True) 280 weave.geometry = weave.geometry.buffer( 281 # self.spacing * tiling_utils.RESOLUTION, 282 tiling_utils.RESOLUTION, 283 join_style = "mitre", cap_style = "square") 284 else: # aspect < 1 is fine without buffering 285 weave = weave.dissolve(by = "tile_id", as_index = False) 286 weave = weave.explode(ignore_index = True) 287 weave.geometry = gpd.GeoSeries( 288 [tiling_utils.get_clean_polygon(p) for p in weave.geometry]) 289 return weave.set_crs(self.crs) 290 291 292 def _get_axis_from_label(self, label:str = "a", strands:str = None): 293 """Determine the axis of a tile_id from the strands spec string. 294 295 Args: 296 label (str, optional): the tile_id. Defaults to "a". 297 strands (str, optional): the strand spec. Defaults to the WeaveUnit 298 strands attribute. 299 300 Returns: 301 _type_: the axis in which the supplied tile is found. 302 303 """ 304 if strands == None: 305 strands = self.strands 306 index = strands.index(label) 307 return strands[:index].count("|") 308 309 310 def _get_legend_tiles(self) -> gpd.GeoDataFrame: 311 """Return tiles suitable for use in a legend representation. 312 313 One tile for each tile_id value will be chosen, close to the 314 centre of the prototile extent, and not among the smallest tiles present 315 (for example not a short length of strand mostly hidden by other 316 strands) 317 318 Returns: 319 gpd.GeoDataFrame: the chosen tiles. 320 321 """ 322 angles = ((0, 240, 120) 323 if self.weave_type in ("hex", "cube") 324 else (90, 0)) 325 tile_ids = pd.Series.unique(self.tiles.tile_id) 326 groups = self.tiles.groupby("tile_id") 327 tiles, rotations = [], [] 328 for id in tile_ids: 329 candidates = groups.get_group(id) 330 axis = self._get_axis_from_label(id, self.strands) 331 tiles.append( 332 self._get_most_central_large_tile(candidates, tiles)) 333 rotations.append(-angles[axis] + self.rotation) 334 return gpd.GeoDataFrame( 335 data = {"tile_id": tile_ids, "rotation": rotations}, 336 crs = self.crs, 337 geometry = gpd.GeoSeries(tiles)) 338 339 340 def _get_most_central_large_tile(self, tiles:gpd.GeoDataFrame, 341 other_tiles:list[geom.Polygon]) -> geom.Polygon: 342 """Get a large tile close to the centre of the WeaveUnit. 343 344 Args: 345 tiles (gpd.GeoDataFrame): the set of tiles to choose from. 346 347 Returns: 348 geom.Polygon: the chosen, large central tile. 349 350 """ 351 areas = [g.area for g in tiles.geometry] 352 min_area, max_area = min(areas), max(areas) 353 if min_area / max_area > 0.5: 354 geoms = list(tiles.geometry) 355 else: 356 mean_log_a = np.mean(np.log(areas)) 357 geoms = [g for g, a in zip(tiles.geometry, areas) 358 if np.log(a) > mean_log_a] 359 if len(other_tiles) == 0 or self.weave_type in ("cube", "hex"): 360 d = [g.centroid.distance(geom.Point(0, 0)) for g in geoms] 361 else: 362 c = geom.MultiPolygon(other_tiles).centroid 363 d = [geom.MultiPolygon([g] + other_tiles).centroid.distance(c) 364 for g in geoms] 365 return geoms[d.index(min(d))] 366 367 368 def _get_legend_key_shapes( 369 self, 370 polygon:geom.Polygon, 371 counts:Iterable = [1] * 25, 372 angle:float = 0, 373 radial:bool = False, 374 ) -> list[geom.Polygon]: 375 """Return polygons obtained by slicing polygon crosswise into n slices. 376 377 Orientation of the polygon is indicated by the angle. 378 379 The returned list of polygons can be used to form a colour ramp in a 380 legend. 381 382 Args: 383 polygon (geom.Polygon): the weave strand polygon to slice. 384 counts (Iterable, optional): an iterable list of the numbers of 385 slices in each category. Defaults to [1] * 25. 386 angle (float, optional): orientation of the polygon. Defaults to 0. 387 radial (bool, optional): ignored by WeaveUnit. 388 389 Returns: 390 list[geom.Polygon]: a list of polygons. 391 392 """ 393 c = polygon.centroid 394 g = affine.rotate(polygon, -angle, origin = c) 395 width, height, left, bottom = \ 396 tiling_utils.get_width_height_left_bottom(gpd.GeoSeries([g])) 397 margin = width / 50 398 total = sum(counts) 399 cuts = list(np.cumsum(counts)) 400 cuts = [0] + [c / total for c in cuts] 401 cuts = [left + c * width for c in cuts] 402 bottom = bottom - margin 403 top = bottom + height + 2 * margin 404 slices = [] 405 for l, r in zip(cuts[:-1], cuts[1:], strict = True): 406 # we add a margin to left and right so that they overplot; otherwise in 407 # rendering matplotlib leaves small gaps which give a washed out look 408 # to the fill colour! 409 slice = geom.Polygon([(l - margin, bottom), (r + margin, bottom), 410 (r + margin, top), (l - margin, top)]) 411 slices.append(slice.intersection(g)) 412 return [affine.rotate(s, angle, origin = c) for s in slices]
Extend Tileable to allow for tiles that appear like woven patterns.
weave_type: str =
'plain'
type of weave pattern, one of plain
, twill
, basket
, cube
, hex
or
this
. Defaults to plain
.