2023-01-18 02:44:23 +00:00
|
|
|
import os
|
2023-04-20 17:25:10 +00:00
|
|
|
from math import ceil
|
2023-01-18 02:44:23 +00:00
|
|
|
|
|
|
|
import inkex
|
2023-02-19 03:24:58 +00:00
|
|
|
import json
|
2023-01-18 02:44:23 +00:00
|
|
|
import lxml
|
2023-02-18 22:44:42 +00:00
|
|
|
import networkx as nx
|
2023-04-02 04:14:57 +00:00
|
|
|
from shapely.geometry import LineString, MultiLineString
|
2022-09-22 23:54:42 +00:00
|
|
|
from shapely.prepared import prep
|
|
|
|
|
2024-05-11 06:14:40 +00:00
|
|
|
from .debug.debug import debug
|
2023-02-19 03:24:58 +00:00
|
|
|
from .i18n import _
|
2022-09-22 23:54:42 +00:00
|
|
|
from .svg import apply_transforms
|
2023-01-18 02:44:23 +00:00
|
|
|
from .utils import Point, cache, get_bundled_dir, guess_inkscape_config_path
|
2023-02-06 03:58:17 +00:00
|
|
|
from .utils.threading import check_stop_flag
|
2022-09-22 23:54:42 +00:00
|
|
|
|
|
|
|
|
|
|
|
class Tile:
|
|
|
|
def __init__(self, path):
|
|
|
|
self._load_tile(path)
|
|
|
|
|
|
|
|
def _load_tile(self, tile_path):
|
2023-02-19 03:24:58 +00:00
|
|
|
self.tile_svg = inkex.load_svg(os.path.join(tile_path, "tile.svg"))
|
2023-03-07 16:44:28 +00:00
|
|
|
self.preview_image = self._load_preview(tile_path)
|
2023-02-19 03:24:58 +00:00
|
|
|
self._load_metadata(tile_path)
|
2023-01-16 19:27:06 +00:00
|
|
|
self.tile = None
|
|
|
|
self.width = None
|
|
|
|
self.height = None
|
|
|
|
self.shift0 = None
|
|
|
|
self.shift1 = None
|
2022-09-22 23:54:42 +00:00
|
|
|
|
2023-02-13 04:09:42 +00:00
|
|
|
def __lt__(self, other):
|
|
|
|
return self.name < other.name
|
|
|
|
|
2022-09-22 23:54:42 +00:00
|
|
|
def __repr__(self):
|
2023-03-07 16:44:28 +00:00
|
|
|
return f"Tile({self.name}, {self.id}, {self.preview_image})"
|
2022-09-22 23:54:42 +00:00
|
|
|
|
|
|
|
__str__ = __repr__
|
|
|
|
|
2023-03-07 16:44:28 +00:00
|
|
|
def _load_preview(self, tile_path):
|
|
|
|
image_path = os.path.join(tile_path, "preview.png")
|
|
|
|
if os.path.isfile(image_path):
|
|
|
|
return image_path
|
|
|
|
return None
|
|
|
|
|
2023-02-19 03:24:58 +00:00
|
|
|
def _load_metadata(self, tile_path):
|
|
|
|
with open(os.path.join(tile_path, "tile.json"), "rb") as tile_json:
|
|
|
|
tile_metadata = json.load(tile_json)
|
|
|
|
self.name = _(tile_metadata.get('name'))
|
|
|
|
self.id = tile_metadata.get('id')
|
|
|
|
|
2023-01-20 16:28:13 +00:00
|
|
|
def _get_name(self, tile_path):
|
|
|
|
return os.path.splitext(os.path.basename(tile_path))[0]
|
2023-01-16 19:27:06 +00:00
|
|
|
|
|
|
|
def _load(self):
|
|
|
|
self._load_paths(self.tile_svg)
|
|
|
|
self._load_dimensions(self.tile_svg)
|
|
|
|
self._load_parallelogram(self.tile_svg)
|
2022-09-22 23:54:42 +00:00
|
|
|
|
|
|
|
def _load_paths(self, tile_svg):
|
2023-01-18 02:44:23 +00:00
|
|
|
path_elements = tile_svg.findall('.//svg:path', namespaces=inkex.NSS)
|
2023-04-02 04:14:57 +00:00
|
|
|
tile = self._path_elements_to_line_strings(path_elements)
|
|
|
|
center, ignore, ignore = self._get_center_and_dimensions(MultiLineString(tile))
|
|
|
|
self.tile = [(start - center, end - center) for start, end in tile]
|
2022-09-22 23:54:42 +00:00
|
|
|
|
|
|
|
def _load_dimensions(self, tile_svg):
|
2023-01-18 02:44:23 +00:00
|
|
|
svg_element = tile_svg.getroot()
|
|
|
|
self.width = svg_element.viewport_width
|
|
|
|
self.height = svg_element.viewport_height
|
2022-09-22 23:54:42 +00:00
|
|
|
|
|
|
|
def _load_parallelogram(self, tile_svg):
|
2023-01-18 02:44:23 +00:00
|
|
|
parallelogram_elements = tile_svg.findall(".//svg:*[@class='para']", namespaces=inkex.NSS)
|
|
|
|
if parallelogram_elements:
|
|
|
|
path_element = parallelogram_elements[0]
|
|
|
|
path = apply_transforms(path_element.get_path(), path_element)
|
|
|
|
subpaths = path.to_superpath()
|
|
|
|
subpath = subpaths[0]
|
|
|
|
points = [Point.from_tuple(p[1]) for p in subpath]
|
|
|
|
self.shift0 = points[1] - points[0]
|
|
|
|
self.shift1 = points[2] - points[1]
|
|
|
|
else:
|
|
|
|
self.shift0 = Point(self.width, 0)
|
|
|
|
self.shift1 = Point(0, self.height)
|
2022-09-22 23:54:42 +00:00
|
|
|
|
|
|
|
def _path_elements_to_line_strings(self, path_elements):
|
|
|
|
lines = []
|
|
|
|
for path_element in path_elements:
|
|
|
|
path = apply_transforms(path_element.get_path(), path_element)
|
|
|
|
for subpath in path.to_superpath():
|
|
|
|
# We only care about the endpoints of each subpath. They're
|
|
|
|
# supposed to be simple line segments.
|
|
|
|
lines.append([Point.from_tuple(subpath[0][1]), Point.from_tuple(subpath[-1][1])])
|
|
|
|
|
|
|
|
return lines
|
|
|
|
|
|
|
|
def _get_center_and_dimensions(self, shape):
|
|
|
|
min_x, min_y, max_x, max_y = shape.bounds
|
|
|
|
center = Point((max_x + min_x) / 2, (max_y + min_y) / 2)
|
|
|
|
width = max_x - min_x
|
|
|
|
height = max_y - min_y
|
|
|
|
|
|
|
|
return center, width, height
|
|
|
|
|
2023-03-07 17:13:57 +00:00
|
|
|
def _translate_tile(self, tile, shift):
|
2022-09-22 23:54:42 +00:00
|
|
|
translated_tile = []
|
|
|
|
|
2023-03-07 17:13:57 +00:00
|
|
|
for start, end in tile:
|
2022-09-22 23:54:42 +00:00
|
|
|
start += shift
|
|
|
|
end += shift
|
|
|
|
translated_tile.append((start.as_int().as_tuple(), end.as_int().as_tuple()))
|
|
|
|
|
|
|
|
return translated_tile
|
|
|
|
|
2023-04-04 02:59:02 +00:00
|
|
|
def _scale_and_rotate(self, x_scale, y_scale, angle):
|
|
|
|
transformed_shift0 = self.shift0.scale(x_scale, y_scale).rotate(angle)
|
|
|
|
transformed_shift1 = self.shift1.scale(x_scale, y_scale).rotate(angle)
|
2023-01-18 02:44:23 +00:00
|
|
|
|
2023-04-04 02:59:02 +00:00
|
|
|
transformed_tile = []
|
2023-01-18 02:44:23 +00:00
|
|
|
for start, end in self.tile:
|
2023-04-04 02:59:02 +00:00
|
|
|
start = start.scale(x_scale, y_scale).rotate(angle)
|
|
|
|
end = end.scale(x_scale, y_scale).rotate(angle)
|
|
|
|
transformed_tile.append((start, end))
|
2023-03-07 17:13:57 +00:00
|
|
|
|
2023-04-04 02:59:02 +00:00
|
|
|
return transformed_shift0, transformed_shift1, transformed_tile
|
2023-01-18 02:44:23 +00:00
|
|
|
|
2023-01-16 19:27:06 +00:00
|
|
|
@debug.time
|
2023-04-04 02:59:02 +00:00
|
|
|
def to_graph(self, shape, scale, angle):
|
2022-09-22 23:54:42 +00:00
|
|
|
"""Apply this tile to a shape, repeating as necessary.
|
|
|
|
|
|
|
|
Return value:
|
|
|
|
networkx.Graph with edges corresponding to lines in the pattern.
|
|
|
|
Each edge has an attribute 'line_string' with the LineString
|
|
|
|
representation of this edge.
|
|
|
|
"""
|
2023-01-16 19:27:06 +00:00
|
|
|
self._load()
|
2023-01-18 02:44:23 +00:00
|
|
|
x_scale, y_scale = scale
|
2023-04-04 02:59:02 +00:00
|
|
|
shift0, shift1, tile = self._scale_and_rotate(x_scale, y_scale, angle)
|
2023-01-16 19:27:06 +00:00
|
|
|
|
2022-09-22 23:54:42 +00:00
|
|
|
shape_center, shape_width, shape_height = self._get_center_and_dimensions(shape)
|
2023-02-08 19:39:25 +00:00
|
|
|
prepared_shape = prep(shape)
|
2022-09-22 23:54:42 +00:00
|
|
|
|
2023-04-02 04:14:57 +00:00
|
|
|
return self._generate_graph(prepared_shape, shape_center, shape_width, shape_height, shift0, shift1, tile)
|
2023-01-18 02:44:23 +00:00
|
|
|
|
2023-04-02 04:14:57 +00:00
|
|
|
@debug.time
|
|
|
|
def _generate_graph(self, shape, shape_center, shape_width, shape_height, shift0, shift1, tile):
|
2023-02-18 22:44:42 +00:00
|
|
|
graph = nx.Graph()
|
2023-04-02 04:14:57 +00:00
|
|
|
|
|
|
|
shape_diagonal = Point(shape_width, shape_height).length()
|
|
|
|
num_tiles = ceil(shape_diagonal / min(shift0.length(), shift1.length()))
|
|
|
|
debug.log(f"num_tiles: {num_tiles}")
|
|
|
|
|
|
|
|
tile_diagonal = (shift0 + shift1).length()
|
|
|
|
x_cutoff = shape_width / 2 + tile_diagonal
|
|
|
|
y_cutoff = shape_height / 2 + tile_diagonal
|
|
|
|
|
|
|
|
for repeat0 in range(-num_tiles, num_tiles):
|
|
|
|
for repeat1 in range(-num_tiles, num_tiles):
|
2023-02-06 03:58:17 +00:00
|
|
|
check_stop_flag()
|
|
|
|
|
2023-03-07 17:13:57 +00:00
|
|
|
offset0 = repeat0 * shift0
|
|
|
|
offset1 = repeat1 * shift1
|
2023-04-02 04:14:57 +00:00
|
|
|
offset = offset0 + offset1
|
|
|
|
|
|
|
|
if abs(offset.x) > x_cutoff or abs(offset.y) > y_cutoff:
|
|
|
|
continue
|
|
|
|
|
|
|
|
this_tile = self._translate_tile(tile, offset + shape_center)
|
2022-09-22 23:54:42 +00:00
|
|
|
for line in this_tile:
|
|
|
|
line_string = LineString(line)
|
2023-01-18 02:44:23 +00:00
|
|
|
if shape.contains(line_string):
|
|
|
|
graph.add_edge(line[0], line[1])
|
2022-09-22 23:54:42 +00:00
|
|
|
|
2023-02-08 20:39:50 +00:00
|
|
|
self._remove_dead_ends(graph)
|
|
|
|
|
2022-09-22 23:54:42 +00:00
|
|
|
return graph
|
|
|
|
|
2023-04-02 04:14:57 +00:00
|
|
|
@debug.time
|
2023-02-08 20:39:50 +00:00
|
|
|
def _remove_dead_ends(self, graph):
|
2023-02-18 22:44:42 +00:00
|
|
|
graph.remove_edges_from(nx.selfloop_edges(graph))
|
2023-02-08 20:39:50 +00:00
|
|
|
while True:
|
2023-02-18 22:44:42 +00:00
|
|
|
dead_end_nodes = [node for node, degree in graph.degree() if degree <= 1]
|
2023-02-08 20:39:50 +00:00
|
|
|
|
2023-02-18 22:44:42 +00:00
|
|
|
if dead_end_nodes:
|
|
|
|
graph.remove_nodes_from(dead_end_nodes)
|
2023-02-08 20:39:50 +00:00
|
|
|
else:
|
|
|
|
return
|
|
|
|
|
2022-09-22 23:54:42 +00:00
|
|
|
|
|
|
|
def all_tile_paths():
|
|
|
|
return [os.path.join(guess_inkscape_config_path(), 'tiles'),
|
|
|
|
get_bundled_dir('tiles')]
|
|
|
|
|
|
|
|
|
2023-01-16 19:27:06 +00:00
|
|
|
@cache
|
2022-09-22 23:54:42 +00:00
|
|
|
def all_tiles():
|
2023-01-16 19:27:06 +00:00
|
|
|
tiles = []
|
2023-02-19 03:24:58 +00:00
|
|
|
for tiles_path in all_tile_paths():
|
2022-09-22 23:54:42 +00:00
|
|
|
try:
|
2023-02-19 03:24:58 +00:00
|
|
|
for tile_dir in sorted(os.listdir(tiles_path)):
|
2023-01-18 02:44:23 +00:00
|
|
|
try:
|
2023-02-19 03:24:58 +00:00
|
|
|
tiles.append(Tile(os.path.join(tiles_path, tile_dir)))
|
|
|
|
except (OSError, lxml.etree.XMLSyntaxError, json.JSONDecodeError, KeyError) as exc:
|
|
|
|
debug.log(f"error loading tile {tiles_path}/{tile_dir}: {exc}")
|
|
|
|
except Exception as exc:
|
|
|
|
debug.log(f"unexpected error loading tile {tiles_path}/{tile_dir}: {exc}")
|
|
|
|
raise
|
2022-09-22 23:54:42 +00:00
|
|
|
except FileNotFoundError:
|
|
|
|
pass
|
2023-01-16 19:27:06 +00:00
|
|
|
|
|
|
|
return tiles
|