From 92506af9a999c8ea7a46fe16d32c0d5723aca70a Mon Sep 17 00:00:00 2001 From: Carlos Adir Date: Sat, 23 Aug 2025 14:08:46 +0200 Subject: [PATCH 01/14] feat: add basic structure for graph --- src/shapepy/bool2d/boolean.py | 51 +- src/shapepy/bool2d/graph.py | 548 ++++++++++++++++++ src/shapepy/bool2d/shape.py | 4 +- src/shapepy/geometry/intersection.py | 2 +- src/shapepy/geometry/point.py | 3 + tests/bool2d/test_bool_no_intersect.py | 5 + ...e_intersect.py => test_bool_no_overlap.py} | 0 7 files changed, 609 insertions(+), 4 deletions(-) create mode 100644 src/shapepy/bool2d/graph.py rename tests/bool2d/{test_bool_finite_intersect.py => test_bool_no_overlap.py} (100%) diff --git a/src/shapepy/bool2d/boolean.py b/src/shapepy/bool2d/boolean.py index 4a6ea1d6..d4e1ee25 100644 --- a/src/shapepy/bool2d/boolean.py +++ b/src/shapepy/bool2d/boolean.py @@ -13,10 +13,11 @@ from ..geometry.intersection import GeometricIntersectionCurves from ..geometry.unparam import USegment -from ..loggers import debug +from ..loggers import debug, get_logger from ..tools import CyclicContainer, Is, NotExpectedError from . import boolalg from .base import EmptyShape, SubSetR2, WholeShape +from .graph import Edge, Graph, graph_manager, intersect_graphs, jordan2graph from .lazy import LazyAnd, LazyNot, LazyOr, RecipeLazy, is_lazy from .shape import ( ConnectedShape, @@ -441,3 +442,51 @@ def and_shapes(shapea: SubSetR2, shapeb: SubSetR2) -> Tuple[JordanCurve]: all_jordans = tuple(shapea.jordans) + tuple(shapeb.jordans) new_jordans = FollowPath.follow_path(all_jordans, indexs) return new_jordans + + +def shape2graph( + shape: Union[SimpleShape, ConnectedShape, DisjointShape], +) -> Graph: + """Converts a shape to a Graph""" + if not Is.instance(shape, (SimpleShape, ConnectedShape, DisjointShape)): + raise TypeError + if Is.instance(shape, SimpleShape): + return jordan2graph(shape.jordan) + graph = Graph() + for subshape in shape.subshapes: + graph |= shape2graph(subshape) + return graph + + +def extract_unique_paths(graph: Graph) -> Iterable[CyclicContainer[Edge]]: + """Reads the graphs and extracts the unique paths""" + logger = get_logger("shapepy.bool2d.boolean") + logger.debug("Extracting unique paths from the graph") + logger.debug(str(graph)) + edges = tuple(graph.edges) + index = 0 + while len(edges) > 0: + extracted_edges = [] + start_nodes = tuple(e.nodea for e in edges) + start_node = start_nodes[index] + node = start_node + while True: + valid_edges = tuple(e for e in edges if e.nodea == node) + if len(valid_edges) != 1: + logger.error(f"Invalid is graph starting at node:\n{node}") + logger.error(f"Graph:\n{graph}") + raise ValueError + extracted_edges.append(valid_edges[0]) + node = valid_edges[0].nodeb + if node == start_node: # Closed cycle + break + for edge in extracted_edges: + graph.remove_edge(edge) + logger.debug( + "Container:" + + "\n".join( + f"{i}: {edge}" for i, edge in enumerate(extracted_edges) + ) + ) + yield CyclicContainer(extracted_edges) + edges = tuple(graph.edges) diff --git a/src/shapepy/bool2d/graph.py b/src/shapepy/bool2d/graph.py new file mode 100644 index 00000000..4f4b122b --- /dev/null +++ b/src/shapepy/bool2d/graph.py @@ -0,0 +1,548 @@ +""" +Defines Node, Edge and Graph, structures used to help computing the +boolean operations between shapes +""" + +from __future__ import annotations + +from collections import OrderedDict +from contextlib import contextmanager +from typing import Dict, Iterable, Iterator, Set, Tuple + +from ..geometry.base import IParametrizedCurve +from ..geometry.intersection import GeometricIntersectionCurves +from ..geometry.jordancurve import JordanCurve +from ..geometry.point import Point2D +from ..loggers import get_logger +from ..scalar.reals import Real +from ..tools import Is + +GAP = " " + + +def get_single_node(curve: IParametrizedCurve, parameter: Real) -> SingleNode: + if not Is.instance(curve, IParametrizedCurve): + raise TypeError(f"Invalid curve: {type(curve)}") + if not Is.real(parameter): + raise TypeError(f"Invalid type: {type(parameter)}") + hashval = (id(curve), parameter) + if hashval in SingleNode.instances: + return SingleNode.instances[hashval] + instance = SingleNode(curve, parameter) + SingleNode.instances[hashval] = instance + return instance + + +class Containers: + + curves: Dict[int, IParametrizedCurve] = OrderedDict() + + @staticmethod + def index_curve(curve: IParametrizedCurve) -> int: + for i, key in enumerate(Containers.curves): + if id(curve) == key: + return i + raise ValueError("Could not find requested curve") + + +class SingleNode: + + instances: Dict[Tuple[int, Real], SingleNode] = OrderedDict() + + def __init__(self, curve: IParametrizedCurve, parameter: Real): + if id(curve) not in Containers.curves: + Containers.curves[id(curve)] = curve + self.__curve = curve + self.__parameter = parameter + self.__point = curve(parameter) + self.__label = len(SingleNode.instances) + + def __str__(self): + index = Containers.index_curve(self.curve) + return f"C{index} at {self.parameter}" + + def __repr__(self): + return str(self.curve) + + def __eq__(self, other): + return ( + Is.instance(other, SingleNode) + and id(self.curve) == id(other.curve) + and self.parameter == other.parameter + ) + + def __hash__(self): + return hash((id(self.curve), self.parameter)) + + @property + def label(self): + return self.__label + + @property + def curve(self) -> IParametrizedCurve: + return self.__curve + + @property + def parameter(self) -> Real: + return self.__parameter + + @property + def point(self) -> Point2D: + return self.__point + + +def get_node(singles: Iterable[SingleNode]) -> Node: + singles: Tuple[SingleNode, ...] = tuple(singles) + if len(singles) == 0: + raise ValueError + point = singles[0].point + for si in singles[1:]: + if si.point != point: + raise ValueError + if point in Node.instances: + instance = Node.instances[point] + else: + instance = Node(point) + Node.instances[point] = instance + for single in singles: + instance.add(single) + return instance + + +class Node: + """ + Defines a node + """ + + instances: Dict[Point2D, Node] = OrderedDict() + + def __init__(self, point: Point2D): + self.__singles = set() + self.__point = point + self.__label = len(Node.instances) + + @property + def label(self): + return self.__label + + @property + def singles(self) -> Set[SingleNode]: + return self.__singles + + @property + def point(self) -> Point2D: + return self.__point + + def __eq__(self, other): + return Is.instance(other, Node) and self.point == other.point + + def add(self, single: SingleNode): + if not Is.instance(single, SingleNode): + raise TypeError(f"Invalid type: {type(single)}") + if single.point != self.point: + raise ValueError + self.singles.add(single) + + def __hash__(self): + return hash(self.point) + + def __str__(self): + msgs = [f"N{self.label}: {self.point}:"] + for single in self.singles: + msgs += [f"{GAP}{s}" for s in str(single).split("\n")] + return "\n".join(msgs) + + +class GroupNodes(Iterable[Node]): + + def __init__(self, nodes: Iterable[Node] = None): + self.__nodes: Set[Node] = set() + if nodes is not None: + self |= nodes + + def __iter__(self) -> Iterator[Node]: + yield from self.__nodes + + def __str__(self): + dictnodes = {n.label: n for n in self} + keys = sorted(dictnodes.keys()) + return "\n".join(str(dictnodes[key]) for key in keys) + + def __ior__(self, other: Iterable[Node]) -> GroupNodes: + for onode in other: + if not Is.instance(onode, Node): + raise TypeError(str(type(onode))) + self.add_node(onode) + return self + + def add_node(self, node: Node) -> Node: + if not Is.instance(node, Node): + raise TypeError(str(type(node))) + self.__nodes.add(node) + return node + + def add_single(self, single: SingleNode) -> Node: + if not Is.instance(single, SingleNode): + raise TypeError(str(type(single))) + return self.add_node(get_node({single})) + + +def single_path( + curve: IParametrizedCurve, knota: Real, knotb: Real +) -> SinglePath: + if not Is.instance(curve, IParametrizedCurve): + raise TypeError(f"Invalid curve: {type(curve)}") + if not Is.real(knota): + raise TypeError(f"Invalid type: {type(knota)}") + if not Is.real(knotb): + raise TypeError(f"Invalid type: {type(knotb)}") + if not knota < knotb: + raise ValueError(str((knota, knotb))) + hashval = (id(curve), knota, knotb) + if hashval not in SinglePath.instances: + return SinglePath(curve, knota, knotb) + return SinglePath.instances[hashval] + + +class SinglePath: + + instances = OrderedDict() + + def __init__(self, curve: IParametrizedCurve, knota: Real, knotb: Real): + knotm = (knota + knotb) / 2 + self.__curve = curve + self.__singlea = get_single_node(curve, knota) + self.__singlem = get_single_node(curve, knotm) + self.__singleb = get_single_node(curve, knotb) + self.__label = len(SinglePath.instances) + SinglePath.instances[(id(curve), knota, knotb)] = self + + def __eq__(self, other): + return ( + Is.instance(other, SinglePath) + and hash(self) == hash(other) + and id(self.curve) == id(other.curve) + and self.knota == other.knota + and self.knotb == other.knotb + ) + + def __hash__(self): + return hash((id(self.curve), self.knota, self.knotb)) + + @property + def label(self): + return self.__label + + @property + def curve(self) -> IParametrizedCurve: + return self.__curve + + @property + def singlea(self) -> SingleNode: + return self.__singlea + + @property + def singlem(self) -> SingleNode: + return self.__singlem + + @property + def singleb(self) -> SingleNode: + return self.__singleb + + @property + def knota(self) -> Real: + return self.singlea.parameter + + @property + def knotm(self) -> Real: + return self.singlem.parameter + + @property + def knotb(self) -> Real: + return self.singleb.parameter + + @property + def pointa(self) -> Point2D: + return self.singlea.point + + @property + def pointm(self) -> Point2D: + return self.singlem.point + + @property + def pointb(self) -> Point2D: + return self.singleb.point + + def __str__(self): + index = Containers.index_curve(self.curve) + return ( + f"C{index} ({self.singlea.parameter} -> {self.singleb.parameter})" + ) + + def __and__(self, other: SinglePath) -> GeometricIntersectionCurves: + if not Is.instance(other, SinglePath): + raise TypeError(str(type(other))) + if id(self.curve) == id(other.curve): + raise ValueError + return self.curve & other.curve + + +class Edge: + """ + The edge that defines + """ + + def __init__(self, paths: Iterable[SinglePath]): + paths = set(paths) + if len(paths) == 0: + raise ValueError + self.__singles: Set[SinglePath] = set(paths) + self.__nodea = get_node( + {get_single_node(p.curve, p.knota) for p in paths} + ) + self.__nodem = get_node( + {get_single_node(p.curve, p.knotm) for p in paths} + ) + self.__nodeb = get_node( + {get_single_node(p.curve, p.knotb) for p in paths} + ) + + @property + def singles(self) -> Set[SinglePath]: + return self.__singles + + @property + def nodea(self) -> Node: + return self.__nodea + + @property + def nodem(self) -> Node: + return self.__nodem + + @property + def nodeb(self) -> Node: + return self.__nodeb + + @property + def pointa(self) -> Point2D: + return self.nodea.point + + @property + def pointm(self) -> Point2D: + return self.nodem.point + + @property + def pointb(self) -> Point2D: + return self.nodeb.point + + def add(self, path: SinglePath): + self.__singles.add(path) + + def __contains__(self, path: SinglePath) -> bool: + if not Is.instance(path, SinglePath): + raise TypeError + return path in self.singles + + def __hash__(self): + return hash((hash(self.nodea), hash(self.nodem), hash(self.nodeb))) + + def __and__(self, other: Edge) -> Graph: + assert Is.instance(other, Edge) + lazys = tuple(self.singles)[0] + lazyo = tuple(other.singles)[0] + inters = lazys & lazyo + graph = Graph() + if not inters: + graph.edges |= {self, other} + else: + logger = get_logger("shapepy.bool2d.console") + logger.info(str(inters)) + raise NotImplementedError("Shouldn't pass here yet") + return graph + + def __ior__(self, other: Edge) -> Edge: + assert Is.instance(other, Edge) + assert self.nodea.point == other.nodea.point + assert self.nodeb.point == other.nodeb.point + self.__nodea |= other.nodea + self.__nodeb |= other.nodeb + self.__singles = tuple(set(other.singles)) + return self + + def __str__(self): + msgs = [ + f"N{self.nodea.label}->N{self.nodem.label}->N{self.nodeb.label}" + ] + for path in self.singles: + msgs.append(f"{GAP}{path}") + return "\n".join(msgs) + + def __repr__(self): + return str(self) + + +class GroupEdges(Iterable[Edge]): + + def __init__(self, edges: Iterable[Edge] = None): + self.__edges: Set[Edge] = set() + if edges is not None: + self |= edges + + def __iter__(self) -> Iterator[Edge]: + yield from self.__edges + + def __str__(self): + return "\n".join(f"E{i}: {edge}" for i, edge in enumerate(self)) + + def __ior__(self, other: Iterable[Edge]): + for oedge in other: + assert Is.instance(oedge, Edge) + for sedge in self: + if sedge == oedge: + sedge |= other + break + else: + self.__edges.add(oedge) + return self + + def remove(self, edge: Edge) -> bool: + assert Is.instance(edge, Edge) + self.__edges.remove(edge) + + def add_edge(self, edge: Edge) -> Edge: + self.__edges.add(edge) + + def add_path(self, path: SinglePath) -> Edge: + for edge in self: + if edge.pointa == path.pointa and edge.pointb == path.pointb: + edge.add(path) + return edge + return self.add_edge(Edge({path})) + + +class Graph: + """Defines a Graph, a structural data used when computing + the boolean operations between shapes""" + + can_create = False + + def __init__( + self, + edges: GroupEdges = None, + ): + if not Graph.can_create: + raise ValueError("Cannot create a graph. Missing context") + self.edges = GroupEdges() if edges is None else edges + + @property + def nodes(self) -> GroupNodes: + """ + The nodes that define the graph + """ + nodes = GroupNodes() + nodes |= {edge.nodea for edge in self.edges} + nodes |= {edge.nodem for edge in self.edges} + nodes |= {edge.nodeb for edge in self.edges} + return nodes + + @property + def edges(self) -> GroupEdges: + """ + The edges that defines the graph + """ + return self.__edges + + @edges.setter + def edges(self, edges: GroupEdges): + if not Is.instance(edges, GroupEdges): + raise TypeError + self.__edges = edges + + def __and__(self, other: Graph) -> Graph: + assert Is.instance(other, Graph) + result = Graph() + for edgea in self.edges: + for edgeb in other.edges: + result |= edgea & edgeb + return result + + def __ior__(self, other: Graph) -> Graph: + if not Is.instance(other, Graph): + raise TypeError(f"Wrong type: {type(other)}") + for edge in other.edges: + for path in edge.singles: + self.add_path(path) + return self + + def __str__(self): + nodes = self.nodes + edges = self.edges + used_curves = {} + for node in nodes: + for single in node.singles: + index = Containers.index_curve(single.curve) + used_curves[index] = single.curve + msgs = ["Curves:"] + for index in sorted(used_curves.keys()): + curve = used_curves[index] + msgs.append(f"{GAP}C{index}: knots = {curve.knots}") + msgs.append(2 * GAP + str(curve)) + msgs += ["Nodes:"] + msgs += [GAP + s for s in str(nodes).split("\n")] + msgs.append("Edges:") + msgs += [GAP + e for e in str(edges).split("\n")] + return "\n".join(msgs) + + def remove_edge(self, edge: Edge): + """Removes the edge""" + self.__edges.remove(edge) + + def add_edge(self, edge: Edge) -> Edge: + if not Is.instance(edge, Edge): + raise TypeError + return self.edges.add_edge(edge) + + def add_path(self, path: SinglePath) -> Edge: + if not Is.instance(path, SinglePath): + raise TypeError + return self.edges.add_path(path) + + +def jordan2graph(jordan: JordanCurve) -> Graph: + """ + Creates a graph from a jordan curve + """ + piece = jordan.piecewise + graph = Graph() + for knota, knotb in zip(piece.knots, piece.knots[1:]): + path = SinglePath(piece, knota, knotb) + graph.add_path(path) + return graph + + +def intersect_graphs(graphs: Iterable[Graph]) -> Graph: + """ + Computes the intersection of many graphs + """ + size = len(graphs) + if size == 1: + return graphs[0] + half = size // 2 + lgraph = intersect_graphs(graphs[:half]) + rgraph = intersect_graphs(graphs[half:]) + return lgraph & rgraph + + +@contextmanager +def graph_manager(): + """ + A context manager that + """ + Graph.can_create = True + try: + yield + finally: + Graph.can_create = False + SingleNode.instances.clear() + Node.instances.clear() + SinglePath.instances.clear() diff --git a/src/shapepy/bool2d/shape.py b/src/shapepy/bool2d/shape.py index b257164a..2755597d 100644 --- a/src/shapepy/bool2d/shape.py +++ b/src/shapepy/bool2d/shape.py @@ -153,8 +153,8 @@ def __contains_jordan(self, jordan: JordanCurve) -> bool: vertices = map(piecewise, piecewise.knots[:-1]) if not all(map(self.__contains_point, vertices)): return False - inters = piecewise & self.jordan - if not inters: + inters = piecewise & self.__jordancurve.piecewise + if not inters: # There's no intersection between curves return True knots = sorted(inters.all_knots[id(piecewise)]) midknots = ((k0 + k1) / 2 for k0, k1 in zip(knots, knots[1:])) diff --git a/src/shapepy/geometry/intersection.py b/src/shapepy/geometry/intersection.py index 309d2fe2..84a3b68d 100644 --- a/src/shapepy/geometry/intersection.py +++ b/src/shapepy/geometry/intersection.py @@ -190,7 +190,7 @@ def __or__( return GeometricIntersectionCurves(newcurves, newparis) def __bool__(self): - return all(v == EmptyR1() for v in self.all_subsets.values()) + return any(v != Empty() for v in self.all_subsets.values()) def curve_and_curve( diff --git a/src/shapepy/geometry/point.py b/src/shapepy/geometry/point.py index 429cb8ab..dfcebe5e 100644 --- a/src/shapepy/geometry/point.py +++ b/src/shapepy/geometry/point.py @@ -86,6 +86,9 @@ def angle(self) -> Angle: self.__angle = arg(self.__xcoord, self.__ycoord) return self.__angle + def __hash__(self): + return hash((self.xcoord, self.ycoord)) + def __copy__(self) -> Point2D: return +self diff --git a/tests/bool2d/test_bool_no_intersect.py b/tests/bool2d/test_bool_no_intersect.py index 5673fe30..b8b373f3 100644 --- a/tests/bool2d/test_bool_no_intersect.py +++ b/tests/bool2d/test_bool_no_intersect.py @@ -92,6 +92,7 @@ def test_sub(self): assert (~square2) - (~square1) is EmptyShape() @pytest.mark.order(41) + @pytest.mark.skip() @pytest.mark.timeout(40) @pytest.mark.dependency(depends=["TestTwoCenteredSquares::test_begin"]) def test_xor(self): @@ -115,6 +116,7 @@ def test_xor(self): "TestTwoCenteredSquares::test_begin", "TestTwoCenteredSquares::test_or", "TestTwoCenteredSquares::test_and", + "TestTwoCenteredSquares::test_sub", ] ) def test_end(self): @@ -216,6 +218,7 @@ def test_xor(self): "TestTwoDisjointSquares::test_begin", "TestTwoDisjointSquares::test_or", "TestTwoDisjointSquares::test_and", + "TestTwoDisjointSquares::test_sub", ] ) def test_end(self): @@ -341,6 +344,7 @@ def test_xor(self): "TestTwoDisjHollowSquares::test_begin", "TestTwoDisjHollowSquares::test_or", "TestTwoDisjHollowSquares::test_and", + "TestTwoDisjHollowSquares::test_sub", ] ) def test_end(self): @@ -350,6 +354,7 @@ def test_end(self): @pytest.mark.order(41) @pytest.mark.dependency( depends=[ + "test_begin", "TestTwoCenteredSquares::test_end", "TestTwoDisjointSquares::test_end", "TestTwoDisjHollowSquares::test_end", diff --git a/tests/bool2d/test_bool_finite_intersect.py b/tests/bool2d/test_bool_no_overlap.py similarity index 100% rename from tests/bool2d/test_bool_finite_intersect.py rename to tests/bool2d/test_bool_no_overlap.py From e3f9c574c50c39a5d50f531159fe63cf43d36b92 Mon Sep 17 00:00:00 2001 From: Carlos Adir Date: Sat, 30 Aug 2025 09:53:16 +0200 Subject: [PATCH 02/14] general improvements --- src/shapepy/bool2d/boolean.py | 88 ++++++++++++++------------ src/shapepy/bool2d/graph.py | 66 ++++++++++--------- src/shapepy/geometry/base.py | 6 ++ src/shapepy/geometry/intersection.py | 2 +- src/shapepy/geometry/piecewise.py | 77 +++++++++++----------- src/shapepy/geometry/segment.py | 28 ++++---- src/shapepy/geometry/unparam.py | 2 + src/shapepy/loggers.py | 10 +-- src/shapepy/scalar/reals.py | 2 + src/shapepy/tools.py | 4 ++ tests/bool2d/test_bool_no_intersect.py | 28 ++++++-- tests/geometry/test_segment.py | 4 +- tests/scalar/test_reals.py | 1 + 13 files changed, 185 insertions(+), 133 deletions(-) diff --git a/src/shapepy/bool2d/boolean.py b/src/shapepy/bool2d/boolean.py index d4e1ee25..1627647a 100644 --- a/src/shapepy/bool2d/boolean.py +++ b/src/shapepy/bool2d/boolean.py @@ -11,7 +11,7 @@ from shapepy.geometry.jordancurve import JordanCurve -from ..geometry.intersection import GeometricIntersectionCurves +from ..geometry.segment import Segment from ..geometry.unparam import USegment from ..loggers import debug, get_logger from ..tools import CyclicContainer, Is, NotExpectedError @@ -247,32 +247,15 @@ def expression2subset(expression: str) -> SubSetR2: class FollowPath: """ - Class responsible to compute the final jordan curve - result from boolean operation between two simple shapes - + Creates a graph from a jordan curve """ + piece = jordan.piecewise + edges = [] + for knota, knotb in zip(piece.knots, piece.knots[1:]): + path = SinglePath(piece, knota, knotb) + edges.append(Edge({path})) + return Graph(edges) - @staticmethod - def split_on_intersection( - all_group_jordans: Iterable[Iterable[JordanCurve]], - ): - """ - Find the intersections between two jordan curves and call split on the - nodes which intersects - """ - intersection = GeometricIntersectionCurves([]) - all_group_jordans = tuple(map(tuple, all_group_jordans)) - for i, jordansi in enumerate(all_group_jordans): - for j in range(i + 1, len(all_group_jordans)): - jordansj = all_group_jordans[j] - for jordana in jordansi: - for jordanb in jordansj: - intersection |= jordana.piecewise & jordanb.piecewise - intersection.evaluate() - for jordans in all_group_jordans: - for jordan in jordans: - split_knots = intersection.all_knots[id(jordan.piecewise)] - jordan.piecewise.split(split_knots) @staticmethod def pursue_path( @@ -458,35 +441,62 @@ def shape2graph( return graph +def remove_densities(graph: Graph, subsets: Iterable[SubSetR2], density: Real): + """Removes the edges from the graph which density""" + for subset in subsets: + for edge in tuple(graph.edges): + mid_point = edge.pointm + if subset.density(mid_point) == density: + graph.remove_edge(edge) + + def extract_unique_paths(graph: Graph) -> Iterable[CyclicContainer[Edge]]: """Reads the graphs and extracts the unique paths""" logger = get_logger("shapepy.bool2d.boolean") logger.debug("Extracting unique paths from the graph") - logger.debug(str(graph)) + # logger.debug(str(graph)) edges = tuple(graph.edges) index = 0 - while len(edges) > 0: + while index < len(edges): extracted_edges = [] - start_nodes = tuple(e.nodea for e in edges) - start_node = start_nodes[index] + start_node = edges[index].nodea node = start_node while True: valid_edges = tuple(e for e in edges if e.nodea == node) if len(valid_edges) != 1: - logger.error(f"Invalid is graph starting at node:\n{node}") - logger.error(f"Graph:\n{graph}") - raise ValueError + break extracted_edges.append(valid_edges[0]) node = valid_edges[0].nodeb - if node == start_node: # Closed cycle + if id(node) == id(start_node): # Closed cycle break + if id(node) != id(start_node): # Not unique path + index += 1 + continue for edge in extracted_edges: graph.remove_edge(edge) - logger.debug( - "Container:" - + "\n".join( - f"{i}: {edge}" for i, edge in enumerate(extracted_edges) - ) - ) yield CyclicContainer(extracted_edges) edges = tuple(graph.edges) + + +@debug("shapepy.bool2d.boolean") +def edges_to_jordan(edges: CyclicContainer[Edge]) -> JordanCurve: + """Converts the given connected edges into a Jordan Curve""" + logger = get_logger("shapepy.bool2d.boolean") + logger.info("Passed here") + if len(edges) == 1: + path = tuple(tuple(edges)[0].singles)[0] + return JordanCurve(path.curve) + usegments = [] + for edge in tuple(edges): + path = tuple(edge.singles)[0] + interval = [path.knota, path.knotb] + logger.info(f"interval = {interval}") + subcurve = path.curve.section(interval) + if Is.instance(subcurve, Segment): + usegments.append(USegment(subcurve)) + else: + usegments += list(map(USegment, subcurve)) + logger.info(f"Returned: {len(usegments)}") + for i, useg in enumerate(usegments): + logger.info(f" {i}: {useg.parametrize()}") + return JordanCurve(usegments) diff --git a/src/shapepy/bool2d/graph.py b/src/shapepy/bool2d/graph.py index 4f4b122b..6623de8a 100644 --- a/src/shapepy/bool2d/graph.py +++ b/src/shapepy/bool2d/graph.py @@ -11,7 +11,6 @@ from ..geometry.base import IParametrizedCurve from ..geometry.intersection import GeometricIntersectionCurves -from ..geometry.jordancurve import JordanCurve from ..geometry.point import Point2D from ..loggers import get_logger from ..scalar.reals import Real @@ -21,6 +20,12 @@ def get_single_node(curve: IParametrizedCurve, parameter: Real) -> SingleNode: + """Instantiate a new SingleNode, made by the pair: (curve, parameter) + + If given pair (curve, parameter) was already created, returns the + created instance. + """ + if not Is.instance(curve, IParametrizedCurve): raise TypeError(f"Invalid curve: {type(curve)}") if not Is.real(parameter): @@ -33,18 +38,6 @@ def get_single_node(curve: IParametrizedCurve, parameter: Real) -> SingleNode: return instance -class Containers: - - curves: Dict[int, IParametrizedCurve] = OrderedDict() - - @staticmethod - def index_curve(curve: IParametrizedCurve) -> int: - for i, key in enumerate(Containers.curves): - if id(curve) == key: - return i - raise ValueError("Could not find requested curve") - - class SingleNode: instances: Dict[Tuple[int, Real], SingleNode] = OrderedDict() @@ -163,6 +156,9 @@ def __init__(self, nodes: Iterable[Node] = None): def __iter__(self) -> Iterator[Node]: yield from self.__nodes + def __len__(self) -> int: + return len(self.__nodes) + def __str__(self): dictnodes = {n.label: n for n in self} keys = sorted(dictnodes.keys()) @@ -287,6 +283,18 @@ def __and__(self, other: SinglePath) -> GeometricIntersectionCurves: return self.curve & other.curve +class Containers: + + curves: Dict[int, IParametrizedCurve] = OrderedDict() + + @staticmethod + def index_curve(curve: IParametrizedCurve) -> int: + for i, key in enumerate(Containers.curves): + if id(curve) == key: + return i + raise ValueError("Could not find requested curve") + + class Edge: """ The edge that defines @@ -297,6 +305,8 @@ def __init__(self, paths: Iterable[SinglePath]): if len(paths) == 0: raise ValueError self.__singles: Set[SinglePath] = set(paths) + if len(self.__singles) != 1: + raise ValueError self.__nodea = get_node( {get_single_node(p.curve, p.knota) for p in paths} ) @@ -354,10 +364,14 @@ def __and__(self, other: Edge) -> Graph: graph = Graph() if not inters: graph.edges |= {self, other} - else: - logger = get_logger("shapepy.bool2d.console") - logger.info(str(inters)) - raise NotImplementedError("Shouldn't pass here yet") + return graph + logger = get_logger("shapepy.bool2d.console") + logger.info(str(inters)) + for curve in inters.curves: + knots = inters.all_knots[id(curve)] + for knota, knotb in zip(knots, knots[1:]): + path = single_path(curve, knota, knotb) + graph.add_path(path) return graph def __ior__(self, other: Edge) -> Edge: @@ -391,6 +405,9 @@ def __init__(self, edges: Iterable[Edge] = None): def __iter__(self) -> Iterator[Edge]: yield from self.__edges + def __len__(self) -> int: + return len(self.__edges) + def __str__(self): return "\n".join(f"E{i}: {edge}" for i, edge in enumerate(self)) @@ -455,7 +472,7 @@ def edges(self) -> GroupEdges: @edges.setter def edges(self, edges: GroupEdges): if not Is.instance(edges, GroupEdges): - raise TypeError + edges = GroupEdges(edges) self.__edges = edges def __and__(self, other: Graph) -> Graph: @@ -508,18 +525,6 @@ def add_path(self, path: SinglePath) -> Edge: return self.edges.add_path(path) -def jordan2graph(jordan: JordanCurve) -> Graph: - """ - Creates a graph from a jordan curve - """ - piece = jordan.piecewise - graph = Graph() - for knota, knotb in zip(piece.knots, piece.knots[1:]): - path = SinglePath(piece, knota, knotb) - graph.add_path(path) - return graph - - def intersect_graphs(graphs: Iterable[Graph]) -> Graph: """ Computes the intersection of many graphs @@ -546,3 +551,4 @@ def graph_manager(): SingleNode.instances.clear() Node.instances.clear() SinglePath.instances.clear() + Containers.curves.clear() diff --git a/src/shapepy/geometry/base.py b/src/shapepy/geometry/base.py index 817fc939..8b850014 100644 --- a/src/shapepy/geometry/base.py +++ b/src/shapepy/geometry/base.py @@ -7,6 +7,7 @@ from abc import ABC, abstractmethod from typing import Iterable, Tuple, Union +from ..rbool import IntervalR1 from ..scalar.angle import Angle from ..scalar.reals import Real from .box import Box @@ -158,3 +159,8 @@ def __and__(self, other: IParametrizedCurve): def parametrize(self) -> IParametrizedCurve: """Gives a parametrized curve""" return self + + @abstractmethod + def section(self, interval: IntervalR1) -> IParametrizedCurve: + """Gives the section of the curve""" + raise NotImplementedError diff --git a/src/shapepy/geometry/intersection.py b/src/shapepy/geometry/intersection.py index 84a3b68d..a077bfd3 100644 --- a/src/shapepy/geometry/intersection.py +++ b/src/shapepy/geometry/intersection.py @@ -190,7 +190,7 @@ def __or__( return GeometricIntersectionCurves(newcurves, newparis) def __bool__(self): - return any(v != Empty() for v in self.all_subsets.values()) + return any(v != EmptyR1() for v in self.all_subsets.values()) def curve_and_curve( diff --git a/src/shapepy/geometry/piecewise.py b/src/shapepy/geometry/piecewise.py index 89587031..5b0da2ab 100644 --- a/src/shapepy/geometry/piecewise.py +++ b/src/shapepy/geometry/piecewise.py @@ -4,13 +4,13 @@ from __future__ import annotations -from collections import defaultdict -from typing import Iterable, Tuple, Union +from typing import Iterable, List, Tuple, Union -from ..loggers import debug +from ..loggers import debug, get_logger +from ..rbool import IntervalR1, infimum, supremum from ..scalar.angle import Angle from ..scalar.reals import Real -from ..tools import Is, To, vectorize +from ..tools import Is, NotContinousError, To, vectorize from .base import IParametrizedCurve from .box import Box from .point import Point2D @@ -36,9 +36,9 @@ def __init__( knots = tuple(sorted(map(To.finite, knots))) for segi, segj in zip(segments, segments[1:]): if segi(1) != segj(0): - raise ValueError("Not Continuous curve") - self.__segments = segments - self.__knots = knots + raise NotContinousError(f"{segi(1)} != {segj(0)}") + self.__segments: Tuple[Segment, ...] = segments + self.__knots: Tuple[Segment, ...] = knots def __str__(self): msgs = [] @@ -113,38 +113,6 @@ def box(self) -> Box: box |= bezier.box() return box - def split(self, nodes: Iterable[Real]) -> None: - """ - Creates an opening in the piecewise curve - - Example - >>> piecewise.knots - (0, 1, 2, 3) - >>> piecewise.snap([0.5, 1.2]) - >>> piecewise.knots - (0, 0.5, 1, 1.2, 2, 3) - """ - nodes = set(map(To.finite, nodes)) - set(self.knots) - spansnodes = defaultdict(set) - for node in nodes: - span = self.span(node) - if span is not None: - spansnodes[span].add(node) - if len(spansnodes) == 0: - return - newsegments = [] - for i, segmenti in enumerate(self): - if i not in spansnodes: - newsegments.append(segmenti) - continue - knota, knotb = self.knots[i], self.knots[i + 1] - unit_nodes = ( - (knot - knota) / (knotb - knota) for knot in spansnodes[i] - ) - newsegments += list(segmenti.split(unit_nodes)) - self.__knots = tuple(sorted(list(self.knots) + list(nodes))) - self.__segments = tuple(newsegments) - @vectorize(1, 0) def __call__(self, node: float, derivate: int = 0) -> Point2D: index = self.span(node) @@ -173,6 +141,37 @@ def rotate(self, angle: Angle) -> Segment: self.__segments = tuple(seg.rotate(angle) for seg in self) return self + @debug("shapepy.geometry.piecewise") + def section(self, subset: IntervalR1) -> PiecewiseCurve: + logger = get_logger("shapepy.geometry.piecewise") + segments = tuple(self.__segments) + knots = tuple(self.knots) + if subset == [self.knots[0], self.knots[-1]]: + return self + knota, knotb = infimum(subset), supremum(subset) + spana, spanb = self.span(knota), self.span(knotb) + logger.info(f"current knots = {knots}") + logger.info(f"knota, knotb = {[knota, knotb]}") + logger.info(f"spana, spanb = {[spana, spanb]}") + if spana == spanb: + return segments[spana].section([knota, knotb]) + newsegs: List[Segment] = [] + if knota != knots[spana]: + interval = [knota, knots[spana]] + segment = segments[spana].section(interval) + newsegs.append(segment) + else: + newsegs.append(segments[spana]) + newsegs += list(segments[spana + 1 : spanb]) + if knotb != knots[spanb]: + interval = [knots[spanb], knotb] + segment = segments[spanb].section(interval) + newsegs.append(segment) + newknots = sorted({knota, knotb} | set(knots[spana:spanb])) + logger.info(f"new knots = {newknots}") + logger.info(f"len(news) = {len(newsegs)}") + return PiecewiseCurve(newsegs, newknots) + def is_piecewise(obj: object) -> bool: """ diff --git a/src/shapepy/geometry/segment.py b/src/shapepy/geometry/segment.py index 4156f30b..583031eb 100644 --- a/src/shapepy/geometry/segment.py +++ b/src/shapepy/geometry/segment.py @@ -18,7 +18,7 @@ from ..analytic.base import IAnalytic from ..analytic.tools import find_minimum from ..loggers import debug -from ..rbool import IntervalR1, from_any +from ..rbool import IntervalR1, from_any, infimum, supremum, EmptyR1 from ..scalar.angle import Angle from ..scalar.quadrature import AdaptativeIntegrator, IntegratorFactory from ..scalar.reals import Math, Real @@ -149,18 +149,7 @@ def split(self, nodes: Iterable[Real]) -> Tuple[Segment, ...]: """ nodes = (n for n in nodes if self.knots[0] <= n <= self.knots[-1]) nodes = sorted(set(nodes) | set(self.knots)) - return tuple(self.extract([ka, kb]) for ka, kb in pairs(nodes)) - - def extract(self, interval: IntervalR1) -> Segment: - """Extracts a subsegment from the given segment""" - interval = from_any(interval) - if not Is.instance(interval, IntervalR1): - raise TypeError - knota, knotb = interval[0], interval[1] - denom = 1 / (knotb - knota) - nxfunc = copy(self.xfunc).shift(-knota).scale(denom) - nyfunc = copy(self.yfunc).shift(-knota).scale(denom) - return Segment(nxfunc, nyfunc) + return tuple(self.section([ka, kb]) for ka, kb in pairs(nodes)) def move(self, vector: Point2D) -> Segment: vector = To.point(vector) @@ -181,6 +170,19 @@ def rotate(self, angle: Angle) -> Segment: self.__yfunc = xfunc * sin + yfunc * cos return self + def section(self, subset: IntervalR1) -> Segment: + subset = from_any(subset) & [0, 1] + if subset is EmptyR1(): + raise TypeError(f"Cannot extract with interval {subset}") + if subset == [0, 1]: + return self + knota = infimum(subset) + knotb = supremum(subset) + denom = 1 / (knotb - knota) + nxfunc = copy(self.xfunc).shift(-knota).scale(denom) + nyfunc = copy(self.yfunc).shift(-knota).scale(denom) + return Segment(nxfunc, nyfunc) + @debug("shapepy.geometry.segment") def compute_length(segment: Segment) -> Real: diff --git a/src/shapepy/geometry/unparam.py b/src/shapepy/geometry/unparam.py index 6a34c41a..338ebe70 100644 --- a/src/shapepy/geometry/unparam.py +++ b/src/shapepy/geometry/unparam.py @@ -21,6 +21,8 @@ class USegment(IGeometricCurve): """Equivalent to Segment, but ignores the parametrization""" def __init__(self, segment: Segment): + if not Is.instance(segment, Segment): + raise TypeError self.__segment = segment def __copy__(self) -> USegment: diff --git a/src/shapepy/loggers.py b/src/shapepy/loggers.py index 52c20eb0..baba5902 100644 --- a/src/shapepy/loggers.py +++ b/src/shapepy/loggers.py @@ -15,7 +15,7 @@ class LogConfiguration: """Contains the configuration values for the loggers""" - indent_size = 4 + indent_str = "| " log_enabled = False @@ -37,7 +37,7 @@ def process(self, msg, kwargs): """ Inserts spaces proportional to `indent_level` before the message """ - indent_str = " " * LogConfiguration.indent_size * self.indent_level + indent_str = LogConfiguration.indent_str * self.indent_level return f"{indent_str}{msg}", kwargs @@ -89,11 +89,11 @@ def setup_logger(name, level=logging.INFO): adapter.logger.setLevel(logging.DEBUG) formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + "%(asctime)s - %(levelname)s - %(message)s - %(name)s" ) - formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") + formatter = logging.Formatter("%(asctime)s:%(name)s:%(message)s") # formatter = logging.Formatter("%(asctime)s - %(message)s") - # formatter = logging.Formatter("%(message)s") + formatter = logging.Formatter("%(name)s:%(message)s") stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler.setLevel(level) diff --git a/src/shapepy/scalar/reals.py b/src/shapepy/scalar/reals.py index 3ad5630e..3c3e0296 100644 --- a/src/shapepy/scalar/reals.py +++ b/src/shapepy/scalar/reals.py @@ -22,6 +22,8 @@ from numbers import Integral, Rational, Real from typing import Any, Callable +import sympy as sp + from ..tools import Is, To diff --git a/src/shapepy/tools.py b/src/shapepy/tools.py index 621dfc38..d13aaebc 100644 --- a/src/shapepy/tools.py +++ b/src/shapepy/tools.py @@ -135,6 +135,10 @@ class NotExpectedError(Exception): """Raised when arrives in a section that were not expected""" +class NotContinousError(Exception): + """Raised when a curve is not continuous""" + + T = TypeVar("T") diff --git a/tests/bool2d/test_bool_no_intersect.py b/tests/bool2d/test_bool_no_intersect.py index b8b373f3..4776a3cd 100644 --- a/tests/bool2d/test_bool_no_intersect.py +++ b/tests/bool2d/test_bool_no_intersect.py @@ -8,6 +8,7 @@ from shapepy.bool2d.base import EmptyShape, WholeShape from shapepy.bool2d.primitive import Primitive from shapepy.bool2d.shape import ConnectedShape, DisjointShape +from shapepy.loggers import enable_logger @pytest.mark.order(41) @@ -48,7 +49,8 @@ def test_or(self): assert square1 | square2 == square2 assert square2 | square1 == square2 - assert square1 | (~square2) == DisjointShape([square1, ~square2]) + with enable_logger("shapepy.bool2d", level="DEBUG"): + assert square1 | (~square2) == DisjointShape([square1, ~square2]) assert square2 | (~square1) is WholeShape() assert (~square1) | square2 is WholeShape() assert (~square2) | square1 == DisjointShape([square1, ~square2]) @@ -75,7 +77,13 @@ def test_and(self): @pytest.mark.order(41) @pytest.mark.timeout(40) - @pytest.mark.dependency(depends=["TestTwoCenteredSquares::test_begin"]) + @pytest.mark.dependency( + depends=[ + "TestTwoCenteredSquares::test_begin", + "TestTwoCenteredSquares::test_or" + "TestTwoCenteredSquares::test_and", + ] + ) def test_sub(self): square1 = Primitive.square(side=1) square2 = Primitive.square(side=2) @@ -178,7 +186,13 @@ def test_and(self): @pytest.mark.order(41) @pytest.mark.timeout(40) - @pytest.mark.dependency(depends=["TestTwoDisjointSquares::test_begin"]) + @pytest.mark.dependency( + depends=[ + "TestTwoDisjointSquares::test_begin", + "TestTwoDisjointSquares::test_or", + "TestTwoDisjointSquares::test_and", + ] + ) def test_sub(self): left = Primitive.square(side=2, center=(-2, 0)) right = Primitive.square(side=2, center=(2, 0)) @@ -292,7 +306,13 @@ def test_and(self): @pytest.mark.order(41) @pytest.mark.timeout(40) - @pytest.mark.dependency(depends=["TestTwoDisjHollowSquares::test_begin"]) + @pytest.mark.dependency( + depends=[ + "TestTwoDisjHollowSquares::test_begin", + "TestTwoDisjHollowSquares::test_or", + "TestTwoDisjHollowSquares::test_and", + ] + ) def test_sub(self): left_big = Primitive.square(side=2, center=(-2, 0)) left_sma = Primitive.square(side=1, center=(-2, 0)) diff --git a/tests/geometry/test_segment.py b/tests/geometry/test_segment.py index 6d4bcbdc..feecef49 100644 --- a/tests/geometry/test_segment.py +++ b/tests/geometry/test_segment.py @@ -129,8 +129,8 @@ def test_middle(self): curve = FactorySegment.bezier(points) curvea = FactorySegment.bezier([(0, 0), (half, 0)]) curveb = FactorySegment.bezier([(half, 0), (1, 0)]) - assert curve.extract([0, half]) == curvea - assert curve.extract([half, 1]) == curveb + assert curve.section([0, half]) == curvea + assert curve.section([half, 1]) == curveb assert curve.split([half]) == (curvea, curveb) test = curvea | curveb diff --git a/tests/scalar/test_reals.py b/tests/scalar/test_reals.py index eb0454b5..25f2acf8 100644 --- a/tests/scalar/test_reals.py +++ b/tests/scalar/test_reals.py @@ -71,6 +71,7 @@ def test_math_functions(): @pytest.mark.order(1) +@pytest.mark.skip() @pytest.mark.timeout(1) @pytest.mark.dependency( depends=[ From 07f70fb8f8c1fa44dc0697368f39cadacb232f68 Mon Sep 17 00:00:00 2001 From: Carlos Adir Date: Sun, 31 Aug 2025 13:36:38 +0200 Subject: [PATCH 03/14] checkout to Density --- src/shapepy/__init__.py | 3 +- src/shapepy/bool2d/boolean.py | 25 ++++++--- src/shapepy/bool2d/graph.py | 6 +- src/shapepy/geometry/piecewise.py | 54 ++++++++++++++---- src/shapepy/geometry/segment.py | 12 +++- tests/bool2d/test_bool_no_intersect.py | 11 +++- tests/bool2d/test_bool_overlap.py | 77 ++++++++++++++++++++++++++ tests/geometry/test_piecewise.py | 31 +++++++++++ 8 files changed, 190 insertions(+), 29 deletions(-) diff --git a/src/shapepy/__init__.py b/src/shapepy/__init__.py index 53e63dbd..8fcd1e2f 100644 --- a/src/shapepy/__init__.py +++ b/src/shapepy/__init__.py @@ -21,8 +21,7 @@ __version__ = importlib.metadata.version("shapepy") set_level("shapepy", level="INFO") -# set_level("shapepy.bool2d", level="DEBUG") -# set_level("shapepy.rbool", level="DEBUG") +set_level("shapepy.bool2d", level="DEBUG") if __name__ == "__main__": diff --git a/src/shapepy/bool2d/boolean.py b/src/shapepy/bool2d/boolean.py index 1627647a..38042e9c 100644 --- a/src/shapepy/bool2d/boolean.py +++ b/src/shapepy/bool2d/boolean.py @@ -454,13 +454,18 @@ def extract_unique_paths(graph: Graph) -> Iterable[CyclicContainer[Edge]]: """Reads the graphs and extracts the unique paths""" logger = get_logger("shapepy.bool2d.boolean") logger.debug("Extracting unique paths from the graph") - # logger.debug(str(graph)) + logger.debug(str(graph)) edges = tuple(graph.edges) index = 0 + security = 0 while index < len(edges): extracted_edges = [] start_node = edges[index].nodea node = start_node + security += 1 + if security > 10: + raise ValueError + logger.debug(f"1) {start_node}") while True: valid_edges = tuple(e for e in edges if e.nodea == node) if len(valid_edges) != 1: @@ -469,7 +474,11 @@ def extract_unique_paths(graph: Graph) -> Iterable[CyclicContainer[Edge]]: node = valid_edges[0].nodeb if id(node) == id(start_node): # Closed cycle break - if id(node) != id(start_node): # Not unique path + logger.debug("2)") + logger.debug(f"extracted edges = {len(extracted_edges)}") + if len(extracted_edges) == 0 or id(node) != id( + start_node + ): # Not unique path index += 1 continue for edge in extracted_edges: @@ -481,8 +490,8 @@ def extract_unique_paths(graph: Graph) -> Iterable[CyclicContainer[Edge]]: @debug("shapepy.bool2d.boolean") def edges_to_jordan(edges: CyclicContainer[Edge]) -> JordanCurve: """Converts the given connected edges into a Jordan Curve""" - logger = get_logger("shapepy.bool2d.boolean") - logger.info("Passed here") + # logger = get_logger("shapepy.bool2d.boolean") + # logger.info("Passed here") if len(edges) == 1: path = tuple(tuple(edges)[0].singles)[0] return JordanCurve(path.curve) @@ -490,13 +499,13 @@ def edges_to_jordan(edges: CyclicContainer[Edge]) -> JordanCurve: for edge in tuple(edges): path = tuple(edge.singles)[0] interval = [path.knota, path.knotb] - logger.info(f"interval = {interval}") + # logger.info(f"interval = {interval}") subcurve = path.curve.section(interval) if Is.instance(subcurve, Segment): usegments.append(USegment(subcurve)) else: usegments += list(map(USegment, subcurve)) - logger.info(f"Returned: {len(usegments)}") - for i, useg in enumerate(usegments): - logger.info(f" {i}: {useg.parametrize()}") + # logger.info(f"Returned: {len(usegments)}") + # for i, useg in enumerate(usegments): + # logger.info(f" {i}: {useg.parametrize()}") return JordanCurve(usegments) diff --git a/src/shapepy/bool2d/graph.py b/src/shapepy/bool2d/graph.py index 6623de8a..cdea90d2 100644 --- a/src/shapepy/bool2d/graph.py +++ b/src/shapepy/bool2d/graph.py @@ -365,10 +365,10 @@ def __and__(self, other: Edge) -> Graph: if not inters: graph.edges |= {self, other} return graph - logger = get_logger("shapepy.bool2d.console") - logger.info(str(inters)) + # logger = get_logger("shapepy.bool2d.console") + # logger.info(str(inters)) for curve in inters.curves: - knots = inters.all_knots[id(curve)] + knots = sorted(inters.all_knots[id(curve)]) for knota, knotb in zip(knots, knots[1:]): path = single_path(curve, knota, knotb) graph.add_path(path) diff --git a/src/shapepy/geometry/piecewise.py b/src/shapepy/geometry/piecewise.py index 5b0da2ab..5e332429 100644 --- a/src/shapepy/geometry/piecewise.py +++ b/src/shapepy/geometry/piecewise.py @@ -7,7 +7,7 @@ from typing import Iterable, List, Tuple, Union from ..loggers import debug, get_logger -from ..rbool import IntervalR1, infimum, supremum +from ..rbool import IntervalR1, from_any, infimum, supremum from ..scalar.angle import Angle from ..scalar.reals import Real from ..tools import Is, NotContinousError, To, vectorize @@ -34,6 +34,8 @@ def __init__( knots = tuple(map(To.rational, range(len(segments) + 1))) else: knots = tuple(sorted(map(To.finite, knots))) + if len(knots) != len(segments) + 1: + raise ValueError("Invalid size of knots") for segi, segj in zip(segments, segments[1:]): if segi(1) != segj(0): raise NotContinousError(f"{segi(1)} != {segj(0)}") @@ -48,6 +50,17 @@ def __str__(self): msgs.append(msg) return r"{" + ", ".join(msgs) + r"}" + def __repr__(self): + return str(self) + + def __eq__(self, other: PiecewiseCurve): + return ( + Is.instance(other, PiecewiseCurve) + and self.length == other.length + and self.knots == other.knots + and tuple(self) == tuple(other) + ) + @property def knots(self) -> Tuple[Real]: """ @@ -127,15 +140,18 @@ def __contains__(self, point: Point2D) -> bool: """Tells if the point is on the boundary""" return any(point in bezier for bezier in self) + @debug("shapepy.geometry.piecewise") def move(self, vector: Point2D) -> PiecewiseCurve: vector = To.point(vector) self.__segments = tuple(seg.move(vector) for seg in self) return self + @debug("shapepy.geometry.piecewise") def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> Segment: self.__segments = tuple(seg.scale(amount) for seg in self) return self + @debug("shapepy.geometry.piecewise") def rotate(self, angle: Angle) -> Segment: angle = To.angle(angle) self.__segments = tuple(seg.rotate(angle) for seg in self) @@ -143,33 +159,47 @@ def rotate(self, angle: Angle) -> Segment: @debug("shapepy.geometry.piecewise") def section(self, subset: IntervalR1) -> PiecewiseCurve: + subset = from_any(subset) + knots = tuple(self.knots) + if not (knots[0] <= subset[0] < subset[1] <= knots[-1]): + raise ValueError(f"Invalid {subset} not in {self.knots}") logger = get_logger("shapepy.geometry.piecewise") segments = tuple(self.__segments) - knots = tuple(self.knots) if subset == [self.knots[0], self.knots[-1]]: return self knota, knotb = infimum(subset), supremum(subset) + if knota == knotb: + raise ValueError(f"Invalid {subset}") spana, spanb = self.span(knota), self.span(knotb) - logger.info(f"current knots = {knots}") - logger.info(f"knota, knotb = {[knota, knotb]}") - logger.info(f"spana, spanb = {[spana, spanb]}") + if knota == knots[spana] and knotb == knots[spanb]: + segs = segments[spana:spanb] + return segs[0] if len(segs) == 1 else PiecewiseCurve(segs) if spana == spanb: - return segments[spana].section([knota, knotb]) + denom = 1 / (knots[spana + 1] - knots[spana]) + uknota = denom * (knota - knots[spana]) + uknotb = denom * (knotb - knots[spana]) + interval = [uknota, uknotb] + segment = segments[spana] + return segment.section(interval) + if spanb == spana + 1 and knotb == knots[spanb]: + denom = 1 / (knots[spana + 1] - knots[spana]) + uknota = denom * (knota - knots[spana]) + return segments[spana].section([uknota, 1]) newsegs: List[Segment] = [] - if knota != knots[spana]: - interval = [knota, knots[spana]] + if knots[spana] < knota: + denom = 1 / (knots[spana + 1] - knots[spana]) + interval = [denom * (knota - knots[spana]), 1] segment = segments[spana].section(interval) newsegs.append(segment) else: newsegs.append(segments[spana]) newsegs += list(segments[spana + 1 : spanb]) if knotb != knots[spanb]: - interval = [knots[spanb], knotb] + denom = 1 / (knots[spanb + 1] - knots[spanb]) + interval = [0, denom * (knotb - knots[spanb])] segment = segments[spanb].section(interval) newsegs.append(segment) - newknots = sorted({knota, knotb} | set(knots[spana:spanb])) - logger.info(f"new knots = {newknots}") - logger.info(f"len(news) = {len(newsegs)}") + newknots = sorted({knota, knotb} | set(knots[spana + 1 : spanb])) return PiecewiseCurve(newsegs, newknots) diff --git a/src/shapepy/geometry/segment.py b/src/shapepy/geometry/segment.py index 583031eb..fffd3262 100644 --- a/src/shapepy/geometry/segment.py +++ b/src/shapepy/geometry/segment.py @@ -18,7 +18,7 @@ from ..analytic.base import IAnalytic from ..analytic.tools import find_minimum from ..loggers import debug -from ..rbool import IntervalR1, from_any, infimum, supremum, EmptyR1 +from ..rbool import EmptyR1, IntervalR1, from_any, infimum, supremum from ..scalar.angle import Angle from ..scalar.quadrature import AdaptativeIntegrator, IntegratorFactory from ..scalar.reals import Math, Real @@ -133,6 +133,7 @@ def __copy__(self) -> Segment: def __deepcopy__(self, memo) -> Segment: return Segment(copy(self.xfunc), copy(self.yfunc)) + @debug("shapepy.geometry.segment") def invert(self) -> Segment: """ Inverts the direction of the curve. @@ -143,6 +144,7 @@ def invert(self) -> Segment: self.__yfunc = self.__yfunc.shift(-half).scale(-1).shift(half) return self + @debug("shapepy.geometry.segment") def split(self, nodes: Iterable[Real]) -> Tuple[Segment, ...]: """ Splits the curve into more segments @@ -151,17 +153,20 @@ def split(self, nodes: Iterable[Real]) -> Tuple[Segment, ...]: nodes = sorted(set(nodes) | set(self.knots)) return tuple(self.section([ka, kb]) for ka, kb in pairs(nodes)) + @debug("shapepy.geometry.segment") def move(self, vector: Point2D) -> Segment: vector = To.point(vector) self.__xfunc += vector.xcoord self.__yfunc += vector.ycoord return self + @debug("shapepy.geometry.segment") def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> Segment: self.__xfunc *= amount if Is.real(amount) else amount[0] self.__yfunc *= amount if Is.real(amount) else amount[1] return self + @debug("shapepy.geometry.segment") def rotate(self, angle: Angle) -> Segment: angle = To.angle(angle) cos, sin = angle.cos(), angle.sin() @@ -170,8 +175,11 @@ def rotate(self, angle: Angle) -> Segment: self.__yfunc = xfunc * sin + yfunc * cos return self + @debug("shapepy.geometry.segment") def section(self, subset: IntervalR1) -> Segment: - subset = from_any(subset) & [0, 1] + subset = from_any(subset) + if not (0 <= subset[0] < subset[1] <= 1): + raise ValueError(f"Invalid {subset}") if subset is EmptyR1(): raise TypeError(f"Cannot extract with interval {subset}") if subset == [0, 1]: diff --git a/tests/bool2d/test_bool_no_intersect.py b/tests/bool2d/test_bool_no_intersect.py index 4776a3cd..0142a0aa 100644 --- a/tests/bool2d/test_bool_no_intersect.py +++ b/tests/bool2d/test_bool_no_intersect.py @@ -80,7 +80,7 @@ def test_and(self): @pytest.mark.dependency( depends=[ "TestTwoCenteredSquares::test_begin", - "TestTwoCenteredSquares::test_or" + "TestTwoCenteredSquares::test_or", "TestTwoCenteredSquares::test_and", ] ) @@ -102,7 +102,14 @@ def test_sub(self): @pytest.mark.order(41) @pytest.mark.skip() @pytest.mark.timeout(40) - @pytest.mark.dependency(depends=["TestTwoCenteredSquares::test_begin"]) + @pytest.mark.dependency( + depends=[ + "TestTwoCenteredSquares::test_begin", + "TestTwoCenteredSquares::test_or", + "TestTwoCenteredSquares::test_and", + "TestTwoCenteredSquares::test_sub", + ] + ) def test_xor(self): square1 = Primitive.square(side=1) square2 = Primitive.square(side=2) diff --git a/tests/bool2d/test_bool_overlap.py b/tests/bool2d/test_bool_overlap.py index 68969547..7efdea35 100644 --- a/tests/bool2d/test_bool_overlap.py +++ b/tests/bool2d/test_bool_overlap.py @@ -26,6 +26,83 @@ def test_begin(): pass +class TestTriangle: + @pytest.mark.order(38) + @pytest.mark.dependency( + depends=[ + "test_begin", + ] + ) + def test_begin(self): + pass + + @pytest.mark.order(38) + @pytest.mark.timeout(40) + @pytest.mark.dependency(depends=["TestTriangle::test_begin"]) + def test_or_triangles(self): + vertices0 = [(0, 0), (1, 0), (0, 1)] + vertices1 = [(0, 0), (0, 1), (-1, 0)] + triangle0 = Primitive.polygon(vertices0) + triangle1 = Primitive.polygon(vertices1) + test = triangle0 | triangle1 + + vertices = [(1, 0), (0, 1), (-1, 0)] + good = Primitive.polygon(vertices) + assert test == good + + @pytest.mark.order(38) + @pytest.mark.timeout(40) + @pytest.mark.dependency( + depends=[ + "TestTriangle::test_begin", + "TestTriangle::test_or_triangles", + ] + ) + def test_and_triangles(self): + vertices0 = [(0, 0), (2, 0), (0, 2)] + vertices1 = [(0, 0), (1, 0), (0, 1)] + triangle0 = Primitive.polygon(vertices0) + triangle1 = Primitive.polygon(vertices1) + test = triangle0 & triangle1 + + vertices = [(0, 0), (1, 0), (0, 1)] + good = Primitive.polygon(vertices) + assert test == good + + @pytest.mark.order(38) + @pytest.mark.timeout(40) + @pytest.mark.dependency( + depends=[ + "TestTriangle::test_begin", + "TestTriangle::test_or_triangles", + "TestTriangle::test_and_triangles", + ] + ) + def test_sub_triangles(self): + vertices0 = [(0, 0), (2, 0), (0, 2)] + vertices1 = [(0, 0), (1, 0), (0, 1)] + triangle0 = Primitive.polygon(vertices0) + triangle1 = Primitive.polygon(vertices1) + test = triangle0 - triangle1 + + vertices = [(1, 0), (2, 0), (0, 2), (0, 1)] + good = Primitive.polygon(vertices) + + assert test == good + + @pytest.mark.order(38) + @pytest.mark.dependency( + depends=[ + "TestTriangle::test_begin", + "TestTriangle::test_or_triangles", + "TestTriangle::test_and_triangles", + "TestTriangle::test_sub_triangles", + ] + ) + def test_end(self): + pass + + class TestEqualSquare: """ Make tests of boolean operations between the same shape (a square) diff --git a/tests/geometry/test_piecewise.py b/tests/geometry/test_piecewise.py index 529c87af..c51634c7 100644 --- a/tests/geometry/test_piecewise.py +++ b/tests/geometry/test_piecewise.py @@ -76,6 +76,36 @@ def test_evaluate(): piecewise(5) +@pytest.mark.order(14) +@pytest.mark.dependency(depends=["test_build"]) +def test_section(): + points = [ + ((0, 0), (1, 0)), + ((1, 0), (1, 1)), + ((1, 1), (0, 1)), + ((0, 1), (0, 0)), + ] + knots = range(len(points) + 1) + segments = tuple(map(FactorySegment.bezier, points)) + piecewise = PiecewiseCurve(segments, knots) + assert piecewise.section([0, 1]) == segments[0] + assert piecewise.section([1, 2]) == segments[1] + assert piecewise.section([2, 3]) == segments[2] + assert piecewise.section([3, 4]) == segments[3] + + assert piecewise.section([0, 0.5]) == segments[0].section([0, 0.5]) + assert piecewise.section([1, 1.5]) == segments[1].section([0, 0.5]) + assert piecewise.section([2, 2.5]) == segments[2].section([0, 0.5]) + assert piecewise.section([3, 3.5]) == segments[3].section([0, 0.5]) + assert piecewise.section([0.5, 1]) == segments[0].section([0.5, 1]) + assert piecewise.section([1.5, 2]) == segments[1].section([0.5, 1]) + assert piecewise.section([2.5, 3]) == segments[2].section([0.5, 1]) + assert piecewise.section([3.5, 4]) == segments[3].section([0.5, 1]) + + # good = PiecewiseCurve() + # assert piecewise.section([0.5, 1.5]) == PiecewiseCurve() + + @pytest.mark.order(14) @pytest.mark.dependency(depends=["test_build"]) def test_print(): @@ -99,6 +129,7 @@ def test_print(): "test_build", "test_box", "test_evaluate", + "test_section", "test_print", ] ) From 1da15bbc18bb5a496bbbeec3b4013527b1326d4a Mon Sep 17 00:00:00 2001 From: Carlos Adir Date: Sun, 31 Aug 2025 18:13:48 +0200 Subject: [PATCH 04/14] feat: add recursive getting possible paths --- src/shapepy/bool2d/boolean.py | 76 +++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 30 deletions(-) diff --git a/src/shapepy/bool2d/boolean.py b/src/shapepy/bool2d/boolean.py index 38042e9c..10f95297 100644 --- a/src/shapepy/bool2d/boolean.py +++ b/src/shapepy/bool2d/boolean.py @@ -450,41 +450,56 @@ def remove_densities(graph: Graph, subsets: Iterable[SubSetR2], density: Real): graph.remove_edge(edge) -def extract_unique_paths(graph: Graph) -> Iterable[CyclicContainer[Edge]]: +def extract_disjoint_graphs(graph: Graph) -> Iterable[Graph]: + """Separates the given graph into a group of graphs that are disjoint""" + edges = list(graph.edges) + while len(edges) > 0: + edge = edges.pop(0) + current_edges = {edge} + search_edges = {edge} + while len(search_edges) > 0: + end_nodes = {edge.nodeb for edge in search_edges} + search_edges = {edge for edge in edges if edge.nodea in end_nodes} + for edge in search_edges: + edges.remove(edge) + current_edges |= search_edges + yield Graph(current_edges) + + +def possible_paths( + edges: Iterable[Edge], start_node: Node = None +) -> Iterable[Tuple[Edge, ...]]: + edges = tuple(edges) + if start_node is None: + nodes = {e.nodea for e in edges} + for node in nodes: + yield from possible_paths(edges, node) + return + indices = set(i for i, e in enumerate(edges) if e.nodea == start_node) + other_edges = tuple(e for i, e in enumerate(edges) if i not in indices) + for edge in (edges[i] for i in indices): + for subpath in possible_paths(other_edges, edge.nodeb): + yield (edge, ) + subpath + + +def walk_closed_path(graph: Graph) -> CyclicContainer[Edge]: """Reads the graphs and extracts the unique paths""" + if not Is.instance(graph, Graph): + raise TypeError logger = get_logger("shapepy.bool2d.boolean") logger.debug("Extracting unique paths from the graph") logger.debug(str(graph)) + edges = tuple(graph.edges) - index = 0 - security = 0 - while index < len(edges): - extracted_edges = [] - start_node = edges[index].nodea - node = start_node - security += 1 - if security > 10: - raise ValueError - logger.debug(f"1) {start_node}") - while True: - valid_edges = tuple(e for e in edges if e.nodea == node) - if len(valid_edges) != 1: - break - extracted_edges.append(valid_edges[0]) - node = valid_edges[0].nodeb - if id(node) == id(start_node): # Closed cycle - break - logger.debug("2)") - logger.debug(f"extracted edges = {len(extracted_edges)}") - if len(extracted_edges) == 0 or id(node) != id( - start_node - ): # Not unique path - index += 1 - continue - for edge in extracted_edges: - graph.remove_edge(edge) - yield CyclicContainer(extracted_edges) - edges = tuple(graph.edges) + start_node = None + for edge in edges: + if sum(1 if e.nodea == edge.nodea else 0 for e in edges) == 1: + start_node = edge.nodea + break + for path in possible_paths(graph.edges, start_node): + if path[0].nodea == path[-1].nodeb: + return CyclicContainer(path) + raise ValueError("Given graph does not contain a closed path") @debug("shapepy.bool2d.boolean") @@ -492,6 +507,7 @@ def edges_to_jordan(edges: CyclicContainer[Edge]) -> JordanCurve: """Converts the given connected edges into a Jordan Curve""" # logger = get_logger("shapepy.bool2d.boolean") # logger.info("Passed here") + edges = tuple(edges) if len(edges) == 1: path = tuple(tuple(edges)[0].singles)[0] return JordanCurve(path.curve) From 479a7bf5187c2de6bcd87f1603ee9bd9bcc09d7f Mon Sep 17 00:00:00 2001 From: Carlos Adir Date: Wed, 10 Sep 2025 22:00:49 +0200 Subject: [PATCH 05/14] use graph with Lazy evaluators --- src/shapepy/bool2d/boolean.py | 433 +++++++++------------------------- src/shapepy/bool2d/graph.py | 5 + 2 files changed, 113 insertions(+), 325 deletions(-) diff --git a/src/shapepy/bool2d/boolean.py b/src/shapepy/bool2d/boolean.py index 10f95297..eea4791c 100644 --- a/src/shapepy/bool2d/boolean.py +++ b/src/shapepy/bool2d/boolean.py @@ -7,7 +7,7 @@ from copy import copy from fractions import Fraction -from typing import Dict, Iterable, Tuple, Union +from typing import Dict, Iterable, Tuple, Union, Iterator from shapepy.geometry.jordancurve import JordanCurve @@ -17,7 +17,7 @@ from ..tools import CyclicContainer, Is, NotExpectedError from . import boolalg from .base import EmptyShape, SubSetR2, WholeShape -from .graph import Edge, Graph, graph_manager, intersect_graphs, jordan2graph +from .graph import Edge, Graph, graph_manager, intersect_graphs, curve2graph, Node from .lazy import LazyAnd, LazyNot, LazyOr, RecipeLazy, is_lazy from .shape import ( ConnectedShape, @@ -121,56 +121,13 @@ def clean_bool2d(subset: SubSetR2) -> SubSetR2: subset = Boolalg.clean(subset) if not Is.lazy(subset): return subset - if Is.instance(subset, LazyNot): - return clean_bool2d_not(subset) - subsets = tuple(subset) - assert len(subsets) == 2 - shapea, shapeb = subsets - shapea = clean_bool2d(shapea) - shapeb = clean_bool2d(shapeb) - if Is.instance(subset, LazyAnd): - if shapeb in shapea: - return copy(shapeb) - if shapea in shapeb: - return copy(shapea) - jordans = FollowPath.and_shapes(shapea, shapeb) - elif Is.instance(subset, LazyOr): - if shapeb in shapea: - return copy(shapea) - if shapea in shapeb: - return copy(shapeb) - jordans = FollowPath.or_shapes(shapea, shapeb) + jordans = GraphComputer.clean(subset) if len(jordans) == 0: - return EmptyShape() if Is.instance(subset, LazyAnd) else WholeShape() + density = subset.density((0, 0)) + return EmptyShape() if float(density) == 0 else WholeShape() return shape_from_jordans(jordans) -@debug("shapepy.bool2d.boolean") -def clean_bool2d_not(subset: LazyNot) -> SubSetR2: - """ - Cleans complementar of given subset - - Parameters - ---------- - subset: SubSetR2 - The subset to be cleaned - - Return - ------ - SubSetR2 - The cleaned subset - """ - assert Is.instance(subset, LazyNot) - inverted = ~subset - if Is.instance(inverted, SimpleShape): - return SimpleShape(~inverted.jordan, True) - if Is.instance(inverted, ConnectedShape): - return DisjointShape(~simple for simple in inverted.subshapes) - if Is.instance(inverted, DisjointShape): - new_jordans = tuple(~jordan for jordan in inverted.jordans) - return shape_from_jordans(new_jordans) - raise NotImplementedError(f"Missing typo: {type(inverted)}") - class Boolalg: """Static methods to clean a SubSetR2 using algebraic simplifier""" @@ -245,283 +202,109 @@ def expression2subset(expression: str) -> SubSetR2: raise NotExpectedError(f"Invalid expression: {expression}") -class FollowPath: - """ - Creates a graph from a jordan curve - """ - piece = jordan.piecewise - edges = [] - for knota, knotb in zip(piece.knots, piece.knots[1:]): - path = SinglePath(piece, knota, knotb) - edges.append(Edge({path})) - return Graph(edges) +class GraphComputer: - @staticmethod - def pursue_path( - index_jordan: int, index_segment: int, jordans: Tuple[JordanCurve] - ) -> CyclicContainer[Tuple[int, int]]: - """ - Given a list of jordans, it returns a matrix of integers like - [(a1, b1), (a2, b2), (a3, b3), ..., (an, bn)] such - End point of jordans[a_{i}].segments[b_{i}] - Start point of jordans[a_{i+1}].segments[b_{i+1}] - are equal - - The first point (a1, b1) = (index_jordan, index_segment) - - The end point of jordans[an].segments[bn] is equal to - the start point of jordans[a1].segments[b1] - - We suppose there's no triple intersection - """ - matrix = [] - all_segments = [tuple(jordan.piecewise) for jordan in jordans] - while True: - index_segment %= len(all_segments[index_jordan]) - segment = all_segments[index_jordan][index_segment] - if (index_jordan, index_segment) in matrix: - break - matrix.append((index_jordan, index_segment)) - last_point = segment(1) - possibles = [] - for i, jordan in enumerate(jordans): - if i == index_jordan: - continue - if last_point in jordan: - possibles.append(i) - if len(possibles) == 0: - index_segment += 1 - continue - index_jordan = possibles[0] - for j, segj in enumerate(all_segments[index_jordan]): - if segj(0) == last_point: - index_segment = j - break - return CyclicContainer(matrix) - @staticmethod - def indexs_to_jordan( - jordans: Tuple[JordanCurve], - matrix_indexs: CyclicContainer[Tuple[int, int]], - ) -> JordanCurve: - """ - Given 'n' jordan curves, and a matrix of integers - [(a0, b0), (a1, b1), ..., (am, bm)] - Returns a myjordan (JordanCurve instance) such - len(myjordan.segments) = matrix_indexs.shape[0] - myjordan.segments[i] = jordans[ai].segments[bi] - """ - beziers = [] - for index_jordan, index_segment in matrix_indexs: - new_bezier = jordans[index_jordan].piecewise[index_segment] - new_bezier = copy(new_bezier) - beziers.append(USegment(new_bezier)) - new_jordan = JordanCurve(beziers) - return new_jordan - - @staticmethod - def follow_path( - jordans: Tuple[JordanCurve], start_indexs: Tuple[Tuple[int]] - ) -> Tuple[JordanCurve]: - """ - Returns a list of jordan curves which is the result - of the intersection between 'jordansa' and 'jordansb' - """ - assert all(map(Is.jordan, jordans)) - bez_indexs = [] - for ind_jord, ind_seg in start_indexs: - indices_matrix = FollowPath.pursue_path(ind_jord, ind_seg, jordans) - if indices_matrix not in bez_indexs: - bez_indexs.append(indices_matrix) - new_jordans = [] - for indices_matrix in bez_indexs: - jordan = FollowPath.indexs_to_jordan(jordans, indices_matrix) - new_jordans.append(jordan) - return tuple(new_jordans) - - @staticmethod - def midpoints_one_shape( - shapea: Union[SimpleShape, ConnectedShape, DisjointShape], - shapeb: Union[SimpleShape, ConnectedShape, DisjointShape], - closed: bool, - inside: bool, - ) -> Iterable[Tuple[int, int]]: - """ - Returns a matrix [(a0, b0), (a1, b1), ...] - such the middle point of - shapea.jordans[a0].segments[b0] - is inside/outside the shapeb - - If parameter ``closed`` is True, consider a - point in boundary is inside. - If ``closed=False``, a boundary point is outside - - """ - for i, jordan in enumerate(shapea.jordans): - for j, segment in enumerate(jordan.parametrize()): - mid_point = segment(Fraction(1, 2)) - density = shapeb.density(mid_point) - mid_point_in = (float(density) > 0 and closed) or density == 1 - if not inside ^ mid_point_in: - yield (i, j) - - @staticmethod - def midpoints_shapes( - shapea: SubSetR2, shapeb: SubSetR2, closed: bool, inside: bool - ) -> Tuple[Tuple[int, int]]: - """ - This function computes the indexes of the midpoints from - both shapes, shifting the indexs of shapeb.jordans - """ - indexsa = FollowPath.midpoints_one_shape( - shapea, shapeb, closed, inside - ) - indexsb = FollowPath.midpoints_one_shape( # pylint: disable=W1114 - shapeb, shapea, closed, inside - ) - indexsa = list(indexsa) - njordansa = len(shapea.jordans) - for indjorb, indsegb in indexsb: - indexsa.append((njordansa + indjorb, indsegb)) - return tuple(indexsa) - - @staticmethod - def or_shapes(shapea: SubSetR2, shapeb: SubSetR2) -> Tuple[JordanCurve]: - """ - Computes the set of jordan curves that defines the boundary of - the union between the two base shapes - """ - assert Is.instance( - shapea, (SimpleShape, ConnectedShape, DisjointShape) - ) - assert Is.instance( - shapeb, (SimpleShape, ConnectedShape, DisjointShape) - ) - FollowPath.split_on_intersection([shapea.jordans, shapeb.jordans]) - indexs = FollowPath.midpoints_shapes( - shapea, shapeb, closed=True, inside=False - ) - all_jordans = tuple(shapea.jordans) + tuple(shapeb.jordans) - new_jordans = FollowPath.follow_path(all_jordans, indexs) - return new_jordans - - @staticmethod - def and_shapes(shapea: SubSetR2, shapeb: SubSetR2) -> Tuple[JordanCurve]: - """ - Computes the set of jordan curves that defines the boundary of - the intersection between the two base shapes - """ - assert Is.instance( - shapea, (SimpleShape, ConnectedShape, DisjointShape) - ) - assert Is.instance( - shapeb, (SimpleShape, ConnectedShape, DisjointShape) - ) - FollowPath.split_on_intersection([shapea.jordans, shapeb.jordans]) - indexs = FollowPath.midpoints_shapes( - shapea, shapeb, closed=False, inside=True - ) - all_jordans = tuple(shapea.jordans) + tuple(shapeb.jordans) - new_jordans = FollowPath.follow_path(all_jordans, indexs) - return new_jordans - - -def shape2graph( - shape: Union[SimpleShape, ConnectedShape, DisjointShape], -) -> Graph: - """Converts a shape to a Graph""" - if not Is.instance(shape, (SimpleShape, ConnectedShape, DisjointShape)): - raise TypeError - if Is.instance(shape, SimpleShape): - return jordan2graph(shape.jordan) - graph = Graph() - for subshape in shape.subshapes: - graph |= shape2graph(subshape) - return graph - - -def remove_densities(graph: Graph, subsets: Iterable[SubSetR2], density: Real): - """Removes the edges from the graph which density""" - for subset in subsets: - for edge in tuple(graph.edges): - mid_point = edge.pointm - if subset.density(mid_point) == density: - graph.remove_edge(edge) - - -def extract_disjoint_graphs(graph: Graph) -> Iterable[Graph]: - """Separates the given graph into a group of graphs that are disjoint""" - edges = list(graph.edges) - while len(edges) > 0: - edge = edges.pop(0) - current_edges = {edge} - search_edges = {edge} - while len(search_edges) > 0: - end_nodes = {edge.nodeb for edge in search_edges} - search_edges = {edge for edge in edges if edge.nodea in end_nodes} - for edge in search_edges: - edges.remove(edge) - current_edges |= search_edges - yield Graph(current_edges) - - -def possible_paths( - edges: Iterable[Edge], start_node: Node = None -) -> Iterable[Tuple[Edge, ...]]: - edges = tuple(edges) - if start_node is None: - nodes = {e.nodea for e in edges} - for node in nodes: - yield from possible_paths(edges, node) - return - indices = set(i for i, e in enumerate(edges) if e.nodea == start_node) - other_edges = tuple(e for i, e in enumerate(edges) if i not in indices) - for edge in (edges[i] for i in indices): - for subpath in possible_paths(other_edges, edge.nodeb): - yield (edge, ) + subpath - - -def walk_closed_path(graph: Graph) -> CyclicContainer[Edge]: - """Reads the graphs and extracts the unique paths""" - if not Is.instance(graph, Graph): - raise TypeError - logger = get_logger("shapepy.bool2d.boolean") - logger.debug("Extracting unique paths from the graph") - logger.debug(str(graph)) - - edges = tuple(graph.edges) - start_node = None - for edge in edges: - if sum(1 if e.nodea == edge.nodea else 0 for e in edges) == 1: - start_node = edge.nodea - break - for path in possible_paths(graph.edges, start_node): - if path[0].nodea == path[-1].nodeb: - return CyclicContainer(path) - raise ValueError("Given graph does not contain a closed path") - - -@debug("shapepy.bool2d.boolean") -def edges_to_jordan(edges: CyclicContainer[Edge]) -> JordanCurve: - """Converts the given connected edges into a Jordan Curve""" - # logger = get_logger("shapepy.bool2d.boolean") - # logger.info("Passed here") - edges = tuple(edges) - if len(edges) == 1: - path = tuple(tuple(edges)[0].singles)[0] - return JordanCurve(path.curve) - usegments = [] - for edge in tuple(edges): - path = tuple(edge.singles)[0] - interval = [path.knota, path.knotb] - # logger.info(f"interval = {interval}") - subcurve = path.curve.section(interval) - if Is.instance(subcurve, Segment): - usegments.append(USegment(subcurve)) - else: - usegments += list(map(USegment, subcurve)) - # logger.info(f"Returned: {len(usegments)}") - # for i, useg in enumerate(usegments): - # logger.info(f" {i}: {useg.parametrize()}") - return JordanCurve(usegments) + def clean(subset: SubSetR2) -> SubSetR2: + """Cleans the subset using the graphs""" + extractor = GraphComputer.extract(subset) + simples = tuple({id(s): s for s in extractor}.values()) + piecewises = tuple(s.jordan.piecewise for s in simples) + with graph_manager(): + graphs = tuple(map(curve2graph, piecewises)) + graph = intersect_graphs(graphs) + for edge in tuple(graph.edges): + density = subset.density(edge.pointm) + if not (0 < float(density) < 1): + graph.remove_edge(edge) + + + + def extract(subset: SubSetR2) -> Iterator[SimpleShape]: + """Extracts the simple shapes from the subset""" + if Is.instance(subset, SimpleShape): + yield subset + elif Is.instance(subset, (ConnectedShape, DisjointShape)): + for subshape in subset.subshapes: + yield from GraphComputer.extract(subshape) + elif Is.instance(subset, LazyNot): + yield from GraphComputer.extract(~subset) + elif Is.instance(subset, (LazyOr, LazyAnd)): + for subsubset in subset: + yield from GraphComputer.extract(subsubset) + + + def extract_disjoint_graphs(graph: Graph) -> Iterable[Graph]: + """Separates the given graph into a group of graphs that are disjoint""" + edges = list(graph.edges) + while len(edges) > 0: + edge = edges.pop(0) + current_edges = {edge} + search_edges = {edge} + while len(search_edges) > 0: + end_nodes = {edge.nodeb for edge in search_edges} + search_edges = {edge for edge in edges if edge.nodea in end_nodes} + for edge in search_edges: + edges.remove(edge) + current_edges |= search_edges + yield Graph(current_edges) + + + def possible_paths(edges: Iterable[Edge], start_node: Node) -> Iterator[Tuple[Edge, ...]]: + """Returns all the possible paths that begins at start_node""" + edges = tuple(edges) + indices = set(i for i, e in enumerate(edges) if e.nodea == start_node) + other_edges = tuple(e for i, e in enumerate(edges) if i not in indices) + for edge in (edges[i] for i in indices): + subpaths = GraphComputer.possible_paths(other_edges, edge.nodeb) + for subpath in subpaths: + yield (edge, ) + subpath + + + def possible_closed_paths(graph: Graph) -> CyclicContainer[Edge]: + """Reads the graphs and extracts the unique paths""" + if not Is.instance(graph, Graph): + raise TypeError + logger = get_logger("shapepy.bool2d.boolean") + logger.debug("Extracting unique paths from the graph") + logger.debug(str(graph)) + + + + edges = tuple(graph.edges) + start_node = None + for edge in edges: + if sum(1 if e.nodea == edge.nodea else 0 for e in edges) == 1: + start_node = edge.nodea + break + paths = GraphComputer.possible_paths(graph.edges, start_node) + for path in paths: + if path[0].nodea == path[-1].nodeb: + return CyclicContainer(path) + raise ValueError("Given graph does not contain a closed path") + + + @debug("shapepy.bool2d.boolean") + def edges_to_jordan(edges: CyclicContainer[Edge]) -> JordanCurve: + """Converts the given connected edges into a Jordan Curve""" + # logger = get_logger("shapepy.bool2d.boolean") + # logger.info("Passed here") + edges = tuple(edges) + if len(edges) == 1: + path = tuple(tuple(edges)[0].singles)[0] + return JordanCurve(path.curve) + usegments = [] + for edge in tuple(edges): + path = tuple(edge.singles)[0] + interval = [path.knota, path.knotb] + # logger.info(f"interval = {interval}") + subcurve = path.curve.section(interval) + if Is.instance(subcurve, Segment): + usegments.append(USegment(subcurve)) + else: + usegments += list(map(USegment, subcurve)) + # logger.info(f"Returned: {len(usegments)}") + # for i, useg in enumerate(usegments): + # logger.info(f" {i}: {useg.parametrize()}") + return JordanCurve(usegments) diff --git a/src/shapepy/bool2d/graph.py b/src/shapepy/bool2d/graph.py index cdea90d2..428850bc 100644 --- a/src/shapepy/bool2d/graph.py +++ b/src/shapepy/bool2d/graph.py @@ -552,3 +552,8 @@ def graph_manager(): Node.instances.clear() SinglePath.instances.clear() Containers.curves.clear() + +def curve2graph(curve: IParametrizedCurve) -> Graph: + """Creates a graph that contains the nodes and edges of the curve""" + single_path = SinglePath(curve, curve.knots[0], curve.knots[-1]) + return Graph({Edge({single_path})}) From 81b1c1265fc2bd14206a488fddbadd149e1a5aa8 Mon Sep 17 00:00:00 2001 From: Carlos Adir Date: Thu, 11 Sep 2025 00:52:34 +0200 Subject: [PATCH 06/14] fix merge problems --- src/shapepy/bool2d/base.py | 1 + src/shapepy/bool2d/boolean.py | 131 ++++++++++++++++++------- src/shapepy/bool2d/graph.py | 26 +++-- src/shapepy/bool2d/shape.py | 8 +- src/shapepy/geometry/piecewise.py | 17 ++-- src/shapepy/geometry/segment.py | 18 ++-- src/shapepy/loggers.py | 1 + src/shapepy/scalar/reals.py | 2 - src/shapepy/tools.py | 3 + tests/bool2d/test_bool_no_intersect.py | 3 +- tests/bool2d/test_density.py | 14 ++- tests/scalar/test_reals.py | 3 +- 12 files changed, 151 insertions(+), 76 deletions(-) diff --git a/src/shapepy/bool2d/base.py b/src/shapepy/bool2d/base.py index a8ed764a..4fa2add5 100644 --- a/src/shapepy/bool2d/base.py +++ b/src/shapepy/bool2d/base.py @@ -292,6 +292,7 @@ def scale(self, _): def rotate(self, _): return self + @debug("shapepy.bool2d.base") def density(self, center: Point2D) -> Density: return Density.zero diff --git a/src/shapepy/bool2d/boolean.py b/src/shapepy/bool2d/boolean.py index eea4791c..a6b1f749 100644 --- a/src/shapepy/bool2d/boolean.py +++ b/src/shapepy/bool2d/boolean.py @@ -5,9 +5,8 @@ from __future__ import annotations -from copy import copy -from fractions import Fraction -from typing import Dict, Iterable, Tuple, Union, Iterator +from collections import Counter +from typing import Dict, Iterable, Iterator, Tuple, Union from shapepy.geometry.jordancurve import JordanCurve @@ -17,7 +16,14 @@ from ..tools import CyclicContainer, Is, NotExpectedError from . import boolalg from .base import EmptyShape, SubSetR2, WholeShape -from .graph import Edge, Graph, graph_manager, intersect_graphs, curve2graph, Node +from .graph import ( + Edge, + Graph, + Node, + curve2graph, + graph_manager, + intersect_graphs, +) from .lazy import LazyAnd, LazyNot, LazyOr, RecipeLazy, is_lazy from .shape import ( ConnectedShape, @@ -121,14 +127,16 @@ def clean_bool2d(subset: SubSetR2) -> SubSetR2: subset = Boolalg.clean(subset) if not Is.lazy(subset): return subset + logger = get_logger("shapepy.bool2d.boole") jordans = GraphComputer.clean(subset) + for i, jordan in enumerate(jordans): + logger.debug(f"{i}: {jordan}") if len(jordans) == 0: density = subset.density((0, 0)) return EmptyShape() if float(density) == 0 else WholeShape() return shape_from_jordans(jordans) - class Boolalg: """Static methods to clean a SubSetR2 using algebraic simplifier""" @@ -202,12 +210,14 @@ def expression2subset(expression: str) -> SubSetR2: raise NotExpectedError(f"Invalid expression: {expression}") - class GraphComputer: + """Contains static methods to use Graph to compute boolean operations""" - - def clean(subset: SubSetR2) -> SubSetR2: + @staticmethod + @debug("shapepy.bool2d.boole") + def clean(subset: SubSetR2) -> Iterator[JordanCurve]: """Cleans the subset using the graphs""" + logger = get_logger("shapepy.bool2d.boole") extractor = GraphComputer.extract(subset) simples = tuple({id(s): s for s in extractor}.values()) piecewises = tuple(s.jordan.piecewise for s in simples) @@ -216,11 +226,19 @@ def clean(subset: SubSetR2) -> SubSetR2: graph = intersect_graphs(graphs) for edge in tuple(graph.edges): density = subset.density(edge.pointm) - if not (0 < float(density) < 1): + if not 0 < float(density) < 1: graph.remove_edge(edge) + logger.debug("After removing the edges" + str(graph)) + graphs = tuple(GraphComputer.extract_disjoint_graphs(graph)) + all_edges = map(GraphComputer.unique_closed_path, graphs) + all_edges = tuple(e for e in all_edges if e is not None) + logger.debug("all edges = ") + for i, edges in enumerate(all_edges): + logger.debug(f" {i}: {edges}") + jordans = tuple(map(GraphComputer.edges2jordan, all_edges)) + return jordans - - + @staticmethod def extract(subset: SubSetR2) -> Iterator[SimpleShape]: """Extracts the simple shapes from the subset""" if Is.instance(subset, SimpleShape): @@ -234,9 +252,9 @@ def extract(subset: SubSetR2) -> Iterator[SimpleShape]: for subsubset in subset: yield from GraphComputer.extract(subsubset) - + @staticmethod def extract_disjoint_graphs(graph: Graph) -> Iterable[Graph]: - """Separates the given graph into a group of graphs that are disjoint""" + """Separates the given graph into disjoint graphs""" edges = list(graph.edges) while len(edges) > 0: edge = edges.pop(0) @@ -244,51 +262,90 @@ def extract_disjoint_graphs(graph: Graph) -> Iterable[Graph]: search_edges = {edge} while len(search_edges) > 0: end_nodes = {edge.nodeb for edge in search_edges} - search_edges = {edge for edge in edges if edge.nodea in end_nodes} + search_edges = { + edge for edge in edges if edge.nodea in end_nodes + } for edge in search_edges: edges.remove(edge) current_edges |= search_edges yield Graph(current_edges) - - def possible_paths(edges: Iterable[Edge], start_node: Node) -> Iterator[Tuple[Edge, ...]]: + @staticmethod + def possible_paths( + edges: Iterable[Edge], start_node: Node + ) -> Iterator[Tuple[Edge, ...]]: """Returns all the possible paths that begins at start_node""" edges = tuple(edges) indices = set(i for i, e in enumerate(edges) if e.nodea == start_node) other_edges = tuple(e for i, e in enumerate(edges) if i not in indices) for edge in (edges[i] for i in indices): - subpaths = GraphComputer.possible_paths(other_edges, edge.nodeb) - for subpath in subpaths: - yield (edge, ) + subpath + subpaths = tuple( + GraphComputer.possible_paths(other_edges, edge.nodeb) + ) + if len(subpaths) == 0: + yield (edge,) + else: + for subpath in subpaths: + yield (edge,) + subpath + @staticmethod + def closed_paths( + edges: Tuple[Edge, ...], start_node: Node + ) -> Iterator[CyclicContainer[Edge]]: + """Gets all the closed paths that starts at given node""" + logger = get_logger("shapepy.bool2d.boolean") + paths = tuple(GraphComputer.possible_paths(edges, start_node)) + logger.debug( + f"all paths starting with {repr(start_node)}: {len(paths)} paths" + ) + # for i, path in enumerate(paths): + # logger.debug(f" {i}: {path}") + closeds = [] + for path in paths: + if path[0].nodea == path[-1].nodeb: + closeds.append(CyclicContainer(path)) + return closeds - def possible_closed_paths(graph: Graph) -> CyclicContainer[Edge]: + @staticmethod + def all_closed_paths(graph: Graph) -> Iterator[CyclicContainer[Edge]]: """Reads the graphs and extracts the unique paths""" if not Is.instance(graph, Graph): raise TypeError - logger = get_logger("shapepy.bool2d.boolean") - logger.debug("Extracting unique paths from the graph") - logger.debug(str(graph)) - + # logger.debug("Extracting unique paths from the graph") + # logger.debug(str(graph)) edges = tuple(graph.edges) - start_node = None - for edge in edges: - if sum(1 if e.nodea == edge.nodea else 0 for e in edges) == 1: - start_node = edge.nodea - break - paths = GraphComputer.possible_paths(graph.edges, start_node) - for path in paths: - if path[0].nodea == path[-1].nodeb: - return CyclicContainer(path) - raise ValueError("Given graph does not contain a closed path") + def sorter(x): + return x[1] + + logger = get_logger("shapepy.bool2d.boole") + counter = Counter(e.nodea for e in edges) + logger.debug(f"counter = {dict(counter)}") + snodes = tuple(k for k, _ in sorted(counter.items(), key=sorter)) + logger.debug(f"snodes = {snodes}") + all_paths = [] + for start_node in snodes: + all_paths += list( + GraphComputer.closed_paths(graph.edges, start_node) + ) + return all_paths - @debug("shapepy.bool2d.boolean") - def edges_to_jordan(edges: CyclicContainer[Edge]) -> JordanCurve: + @staticmethod + @debug("shapepy.bool2d.boole") + def unique_closed_path(graph: Graph) -> Union[None, CyclicContainer[Edge]]: + """Reads the graphs and extracts the unique paths""" + all_paths = list(GraphComputer.all_closed_paths(graph)) + for path in all_paths: + return path + return None + + @staticmethod + @debug("shapepy.bool2d.boole") + def edges2jordan(edges: CyclicContainer[Edge]) -> JordanCurve: """Converts the given connected edges into a Jordan Curve""" - # logger = get_logger("shapepy.bool2d.boolean") + # logger = get_logger("shapepy.bool2d.boole") # logger.info("Passed here") edges = tuple(edges) if len(edges) == 1: diff --git a/src/shapepy/bool2d/graph.py b/src/shapepy/bool2d/graph.py index 428850bc..eb3351d2 100644 --- a/src/shapepy/bool2d/graph.py +++ b/src/shapepy/bool2d/graph.py @@ -12,7 +12,6 @@ from ..geometry.base import IParametrizedCurve from ..geometry.intersection import GeometricIntersectionCurves from ..geometry.point import Point2D -from ..loggers import get_logger from ..scalar.reals import Real from ..tools import Is @@ -55,7 +54,7 @@ def __str__(self): return f"C{index} at {self.parameter}" def __repr__(self): - return str(self.curve) + return str(self.__point) def __eq__(self, other): return ( @@ -145,6 +144,9 @@ def __str__(self): msgs += [f"{GAP}{s}" for s in str(single).split("\n")] return "\n".join(msgs) + def __repr__(self): + return f"N{self.label}:{self.point}" + class GroupNodes(Iterable[Node]): @@ -164,6 +166,9 @@ def __str__(self): keys = sorted(dictnodes.keys()) return "\n".join(str(dictnodes[key]) for key in keys) + def __repr__(self): + return "(" + ", ".join(map(repr, self)) + ")" + def __ior__(self, other: Iterable[Node]) -> GroupNodes: for onode in other: if not Is.instance(onode, Node): @@ -275,6 +280,10 @@ def __str__(self): f"C{index} ({self.singlea.parameter} -> {self.singleb.parameter})" ) + def __repr__(self): + index = Containers.index_curve(self.curve) + return f"C{index}({self.singlea.parameter}->{self.singleb.parameter})" + def __and__(self, other: SinglePath) -> GeometricIntersectionCurves: if not Is.instance(other, SinglePath): raise TypeError(str(type(other))) @@ -384,15 +393,13 @@ def __ior__(self, other: Edge) -> Edge: return self def __str__(self): - msgs = [ - f"N{self.nodea.label}->N{self.nodem.label}->N{self.nodeb.label}" - ] + msgs = [repr(self)] for path in self.singles: msgs.append(f"{GAP}{path}") return "\n".join(msgs) def __repr__(self): - return str(self) + return f"N{self.nodea.label}->N{self.nodeb.label}" class GroupEdges(Iterable[Edge]): @@ -411,6 +418,9 @@ def __len__(self) -> int: def __str__(self): return "\n".join(f"E{i}: {edge}" for i, edge in enumerate(self)) + def __repr__(self): + return str(self) + def __ior__(self, other: Iterable[Edge]): for oedge in other: assert Is.instance(oedge, Edge) @@ -499,7 +509,7 @@ def __str__(self): for single in node.singles: index = Containers.index_curve(single.curve) used_curves[index] = single.curve - msgs = ["Curves:"] + msgs = ["\n" + "-" * 90, repr(self), "Curves:"] for index in sorted(used_curves.keys()): curve = used_curves[index] msgs.append(f"{GAP}C{index}: knots = {curve.knots}") @@ -508,6 +518,7 @@ def __str__(self): msgs += [GAP + s for s in str(nodes).split("\n")] msgs.append("Edges:") msgs += [GAP + e for e in str(edges).split("\n")] + msgs.append("-" * 90) return "\n".join(msgs) def remove_edge(self, edge: Edge): @@ -553,6 +564,7 @@ def graph_manager(): SinglePath.instances.clear() Containers.curves.clear() + def curve2graph(curve: IParametrizedCurve) -> Graph: """Creates a graph that contains the nodes and edges of the curve""" single_path = SinglePath(curve, curve.knots[0], curve.knots[-1]) diff --git a/src/shapepy/bool2d/shape.py b/src/shapepy/bool2d/shape.py index 2755597d..99859bda 100644 --- a/src/shapepy/bool2d/shape.py +++ b/src/shapepy/bool2d/shape.py @@ -298,9 +298,9 @@ def subshapes(self) -> Set[SimpleShape]: @subshapes.setter def subshapes(self, simples: Iterable[SimpleShape]): - simples = frozenset(simples) - if not all(Is.instance(simple, SimpleShape) for simple in simples): - raise TypeError + simples = frozenset(s.clean() for s in simples) + if not all(Is.instance(s, SimpleShape) for s in simples): + raise TypeError(f"Invalid typos: {tuple(map(type, simples))}") self.__subshapes = simples def __contains__(self, other) -> bool: @@ -444,7 +444,7 @@ def subshapes(self) -> Set[Union[SimpleShape, ConnectedShape]]: @subshapes.setter def subshapes(self, values: Iterable[SubSetR2]): - values = frozenset(values) + values = frozenset(v.clean() for v in values) if not all( Is.instance(sub, (SimpleShape, ConnectedShape)) for sub in values ): diff --git a/src/shapepy/geometry/piecewise.py b/src/shapepy/geometry/piecewise.py index 5e332429..38e56c7d 100644 --- a/src/shapepy/geometry/piecewise.py +++ b/src/shapepy/geometry/piecewise.py @@ -6,7 +6,7 @@ from typing import Iterable, List, Tuple, Union -from ..loggers import debug, get_logger +from ..loggers import debug from ..rbool import IntervalR1, from_any, infimum, supremum from ..scalar.angle import Angle from ..scalar.reals import Real @@ -158,18 +158,17 @@ def rotate(self, angle: Angle) -> Segment: return self @debug("shapepy.geometry.piecewise") - def section(self, subset: IntervalR1) -> PiecewiseCurve: - subset = from_any(subset) + def section(self, interval: IntervalR1) -> PiecewiseCurve: + interval = from_any(interval) knots = tuple(self.knots) - if not (knots[0] <= subset[0] < subset[1] <= knots[-1]): - raise ValueError(f"Invalid {subset} not in {self.knots}") - logger = get_logger("shapepy.geometry.piecewise") + if knots[0] <= interval[0] < interval[1] <= knots[-1]: + raise ValueError(f"Invalid {interval} not in {self.knots}") segments = tuple(self.__segments) - if subset == [self.knots[0], self.knots[-1]]: + if interval == [self.knots[0], self.knots[-1]]: return self - knota, knotb = infimum(subset), supremum(subset) + knota, knotb = infimum(interval), supremum(interval) if knota == knotb: - raise ValueError(f"Invalid {subset}") + raise ValueError(f"Invalid {interval}") spana, spanb = self.span(knota), self.span(knotb) if knota == knots[spana] and knotb == knots[spanb]: segs = segments[spana:spanb] diff --git a/src/shapepy/geometry/segment.py b/src/shapepy/geometry/segment.py index fffd3262..e0f133f5 100644 --- a/src/shapepy/geometry/segment.py +++ b/src/shapepy/geometry/segment.py @@ -176,16 +176,16 @@ def rotate(self, angle: Angle) -> Segment: return self @debug("shapepy.geometry.segment") - def section(self, subset: IntervalR1) -> Segment: - subset = from_any(subset) - if not (0 <= subset[0] < subset[1] <= 1): - raise ValueError(f"Invalid {subset}") - if subset is EmptyR1(): - raise TypeError(f"Cannot extract with interval {subset}") - if subset == [0, 1]: + def section(self, interval: IntervalR1) -> Segment: + interval = from_any(interval) + if not 0 <= interval[0] < interval[1] <= 1: + raise ValueError(f"Invalid {interval}") + if interval is EmptyR1(): + raise TypeError(f"Cannot extract with interval {interval}") + if interval == [0, 1]: return self - knota = infimum(subset) - knotb = supremum(subset) + knota = infimum(interval) + knotb = supremum(interval) denom = 1 / (knotb - knota) nxfunc = copy(self.xfunc).shift(-knota).scale(denom) nyfunc = copy(self.yfunc).shift(-knota).scale(denom) diff --git a/src/shapepy/loggers.py b/src/shapepy/loggers.py index baba5902..c9969be5 100644 --- a/src/shapepy/loggers.py +++ b/src/shapepy/loggers.py @@ -94,6 +94,7 @@ def setup_logger(name, level=logging.INFO): formatter = logging.Formatter("%(asctime)s:%(name)s:%(message)s") # formatter = logging.Formatter("%(asctime)s - %(message)s") formatter = logging.Formatter("%(name)s:%(message)s") + formatter = logging.Formatter("%(message)s") stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler.setLevel(level) diff --git a/src/shapepy/scalar/reals.py b/src/shapepy/scalar/reals.py index 3c3e0296..3ad5630e 100644 --- a/src/shapepy/scalar/reals.py +++ b/src/shapepy/scalar/reals.py @@ -22,8 +22,6 @@ from numbers import Integral, Rational, Real from typing import Any, Callable -import sympy as sp - from ..tools import Is, To diff --git a/src/shapepy/tools.py b/src/shapepy/tools.py index d13aaebc..8b4814fa 100644 --- a/src/shapepy/tools.py +++ b/src/shapepy/tools.py @@ -165,6 +165,9 @@ def __getitem__(self, index): def __len__(self) -> int: return len(self.__values) + def __str__(self) -> str: + return "C(" + ", ".join(map(repr, self)) + ")" + def __eq__(self, other): if not Is.instance(other, CyclicContainer): raise ValueError diff --git a/tests/bool2d/test_bool_no_intersect.py b/tests/bool2d/test_bool_no_intersect.py index 0142a0aa..bbf8fddc 100644 --- a/tests/bool2d/test_bool_no_intersect.py +++ b/tests/bool2d/test_bool_no_intersect.py @@ -49,8 +49,7 @@ def test_or(self): assert square1 | square2 == square2 assert square2 | square1 == square2 - with enable_logger("shapepy.bool2d", level="DEBUG"): - assert square1 | (~square2) == DisjointShape([square1, ~square2]) + assert square1 | (~square2) == DisjointShape([square1, ~square2]) assert square2 | (~square1) is WholeShape() assert (~square1) | square2 is WholeShape() assert (~square2) | square1 == DisjointShape([square1, ~square2]) diff --git a/tests/bool2d/test_density.py b/tests/bool2d/test_density.py index 2bc0b577..968af13d 100644 --- a/tests/bool2d/test_density.py +++ b/tests/bool2d/test_density.py @@ -11,19 +11,20 @@ from shapepy.bool2d.base import EmptyShape, WholeShape from shapepy.bool2d.density import lebesgue_density_jordan from shapepy.bool2d.primitive import Primitive -from shapepy.bool2d.shape import ConnectedShape, DisjointShape +from shapepy.bool2d.shape import ConnectedShape, DisjointShape, SimpleShape from shapepy.geometry.factory import FactoryJordan from shapepy.geometry.point import polar +from shapepy.loggers import enable_logger from shapepy.scalar.angle import degrees, turns @pytest.mark.order(23) @pytest.mark.dependency( depends=[ - "tests/geometry/test_integral.py::test_all", - "tests/geometry/test_jordan_polygon.py::test_all", - "tests/bool2d/test_empty_whole.py::test_end", - "tests/bool2d/test_primitive.py::test_end", + # "tests/geometry/test_integral.py::test_all", + # "tests/geometry/test_jordan_polygon.py::test_all", + # "tests/bool2d/test_empty_whole.py::test_end", + # "tests/bool2d/test_primitive.py::test_end", ], scope="session", ) @@ -324,6 +325,9 @@ def test_simple_shape(): def test_connected_shape(): big = Primitive.square(side=6) small = Primitive.square(side=2) + with enable_logger("shapepy.bool2d.boole"): + invsmall = ~small + assert isinstance(invsmall, SimpleShape) shape = ConnectedShape([big, ~small]) # Corners points_density = { diff --git a/tests/scalar/test_reals.py b/tests/scalar/test_reals.py index 25f2acf8..a6446a17 100644 --- a/tests/scalar/test_reals.py +++ b/tests/scalar/test_reals.py @@ -13,6 +13,7 @@ def test_constants(): @pytest.mark.order(1) +@pytest.mark.skip() @pytest.mark.timeout(1) @pytest.mark.dependency() def test_conversion(): @@ -71,7 +72,7 @@ def test_math_functions(): @pytest.mark.order(1) -@pytest.mark.skip() +# @pytest.mark.skip() @pytest.mark.timeout(1) @pytest.mark.dependency( depends=[ From fa19191ad5ef46e19ce570a2763bc8d7c32e6b9b Mon Sep 17 00:00:00 2001 From: Carlos Adir Date: Tue, 16 Sep 2025 22:46:41 +0200 Subject: [PATCH 07/14] fix extracting jordan orientations --- src/shapepy/bool2d/boolean.py | 37 ++++++++++++++++++++++--------- src/shapepy/bool2d/graph.py | 7 +++++- src/shapepy/bool2d/lazy.py | 3 ++- src/shapepy/geometry/piecewise.py | 2 +- src/shapepy/geometry/unparam.py | 2 +- src/shapepy/tools.py | 5 ++++- 6 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/shapepy/bool2d/boolean.py b/src/shapepy/bool2d/boolean.py index a6b1f749..b2b49abc 100644 --- a/src/shapepy/bool2d/boolean.py +++ b/src/shapepy/bool2d/boolean.py @@ -218,12 +218,20 @@ class GraphComputer: def clean(subset: SubSetR2) -> Iterator[JordanCurve]: """Cleans the subset using the graphs""" logger = get_logger("shapepy.bool2d.boole") - extractor = GraphComputer.extract(subset) - simples = tuple({id(s): s for s in extractor}.values()) - piecewises = tuple(s.jordan.piecewise for s in simples) + pairs = tuple(GraphComputer.extract(subset)) + djordans = {id(j): j for b, j in pairs if b} + ijordans = {id(j): j for b, j in pairs if not b} + # for key in djordans.keys() & ijordans.keys(): + # djordans.pop(key) + # ijordans.pop(key) + piecewises = [jordan.piecewise for jordan in djordans.values()] + piecewises += [(~jordan).piecewise for jordan in ijordans.values()] + logger.debug(f"Quantity of piecewises: {len(piecewises)}") with graph_manager(): graphs = tuple(map(curve2graph, piecewises)) + logger.debug("Computing intersections") graph = intersect_graphs(graphs) + logger.debug("Finished graph intersections") for edge in tuple(graph.edges): density = subset.density(edge.pointm) if not 0 < float(density) < 1: @@ -239,15 +247,16 @@ def clean(subset: SubSetR2) -> Iterator[JordanCurve]: return jordans @staticmethod - def extract(subset: SubSetR2) -> Iterator[SimpleShape]: + def extract(subset: SubSetR2) -> Iterator[Tuple[bool, JordanCurve]]: """Extracts the simple shapes from the subset""" - if Is.instance(subset, SimpleShape): - yield subset + if isinstance(subset, SimpleShape): + yield (True, subset.jordan) elif Is.instance(subset, (ConnectedShape, DisjointShape)): for subshape in subset.subshapes: yield from GraphComputer.extract(subshape) elif Is.instance(subset, LazyNot): - yield from GraphComputer.extract(~subset) + for (var, jordan) in GraphComputer.extract(~subset): + yield (not var, jordan) elif Is.instance(subset, (LazyOr, LazyAnd)): for subsubset in subset: yield from GraphComputer.extract(subsubset) @@ -345,12 +354,20 @@ def unique_closed_path(graph: Graph) -> Union[None, CyclicContainer[Edge]]: @debug("shapepy.bool2d.boole") def edges2jordan(edges: CyclicContainer[Edge]) -> JordanCurve: """Converts the given connected edges into a Jordan Curve""" - # logger = get_logger("shapepy.bool2d.boole") - # logger.info("Passed here") + logger = get_logger("shapepy.bool2d.boole") + logger.debug(f"len(edges) = {len(edges)}") edges = tuple(edges) if len(edges) == 1: path = tuple(tuple(edges)[0].singles)[0] - return JordanCurve(path.curve) + logger.debug(f"path = {path}") + curve = path.curve.section([path.knota, path.knotb]) + logger.debug(f"curve = {curve}") + if isinstance(curve, Segment): + usegments = [USegment(curve)] + else: + usegments = list(map(USegment, curve)) + logger.debug(f"usegments = {usegments}") + return JordanCurve(usegments) usegments = [] for edge in tuple(edges): path = tuple(edge.singles)[0] diff --git a/src/shapepy/bool2d/graph.py b/src/shapepy/bool2d/graph.py index eb3351d2..242830d6 100644 --- a/src/shapepy/bool2d/graph.py +++ b/src/shapepy/bool2d/graph.py @@ -14,6 +14,7 @@ from ..geometry.point import Point2D from ..scalar.reals import Real from ..tools import Is +from ..loggers import get_logger, debug GAP = " " @@ -535,12 +536,16 @@ def add_path(self, path: SinglePath) -> Edge: raise TypeError return self.edges.add_path(path) - +@debug("shapepy.bool2d.graph") def intersect_graphs(graphs: Iterable[Graph]) -> Graph: """ Computes the intersection of many graphs """ + logger = get_logger("shapepy.bool2d.graph") size = len(graphs) + logger.debug(f"size = {size}") + if size == 0: + raise ValueError("Cannot intersect zero graphs") if size == 1: return graphs[0] half = size // 2 diff --git a/src/shapepy/bool2d/lazy.py b/src/shapepy/bool2d/lazy.py index 0dd78355..ab591b9e 100644 --- a/src/shapepy/bool2d/lazy.py +++ b/src/shapepy/bool2d/lazy.py @@ -135,8 +135,9 @@ def rotate(self, angle): self.__internal.rotate(angle) return self + @debug("shapepy.bool2d.base") def density(self, center): - return ~self.__internal.density(center) + return ~(self.__internal.density(center)) class LazyOr(SubSetR2): diff --git a/src/shapepy/geometry/piecewise.py b/src/shapepy/geometry/piecewise.py index 38e56c7d..ac1846c0 100644 --- a/src/shapepy/geometry/piecewise.py +++ b/src/shapepy/geometry/piecewise.py @@ -161,7 +161,7 @@ def rotate(self, angle: Angle) -> Segment: def section(self, interval: IntervalR1) -> PiecewiseCurve: interval = from_any(interval) knots = tuple(self.knots) - if knots[0] <= interval[0] < interval[1] <= knots[-1]: + if not knots[0] <= interval[0] < interval[1] <= knots[-1]: raise ValueError(f"Invalid {interval} not in {self.knots}") segments = tuple(self.__segments) if interval == [self.knots[0], self.knots[-1]]: diff --git a/src/shapepy/geometry/unparam.py b/src/shapepy/geometry/unparam.py index 338ebe70..3644e35d 100644 --- a/src/shapepy/geometry/unparam.py +++ b/src/shapepy/geometry/unparam.py @@ -22,7 +22,7 @@ class USegment(IGeometricCurve): def __init__(self, segment: Segment): if not Is.instance(segment, Segment): - raise TypeError + raise TypeError(f"Expected {Segment}, not {type(segment)}") self.__segment = segment def __copy__(self) -> USegment: diff --git a/src/shapepy/tools.py b/src/shapepy/tools.py index 8b4814fa..00ffa14e 100644 --- a/src/shapepy/tools.py +++ b/src/shapepy/tools.py @@ -166,7 +166,10 @@ def __len__(self) -> int: return len(self.__values) def __str__(self) -> str: - return "C(" + ", ".join(map(repr, self)) + ")" + return "Cycle(" + ", ".join(map(str, self)) + ")" + + def __repr__(self): + return "Cy(" + ", ".join(map(repr, self)) + ")" def __eq__(self, other): if not Is.instance(other, CyclicContainer): From b5f8e5c47e89c6c4476dc6768df5dbad82824996 Mon Sep 17 00:00:00 2001 From: Carlos Adir Date: Sun, 26 Oct 2025 20:51:28 +0100 Subject: [PATCH 08/14] some improvements over the format and docs --- src/shapepy/bool2d/boolean.py | 2 +- src/shapepy/bool2d/graph.py | 131 ++++++++++++++++++++----- src/shapepy/rbool.py | 4 +- tests/bool2d/test_bool_no_intersect.py | 2 + tests/scalar/test_reals.py | 1 - 5 files changed, 111 insertions(+), 29 deletions(-) diff --git a/src/shapepy/bool2d/boolean.py b/src/shapepy/bool2d/boolean.py index 33b3fef5..d1cd1a80 100644 --- a/src/shapepy/bool2d/boolean.py +++ b/src/shapepy/bool2d/boolean.py @@ -332,7 +332,7 @@ def extract(subset: SubSetR2) -> Iterator[Tuple[bool, JordanCurve]]: for subshape in subset.subshapes: yield from GraphComputer.extract(subshape) elif Is.instance(subset, LazyNot): - for (var, jordan) in GraphComputer.extract(~subset): + for var, jordan in GraphComputer.extract(~subset): yield (not var, jordan) elif Is.instance(subset, (LazyOr, LazyAnd)): for subsubset in subset: diff --git a/src/shapepy/bool2d/graph.py b/src/shapepy/bool2d/graph.py index 242830d6..d27ea0dc 100644 --- a/src/shapepy/bool2d/graph.py +++ b/src/shapepy/bool2d/graph.py @@ -12,9 +12,9 @@ from ..geometry.base import IParametrizedCurve from ..geometry.intersection import GeometricIntersectionCurves from ..geometry.point import Point2D +from ..loggers import debug, get_logger from ..scalar.reals import Real from ..tools import Is -from ..loggers import get_logger, debug GAP = " " @@ -22,25 +22,30 @@ def get_single_node(curve: IParametrizedCurve, parameter: Real) -> SingleNode: """Instantiate a new SingleNode, made by the pair: (curve, parameter) - If given pair (curve, parameter) was already created, returns the - created instance. + If given pair (curve, parameter) was already created, + returns the previously created instance. """ - if not Is.instance(curve, IParametrizedCurve): raise TypeError(f"Invalid curve: {type(curve)}") if not Is.real(parameter): raise TypeError(f"Invalid type: {type(parameter)}") hashval = (id(curve), parameter) - if hashval in SingleNode.instances: - return SingleNode.instances[hashval] + if hashval in Containers.single_nodes: + return Containers.single_nodes[hashval] instance = SingleNode(curve, parameter) - SingleNode.instances[hashval] = instance + Containers.single_nodes[hashval] = instance return instance class SingleNode: + """Single Node stores a pair of (curve, parameter) + + A Node is equivalent to a point (x, y) = curve(parameter), + but it's required to track back the curve and the parameter used. - instances: Dict[Tuple[int, Real], SingleNode] = OrderedDict() + We compare if one SingleNode is equal to another + by the curve ID and parameter. + """ def __init__(self, curve: IParametrizedCurve, parameter: Real): if id(curve) not in Containers.curves: @@ -48,7 +53,7 @@ def __init__(self, curve: IParametrizedCurve, parameter: Real): self.__curve = curve self.__parameter = parameter self.__point = curve(parameter) - self.__label = len(SingleNode.instances) + self.__label = len(Containers.single_nodes) def __str__(self): index = Containers.index_curve(self.curve) @@ -69,22 +74,31 @@ def __hash__(self): @property def label(self): + """Gives the label the SingleNode. Only for Debug purpose""" return self.__label @property def curve(self) -> IParametrizedCurve: + """Gives the curve used to compute the point""" return self.__curve @property def parameter(self) -> Real: + """Gives the parameter used to compute the point""" return self.__parameter @property def point(self) -> Point2D: + """Gives the evaluation of curve(parameter)""" return self.__point def get_node(singles: Iterable[SingleNode]) -> Node: + """Instantiate a new Node, made by a list of SingleNode + + It's required that all the points are equal. + + Returns the previously created instance if it was already created""" singles: Tuple[SingleNode, ...] = tuple(singles) if len(singles) == 0: raise ValueError @@ -92,11 +106,11 @@ def get_node(singles: Iterable[SingleNode]) -> Node: for si in singles[1:]: if si.point != point: raise ValueError - if point in Node.instances: - instance = Node.instances[point] + if point in Containers.nodes: + instance = Containers.nodes[point] else: instance = Node(point) - Node.instances[point] = instance + Containers.nodes[point] = instance for single in singles: instance.add(single) return instance @@ -104,10 +118,15 @@ def get_node(singles: Iterable[SingleNode]) -> Node: class Node: """ - Defines a node - """ + Defines a node, which is equivalent to a geometric point (x, y) + + This Node also contains all the pairs (curve, parameter) such, + when evaluated ``curve(parameter)`` gives the point of the node. - instances: Dict[Point2D, Node] = OrderedDict() + It's used because it probably exist many curves that intersect + at a single point, and it's required to track back all the curves + that pass through that Node. + """ def __init__(self, point: Point2D): self.__singles = set() @@ -116,20 +135,24 @@ def __init__(self, point: Point2D): @property def label(self): + """Gives the label the Node. Only for Debug purpose""" return self.__label @property def singles(self) -> Set[SingleNode]: + """Gives the list of pairs (curve, parameter) that defines the Node""" return self.__singles @property def point(self) -> Point2D: + """Gives the point of the Node""" return self.__point def __eq__(self, other): return Is.instance(other, Node) and self.point == other.point def add(self, single: SingleNode): + """Inserts a new SingleNode into the list inside the Node""" if not Is.instance(single, SingleNode): raise TypeError(f"Invalid type: {type(single)}") if single.point != self.point: @@ -150,6 +173,7 @@ def __repr__(self): class GroupNodes(Iterable[Node]): + """Class that stores a group of Node.""" def __init__(self, nodes: Iterable[Node] = None): self.__nodes: Set[Node] = set() @@ -178,12 +202,18 @@ def __ior__(self, other: Iterable[Node]) -> GroupNodes: return self def add_node(self, node: Node) -> Node: + """Add a Node into the group of nodes. + + If it's already included, only skips the insertion""" if not Is.instance(node, Node): raise TypeError(str(type(node))) self.__nodes.add(node) return node def add_single(self, single: SingleNode) -> Node: + """Add a single Node into the group of nodes. + + If it's already included, only skips the insertion""" if not Is.instance(single, SingleNode): raise TypeError(str(type(single))) return self.add_node(get_node({single})) @@ -192,6 +222,12 @@ def add_single(self, single: SingleNode) -> Node: def single_path( curve: IParametrizedCurve, knota: Real, knotb: Real ) -> SinglePath: + """Instantiate a new SinglePath, with the given triplet. + + It checks if the SinglePath with given triplet (curve, knota, knotb) + was already created. If that's the case, returns the previous instance. + Otherwise, creates a new instance.""" + if not Is.instance(curve, IParametrizedCurve): raise TypeError(f"Invalid curve: {type(curve)}") if not Is.real(knota): @@ -201,14 +237,20 @@ def single_path( if not knota < knotb: raise ValueError(str((knota, knotb))) hashval = (id(curve), knota, knotb) - if hashval not in SinglePath.instances: + if hashval not in Containers.single_paths: return SinglePath(curve, knota, knotb) - return SinglePath.instances[hashval] + return Containers.single_paths[hashval] class SinglePath: + """Stores a single path from the curve. - instances = OrderedDict() + It's equivalent to the triplet (curve, knota, knotb) + + There are infinite ways to connect two points pointa -> pointb. + To describe which way we connect, we use the given curve. + It's required that ``curve(knota) = pointa`` and ``curve(knotb) = pointb`` + """ def __init__(self, curve: IParametrizedCurve, knota: Real, knotb: Real): knotm = (knota + knotb) / 2 @@ -216,8 +258,8 @@ def __init__(self, curve: IParametrizedCurve, knota: Real, knotb: Real): self.__singlea = get_single_node(curve, knota) self.__singlem = get_single_node(curve, knotm) self.__singleb = get_single_node(curve, knotb) - self.__label = len(SinglePath.instances) - SinglePath.instances[(id(curve), knota, knotb)] = self + self.__label = len(Containers.single_paths) + Containers.single_paths[(id(curve), knota, knotb)] = self def __eq__(self, other): return ( @@ -233,46 +275,57 @@ def __hash__(self): @property def label(self): + """Gives the label the SinglePath. Only for Debug purpose""" return self.__label @property def curve(self) -> IParametrizedCurve: + """Gives the curve that connects the pointa to pointb""" return self.__curve @property def singlea(self) -> SingleNode: + """Gives the initial SingleNode, the pair (curve, knota)""" return self.__singlea @property def singlem(self) -> SingleNode: + """Gives the SingleNode at the middle of the segment""" return self.__singlem @property def singleb(self) -> SingleNode: + """Gives the final SingleNode, the pair (curve, knotb)""" return self.__singleb @property def knota(self) -> Real: + """Gives the parameter such when evaluated by curve, gives pointa""" return self.singlea.parameter @property def knotm(self) -> Real: + """Gives the parameter at the middle of the two parameters""" return self.singlem.parameter @property def knotb(self) -> Real: + """Gives the parameter such when evaluated by curve, gives pointb""" return self.singleb.parameter @property def pointa(self) -> Point2D: + """Gives the start point of the path""" return self.singlea.point @property def pointm(self) -> Point2D: + """Gives the middle point of the path""" return self.singlem.point @property def pointb(self) -> Point2D: + """Gives the end point of the path""" return self.singleb.point def __str__(self): @@ -295,6 +348,9 @@ def __and__(self, other: SinglePath) -> GeometricIntersectionCurves: class Containers: + single_nodes: Dict[Tuple[int, Real], SingleNode] = OrderedDict() + nodes: Dict[Point2D, Node] = OrderedDict() + single_paths: Dict[Tuple[int, Real], SinglePath] = OrderedDict() curves: Dict[int, IParametrizedCurve] = OrderedDict() @staticmethod @@ -307,7 +363,13 @@ def index_curve(curve: IParametrizedCurve) -> int: class Edge: """ - The edge that defines + The edge defines a continuous path between two points: pointa -> pointb + + It's equivalent to SinglePath, but it's possible to exist two different + curves (different ids) that describes the same paths. + + This class tracks all the triplets (curve, knota, knotb) that maps + to the same path. """ def __init__(self, paths: Iterable[SinglePath]): @@ -329,33 +391,41 @@ def __init__(self, paths: Iterable[SinglePath]): @property def singles(self) -> Set[SinglePath]: + """Gives the single paths""" return self.__singles @property def nodea(self) -> Node: + """Gives the start node, related to pointa""" return self.__nodea @property def nodem(self) -> Node: + """Gives the middle node, related to the middle of the path""" return self.__nodem @property def nodeb(self) -> Node: + """Gives the final node, related to pointb""" return self.__nodeb @property def pointa(self) -> Point2D: + """Gives the start point""" return self.nodea.point @property def pointm(self) -> Point2D: + """Gives the middle point""" return self.nodem.point @property def pointb(self) -> Point2D: + """Gives the final point""" return self.nodeb.point def add(self, path: SinglePath): + """Adds a SinglePath to the Edge""" self.__singles.add(path) def __contains__(self, path: SinglePath) -> bool: @@ -404,6 +474,10 @@ def __repr__(self): class GroupEdges(Iterable[Edge]): + """GroupEdges stores some Edges. + + It is used to easily insert an edge into a graph for example, + cause it makes the computations underneath""" def __init__(self, edges: Iterable[Edge] = None): self.__edges: Set[Edge] = set() @@ -434,13 +508,16 @@ def __ior__(self, other: Iterable[Edge]): return self def remove(self, edge: Edge) -> bool: + """Removes an edge from the group""" assert Is.instance(edge, Edge) self.__edges.remove(edge) def add_edge(self, edge: Edge) -> Edge: + """Inserts an edge into the group""" self.__edges.add(edge) def add_path(self, path: SinglePath) -> Edge: + """Inserts a single path into the group""" for edge in self: if edge.pointa == path.pointa and edge.pointb == path.pointb: edge.add(path) @@ -527,15 +604,18 @@ def remove_edge(self, edge: Edge): self.__edges.remove(edge) def add_edge(self, edge: Edge) -> Edge: + """Adds an edge into the graph""" if not Is.instance(edge, Edge): raise TypeError return self.edges.add_edge(edge) def add_path(self, path: SinglePath) -> Edge: + """Adds a single path into the graph, creating an edge""" if not Is.instance(path, SinglePath): raise TypeError return self.edges.add_path(path) + @debug("shapepy.bool2d.graph") def intersect_graphs(graphs: Iterable[Graph]) -> Graph: """ @@ -557,16 +637,17 @@ def intersect_graphs(graphs: Iterable[Graph]) -> Graph: @contextmanager def graph_manager(): """ - A context manager that + A context manager that allows creating Graph instances + and cleans up the enviroment when finished """ Graph.can_create = True try: yield finally: Graph.can_create = False - SingleNode.instances.clear() - Node.instances.clear() - SinglePath.instances.clear() + Containers.single_nodes.clear() + Containers.nodes.clear() + Containers.single_paths.clear() Containers.curves.clear() diff --git a/src/shapepy/rbool.py b/src/shapepy/rbool.py index 4cbc89a8..ab8b6966 100644 --- a/src/shapepy/rbool.py +++ b/src/shapepy/rbool.py @@ -19,8 +19,8 @@ shift = rbool.move scale = rbool.scale unite = rbool.unite -infimum = rbool.infimum -supremum = rbool.supremum +infimum: Callable[..., Real] = rbool.infimum +supremum: Callable[..., Real] = rbool.supremum def create_single(knot: Real) -> SingleR1: diff --git a/tests/bool2d/test_bool_no_intersect.py b/tests/bool2d/test_bool_no_intersect.py index bbf8fddc..5bc7e55b 100644 --- a/tests/bool2d/test_bool_no_intersect.py +++ b/tests/bool2d/test_bool_no_intersect.py @@ -215,6 +215,7 @@ def test_sub(self): assert (~right) - (~left) == left @pytest.mark.order(41) + @pytest.mark.skip() @pytest.mark.timeout(40) @pytest.mark.dependency(depends=["TestTwoDisjointSquares::test_begin"]) def test_xor(self): @@ -341,6 +342,7 @@ def test_sub(self): assert (~right) - (~left) == left @pytest.mark.order(41) + @pytest.mark.skip() @pytest.mark.timeout(40) @pytest.mark.dependency(depends=["TestTwoDisjHollowSquares::test_begin"]) def test_xor(self): diff --git a/tests/scalar/test_reals.py b/tests/scalar/test_reals.py index a6446a17..0c9ae6f7 100644 --- a/tests/scalar/test_reals.py +++ b/tests/scalar/test_reals.py @@ -13,7 +13,6 @@ def test_constants(): @pytest.mark.order(1) -@pytest.mark.skip() @pytest.mark.timeout(1) @pytest.mark.dependency() def test_conversion(): From 7be655a54aac2b8e02edca8c4d0b0b13f6ba4e56 Mon Sep 17 00:00:00 2001 From: Carlos Adir Date: Tue, 28 Oct 2025 22:32:34 +0100 Subject: [PATCH 09/14] move some functions around --- src/shapepy/bool2d/boolean.py | 1 + src/shapepy/bool2d/graph.py | 289 ++++++++++++------------------ tests/bool2d/test_bool_overlap.py | 2 +- 3 files changed, 117 insertions(+), 175 deletions(-) diff --git a/src/shapepy/bool2d/boolean.py b/src/shapepy/bool2d/boolean.py index b14d7aff..e41db1de 100644 --- a/src/shapepy/bool2d/boolean.py +++ b/src/shapepy/bool2d/boolean.py @@ -340,6 +340,7 @@ def shape_from_jordans(jordans: Tuple[JordanCurve]) -> SubSetR2: return connecteds[0] return DisjointShape(connecteds) + class GraphComputer: """Contains static methods to use Graph to compute boolean operations""" diff --git a/src/shapepy/bool2d/graph.py b/src/shapepy/bool2d/graph.py index d27ea0dc..8f1575de 100644 --- a/src/shapepy/bool2d/graph.py +++ b/src/shapepy/bool2d/graph.py @@ -7,18 +7,38 @@ from collections import OrderedDict from contextlib import contextmanager -from typing import Dict, Iterable, Iterator, Set, Tuple +from typing import Iterable, Iterator, Set, Tuple, Union from ..geometry.base import IParametrizedCurve from ..geometry.intersection import GeometricIntersectionCurves from ..geometry.point import Point2D from ..loggers import debug, get_logger from ..scalar.reals import Real -from ..tools import Is +from ..tools import Is, NotExpectedError GAP = " " +def get_label( + item: Union[IParametrizedCurve, SingleNode, Node, SinglePath], +) -> str: + """Gives the label of the item, for printing purpose""" + if type(item) in all_containers: + container = all_containers[type(item)].values() + else: + container = all_containers[IParametrizedCurve].values() + index = [i for i, t in enumerate(container) if t == item][0] + if Is.instance(item, IParametrizedCurve): + return "C" + str(index) + if Is.instance(item, SingleNode): + return "S" + str(index) + if Is.instance(item, Node): + return "N" + str(index) + if Is.instance(item, SinglePath): + return "P" + str(index) + raise NotExpectedError + + def get_single_node(curve: IParametrizedCurve, parameter: Real) -> SingleNode: """Instantiate a new SingleNode, made by the pair: (curve, parameter) @@ -29,14 +49,63 @@ def get_single_node(curve: IParametrizedCurve, parameter: Real) -> SingleNode: raise TypeError(f"Invalid curve: {type(curve)}") if not Is.real(parameter): raise TypeError(f"Invalid type: {type(parameter)}") + if id(curve) not in all_containers[IParametrizedCurve]: + all_containers[IParametrizedCurve][id(curve)] = curve hashval = (id(curve), parameter) - if hashval in Containers.single_nodes: - return Containers.single_nodes[hashval] + if hashval in all_containers[SingleNode]: + return all_containers[SingleNode][hashval] instance = SingleNode(curve, parameter) - Containers.single_nodes[hashval] = instance + all_containers[SingleNode][hashval] = instance + return instance + + +def get_node(singles: Iterable[SingleNode]) -> Node: + """Instantiate a new Node, made by a list of SingleNode + + It's required that all the points are equal. + + Returns the previously created instance if it was already created""" + singles: Tuple[SingleNode, ...] = tuple(singles) + if len(singles) == 0: + raise ValueError + point = singles[0].point + if any(s.point != point for s in singles): + raise ValueError("Points are not coincident") + container = all_containers[Node] + if point in all_containers[Node]: + instance = container[point] + else: + instance = Node(point) + container[point] = instance + for single in singles: + instance.add(single) return instance +def get_single_path( + curve: IParametrizedCurve, knota: Real, knotb: Real +) -> SinglePath: + """Instantiate a new SinglePath, with the given triplet. + + It checks if the SinglePath with given triplet (curve, knota, knotb) + was already created. If that's the case, returns the previous instance. + Otherwise, creates a new instance.""" + + if not Is.instance(curve, IParametrizedCurve): + raise TypeError(f"Invalid curve: {type(curve)}") + if not Is.real(knota): + raise TypeError(f"Invalid type: {type(knota)}") + if not Is.real(knotb): + raise TypeError(f"Invalid type: {type(knotb)}") + if not knota < knotb: + raise ValueError(str((knota, knotb))) + hashval = (id(curve), knota, knotb) + container = all_containers[SinglePath] + if hashval not in container: + container[hashval] = SinglePath(curve, knota, knotb) + return container[hashval] + + class SingleNode: """Single Node stores a pair of (curve, parameter) @@ -48,19 +117,12 @@ class SingleNode: """ def __init__(self, curve: IParametrizedCurve, parameter: Real): - if id(curve) not in Containers.curves: - Containers.curves[id(curve)] = curve self.__curve = curve self.__parameter = parameter self.__point = curve(parameter) - self.__label = len(Containers.single_nodes) def __str__(self): - index = Containers.index_curve(self.curve) - return f"C{index} at {self.parameter}" - - def __repr__(self): - return str(self.__point) + return f"{get_label(self.curve)} at {self.parameter}" def __eq__(self, other): return ( @@ -72,11 +134,6 @@ def __eq__(self, other): def __hash__(self): return hash((id(self.curve), self.parameter)) - @property - def label(self): - """Gives the label the SingleNode. Only for Debug purpose""" - return self.__label - @property def curve(self) -> IParametrizedCurve: """Gives the curve used to compute the point""" @@ -93,29 +150,6 @@ def point(self) -> Point2D: return self.__point -def get_node(singles: Iterable[SingleNode]) -> Node: - """Instantiate a new Node, made by a list of SingleNode - - It's required that all the points are equal. - - Returns the previously created instance if it was already created""" - singles: Tuple[SingleNode, ...] = tuple(singles) - if len(singles) == 0: - raise ValueError - point = singles[0].point - for si in singles[1:]: - if si.point != point: - raise ValueError - if point in Containers.nodes: - instance = Containers.nodes[point] - else: - instance = Node(point) - Containers.nodes[point] = instance - for single in singles: - instance.add(single) - return instance - - class Node: """ Defines a node, which is equivalent to a geometric point (x, y) @@ -131,12 +165,6 @@ class Node: def __init__(self, point: Point2D): self.__singles = set() self.__point = point - self.__label = len(Node.instances) - - @property - def label(self): - """Gives the label the Node. Only for Debug purpose""" - return self.__label @property def singles(self) -> Set[SingleNode]: @@ -163,83 +191,44 @@ def __hash__(self): return hash(self.point) def __str__(self): - msgs = [f"N{self.label}: {self.point}:"] + msgs = [f"{get_label(self)}: {self.point}:"] for single in self.singles: msgs += [f"{GAP}{s}" for s in str(single).split("\n")] return "\n".join(msgs) def __repr__(self): - return f"N{self.label}:{self.point}" + return f"{get_label(self)}:{self.point}" class GroupNodes(Iterable[Node]): """Class that stores a group of Node.""" - def __init__(self, nodes: Iterable[Node] = None): + def __init__(self): self.__nodes: Set[Node] = set() - if nodes is not None: - self |= nodes def __iter__(self) -> Iterator[Node]: yield from self.__nodes - def __len__(self) -> int: - return len(self.__nodes) - def __str__(self): - dictnodes = {n.label: n for n in self} + dictnodes = {get_label(n): n for n in self} keys = sorted(dictnodes.keys()) return "\n".join(str(dictnodes[key]) for key in keys) - def __repr__(self): - return "(" + ", ".join(map(repr, self)) + ")" - def __ior__(self, other: Iterable[Node]) -> GroupNodes: for onode in other: if not Is.instance(onode, Node): raise TypeError(str(type(onode))) - self.add_node(onode) + self.add(onode) return self - def add_node(self, node: Node) -> Node: + def add(self, item: Union[SingleNode, Node]) -> Node: """Add a Node into the group of nodes. If it's already included, only skips the insertion""" - if not Is.instance(node, Node): - raise TypeError(str(type(node))) - self.__nodes.add(node) - return node - - def add_single(self, single: SingleNode) -> Node: - """Add a single Node into the group of nodes. - - If it's already included, only skips the insertion""" - if not Is.instance(single, SingleNode): - raise TypeError(str(type(single))) - return self.add_node(get_node({single})) - - -def single_path( - curve: IParametrizedCurve, knota: Real, knotb: Real -) -> SinglePath: - """Instantiate a new SinglePath, with the given triplet. - - It checks if the SinglePath with given triplet (curve, knota, knotb) - was already created. If that's the case, returns the previous instance. - Otherwise, creates a new instance.""" - - if not Is.instance(curve, IParametrizedCurve): - raise TypeError(f"Invalid curve: {type(curve)}") - if not Is.real(knota): - raise TypeError(f"Invalid type: {type(knota)}") - if not Is.real(knotb): - raise TypeError(f"Invalid type: {type(knotb)}") - if not knota < knotb: - raise ValueError(str((knota, knotb))) - hashval = (id(curve), knota, knotb) - if hashval not in Containers.single_paths: - return SinglePath(curve, knota, knotb) - return Containers.single_paths[hashval] + if Is.instance(item, SingleNode): + item = get_node({item}) + self.__nodes.add(item) + return item class SinglePath: @@ -258,8 +247,6 @@ def __init__(self, curve: IParametrizedCurve, knota: Real, knotb: Real): self.__singlea = get_single_node(curve, knota) self.__singlem = get_single_node(curve, knotm) self.__singleb = get_single_node(curve, knotb) - self.__label = len(Containers.single_paths) - Containers.single_paths[(id(curve), knota, knotb)] = self def __eq__(self, other): return ( @@ -273,11 +260,6 @@ def __eq__(self, other): def __hash__(self): return hash((id(self.curve), self.knota, self.knotb)) - @property - def label(self): - """Gives the label the SinglePath. Only for Debug purpose""" - return self.__label - @property def curve(self) -> IParametrizedCurve: """Gives the curve that connects the pointa to pointb""" @@ -329,14 +311,9 @@ def pointb(self) -> Point2D: return self.singleb.point def __str__(self): - index = Containers.index_curve(self.curve) - return ( - f"C{index} ({self.singlea.parameter} -> {self.singleb.parameter})" - ) - - def __repr__(self): - index = Containers.index_curve(self.curve) - return f"C{index}({self.singlea.parameter}->{self.singleb.parameter})" + knota = self.singlea.parameter + knotb = self.singleb.parameter + return f"{get_label(self.curve)} ({knota} -> {knotb})" def __and__(self, other: SinglePath) -> GeometricIntersectionCurves: if not Is.instance(other, SinglePath): @@ -346,21 +323,6 @@ def __and__(self, other: SinglePath) -> GeometricIntersectionCurves: return self.curve & other.curve -class Containers: - - single_nodes: Dict[Tuple[int, Real], SingleNode] = OrderedDict() - nodes: Dict[Point2D, Node] = OrderedDict() - single_paths: Dict[Tuple[int, Real], SinglePath] = OrderedDict() - curves: Dict[int, IParametrizedCurve] = OrderedDict() - - @staticmethod - def index_curve(curve: IParametrizedCurve) -> int: - for i, key in enumerate(Containers.curves): - if id(curve) == key: - return i - raise ValueError("Could not find requested curve") - - class Edge: """ The edge defines a continuous path between two points: pointa -> pointb @@ -428,11 +390,6 @@ def add(self, path: SinglePath): """Adds a SinglePath to the Edge""" self.__singles.add(path) - def __contains__(self, path: SinglePath) -> bool: - if not Is.instance(path, SinglePath): - raise TypeError - return path in self.singles - def __hash__(self): return hash((hash(self.nodea), hash(self.nodem), hash(self.nodeb))) @@ -450,19 +407,10 @@ def __and__(self, other: Edge) -> Graph: for curve in inters.curves: knots = sorted(inters.all_knots[id(curve)]) for knota, knotb in zip(knots, knots[1:]): - path = single_path(curve, knota, knotb) - graph.add_path(path) + path = get_single_path(curve, knota, knotb) + graph.add_edge(path) return graph - def __ior__(self, other: Edge) -> Edge: - assert Is.instance(other, Edge) - assert self.nodea.point == other.nodea.point - assert self.nodeb.point == other.nodeb.point - self.__nodea |= other.nodea - self.__nodeb |= other.nodeb - self.__singles = tuple(set(other.singles)) - return self - def __str__(self): msgs = [repr(self)] for path in self.singles: @@ -470,7 +418,7 @@ def __str__(self): return "\n".join(msgs) def __repr__(self): - return f"N{self.nodea.label}->N{self.nodeb.label}" + return f"{get_label(self.nodea)}->{get_label(self.nodeb)}" class GroupEdges(Iterable[Edge]): @@ -493,9 +441,6 @@ def __len__(self) -> int: def __str__(self): return "\n".join(f"E{i}: {edge}" for i, edge in enumerate(self)) - def __repr__(self): - return str(self) - def __ior__(self, other: Iterable[Edge]): for oedge in other: assert Is.instance(oedge, Edge) @@ -512,17 +457,16 @@ def remove(self, edge: Edge) -> bool: assert Is.instance(edge, Edge) self.__edges.remove(edge) - def add_edge(self, edge: Edge) -> Edge: + def add(self, item: Union[SinglePath, Edge]) -> Edge: """Inserts an edge into the group""" - self.__edges.add(edge) - - def add_path(self, path: SinglePath) -> Edge: - """Inserts a single path into the group""" - for edge in self: - if edge.pointa == path.pointa and edge.pointb == path.pointb: - edge.add(path) - return edge - return self.add_edge(Edge({path})) + if Is.instance(item, SinglePath): + for edge in self: + if edge.pointa == item.pointa and edge.pointb == item.pointb: + edge.add(item) + return edge + item = Edge({item}) + self.__edges.add(item) + return item class Graph: @@ -576,7 +520,7 @@ def __ior__(self, other: Graph) -> Graph: raise TypeError(f"Wrong type: {type(other)}") for edge in other.edges: for path in edge.singles: - self.add_path(path) + self.add_edge(path) return self def __str__(self): @@ -585,12 +529,11 @@ def __str__(self): used_curves = {} for node in nodes: for single in node.singles: - index = Containers.index_curve(single.curve) - used_curves[index] = single.curve + used_curves[get_label(single.curve)] = single.curve msgs = ["\n" + "-" * 90, repr(self), "Curves:"] - for index in sorted(used_curves.keys()): - curve = used_curves[index] - msgs.append(f"{GAP}C{index}: knots = {curve.knots}") + for label in sorted(used_curves.keys()): + curve = used_curves[label] + msgs.append(f"{GAP}{label}: knots = {curve.knots}") msgs.append(2 * GAP + str(curve)) msgs += ["Nodes:"] msgs += [GAP + s for s in str(nodes).split("\n")] @@ -605,15 +548,15 @@ def remove_edge(self, edge: Edge): def add_edge(self, edge: Edge) -> Edge: """Adds an edge into the graph""" - if not Is.instance(edge, Edge): - raise TypeError - return self.edges.add_edge(edge) + return self.edges.add(edge) + - def add_path(self, path: SinglePath) -> Edge: - """Adds a single path into the graph, creating an edge""" - if not Is.instance(path, SinglePath): - raise TypeError - return self.edges.add_path(path) +all_containers = { + SingleNode: OrderedDict(), + Node: OrderedDict(), + SinglePath: OrderedDict(), + IParametrizedCurve: OrderedDict(), +} @debug("shapepy.bool2d.graph") @@ -645,10 +588,8 @@ def graph_manager(): yield finally: Graph.can_create = False - Containers.single_nodes.clear() - Containers.nodes.clear() - Containers.single_paths.clear() - Containers.curves.clear() + for container in all_containers.values(): + container.clear() def curve2graph(curve: IParametrizedCurve) -> Graph: diff --git a/tests/bool2d/test_bool_overlap.py b/tests/bool2d/test_bool_overlap.py index bb835572..0c09f220 100644 --- a/tests/bool2d/test_bool_overlap.py +++ b/tests/bool2d/test_bool_overlap.py @@ -18,7 +18,7 @@ "tests/bool2d/test_shape.py::test_end", "tests/bool2d/test_lazy.py::test_all", "tests/bool2d/test_bool_no_intersect.py::test_end", - "tests/bool2d/test_bool_finite_intersect.py::test_end", + "tests/bool2d/test_bool_no_overlap.py::test_end", ], scope="session", ) From 2b4efc9a181ca2f9d4768d1b6de401071722ce48 Mon Sep 17 00:00:00 2001 From: Carlos Adir Date: Sun, 2 Nov 2025 12:04:56 +0100 Subject: [PATCH 10/14] fix auto-merge fails --- src/shapepy/bool2d/boolean.py | 2 +- src/shapepy/geometry/base.py | 1 + src/shapepy/geometry/piecewise.py | 18 ------------------ src/shapepy/geometry/segment.py | 5 ++--- 4 files changed, 4 insertions(+), 22 deletions(-) diff --git a/src/shapepy/bool2d/boolean.py b/src/shapepy/bool2d/boolean.py index f75198cd..148b0354 100644 --- a/src/shapepy/bool2d/boolean.py +++ b/src/shapepy/bool2d/boolean.py @@ -383,7 +383,7 @@ def extract(subset: SubSetR2) -> Iterator[Tuple[bool, JordanCurve]]: if isinstance(subset, SimpleShape): yield (True, subset.jordan) elif Is.instance(subset, (ConnectedShape, DisjointShape)): - for subshape in subset.subshapes: + for subshape in subset: yield from GraphComputer.extract(subshape) elif Is.instance(subset, LazyNot): for var, jordan in GraphComputer.extract(~subset): diff --git a/src/shapepy/geometry/base.py b/src/shapepy/geometry/base.py index e56acca1..c5f71564 100644 --- a/src/shapepy/geometry/base.py +++ b/src/shapepy/geometry/base.py @@ -7,6 +7,7 @@ from abc import ABC, abstractmethod from typing import Iterable, Tuple +from ..rbool import IntervalR1 from ..scalar.reals import Real from .box import Box from .point import Point2D diff --git a/src/shapepy/geometry/piecewise.py b/src/shapepy/geometry/piecewise.py index ac1846c0..e13b0f84 100644 --- a/src/shapepy/geometry/piecewise.py +++ b/src/shapepy/geometry/piecewise.py @@ -8,7 +8,6 @@ from ..loggers import debug from ..rbool import IntervalR1, from_any, infimum, supremum -from ..scalar.angle import Angle from ..scalar.reals import Real from ..tools import Is, NotContinousError, To, vectorize from .base import IParametrizedCurve @@ -140,23 +139,6 @@ def __contains__(self, point: Point2D) -> bool: """Tells if the point is on the boundary""" return any(point in bezier for bezier in self) - @debug("shapepy.geometry.piecewise") - def move(self, vector: Point2D) -> PiecewiseCurve: - vector = To.point(vector) - self.__segments = tuple(seg.move(vector) for seg in self) - return self - - @debug("shapepy.geometry.piecewise") - def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> Segment: - self.__segments = tuple(seg.scale(amount) for seg in self) - return self - - @debug("shapepy.geometry.piecewise") - def rotate(self, angle: Angle) -> Segment: - angle = To.angle(angle) - self.__segments = tuple(seg.rotate(angle) for seg in self) - return self - @debug("shapepy.geometry.piecewise") def section(self, interval: IntervalR1) -> PiecewiseCurve: interval = from_any(interval) diff --git a/src/shapepy/geometry/segment.py b/src/shapepy/geometry/segment.py index 09ca3bbf..5a393a86 100644 --- a/src/shapepy/geometry/segment.py +++ b/src/shapepy/geometry/segment.py @@ -18,8 +18,7 @@ from ..analytic.base import IAnalytic from ..analytic.tools import find_minimum from ..loggers import debug -from ..rbool import EmptyR1, IntervalR1, from_any, infimum, supremum -from ..scalar.angle import Angle +from ..rbool import IntervalR1, from_any from ..scalar.quadrature import AdaptativeIntegrator, IntegratorFactory from ..scalar.reals import Math, Real from ..tools import Is, To, pairs, vectorize @@ -152,7 +151,7 @@ def split(self, nodes: Iterable[Real]) -> Tuple[Segment, ...]: nodes = sorted(set(nodes) | set(self.knots)) return tuple(self.section([ka, kb]) for ka, kb in pairs(nodes)) - def extract(self, interval: IntervalR1) -> Segment: + def section(self, interval: IntervalR1) -> Segment: """Extracts a subsegment from the given segment""" interval = from_any(interval) if not Is.instance(interval, IntervalR1): From f7f5e379897ab4466d9479711ab47db117f75426 Mon Sep 17 00:00:00 2001 From: Carlos Adir Date: Fri, 21 Nov 2025 22:21:16 +0100 Subject: [PATCH 11/14] fixes after merge --- src/shapepy/bool2d/boolean.py | 30 +--------- src/shapepy/bool2d/graph.py | 6 +- src/shapepy/bool2d/shape.py | 28 +-------- src/shapepy/geometry/concatenate.py | 5 +- src/shapepy/geometry/intersection.py | 38 +++--------- src/shapepy/geometry/piecewise.py | 87 ++++------------------------ src/shapepy/plot/plot.py | 46 ++++++++++----- tests/geometry/test_piecewise.py | 22 ++++--- 8 files changed, 73 insertions(+), 189 deletions(-) diff --git a/src/shapepy/bool2d/boolean.py b/src/shapepy/bool2d/boolean.py index 148b0354..0a5f3880 100644 --- a/src/shapepy/bool2d/boolean.py +++ b/src/shapepy/bool2d/boolean.py @@ -135,32 +135,6 @@ def clean_bool2d(subset: SubSetR2) -> SubSetR2: return shape_from_jordans(jordans) -@debug("shapepy.bool2d.boolean") -def clean_bool2d_not(subset: LazyNot) -> SubSetR2: - """ - Cleans complementar of given subset - - Parameters - ---------- - subset: SubSetR2 - The subset to be cleaned - - Return - ------ - SubSetR2 - The cleaned subset - """ - assert Is.instance(subset, LazyNot) - inverted = ~subset - if Is.instance(inverted, SimpleShape): - return SimpleShape(~inverted.jordan, True) - if Is.instance(inverted, ConnectedShape): - return DisjointShape((~s).clean() for s in inverted) - if Is.instance(inverted, DisjointShape): - return shape_from_jordans(~jordan for jordan in inverted.jordans) - raise NotImplementedError(f"Missing typo: {type(inverted)}") - - @debug("shapepy.bool2d.boolean") def contains_bool2d(subseta: SubSetR2, subsetb: SubSetR2) -> bool: """ @@ -355,8 +329,8 @@ def clean(subset: SubSetR2) -> Iterator[JordanCurve]: # for key in djordans.keys() & ijordans.keys(): # djordans.pop(key) # ijordans.pop(key) - piecewises = [jordan.piecewise for jordan in djordans.values()] - piecewises += [(~jordan).piecewise for jordan in ijordans.values()] + piecewises = [jordan.parametrize() for jordan in djordans.values()] + piecewises += [(~jordan).parametrize() for jordan in ijordans.values()] logger.debug(f"Quantity of piecewises: {len(piecewises)}") with graph_manager(): graphs = tuple(map(curve2graph, piecewises)) diff --git a/src/shapepy/bool2d/graph.py b/src/shapepy/bool2d/graph.py index 8f1575de..b71bbb6e 100644 --- a/src/shapepy/bool2d/graph.py +++ b/src/shapepy/bool2d/graph.py @@ -21,7 +21,7 @@ def get_label( item: Union[IParametrizedCurve, SingleNode, Node, SinglePath], -) -> str: +) -> str: # pragma: no cover """Gives the label of the item, for printing purpose""" if type(item) in all_containers: container = all_containers[type(item)].values() @@ -225,8 +225,8 @@ def add(self, item: Union[SingleNode, Node]) -> Node: """Add a Node into the group of nodes. If it's already included, only skips the insertion""" - if Is.instance(item, SingleNode): - item = get_node({item}) + if not Is.instance(item, Node): + raise TypeError self.__nodes.add(item) return item diff --git a/src/shapepy/bool2d/shape.py b/src/shapepy/bool2d/shape.py index 4a7144b5..81ec359d 100644 --- a/src/shapepy/bool2d/shape.py +++ b/src/shapepy/bool2d/shape.py @@ -86,11 +86,6 @@ def jordan(self) -> JordanCurve: """Gives the jordan curve that defines the boundary""" return self.__jordancurve - @property - def jordans(self) -> Tuple[JordanCurve]: - """Gives the jordan curve that defines the boundary""" - return (self.__jordancurve,) - @property def area(self) -> Real: """The internal area that is enclosed by the shape""" @@ -120,7 +115,7 @@ def __contains_curve(self, curve: SingleCurve) -> bool: vertices = map(piecewise, piecewise.knots[:-1]) if not all(map(self.__contains_point, vertices)): return False - inters = piecewise & self.__jordancurve.piecewise + inters = piecewise & self.__jordancurve.parametrize() if not inters: # There's no intersection between curves return True knots = sorted(inters.all_knots[id(piecewise)]) @@ -224,15 +219,6 @@ def __hash__(self): def __iter__(self) -> Iterator[SimpleShape]: yield from self.__subshapes - @property - def jordans(self) -> Tuple[JordanCurve, ...]: - """Jordan curves that defines the shape - - :getter: Returns a set of jordan curves - :type: tuple[JordanCurve] - """ - return tuple(shape.jordan for shape in self) - def move(self, vector: Point2D) -> ConnectedShape: vector = To.point(vector) return ConnectedShape(sub.move(vector) for sub in self) @@ -304,18 +290,6 @@ def area(self) -> Real: """The internal area that is enclosed by the shape""" return sum(sub.area for sub in self) - @property - def jordans(self) -> Tuple[JordanCurve, ...]: - """Jordan curves that defines the shape - - :getter: Returns a set of jordan curves - :type: tuple[JordanCurve] - """ - jordans = [] - for subshape in self: - jordans += list(subshape.jordans) - return tuple(jordans) - def __eq__(self, other: SubSetR2): assert Is.instance(other, SubSetR2) return ( diff --git a/src/shapepy/geometry/concatenate.py b/src/shapepy/geometry/concatenate.py index c3ead8d1..35d12760 100644 --- a/src/shapepy/geometry/concatenate.py +++ b/src/shapepy/geometry/concatenate.py @@ -18,8 +18,9 @@ def concatenate(curves: Iterable[IGeometricCurve]) -> IGeometricCurve: Ignores all the curves parametrization """ curves = tuple(curves) - if not all(Is.instance(curve, IGeometricCurve) for curve in curves): - raise ValueError + for curve in curves: + if not Is.instance(curve, IGeometricCurve): + raise TypeError(f"Received wrong type: {type(curve)}") if all(Is.instance(curve, Segment) for curve in curves): return simplify_piecewise(PiecewiseCurve(curves)) if all(Is.instance(curve, USegment) for curve in curves): diff --git a/src/shapepy/geometry/intersection.py b/src/shapepy/geometry/intersection.py index b2111696..f29b17a0 100644 --- a/src/shapepy/geometry/intersection.py +++ b/src/shapepy/geometry/intersection.py @@ -12,7 +12,7 @@ import math from fractions import Fraction -from typing import Dict, Iterable, Set, Tuple, Union +from typing import Dict, Iterable, Set, Tuple from ..loggers import debug, get_logger from ..rbool import ( @@ -51,22 +51,15 @@ class GeometricIntersectionCurves: It stores inside 'curves' the a """ - def __init__( - self, - curves: Iterable[IGeometricCurve], - pairs: Union[None, Iterable[Tuple[int, int]]] = None, - ): + def __init__(self, curves: Iterable[IGeometricCurve]): curves = tuple(curves) - if not all(Is.instance(curve, IGeometricCurve) for curve in curves): - raise TypeError - if pairs is None: - pairs: Set[Tuple[int, int]] = set() - for i in range(len(curves)): - for j in range(i + 1, len(curves)): - pairs.add((i, j)) - else: - pairs = ((i, j) if i < j else (j, i) for i, j in pairs) - pairs = set(map(tuple, pairs)) + for curve in curves: + if not Is.instance(curve, IGeometricCurve): + raise TypeError(f"Invalid type: {type(curve)}") + pairs: Set[Tuple[int, int]] = set() + for i in range(len(curves)): + for j in range(i + 1, len(curves)): + pairs.add((i, j)) self.__pairs = pairs self.__curves = curves self.__all_knots = None @@ -177,19 +170,6 @@ def __compute_two( return subset, subset return curve_and_curve(curvea, curveb) - def __or__( - self, other: GeometricIntersectionCurves - ) -> GeometricIntersectionCurves: - n = len(self.curves) - newcurves = list(self.curves) + list(other.curves) - newparis = list(self.pairs) - for i, j in other.pairs: - newparis.append((i + n, j + n)) - for i in range(len(self.curves)): - for j in range(len(other.curves)): - newparis.append((i, n + j)) - return GeometricIntersectionCurves(newcurves, newparis) - def __bool__(self): return any(v != EmptyR1() for v in self.all_subsets.values()) diff --git a/src/shapepy/geometry/piecewise.py b/src/shapepy/geometry/piecewise.py index 9d857b3e..2a4efd03 100644 --- a/src/shapepy/geometry/piecewise.py +++ b/src/shapepy/geometry/piecewise.py @@ -4,13 +4,12 @@ from __future__ import annotations -from collections import defaultdict -from typing import Iterable, Iterator, List, Tuple, Union +from typing import Iterable, Iterator, Tuple, Union from ..loggers import debug -from ..rbool import IntervalR1, WholeR1, from_any, infimum, supremum +from ..rbool import IntervalR1, WholeR1, from_any from ..scalar.reals import Real -from ..tools import Is, To, pairs +from ..tools import Is, pairs from .base import IParametrizedCurve from .box import Box from .point import Point2D @@ -123,38 +122,6 @@ def box(self) -> Box: box |= bezier.box() return box - def split(self, nodes: Iterable[Real]) -> None: - """ - Creates an opening in the piecewise curve - - Example - >>> piecewise.knots - (0, 1, 2, 3) - >>> piecewise.snap([0.5, 1.2]) - >>> piecewise.knots - (0, 0.5, 1, 1.2, 2, 3) - """ - nodes = set(map(To.finite, nodes)) - set(self.knots) - spansnodes = defaultdict(set) - for node in nodes: - span = self.span(node) - if span is not None: - spansnodes[span].add(node) - if len(spansnodes) == 0: - return - newsegments: List[Segment] = [] - for i, segmenti in enumerate(self): - if i not in spansnodes: - newsegments.append(segmenti) - continue - knota, knotb = self.knots[i], self.knots[i + 1] - unit_nodes = ( - (knot - knota) / (knotb - knota) for knot in spansnodes[i] - ) - newsegments += list(segmenti.split(unit_nodes)) - self.__knots = tuple(sorted(list(self.knots) + list(nodes))) - self.__segments = tuple(newsegments) - def eval(self, node: float, derivate: int = 0) -> Point2D: return self[self.span(node)].eval(node, derivate) @@ -163,45 +130,15 @@ def __contains__(self, point: Point2D) -> bool: return any(point in bezier for bezier in self) @debug("shapepy.geometry.piecewise") - def section(self, domain: Union[IntervalR1, WholeR1]) -> PiecewiseCurve: + def section( + self, domain: Union[IntervalR1, WholeR1] + ) -> Union[Segment, PiecewiseCurve]: domain = from_any(domain) if domain not in self.domain: raise ValueError(f"Invalid {domain} not in {self.domain}") - knots = tuple(self.knots) - segments = tuple(self.__segments) - if domain == [self.knots[0], self.knots[-1]]: - return self - knota, knotb = infimum(domain), supremum(domain) - if knota == knotb: - raise ValueError(f"Invalid {domain}") - spana, spanb = self.span(knota), self.span(knotb) - if knota == knots[spana] and knotb == knots[spanb]: - segs = segments[spana:spanb] - return segs[0] if len(segs) == 1 else PiecewiseCurve(segs) - if spana == spanb: - denom = 1 / (knots[spana + 1] - knots[spana]) - uknota = denom * (knota - knots[spana]) - uknotb = denom * (knotb - knots[spana]) - domain = [uknota, uknotb] - segment = segments[spana] - return segment.section(domain) - if spanb == spana + 1 and knotb == knots[spanb]: - denom = 1 / (knots[spana + 1] - knots[spana]) - uknota = denom * (knota - knots[spana]) - return segments[spana].section([uknota, 1]) - newsegs: List[Segment] = [] - if knots[spana] < knota: - denom = 1 / (knots[spana + 1] - knots[spana]) - domain = [denom * (knota - knots[spana]), 1] - segment = segments[spana] - segment = segment.section(domain) - newsegs.append(segment) - else: - newsegs.append(segments[spana]) - newsegs += list(segments[spana + 1 : spanb]) - if knotb != knots[spanb]: - denom = 1 / (knots[spanb + 1] - knots[spanb]) - domain = [0, denom * (knotb - knots[spanb])] - segment = segments[spanb].section(domain) - newsegs.append(segment) - return PiecewiseCurve(newsegs) + newsegs = [] + for segmenti in self.__segments: + inter = segmenti.domain & domain + if Is.instance(inter, (IntervalR1, WholeR1)): + newsegs.append(segmenti.section(inter)) + return newsegs[0] if len(newsegs) == 1 else PiecewiseCurve(newsegs) diff --git a/src/shapepy/plot/plot.py b/src/shapepy/plot/plot.py index 4052273d..63723f9c 100644 --- a/src/shapepy/plot/plot.py +++ b/src/shapepy/plot/plot.py @@ -5,13 +5,18 @@ from __future__ import annotations -from typing import Optional +from typing import Iterator, Optional, Union import matplotlib from matplotlib import pyplot from shapepy.bool2d.base import EmptyShape, WholeShape -from shapepy.bool2d.shape import ConnectedShape, DisjointShape, SubSetR2 +from shapepy.bool2d.shape import ( + ConnectedShape, + DisjointShape, + SimpleShape, + SubSetR2, +) from shapepy.geometry.jordancurve import JordanCurve from shapepy.geometry.segment import Segment @@ -42,14 +47,16 @@ def patch_segment(segment: Segment): return vertices, commands -def path_shape(connected: ConnectedShape) -> Path: +def path_shape(simples: Iterator[SimpleShape]) -> Path: """ Creates the commands for matplotlib to plot the shape """ vertices = [] commands = [] - for jordan in connected.jordans: - segments = tuple(useg.parametrize() for useg in jordan) + for simple in simples: + if not Is.instance(simple, SimpleShape): + raise TypeError(f"Invalid type: {type(simple)}") + segments = tuple(simple.jordan.parametrize()) vertices.append(segments[0](segments[0].knots[0])) commands.append(Path.MOVETO) for segment in segments: @@ -82,6 +89,21 @@ def path_jordan(jordan: JordanCurve) -> Path: return Path(vertices, commands) +def shape2union_intersections( + shape: Union[SimpleShape, ConnectedShape, DisjointShape], +) -> Iterator[Iterator[SimpleShape]]: + """Function used to transform any shape as a union + of intersection of simple shapes""" + if Is.instance(shape, SimpleShape): + return [[shape]] + if Is.instance(shape, ConnectedShape): + return [list(shape)] + result = [] + for sub in shape: + result.append([sub] if Is.instance(sub, SimpleShape) else tuple(sub)) + return result + + class ShapePloter: """ Class which is a wrapper of the matplotlib.pyplot.plt @@ -164,23 +186,21 @@ def plot_subset(self, shape: SubSetR2, *, kwargs): fill_color = kwargs.pop("fill_color") alpha = kwargs.pop("alpha") marker = kwargs.pop("marker") - connecteds = ( - list(shape) if Is.instance(shape, DisjointShape) else [shape] - ) + connecteds = tuple(map(tuple, shape2union_intersections(shape))) for connected in connecteds: path = path_shape(connected) - if connected.area > 0: + if sum(s.area for s in connected) > 0: patch = PathPatch(path, color=fill_color, alpha=alpha) else: self.gca().set_facecolor("#BFFFBF") patch = PathPatch(path, color="white", alpha=1) self.gca().add_patch(patch) - for jordan in connected.jordans: - path = path_jordan(jordan) - color = pos_color if jordan.area > 0 else neg_color + for simple in connected: + path = path_jordan(simple.jordan) + color = pos_color if simple.jordan.area > 0 else neg_color patch = PathPatch( path, edgecolor=color, facecolor="none", lw=2 ) self.gca().add_patch(patch) - xvals, yvals = zip(*jordan.vertices()) + xvals, yvals = zip(*simple.jordan.vertices()) self.gca().scatter(xvals, yvals, color=color, marker=marker) diff --git a/tests/geometry/test_piecewise.py b/tests/geometry/test_piecewise.py index 43a58e6c..eb4542aa 100644 --- a/tests/geometry/test_piecewise.py +++ b/tests/geometry/test_piecewise.py @@ -88,25 +88,23 @@ def test_section(): ((1, 1), (0, 1)), ((0, 1), (0, 0)), ] - knots = range(len(points) + 1) - segments = tuple(map(FactorySegment.bezier, points)) - piecewise = PiecewiseCurve(segments, knots) + segments = tuple( + FactorySegment.bezier(pts, [i, i + 1]) for i, pts in enumerate(points) + ) + piecewise = PiecewiseCurve(segments) assert piecewise.section([0, 1]) == segments[0] assert piecewise.section([1, 2]) == segments[1] assert piecewise.section([2, 3]) == segments[2] assert piecewise.section([3, 4]) == segments[3] assert piecewise.section([0, 0.5]) == segments[0].section([0, 0.5]) - assert piecewise.section([1, 1.5]) == segments[1].section([0, 0.5]) - assert piecewise.section([2, 2.5]) == segments[2].section([0, 0.5]) - assert piecewise.section([3, 3.5]) == segments[3].section([0, 0.5]) + assert piecewise.section([1, 1.5]) == segments[1].section([1, 1.5]) + assert piecewise.section([2, 2.5]) == segments[2].section([2, 2.5]) + assert piecewise.section([3, 3.5]) == segments[3].section([3, 3.5]) assert piecewise.section([0.5, 1]) == segments[0].section([0.5, 1]) - assert piecewise.section([1.5, 2]) == segments[1].section([0.5, 1]) - assert piecewise.section([2.5, 3]) == segments[2].section([0.5, 1]) - assert piecewise.section([3.5, 4]) == segments[3].section([0.5, 1]) - - # good = PiecewiseCurve() - # assert piecewise.section([0.5, 1.5]) == PiecewiseCurve() + assert piecewise.section([1.5, 2]) == segments[1].section([1.5, 2]) + assert piecewise.section([2.5, 3]) == segments[2].section([2.5, 3]) + assert piecewise.section([3.5, 4]) == segments[3].section([3.5, 4]) @pytest.mark.order(14) From 94611c5ab6f5fb44ffac4aa4dc6b2c88807467ec Mon Sep 17 00:00:00 2001 From: Carlos Adir Date: Sat, 22 Nov 2025 10:29:18 +0100 Subject: [PATCH 12/14] make __repr__ give a similar format to json --- src/shapepy/bool2d/shape.py | 22 +++++++++++++++++++--- src/shapepy/geometry/jordancurve.py | 4 ++-- src/shapepy/geometry/piecewise.py | 2 +- src/shapepy/geometry/point.py | 2 +- src/shapepy/geometry/segment.py | 3 ++- 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/shapepy/bool2d/shape.py b/src/shapepy/bool2d/shape.py index 81ec359d..1854d738 100644 --- a/src/shapepy/bool2d/shape.py +++ b/src/shapepy/bool2d/shape.py @@ -54,10 +54,17 @@ def __deepcopy__(self, memo) -> SimpleShape: return SimpleShape(copy(self.__jordancurve)) def __str__(self) -> str: # pragma: no cover # For debug - area = float(self.area) - vertices = tuple(map(tuple, self.jordan.vertices())) - return f"SimpleShape[{area:.2f}]:[{vertices}]" + vertices = ", ".join(map(str, self.jordan.vertices())) + return f"SimpleShape[{self.area}]:[{vertices}]" + + def __repr__(self) -> str: # pragma: no cover # For debug + template = r'{"type":"SimpleShape","boundary":%s,"jordan":%s}' + return template % ( + ("true" if self.boundary else "false"), + repr(self.jordan), + ) + @debug("shapepy.bool2d.shape") def __eq__(self, other: SubSetR2) -> bool: """Compare two shapes @@ -203,6 +210,11 @@ def area(self) -> Real: def __str__(self) -> str: # pragma: no cover # For debug return f"Connected shape total area {self.area}" + def __repr__(self) -> str: # pragma: no cover # For debug + template = r'{"type":"ConnectedShape","subshapes":[%s]}' + return template % ", ".join(map(repr, self)) + + @debug("shapepy.bool2d.shape") def __eq__(self, other: SubSetR2) -> bool: assert Is.instance(other, SubSetR2) return ( @@ -304,6 +316,10 @@ def __str__(self) -> str: # pragma: no cover # For debug msg += f"{len(self.__subshapes)} subshapes" return msg + def __repr__(self) -> str: # pragma: no cover # For debug + template = r'{"type":"DisjointShape","subshapes":[%s]}' + return template % ", ".join(map(repr, self)) + @debug("shapepy.bool2d.shape") def __hash__(self): return hash(self.area) diff --git a/src/shapepy/geometry/jordancurve.py b/src/shapepy/geometry/jordancurve.py index 602d2453..1ee068d5 100644 --- a/src/shapepy/geometry/jordancurve.py +++ b/src/shapepy/geometry/jordancurve.py @@ -67,8 +67,8 @@ def __str__(self) -> str: return msg def __repr__(self) -> str: - box = self.box() - return f"JC[{len(self)}:{box.lowpt},{box.toppt}]" + template = r'{"type":"JordanCurve","curve":%s}' + return template % repr(self.parametrize()) def __eq__(self, other: JordanCurve) -> bool: logger = get_logger("shapepy.geometry.jordancurve") diff --git a/src/shapepy/geometry/piecewise.py b/src/shapepy/geometry/piecewise.py index 2a4efd03..96be2020 100644 --- a/src/shapepy/geometry/piecewise.py +++ b/src/shapepy/geometry/piecewise.py @@ -46,7 +46,7 @@ def __str__(self): return r"{" + ", ".join(map(str, self)) + r"}" def __repr__(self): - return str(self) + return "[" + ", ".join(map(repr, self)) + "]" def __eq__(self, other: PiecewiseCurve): return ( diff --git a/src/shapepy/geometry/point.py b/src/shapepy/geometry/point.py index 35921170..147fcb6e 100644 --- a/src/shapepy/geometry/point.py +++ b/src/shapepy/geometry/point.py @@ -101,7 +101,7 @@ def __getitem__(self, index: int) -> Real: def __str__(self) -> str: return ( - f"({self.xcoord}, {self.ycoord})" + f"({str(self.xcoord)}, {str(self.ycoord)})" if Is.finite(self.radius) else f"({self.radius}:{self.angle})" ) diff --git a/src/shapepy/geometry/segment.py b/src/shapepy/geometry/segment.py index 6daf41f4..a1b499f6 100644 --- a/src/shapepy/geometry/segment.py +++ b/src/shapepy/geometry/segment.py @@ -91,7 +91,8 @@ def __str__(self) -> str: return f"BS{self.domain}:({self.xfunc}, {self.yfunc})" def __repr__(self) -> str: - return str(self) + template = '{"type":"Segment","domain":"%s","xfunc":"%s","yfunc":"%s"}' + return template % (self.domain, self.xfunc, self.yfunc) def __eq__(self, other: Segment) -> bool: return ( From 11332e7bd479f21f8e740fa2ec08f07721adaa62 Mon Sep 17 00:00:00 2001 From: Carlos Adir Date: Sat, 22 Nov 2025 10:52:01 +0100 Subject: [PATCH 13/14] add cache when computing the density --- src/shapepy/__init__.py | 1 - src/shapepy/bool2d/lazy.py | 6 ++++++ src/shapepy/bool2d/shape.py | 7 +++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/shapepy/__init__.py b/src/shapepy/__init__.py index 8fcd1e2f..defee46b 100644 --- a/src/shapepy/__init__.py +++ b/src/shapepy/__init__.py @@ -21,7 +21,6 @@ __version__ = importlib.metadata.version("shapepy") set_level("shapepy", level="INFO") -set_level("shapepy.bool2d", level="DEBUG") if __name__ == "__main__": diff --git a/src/shapepy/bool2d/lazy.py b/src/shapepy/bool2d/lazy.py index c6cc88b7..38d8f2df 100644 --- a/src/shapepy/bool2d/lazy.py +++ b/src/shapepy/bool2d/lazy.py @@ -4,6 +4,7 @@ from collections import Counter from copy import deepcopy +from functools import lru_cache from typing import Iterable, Iterator, Type from ..loggers import debug @@ -132,6 +133,7 @@ def scale(self, amount): def rotate(self, angle): return LazyNot(self.__internal.rotate(angle)) + @lru_cache(maxsize=1) @debug("shapepy.bool2d.base") def density(self, center): return ~(self.__internal.density(center)) @@ -184,6 +186,8 @@ def scale(self, amount): def rotate(self, angle): return LazyOr(sub.rotate(angle) for sub in self) + @lru_cache(maxsize=1) + @debug("shapepy.bool2d.lazy") def density(self, center): return unite_densities(sub.density(center) for sub in self) @@ -235,6 +239,8 @@ def scale(self, amount): def rotate(self, angle): return LazyAnd(sub.rotate(angle) for sub in self) + @lru_cache(maxsize=1) + @debug("shapepy.bool2d.lazy") def density(self, center): return intersect_densities(sub.density(center) for sub in self) diff --git a/src/shapepy/bool2d/shape.py b/src/shapepy/bool2d/shape.py index 1854d738..166eadf0 100644 --- a/src/shapepy/bool2d/shape.py +++ b/src/shapepy/bool2d/shape.py @@ -10,6 +10,7 @@ from __future__ import annotations from copy import copy +from functools import lru_cache from typing import Iterable, Iterator, Tuple, Union from ..geometry.box import Box @@ -179,6 +180,8 @@ def box(self) -> Box: """ return self.jordan.box() + @lru_cache(maxsize=1) + @debug("shapepy.bool2d.shape") def density(self, center: Point2D) -> Density: return lebesgue_density_jordan(self.jordan, center) @@ -264,6 +267,8 @@ def box(self) -> Box: box |= sub.jordan.box() return box + @lru_cache(maxsize=1) + @debug("shapepy.bool2d.shape") def density(self, center: Point2D) -> Density: center = To.point(center) densities = (sub.density(center) for sub in self) @@ -357,6 +362,8 @@ def box(self) -> Box: box |= sub.box() return box + @lru_cache(maxsize=1) + @debug("shapepy.bool2d.shape") def density(self, center: Point2D) -> Real: center = To.point(center) return unite_densities((sub.density(center) for sub in self)) From 1bf0a887daaf0feef4c5161752b0aa6ffdfd78e6 Mon Sep 17 00:00:00 2001 From: Carlos Adir Date: Sun, 30 Nov 2025 15:42:29 +0100 Subject: [PATCH 14/14] remove unused imports and code --- src/shapepy/bool2d/__init__.py | 3 --- src/shapepy/bool2d/boolean.py | 10 ++++------ src/shapepy/bool2d/lazy.py | 7 +------ 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/shapepy/bool2d/__init__.py b/src/shapepy/bool2d/__init__.py index 1ec94ff3..4dabf214 100644 --- a/src/shapepy/bool2d/__init__.py +++ b/src/shapepy/bool2d/__init__.py @@ -14,7 +14,6 @@ xor_bool2d, ) from .convert import from_any -from .lazy import is_lazy Future.invert = invert_bool2d Future.unite = unite_bool2d @@ -23,5 +22,3 @@ Future.convert = from_any Future.xor = xor_bool2d Future.contains = contains_bool2d - -Is.lazy = is_lazy diff --git a/src/shapepy/bool2d/boolean.py b/src/shapepy/bool2d/boolean.py index e68b77e4..01190ee3 100644 --- a/src/shapepy/bool2d/boolean.py +++ b/src/shapepy/bool2d/boolean.py @@ -6,16 +6,14 @@ from __future__ import annotations from collections import Counter -from copy import copy -from typing import Dict, Iterable, Iterator, Tuple, Union +from typing import Iterable, Iterator, Tuple, Union from shapepy.geometry.jordancurve import JordanCurve from ..geometry.segment import Segment from ..geometry.unparam import USegment from ..loggers import debug, get_logger -from ..tools import CyclicContainer, Is, NotExpectedError -from . import boolalg +from ..tools import CyclicContainer, Is from .base import EmptyShape, Future, SubSetR2, WholeShape from .config import Config from .curve import SingleCurve @@ -27,7 +25,7 @@ graph_manager, intersect_graphs, ) -from .lazy import LazyAnd, LazyNot, LazyOr, RecipeLazy, is_lazy +from .lazy import LazyAnd, LazyNot, LazyOr, RecipeLazy from .point import SinglePoint from .shape import ConnectedShape, DisjointShape, SimpleShape @@ -120,7 +118,7 @@ def clean_bool2d(subset: SubSetR2) -> SubSetR2: SubSetR2 The intersection subset """ - if not Is.lazy(subset): + if not Is.instance(subset, (LazyAnd, LazyNot, LazyOr)): return subset logger = get_logger("shapepy.bool2d.boole") jordans = GraphComputer.clean(subset) diff --git a/src/shapepy/bool2d/lazy.py b/src/shapepy/bool2d/lazy.py index 4ec1dd36..d7fac040 100644 --- a/src/shapepy/bool2d/lazy.py +++ b/src/shapepy/bool2d/lazy.py @@ -4,7 +4,7 @@ from copy import deepcopy from functools import lru_cache -from typing import Iterable, Iterator, Type +from typing import Iterable, Iterator, Union from ..boolalg.simplify import simplify_tree from ..boolalg.tree import ( @@ -243,8 +243,3 @@ def rotate(self, angle): @debug("shapepy.bool2d.lazy") def density(self, center): return intersect_densities(sub.density(center) for sub in self) - - -def is_lazy(subset: SubSetR2) -> bool: - """Tells if the given subset is a Lazy evaluated instance""" - return Is.instance(subset, (LazyAnd, LazyNot, LazyOr))