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]
@dataclass
class WeaveUnit(weavingspace.tileable.Tileable):
 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.

WeaveUnit(**kwargs: float | str)
61  def __init__(self, **kwargs:float|str) -> None:
62    super().__init__(**kwargs)
weave_type: str = 'plain'

type of weave pattern, one of plain, twill, basket, cube, hex or this. Defaults to plain.

aspect: float = 1.0

width of strands relative to the spacing. Defaults to 1.0.

n: int | tuple[int] = (2, 2)

number of over-under strands in biaxial weaves. Only one item is required in a plain weave. Twill and basket patterns expect an even number of entries in the tuple.

strands: str = 'a|b|c'

specification of the strand labels along each axis. Defaults to a|b|c.