├── tests ├── __init__.py ├── core │ ├── __init__.py │ └── test_precision.py ├── utils │ ├── __init__.py │ └── test_validators.py ├── operations │ ├── __init__.py │ ├── test_triangulation.py │ ├── test_intersections_2d.py │ ├── test_measurements.py │ ├── test_convex_hull.py │ └── test_containment.py ├── primitives_2d │ ├── __init__.py │ ├── curve │ │ ├── __init__.py │ │ ├── test_bezier.py │ │ ├── test_base.py │ │ └── test_spline.py │ ├── test_ellipse.py │ ├── test_rectangle.py │ ├── test_triangle.py │ ├── test_polygon.py │ ├── test_circle.py │ └── test_line.py └── primitives_3d │ ├── __init__.py │ ├── test_polyhedra.py │ ├── test_cone.py │ ├── test_cube.py │ ├── test_cylinder.py │ ├── test_plane.py │ └── test_line_3d.py ├── docs ├── tutorials │ ├── __init__.py │ ├── triangulation.rst │ ├── 3d_intersections.rst │ └── polygons.rst ├── _static │ ├── geologo.png │ └── geologo.svg ├── quickstart.rst ├── index.rst ├── api_reference.rst ├── installation.rst ├── README.md └── conf.py ├── asset ├── geologo.png └── geologo.svg ├── examples ├── __init__.py ├── 01_polygon_area_centroid.py ├── 00_basic_points_vectors.py ├── 02_convex_hull_demo.py ├── 04_intersections_3d.py ├── README.md ├── 03_boolean_clipping.py └── 05_triangulation_demo.py ├── geo ├── primitives_2d │ ├── curve │ │ ├── __init__.py │ │ ├── base.py │ │ └── bezier.py │ ├── __init__.py │ ├── rectangle.py │ └── triangle.py ├── primitives_3d │ ├── __init__.py │ ├── plane.py │ └── cube.py ├── README.md ├── utils │ ├── __init__.py │ └── validators.py ├── core │ ├── __init__.py │ └── precision.py ├── operations │ ├── __init__.py │ ├── containment.py │ ├── measurements.py │ ├── triangulation.py │ └── convex_hull.py └── __init__.py ├── README.md ├── setup.py ├── pyproject.toml ├── LICENSE └── .gitignore /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # tests/__init__.py 2 | -------------------------------------------------------------------------------- /tests/core/__init__.py: -------------------------------------------------------------------------------- 1 | # tests/core/__init__.py 2 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # tests/utils/__init__.py 2 | -------------------------------------------------------------------------------- /docs/tutorials/__init__.py: -------------------------------------------------------------------------------- 1 | # docs/tutorials/__init__.py -------------------------------------------------------------------------------- /tests/operations/__init__.py: -------------------------------------------------------------------------------- 1 | # tests/operations/__init__.py 2 | -------------------------------------------------------------------------------- /tests/primitives_2d/__init__.py: -------------------------------------------------------------------------------- 1 | # tests/primitives_2d/__init__.py 2 | -------------------------------------------------------------------------------- /tests/primitives_3d/__init__.py: -------------------------------------------------------------------------------- 1 | # tests/primitives_3d/__init__.py 2 | -------------------------------------------------------------------------------- /asset/geologo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mctrinh/geo/HEAD/asset/geologo.png -------------------------------------------------------------------------------- /tests/primitives_2d/curve/__init__.py: -------------------------------------------------------------------------------- 1 | # tests/primitives_2d/curve/__init__.py 2 | -------------------------------------------------------------------------------- /docs/_static/geologo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mctrinh/geo/HEAD/docs/_static/geologo.png -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | # examples/__init__.py 2 | 3 | """Top‑level package marker so the folder can be imported as examples.*.""" -------------------------------------------------------------------------------- /docs/tutorials/triangulation.rst: -------------------------------------------------------------------------------- 1 | Triangulation 101 2 | ================= 3 | 4 | .. literalinclude:: ../../examples/05_triangulation_demo.py 5 | :language: python 6 | :linenos: -------------------------------------------------------------------------------- /docs/tutorials/3d_intersections.rst: -------------------------------------------------------------------------------- 1 | 3-D Intersection Cookbook 2 | ========================= 3 | 4 | .. literalinclude:: ../../examples/04_intersections_3d.py 5 | :language: python 6 | :linenos: -------------------------------------------------------------------------------- /docs/tutorials/polygons.rst: -------------------------------------------------------------------------------- 1 | Polygons & Areas 2 | ================ 3 | 4 | This tutorial walks through polygon construction, area, centroid, convexity checks and clipping. 5 | 6 | .. literalinclude:: ../../examples/01_polygon_area_centroid.py 7 | :language: python 8 | :linenos: -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quick-Start Guide 2 | ================= 3 | 4 | Create two points and measure the distance:: 5 | 6 | >>> from geo.core import Point2D 7 | >>> a = Point2D(0, 0) 8 | >>> b = Point2D(3, 4) 9 | >>> a.distance_to(b) 10 | 5.0 11 | 12 | Further examples can be found in the :pydata:`examples` folder. -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. geo documentation master file 2 | 3 | ``GEO`` Documentation 4 | =================================== 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | :caption: Contents 9 | 10 | installation 11 | quickstart 12 | tutorials/polygons 13 | tutorials/3d_intersections 14 | tutorials/triangulation 15 | api_reference -------------------------------------------------------------------------------- /examples/01_polygon_area_centroid.py: -------------------------------------------------------------------------------- 1 | """Create a polygon & compute derived properties.""" 2 | from geo.core import Point2D 3 | from geo.primitives_2d import Polygon 4 | 5 | square = Polygon([ 6 | Point2D(0, 0), Point2D(3, 0), Point2D(3, 3), Point2D(0, 3) 7 | ]) 8 | print("square area =", square.area) 9 | print("square centroid =", square.centroid()) -------------------------------------------------------------------------------- /geo/primitives_2d/curve/__init__.py: -------------------------------------------------------------------------------- 1 | # geo/primitives_2d/curve/__init__.py 2 | 3 | """ 4 | Curve sub-package for 2D primitives. 5 | Exports base Curve2D and specific curve types. 6 | """ 7 | 8 | from .base import Curve2D 9 | from .bezier import BezierCurve 10 | from .spline import SplineCurve 11 | 12 | __all__ = [ 13 | 'Curve2D', 14 | 'BezierCurve', 15 | 'SplineCurve', 16 | ] -------------------------------------------------------------------------------- /docs/api_reference.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. automodule:: geo 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | .. automodule:: geo.core 10 | :members: 11 | :undoc-members: 12 | :show-inheritance: 13 | 14 | .. automodule:: geo.operations 15 | :members: 16 | :undoc-members: 17 | :show-inheritance: 18 | 19 | .. automodule:: geo.utils 20 | :members: 21 | :undoc-members: 22 | :show-inheritance: -------------------------------------------------------------------------------- /examples/00_basic_points_vectors.py: -------------------------------------------------------------------------------- 1 | """Minimal tour of Point2D / Vector2D / transformations.""" 2 | from geo.core import Point2D, Vector2D 3 | 4 | p = Point2D(1, 2) 5 | q = Point2D(4, -1) 6 | 7 | print("p =", p, " q =", q) 8 | print("distance(p, q) =", p.distance_to(q)) 9 | 10 | v = q - p # Vector from p→q 11 | print("vector v =", v, " length =", v.magnitude()) 12 | print("normalised v =", v.normalize()) 13 | 14 | # simple affine combo 15 | mid = p.midpoint(q) 16 | print("mid-point =", mid) -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | The *geo* library supports Python ≥ 3.9. 5 | 6 | To install via PyPI: 7 | 8 | .. code-block:: console 9 | 10 | $ pip install geo 11 | 12 | To install from source (with extras): 13 | 14 | .. code-block:: console 15 | 16 | $ git clone https://github.com/mctrinh/geo.git 17 | $ cd geo 18 | $ python -m venv .venv && source .venv/bin/activate 19 | $ pip install -e .[full] # extras: shapely, trimesh, scipy, matplotlib 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |


4 | 5 | [![PyPI Downloads](https://img.shields.io/pypi/dm/geo)](https://pypi.org/project/geo/) 6 | 7 | A Python package for computational geometry. 8 | 9 | # Download 10 | `geo` can be downloaded from [PyPi](https://pypi.org/project/geo/) using the following command. 11 | 12 | ```pip install geo``` 13 | 14 | # Documentation 15 | The official documentation can be found [here](https://mctrinh.github.io/geo/). 16 | 17 | # Call for Contributions 18 | The `geo` project welcomes your expertise and enthusiasm. -------------------------------------------------------------------------------- /examples/02_convex_hull_demo.py: -------------------------------------------------------------------------------- 1 | """Random scatter -> convex hull with matplotlib visualisation.""" 2 | import random, math 3 | import matplotlib.pyplot as plt 4 | from geo.core import Point2D 5 | from geo.operations.convex_hull import convex_hull_2d_monotone_chain 6 | 7 | random.seed(0) 8 | pts = [Point2D(random.uniform(-10, 10), random.uniform(-10, 10)) for _ in range(200)] 9 | hull = convex_hull_2d_monotone_chain(pts) 10 | 11 | plt.scatter([p.x for p in pts], [p.y for p in pts], s=10, label="points") 12 | hx = [p.x for p in hull] + [hull[0].x] 13 | hy = [p.y for p in hull] + [hull[0].y] 14 | plt.plot(hx, hy, "r-", label="convex hull") 15 | plt.axis("equal") 16 | plt.legend() 17 | plt.show() -------------------------------------------------------------------------------- /geo/primitives_3d/__init__.py: -------------------------------------------------------------------------------- 1 | # geo/primitives_3d/__init__.py 2 | 3 | """ 4 | Primitives_3D module for the geometry package. 5 | 6 | This module provides classes for various 3D geometric primitives. 7 | """ 8 | 9 | from .plane import Plane 10 | from .line_3d import Line3D, Segment3D, Ray3D 11 | from .sphere import Circle3D, Sphere 12 | from .cube import Cube 13 | from .cylinder import Cylinder 14 | from .cone import Cone 15 | from .polyhedra import Polyhedron # Basic Polyhedron class 16 | 17 | __all__ = [ 18 | 'Plane', 19 | 'Line3D', 20 | 'Segment3D', 21 | 'Ray3D', 22 | 'Circle3D', 23 | 'Sphere', 24 | 'Cube', 25 | 'Cylinder', 26 | 'Cone', 27 | 'Polyhedron', 28 | ] -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | ``` 3 | docs/ 4 | ├── conf.py 5 | ├── index.rst 6 | ├── installation.rst 7 | ├── quickstart.rst 8 | ├── api_reference.rst 9 | ├── tutorials/ 10 | │ ├── __init__.py # empty – lets Sphinx treat as package 11 | │ ├── polygons.rst 12 | │ ├── 3d_intersections.rst 13 | │ └── triangulation.rst 14 | └── _static/ # place for custom CSS / images 15 | └── geo_logo.svg 16 | ``` 17 | 18 | ## How to build 19 | ``` 20 | pip install sphinx furo 21 | sphinx-build -b html docs docs/_build/html 22 | ``` 23 | Open ```docs/_build/html/index.html``` 24 | 25 | ## How to host 26 | ``` 27 | pip install ghp-import 28 | ghp-import -n -p docs/_build/html 29 | ``` 30 | The official documentation of `geo` was published to the [GitHub Page](https://mctrinh.github.io/geo/). -------------------------------------------------------------------------------- /geo/primitives_2d/__init__.py: -------------------------------------------------------------------------------- 1 | # geo/primitives_2d/__init__.py 2 | 3 | """ 4 | Primitives_2D module for the geometry package. 5 | 6 | This module provides classes for various 2D geometric primitives. 7 | """ 8 | 9 | from .line import Line2D, Segment2D, Ray2D 10 | from .circle import Circle 11 | from .ellipse import Ellipse 12 | from .polygon import Polygon 13 | from .triangle import Triangle 14 | from .rectangle import Rectangle 15 | from .curve.base import Curve2D # Base class for curves 16 | from .curve.bezier import BezierCurve 17 | from .curve.spline import SplineCurve 18 | 19 | __all__ = [ 20 | 'Line2D', 21 | 'Segment2D', 22 | 'Ray2D', 23 | 'Circle', 24 | 'Ellipse', 25 | 'Polygon', 26 | 'Triangle', 27 | 'Rectangle', 28 | 'Curve2D', 29 | 'BezierCurve', 30 | 'SplineCurve', 31 | ] -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | setup( 5 | name="geo", 6 | version="1.0.1", 7 | author="Minh-Chien Trinh", 8 | author_email="mctrinh@jbnu.ac.kr", 9 | description="A Python package for computational geometry.", 10 | url="https://github.com/mctrinh/geo", 11 | license="MIT", 12 | python_requires = ">=3.9", 13 | packages=find_packages(), 14 | keywords="geometry, geo", 15 | install_requires = [], 16 | extras_require={ 17 | "full": ["shapely", "trimesh", "scipy", "matplotlib"], 18 | "dev": ["pytest", "sphinx", "furo", "black", "mypy"] 19 | }, 20 | classifiers=[ 21 | "Programming Language :: Python :: 3", 22 | "License :: OSI Approved :: MIT License", 23 | "Operating System :: OS Independent" 24 | ], 25 | ) -------------------------------------------------------------------------------- /examples/04_intersections_3d.py: -------------------------------------------------------------------------------- 1 | """Demonstrate 3‑D intersection helpers.""" 2 | from geo.core import Point3D, Vector3D 3 | from geo.primitives_3d import Sphere, Plane 4 | from geo.operations.intersections_3d import ( 5 | sphere_sphere_intersection, plane_plane_intersection, line_triangle_intersection_moller_trumbore 6 | ) 7 | from geo.primitives_3d import Line3D 8 | 9 | s1 = Sphere(Point3D(0, 0, 0), 2) 10 | s2 = Sphere(Point3D(3, 0, 0), 2) 11 | print("Sphere–sphere →", sphere_sphere_intersection(s1, s2).type) 12 | 13 | pl1 = Plane(Point3D(0, 0, 0), Vector3D(0, 0, 1)) 14 | pl2 = Plane(Point3D(0, 0, 1), Vector3D(1, 0, 1)) 15 | print("Plane–plane line =", plane_plane_intersection(pl1, pl2)) 16 | 17 | orig = Point3D(0, 0, 5) 18 | dir = Vector3D(0, 0, -1) 19 | tri = ( 20 | Point3D(-1, -1, 0), Point3D(1, -1, 0), Point3D(0, 1, 0) 21 | ) 22 | print("Ray-triangle hit:", line_triangle_intersection_moller_trumbore(orig, dir, *tri)) -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # geo – examples gallery 2 | 3 | Tiny runnable snippets that illustrate the most important parts of the **geo** library. 4 | 5 | * **00_basic_points_vectors.py** – create and manipulate points / vectors 6 | * **01_polygon_area_centroid.py** – build a polygon, compute area & centroid 7 | * **02_convex_hull_demo.py** – convex hull via monotone chain, with matplotlib plot 8 | * **03_boolean_clipping.py** – polygon clipping (Sutherland–Hodgman) & union/intersection 9 | * **04_intersections_3d.py** – sphere–sphere / plane–plane / ray–triangle intersections 10 | * **05_triangulation_demo.py** – ear‑clipping & Delaunay triangulation of a simple polygon 11 | 12 | All scripts assume `pip install matplotlib` if you wish to see plots. 13 | 14 | Run any script with `python -m examples.00_basic_points_vectors` if the project root is on your `PYTHONPATH`, or simply `python examples/00_basic_points_vectors.py` once the package is installed in editable mode `pip install -e .` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 61.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | 6 | [project] 7 | name = "geo" 8 | version = "1.0.1" 9 | description = "A Python package for computational geometry." 10 | authors = [ 11 | { name="Minh-Chien Trinh", email="mctrinh@jbnu.ac.kr"}, 12 | ] 13 | license = {text = "MIT"} 14 | keywords = ["geometry", "geo"] 15 | readme = "README.md" 16 | requires-python = ">=3.9" 17 | classifiers = [ 18 | "Programming Language :: Python :: 3", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | ] 22 | 23 | [project.urls] 24 | "Homepage" = "https://github.com/mctrinh/geo" 25 | "Bug Tracker" = "https://github.com/mctrinh/geo/issues" 26 | 27 | [project.optional-dependencies] 28 | full = [ 29 | "shapely", 30 | "trimesh", 31 | "scipy", 32 | "matplotlib" 33 | ] 34 | dev = [ 35 | "pytest", 36 | "sphinx", 37 | "furo", 38 | "black", 39 | "mypy" 40 | ] -------------------------------------------------------------------------------- /examples/03_boolean_clipping.py: -------------------------------------------------------------------------------- 1 | """Clip a square by a triangle and compare union / intersection via Shapely backend.""" 2 | from geo.core import Point2D 3 | from geo.primitives_2d import Polygon 4 | from geo.operations.boolean_ops import ( 5 | clip_polygon_sutherland_hodgman, polygon_union, polygon_intersection 6 | ) 7 | 8 | square = Polygon([ 9 | Point2D(0, 0), Point2D(4, 0), Point2D(4, 4), Point2D(0, 4) 10 | ]) 11 | tri = Polygon([ 12 | Point2D(1, 1), Point2D(3, 1), Point2D(2, 3) 13 | ]) 14 | 15 | clipped = clip_polygon_sutherland_hodgman(square, tri) 16 | print("Clipped poly vertices:") 17 | for v in clipped.vertices: 18 | print(" ", v) 19 | 20 | # If Shapely available 21 | try: 22 | union = polygon_union(square, tri) 23 | inter = polygon_intersection(square, tri) 24 | print("union area =", sum(p.area for p in union)) 25 | print("intersection area =", sum(p.area for p in inter)) 26 | except NotImplementedError: 27 | print("Install shapely for full boolean demo → pip install shapely") -------------------------------------------------------------------------------- /examples/05_triangulation_demo.py: -------------------------------------------------------------------------------- 1 | """Ear‑clipping and Delaunay examples.""" 2 | from geo.core import Point2D 3 | from geo.primitives_2d import Polygon 4 | from geo.operations.triangulation import ( 5 | triangulate_simple_polygon_ear_clipping, delaunay_triangulation_points_2d 6 | ) 7 | import matplotlib.pyplot as plt 8 | 9 | poly = Polygon([ 10 | Point2D(0, 0), Point2D(4, 0), Point2D(5, 2), Point2D(3, 4), Point2D(1, 3) 11 | ]) 12 | triangles = triangulate_simple_polygon_ear_clipping(poly) 13 | print("Ear-clipping output:", len(triangles), "triangles") 14 | 15 | pts = [Point2D(x, y) for x, y in [(0,0),(4,0),(5,2),(3,4),(1,3)]] 16 | tris = delaunay_triangulation_points_2d(pts) 17 | print("Delaunay produced", len(tris), "triangles") 18 | 19 | # crude plot 20 | for t in tris: 21 | xs = [t.p1.x, t.p2.x, t.p3.x, t.p1.x] 22 | ys = [t.p1.y, t.p2.y, t.p3.y, t.p1.y] 23 | plt.plot(xs, ys, "k-") 24 | plt.scatter([p.x for p in pts], [p.y for p in pts]) 25 | plt.gca().set_aspect("equal", adjustable="box") 26 | plt.show() -------------------------------------------------------------------------------- /geo/README.md: -------------------------------------------------------------------------------- 1 | # ```geo``` tree 2 | 3 | ``` 4 | geo/ 5 | │ 6 | ├── __init__.py 7 | ├── core/ 8 | │ ├── __init__.py 9 | │ ├── point.py 10 | │ ├── precision.py 11 | │ ├── transform.py 12 | │ └── vector.py 13 | │ 14 | ├── primitives_2d/ 15 | │ ├── __init__.py 16 | │ ├── circle.py 17 | │ ├── ellipse.py 18 | │ ├── line.py 19 | │ ├── polygon.py 20 | │ ├── rectangle.py 21 | │ ├── triangle.py 22 | │ └── curve/ 23 | │ ├── __init__.py 24 | │ ├── base.py 25 | │ ├── bezier.py 26 | │ └── spline.py 27 | │ 28 | ├── primitives_3d/ 29 | │ ├── __init__.py 30 | │ ├── cone.py 31 | │ ├── cube.py 32 | │ ├── cylinder.py 33 | │ ├── line_3d.py 34 | │ ├── plane.py 35 | │ ├── polyhedra.py 36 | │ └── sphere.py 37 | │ 38 | ├── operations/ 39 | │ ├── __init__.py 40 | │ ├── boolean_ops.py 41 | │ ├── containment.py 42 | │ ├── convex_hull.py 43 | │ ├── intersections_2d.py 44 | │ ├── intersections_3d.py 45 | │ ├── measurements.py 46 | │ └── triangulation.py 47 | │ 48 | └── utils/ 49 | ├── __init__.py 50 | ├── io.py 51 | └── validators.py 52 | ``` 53 | -------------------------------------------------------------------------------- /geo/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # geo/utils/__init__.py 2 | 3 | """ 4 | Utilities sub-package for the geometry library. 5 | 6 | Contains helper functions for validation, I/O, and other miscellaneous tasks 7 | that are not core geometric primitives or operations but support the library. 8 | """ 9 | 10 | from .validators import ( 11 | validate_non_negative, 12 | validate_positive, 13 | validate_list_of_points, 14 | validate_polygon_vertices, 15 | ) 16 | 17 | from .io import ( 18 | parse_points_from_string, 19 | format_point_to_string, 20 | save_polyhedron_to_obj_simple, 21 | load_polyhedron_from_obj_simple, 22 | save_polygon2d_to_csv, 23 | load_polygon2d_from_csv, 24 | save_polygon2d_to_json, 25 | load_polygon2d_from_json, 26 | ) 27 | 28 | __all__ = [ 29 | # Validators 30 | 'validate_non_negative', 31 | 'validate_positive', 32 | 'validate_list_of_points', 33 | 'validate_polygon_vertices', 34 | 35 | # I/O 36 | 'parse_points_from_string', 37 | 'format_point_to_string', 38 | 'save_polyhedron_to_obj_simple', 39 | 'load_polyhedron_from_obj_simple', 40 | 'save_polygon2d_to_csv', 41 | 'load_polygon2d_from_csv', 42 | 'save_polygon2d_to_json', 43 | 'load_polygon2d_from_json', 44 | ] -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """A minimal Sphinx configuration so that sphinx-build docs _build/html works immediately. 2 | Adjust project metadata and extensions as needed.""" 3 | 4 | import os, sys 5 | from datetime import datetime 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) 9 | sys.path.insert(0, ROOT) # import `geo` from source 10 | 11 | # -- Project information ----------------------------------------------------- 12 | project = 'geo' 13 | author = 'Minh-Chien Trinh' 14 | copyright = f"{datetime.now().year}, {author}" 15 | release = '1.0.0' 16 | 17 | # -- General configuration --------------------------------------------------- 18 | extensions = [ 19 | 'sphinx.ext.autodoc', 20 | 'sphinx.ext.napoleon', # Google/NumPy docstrings 21 | 'sphinx.ext.autosummary', 22 | 'sphinx.ext.viewcode', 23 | ] 24 | autosummary_generate = True 25 | 26 | templates_path = ['_templates'] 27 | exclude_patterns = ['_build'] 28 | 29 | # -- HTML output ------------------------------------------------------------- 30 | html_theme = 'furo' # modern, minimal theme (pip install furo) 31 | html_static_path = ['_static'] 32 | html_logo = '_static/geologo.svg' 33 | html_title = 'Documentation' -------------------------------------------------------------------------------- /asset/geologo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 35 | 36 | 37 | 38 | 39 | 40 | GEO 41 | 42 | -------------------------------------------------------------------------------- /docs/_static/geologo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 35 | 36 | 37 | 38 | 39 | 40 | GEO 41 | 42 | -------------------------------------------------------------------------------- /geo/core/__init__.py: -------------------------------------------------------------------------------- 1 | # geo/core/__init__.py 2 | 3 | """ 4 | Core module for the geo package. 5 | This module aggregates the fundamental classes and functions from its submodules, making them easily accessible from the 'core' namespace. 6 | """ 7 | 8 | # Import from precision first as other modules might depend on it 9 | from .precision import ( 10 | DEFAULT_EPSILON, 11 | is_equal, 12 | is_zero, 13 | is_positive, 14 | is_negative 15 | ) 16 | 17 | # Import Point classes 18 | from .point import ( 19 | Point, 20 | Point2D, 21 | Point3D 22 | ) 23 | 24 | # Import Vector classes 25 | from .vector import ( 26 | Vector, 27 | Vector2D, 28 | Vector3D 29 | ) 30 | 31 | # Import Transformation functions/classes 32 | from .transform import ( 33 | translate, 34 | rotate_2d, 35 | rotate_3d, 36 | scale 37 | ) 38 | 39 | 40 | __all__ = [ 41 | # From precision.py 42 | 'DEFAULT_EPSILON', 43 | 'is_equal', 44 | 'is_zero', 45 | 'is_positive', 46 | 'is_negative', 47 | 48 | # From point.py 49 | 'Point', 50 | 'Point2D', 51 | 'Point3D', 52 | 53 | # From vector.py 54 | 'Vector', 55 | 'Vector2D', 56 | 'Vector3D', 57 | 58 | # From transform.py 59 | 'translate', 60 | 'rotate_2d', 61 | 'rotate_3d', 62 | 'scale', 63 | ] 64 | 65 | # Ensure that the __all__ list is complete 66 | for module in __all__: 67 | if module not in globals(): 68 | raise ImportError(f"Module {module} is not imported in __init__.py") -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024-2025, GEO Developers. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | * Neither the name of the GEO Developers nor the names of any contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 13 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 14 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 15 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 16 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 17 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 18 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 19 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 20 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 22 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /geo/core/precision.py: -------------------------------------------------------------------------------- 1 | # geo/core/precision.py 2 | 3 | """ 4 | Handles floating-point precision issues common in geometric calculations. 5 | 6 | Provides a default epsilon value and functions for comparing floating-point numbers with a tolerance. 7 | """ 8 | 9 | import math 10 | 11 | DEFAULT_EPSILON = 1e-9 # Default tolerance for floating point comparisons 12 | 13 | def is_equal(a: float, b: float, epsilon: float = DEFAULT_EPSILON) -> bool: 14 | """ 15 | Checks if two floating-point numbers are equal within a specified tolerance. 16 | 17 | Args: 18 | a: The first float. 19 | b: The second float. 20 | epsilon: The tolerance. Defaults to DEFAULT_EPSILON. 21 | 22 | Returns: 23 | True if the absolute difference between a and b is less than epsilon, False otherwise. 24 | """ 25 | return math.fabs(a - b) < epsilon 26 | 27 | def is_zero(a: float, epsilon: float = DEFAULT_EPSILON) -> bool: 28 | """ 29 | Checks if a floating-point number is close enough to zero. 30 | 31 | Args: 32 | a: The float to check. 33 | epsilon: The tolerance. Defaults to DEFAULT_EPSILON. 34 | 35 | Returns: 36 | True if the absolute value of a is less than epsilon, False otherwise. 37 | """ 38 | return math.fabs(a) < epsilon 39 | 40 | def is_positive(a: float, epsilon: float = DEFAULT_EPSILON) -> bool: 41 | """ 42 | Checks if a floating-point number is definitively positive (greater than epsilon). 43 | 44 | Args: 45 | a: The float to check. 46 | epsilon: The tolerance. Defaults to DEFAULT_EPSILON. 47 | 48 | Returns: 49 | True if a is greater than epsilon, False otherwise. 50 | """ 51 | return a > epsilon 52 | 53 | def is_negative(a: float, epsilon: float = DEFAULT_EPSILON) -> bool: 54 | """ 55 | Checks if a floating-point number is definitively negative (less than -epsilon). 56 | 57 | Args: 58 | a: The float to check. 59 | epsilon: The tolerance. Defaults to DEFAULT_EPSILON. 60 | 61 | Returns: 62 | True if a is less than -epsilon, False otherwise. 63 | """ 64 | return a < -epsilon -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | 132 | # Local 133 | local/* -------------------------------------------------------------------------------- /tests/core/test_precision.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/core/test_precision.py 2 | # (2) python -m unittest tests/core/test_precision.py (verbose output) (auto add sys.path) 3 | 4 | import unittest 5 | import math 6 | import sys 7 | import os 8 | 9 | # For (1): Add the project root to sys.path so `geo` can be imported 10 | # For (2): Don't need 11 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 12 | 13 | from geo.core.precision import ( 14 | DEFAULT_EPSILON, is_equal, is_zero, is_positive, is_negative 15 | ) 16 | 17 | 18 | class TestPrecision(unittest.TestCase): 19 | 20 | def test_default_epsilon(self): 21 | self.assertAlmostEqual(DEFAULT_EPSILON, 1e-9) 22 | 23 | def test_is_equal(self): 24 | self.assertTrue(is_equal(1.0, 1.0000000001)) 25 | self.assertTrue(is_equal(1.0, 1.0 + DEFAULT_EPSILON / 2)) 26 | self.assertFalse(is_equal(1.0, 1.0 + DEFAULT_EPSILON * 2)) 27 | self.assertTrue(is_equal(0.0, 0.0)) 28 | self.assertTrue(is_equal(-1.0, -1.0000000001)) 29 | self.assertTrue(is_equal(1.23456789, 1.23456789)) 30 | self.assertFalse(is_equal(1.0, 2.0)) 31 | # Test with custom epsilon 32 | self.assertTrue(is_equal(1.0, 1.001, epsilon=1e-2)) 33 | self.assertFalse(is_equal(1.0, 1.001, epsilon=1e-4)) 34 | 35 | def test_is_zero(self): 36 | self.assertTrue(is_zero(0.0)) 37 | self.assertTrue(is_zero(DEFAULT_EPSILON / 2)) 38 | self.assertFalse(is_zero(DEFAULT_EPSILON * 2)) 39 | self.assertTrue(is_zero(-DEFAULT_EPSILON / 2)) 40 | self.assertFalse(is_zero(-DEFAULT_EPSILON * 2)) 41 | self.assertFalse(is_zero(1.0)) 42 | # Test with custom epsilon 43 | self.assertTrue(is_zero(0.001, epsilon=1e-2)) 44 | self.assertFalse(is_zero(0.001, epsilon=1e-4)) 45 | 46 | def test_is_positive(self): 47 | self.assertTrue(is_positive(1.0)) 48 | self.assertTrue(is_positive(DEFAULT_EPSILON * 2)) 49 | self.assertFalse(is_positive(DEFAULT_EPSILON / 2)) # Too close to zero 50 | self.assertFalse(is_positive(0.0)) 51 | self.assertFalse(is_positive(-1.0)) 52 | # Test with custom epsilon 53 | self.assertTrue(is_positive(0.001, epsilon=1e-4)) 54 | self.assertFalse(is_positive(0.00001, epsilon=1e-4)) 55 | 56 | 57 | def test_is_negative(self): 58 | self.assertTrue(is_negative(-1.0)) 59 | self.assertTrue(is_negative(-DEFAULT_EPSILON * 2)) 60 | self.assertFalse(is_negative(-DEFAULT_EPSILON / 2)) # Too close to zero 61 | self.assertFalse(is_negative(0.0)) 62 | self.assertFalse(is_negative(1.0)) 63 | # Test with custom epsilon 64 | self.assertTrue(is_negative(-0.001, epsilon=1e-4)) 65 | self.assertFalse(is_negative(-0.00001, epsilon=1e-4)) 66 | 67 | if __name__ == '__main__': 68 | unittest.main() -------------------------------------------------------------------------------- /tests/primitives_2d/curve/test_bezier.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/primitives_2d/curve/test_bezier.py 2 | # (2) python -m unittest tests/primitives_2d/curve/test_bezier.py (verbose output) (auto add sys.path) 3 | 4 | import unittest 5 | import sys 6 | import os 7 | 8 | # For (1): Add the project root to sys.path so `geo` can be imported 9 | # For (2): Don't need 10 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../'))) 11 | 12 | from geo.core import Point2D, Vector2D 13 | from geo.primitives_2d.curve.bezier import BezierCurve 14 | 15 | class TestBezierCurve(unittest.TestCase): 16 | 17 | def test_invalid_control_points(self): 18 | with self.assertRaises(ValueError): 19 | BezierCurve([]) 20 | with self.assertRaises(ValueError): 21 | BezierCurve([Point2D(0, 0)]) 22 | 23 | def test_linear_bezier(self): 24 | p0 = Point2D(0, 0) 25 | p1 = Point2D(1, 1) 26 | curve = BezierCurve([p0, p1]) 27 | self.assertEqual(curve.degree, 1) 28 | self.assertEqual(curve.point_at(0.0), p0) 29 | self.assertEqual(curve.point_at(1.0), p1) 30 | self.assertEqual(curve.point_at(0.5), Point2D(0.5, 0.5)) 31 | self.assertEqual(curve.tangent_at(0.3), p1 - p0) 32 | 33 | def test_quadratic_bezier(self): 34 | p0 = Point2D(0, 0) 35 | p1 = Point2D(1, 2) 36 | p2 = Point2D(2, 0) 37 | curve = BezierCurve([p0, p1, p2]) 38 | self.assertEqual(curve.degree, 2) 39 | pt = curve.point_at(0.5) 40 | self.assertTrue(isinstance(pt, Point2D)) 41 | tangent = curve.tangent_at(0.5) 42 | self.assertTrue(isinstance(tangent, Vector2D)) 43 | 44 | def test_cubic_bezier(self): 45 | p0 = Point2D(0, 0) 46 | p1 = Point2D(1, 3) 47 | p2 = Point2D(2, 3) 48 | p3 = Point2D(3, 0) 49 | curve = BezierCurve([p0, p1, p2, p3]) 50 | pt = curve.point_at(0.25) 51 | pt_casteljau = curve.point_at(0.25, use_casteljau=True) 52 | self.assertAlmostEqual(pt.x, pt_casteljau.x, places=10) 53 | self.assertAlmostEqual(pt.y, pt_casteljau.y, places=10) 54 | 55 | def test_tangent_zero_degree(self): 56 | # Should be replaced with a 2-point zero vector curve 57 | curve = BezierCurve([Point2D(0, 0), Point2D(0, 0)]) 58 | self.assertEqual(curve.tangent_at(0.5), Vector2D(0.0, 0.0)) 59 | 60 | def test_extrapolation(self): 61 | p0 = Point2D(0, 0) 62 | p1 = Point2D(1, 1) 63 | curve = BezierCurve([p0, p1]) 64 | pt_low = curve.point_at(-0.5) 65 | pt_high = curve.point_at(1.5) 66 | self.assertTrue(isinstance(pt_low, Point2D)) 67 | self.assertTrue(isinstance(pt_high, Point2D)) 68 | 69 | def test_derivative_curve(self): 70 | p0 = Point2D(0, 0) 71 | p1 = Point2D(2, 2) 72 | p2 = Point2D(4, 0) 73 | curve = BezierCurve([p0, p1, p2]) 74 | deriv = curve.derivative_curve() 75 | self.assertEqual(deriv.degree, 1) 76 | t0 = curve.tangent_at(0.3) 77 | t1 = deriv.point_at(0.3) 78 | self.assertAlmostEqual(t0.x, t1.x, places=10) 79 | self.assertAlmostEqual(t0.y, t1.y, places=10) 80 | 81 | def test_repr(self): 82 | curve = BezierCurve([Point2D(0, 0), Point2D(1, 1)]) 83 | self.assertIn("BezierCurve", repr(curve)) 84 | 85 | if __name__ == '__main__': 86 | unittest.main() 87 | -------------------------------------------------------------------------------- /tests/primitives_2d/test_ellipse.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/primitives_2d/test_ellipse.py 2 | # (2) python -m unittest tests/primitives_2d/test_ellipse.py (verbose output) (auto add sys.path) 3 | 4 | import unittest 5 | import math 6 | import sys 7 | import os 8 | 9 | # For (1): Add the project root to sys.path so `geo` can be imported 10 | # For (2): Don't need 11 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 12 | 13 | from geo.primitives_2d import Ellipse 14 | from geo.core import Point2D 15 | from geo.core.precision import DEFAULT_EPSILON, is_equal 16 | 17 | 18 | class TestEllipseBasics(unittest.TestCase): 19 | def test_constructor_invalid_radius(self): 20 | with self.assertRaises(ValueError): 21 | Ellipse(Point2D(0, 0), -1, 2) 22 | with self.assertRaises(ValueError): 23 | Ellipse(Point2D(0, 0), 2, -1) 24 | 25 | def test_zero_radii(self): 26 | e = Ellipse(Point2D(0, 0), 0, 0) 27 | self.assertEqual(e.area, 0.0) 28 | self.assertEqual(e.circumference(), 0.0) 29 | self.assertTrue(e.contains_point(Point2D(0, 0))) 30 | self.assertFalse(e.contains_point(Point2D(DEFAULT_EPSILON, 0))) 31 | 32 | def test_area_and_circumference(self): 33 | e = Ellipse(Point2D(0, 0), 4, 3) 34 | expected_area = math.pi * 4 * 3 35 | self.assertAlmostEqual(e.area, expected_area) 36 | self.assertTrue(e.circumference() > 0) # Approximation formula used internally 37 | 38 | def test_equality(self): 39 | e1 = Ellipse(Point2D(1, 1), 3, 2) 40 | e2 = Ellipse(Point2D(1, 1), 3 + DEFAULT_EPSILON / 2, 2 + DEFAULT_EPSILON / 2) 41 | self.assertEqual(e1, e2) 42 | 43 | 44 | class TestEllipseContainment(unittest.TestCase): 45 | def setUp(self): 46 | self.ellipse = Ellipse(Point2D(0, 0), 5, 3) 47 | 48 | def test_point_inside(self): 49 | self.assertTrue(self.ellipse.contains_point(Point2D(3, 1))) 50 | self.assertTrue(self.ellipse.contains_point(Point2D(0, 0))) 51 | 52 | def test_point_on_boundary(self): 53 | pt = Point2D(5, 0) # On major axis edge 54 | self.assertTrue(self.ellipse.contains_point(pt)) 55 | 56 | def test_point_outside(self): 57 | pt = Point2D(6, 0) 58 | self.assertFalse(self.ellipse.contains_point(pt)) 59 | pt_near = Point2D(5 + DEFAULT_EPSILON * 2, 0) 60 | self.assertFalse(self.ellipse.contains_point(pt_near)) 61 | 62 | def test_rotated_axes_equivalence(self): 63 | # Ellipse with same radius_x and radius_y is a circle 64 | e = Ellipse(Point2D(0, 0), 4, 4) 65 | self.assertTrue(e.contains_point(Point2D(2, 2))) 66 | self.assertFalse(e.contains_point(Point2D(4.1, 0))) 67 | 68 | 69 | class TestEllipseBoundaryPoints(unittest.TestCase): 70 | def setUp(self): 71 | self.e = Ellipse(Point2D(0, 0), 3, 2) 72 | 73 | def test_boundary_extents(self): 74 | self.assertTrue(self.e.contains_point(Point2D(3, 0))) 75 | self.assertTrue(self.e.contains_point(Point2D(0, 2))) 76 | self.assertFalse(self.e.contains_point(Point2D(3.1, 0))) 77 | self.assertFalse(self.e.contains_point(Point2D(0, 2.1))) 78 | 79 | def test_symmetry_points(self): 80 | pts = [ 81 | Point2D(-3, 0), Point2D(3, 0), 82 | Point2D(0, -2), Point2D(0, 2), 83 | ] 84 | for pt in pts: 85 | self.assertTrue(self.e.contains_point(pt)) 86 | 87 | 88 | if __name__ == "__main__": 89 | unittest.main() 90 | -------------------------------------------------------------------------------- /tests/primitives_2d/test_rectangle.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/primitives_2d/test_rectangle.py 2 | # (2) python -m unittest tests/primitives_2d/test_rectangle.py (verbose output) (auto add sys.path) 3 | 4 | from __future__ import annotations 5 | 6 | import math 7 | import unittest 8 | import sys 9 | import os 10 | 11 | # For (1): Add the project root to sys.path so `geo` can be imported 12 | # For (2): Don't need 13 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 14 | 15 | from geo.primitives_2d import Rectangle 16 | from geo.core import Point2D 17 | from geo.core.precision import DEFAULT_EPSILON, is_equal 18 | 19 | 20 | class TestRectangleCornerConstructor(unittest.TestCase): 21 | """Constructor that takes two opposite corners (p1, p3).""" 22 | 23 | def test_identical_points_raises(self): 24 | p = Point2D(0, 0) 25 | with self.assertRaises(ValueError): 26 | Rectangle(p, p) 27 | 28 | def test_axis_aligned_square(self): 29 | r = Rectangle(Point2D(0, 0), Point2D(2, 2)) 30 | self.assertTrue(r.is_square()) 31 | self.assertAlmostEqual(r.width, 2.0) 32 | self.assertAlmostEqual(r.height, 2.0) 33 | self.assertAlmostEqual(r.area, 4.0) 34 | self.assertAlmostEqual(r.diagonal_length, math.sqrt(8)) 35 | # Containment – inside, boundary, outside 36 | self.assertTrue(r.contains_point(Point2D(1, 1))) # Inside 37 | self.assertTrue(r.contains_point(Point2D(0, 1))) # On left edge 38 | self.assertFalse(r.contains_point(Point2D(3, 3))) # Outside 39 | 40 | def test_axis_aligned_rectangle(self): 41 | r = Rectangle(Point2D(0, 0), Point2D(3, 1)) 42 | self.assertFalse(r.is_square()) 43 | self.assertAlmostEqual(r.width, 3.0) 44 | self.assertAlmostEqual(r.height, 1.0) 45 | self.assertAlmostEqual(r.area, 3.0) 46 | self.assertAlmostEqual(r.diagonal_length, math.sqrt(10)) 47 | 48 | 49 | class TestRectangleWHConstructor(unittest.TestCase): 50 | """Constructor that takes bottom‑left point, width, height, and optional rotation.""" 51 | 52 | def test_negative_or_zero_dimensions_raise(self): 53 | with self.assertRaises(ValueError): 54 | Rectangle(Point2D(0, 0), -1.0, 1.0) 55 | with self.assertRaises(ValueError): 56 | Rectangle(Point2D(0, 0), 1.0, 0.0) 57 | 58 | def test_basic_properties(self): 59 | r = Rectangle(Point2D(1, 1), 4.0, 2.0) 60 | self.assertAlmostEqual(r.width, 4.0) 61 | self.assertAlmostEqual(r.height, 2.0) 62 | self.assertAlmostEqual(r.area, 8.0) 63 | self.assertAlmostEqual(r.angle, 0.0) 64 | 65 | def test_rotated_rectangle(self): 66 | angle = math.pi / 4 # 45 degrees 67 | r = Rectangle(Point2D(0, 0), 2.0, 1.0, angle_rad=angle) 68 | self.assertAlmostEqual(r.width, 2.0) 69 | self.assertAlmostEqual(r.height, 1.0) 70 | self.assertTrue(is_equal(r.angle, angle)) 71 | self.assertAlmostEqual(r.area, 2.0) 72 | # Inside test (centre should be inside) 73 | centre = Point2D(1.0, 0.5) # before rotation centre; for small rectangle rotation shouldn't move much 74 | self.assertFalse(r.contains_point(centre)) 75 | 76 | def test_is_square(self): 77 | r = Rectangle(Point2D(-1, -1), 3.0, 3.0) 78 | self.assertTrue(r.is_square()) 79 | r2 = Rectangle(Point2D(-1, -1), 3.0, 3.0001) 80 | self.assertFalse(r2.is_square(epsilon=1e-5)) 81 | 82 | 83 | if __name__ == "__main__": # pragma: no cover 84 | unittest.main() 85 | 86 | -------------------------------------------------------------------------------- /tests/primitives_2d/test_triangle.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/primitives_2d/test_triangle.py 2 | # (2) python -m unittest tests/primitives_2d/test_triangle.py (verbose output) (auto add sys.path) 3 | 4 | from __future__ import annotations 5 | 6 | import math 7 | import unittest 8 | import sys 9 | import os 10 | 11 | # For (1): Add the project root to sys.path so `geo` can be imported 12 | # For (2): Don't need 13 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 14 | 15 | from geo.primitives_2d import Triangle 16 | from geo.core import Point2D 17 | from geo.core.precision import DEFAULT_EPSILON, is_equal 18 | 19 | 20 | class TestTriangleConstructor(unittest.TestCase): 21 | def test_collinear_points_raises(self): 22 | with self.assertRaises(ValueError): 23 | Triangle(Point2D(0, 0), Point2D(1, 1), Point2D(2, 2)) 24 | 25 | def test_duplicate_points_raises(self): 26 | p = Point2D(0, 0) 27 | with self.assertRaises(ValueError): 28 | Triangle(p, p, Point2D(1, 0)) 29 | 30 | 31 | class TestTriangleMetrics(unittest.TestCase): 32 | def setUp(self): 33 | # Classic 3‑4‑5 right triangle 34 | self.t = Triangle(Point2D(0, 0), Point2D(4, 0), Point2D(0, 3)) 35 | 36 | def test_area(self): 37 | self.assertAlmostEqual(self.t.area, 6.0) 38 | 39 | def test_centroid(self): 40 | c = self.t.centroid() 41 | self.assertAlmostEqual(c.x, 4/3) 42 | self.assertAlmostEqual(c.y, 1.0) 43 | 44 | def test_side_lengths(self): 45 | if hasattr(self.t, "side_lengths"): 46 | a, b, c = sorted(self.t.side_lengths) # type: ignore[attr-defined] 47 | self.assertTrue(is_equal(a, 3)) 48 | self.assertTrue(is_equal(b, 4)) 49 | self.assertTrue(is_equal(c, 5)) 50 | else: 51 | self.skipTest("Triangle.side_lengths not implemented") 52 | 53 | def test_perimeter(self): 54 | if hasattr(self.t, "perimeter"): 55 | self.assertAlmostEqual(self.t.perimeter, 12.0) # 3+4+5 56 | else: 57 | self.skipTest("Triangle.perimeter not implemented") 58 | 59 | def test_is_right(self): 60 | if hasattr(self.t, "is_right"): 61 | self.assertTrue(self.t.is_right()) 62 | else: 63 | self.skipTest("Triangle.is_right() not implemented") 64 | 65 | 66 | class TestTriangleContainsPoint(unittest.TestCase): 67 | def setUp(self): 68 | self.t = Triangle(Point2D(0, 0), Point2D(5, 0), Point2D(0, 5)) 69 | 70 | def test_inside(self): 71 | self.assertTrue(self.t.contains_point(Point2D(1, 1))) 72 | 73 | def test_boundary(self): 74 | self.assertTrue(self.t.contains_point(Point2D(0, 2))) 75 | self.assertTrue(self.t.contains_point(Point2D(2.5, 2.5))) 76 | 77 | def test_outside(self): 78 | self.assertFalse(self.t.contains_point(Point2D(3, 3.1))) 79 | 80 | def test_near_boundary_epsilon(self): 81 | near = Point2D(0, -DEFAULT_EPSILON/2) 82 | self.assertTrue(self.t.contains_point(near)) 83 | 84 | 85 | class TestTriangleSpecialTypes(unittest.TestCase): 86 | def test_equilateral_properties(self): 87 | t = Triangle(Point2D(0, 0), Point2D(1, 0), Point2D(0.5, math.sqrt(3)/2)) 88 | if hasattr(t, "is_equilateral"): 89 | self.assertTrue(t.is_equilateral()) 90 | if hasattr(t, "is_isosceles"): 91 | self.assertTrue(t.is_isosceles()) 92 | if hasattr(t, "is_scalene"): 93 | self.assertFalse(t.is_scalene()) 94 | 95 | 96 | if __name__ == "__main__": 97 | unittest.main() 98 | -------------------------------------------------------------------------------- /geo/operations/__init__.py: -------------------------------------------------------------------------------- 1 | # geo/operations/__init__.py 2 | 3 | """ 4 | Operations module for the geometry package. 5 | 6 | This module provides functions for various geometric operations and algorithms, 7 | such as intersection detection, measurements, containment checks, etc. 8 | """ 9 | 10 | from .intersections_2d import ( 11 | IntersectionType, 12 | segment_segment_intersection_detail, 13 | line_polygon_intersections, 14 | segment_contains_point_collinear, 15 | segment_circle_intersections, 16 | ) 17 | from .measurements import ( 18 | closest_point_on_segment_to_point, 19 | distance_segment_segment_2d, 20 | closest_points_segments_2d, 21 | signed_angle_between_vectors_2d, 22 | distance_point_line_3d, 23 | distance_point_plane, 24 | distance_line_line_3d, 25 | ) 26 | from .containment import ( 27 | check_point_left_of_line, 28 | is_polygon_simple, 29 | point_on_polygon_boundary, 30 | point_in_convex_polygon_2d, 31 | point_in_polyhedron_convex, 32 | ) 33 | from .intersections_3d import ( 34 | sphere_sphere_intersection, 35 | plane_plane_intersection, 36 | line_triangle_intersection_moller_trumbore, 37 | SphereSphereIntersectionResult, 38 | AABB, 39 | ) 40 | from .convex_hull import ( 41 | convex_hull_2d_monotone_chain, 42 | convex_hull_3d, 43 | plot_convex_hull_2d, 44 | plot_convex_hull_3d, 45 | ) 46 | from .triangulation import ( 47 | triangulate_simple_polygon_ear_clipping, 48 | delaunay_triangulation_points_2d, 49 | constrained_delaunay_triangulation, 50 | tetrahedralise, 51 | 52 | ) 53 | from .boolean_ops import ( 54 | # Placeholders or stubs for boolean operations 55 | clip_polygon_sutherland_hodgman, # Example of a specific clipping 56 | polygon_union, 57 | polygon_intersection, 58 | polygon_difference, 59 | polyhedron_union, 60 | polyhedron_intersection, 61 | polyhedron_difference, 62 | ) 63 | 64 | 65 | __all__ = [ 66 | # From intersections_2d.py 67 | 'IntersectionType', 68 | 'segment_segment_intersection_detail', 69 | 'line_polygon_intersections', 70 | 'segment_contains_point_collinear', 71 | 'segment_circle_intersections', 72 | 73 | # From measurements.py 74 | 'closest_point_on_segment_to_point', 75 | 'distance_segment_segment_2d', 76 | 'closest_points_segments_2d', 77 | 'signed_angle_between_vectors_2d', 78 | 'distance_point_line_3d', 79 | 'distance_point_plane', 80 | 'distance_line_line_3d', 81 | 82 | # From containment.py 83 | 'check_point_left_of_line', 84 | 'is_polygon_simple', 85 | 'point_on_polygon_boundary', 86 | 'point_in_convex_polygon_2d', 87 | 'point_in_polyhedron_convex', 88 | 89 | # From intersections_3d.py 90 | 'sphere_sphere_intersection', 91 | 'plane_plane_intersection', 92 | 'line_triangle_intersection_moller_trumbore', 93 | 'SphereSphereIntersectionResult', 94 | 'AABB', 95 | 96 | # From convex_hull.py 97 | 'convex_hull_2d_monotone_chain', 98 | 'convex_hull_3d', 99 | 'plot_convex_hull_2d', 100 | 'plot_convex_hull_3d', 101 | 102 | # From triangulation.py 103 | 'triangulate_simple_polygon_ear_clipping', 104 | 'delaunay_triangulation_points_2d', 105 | 'constrained_delaunay_triangulation', 106 | 'tetrahedralise', 107 | 108 | # From boolean_ops.py 109 | 'clip_polygon_sutherland_hodgman', 110 | 'polygon_union', 111 | 'polygon_intersection', 112 | 'polygon_difference', 113 | 'polyhedron_union', 114 | 'polyhedron_intersection', 115 | 'polyhedron_difference', 116 | ] -------------------------------------------------------------------------------- /tests/operations/test_triangulation.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/operations/test_triangulation.py 2 | # (2) python -m unittest tests/operations/test_triangulation.py (verbose output) (auto add sys.path) 3 | 4 | import unittest 5 | import math 6 | import numpy as np 7 | import os 8 | import sys 9 | 10 | # Add project root to sys.path for direct execution 11 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 12 | 13 | from geo.core import Point2D, Point3D 14 | from geo.primitives_2d import Polygon, Triangle 15 | from geo.operations import triangulation 16 | 17 | 18 | class TestTriangulation(unittest.TestCase): 19 | 20 | def test_ear_clipping_basic_triangle(self): 21 | pts = [Point2D(0, 0), Point2D(1, 0), Point2D(0, 1)] 22 | poly = Polygon(pts) 23 | tris = triangulation.triangulate_simple_polygon_ear_clipping(poly) 24 | self.assertEqual(len(tris), 1) 25 | tri = tris[0] 26 | self.assertIsInstance(tri, Triangle) 27 | verts = [tri.p1, tri.p2, tri.p3] 28 | for p in pts: 29 | self.assertIn(p, verts) 30 | 31 | def test_ear_clipping_square(self): 32 | pts = [Point2D(0, 0), Point2D(1, 0), Point2D(1, 1), Point2D(0, 1)] 33 | poly = Polygon(pts) 34 | tris = triangulation.triangulate_simple_polygon_ear_clipping(poly) 35 | self.assertEqual(len(tris), 2) 36 | for tri in tris: 37 | self.assertIsInstance(tri, Triangle) 38 | 39 | def test_ear_clipping_concave_polygon(self): 40 | pts = [ 41 | Point2D(0, 0), Point2D(2, 0), Point2D(2, 2), 42 | Point2D(1, 1), Point2D(0, 2) 43 | ] 44 | poly = Polygon(pts) 45 | tris = triangulation.triangulate_simple_polygon_ear_clipping(poly) 46 | self.assertGreaterEqual(len(tris), 3) # Number of triangles depends on polygon shape 47 | for tri in tris: 48 | self.assertIsInstance(tri, Triangle) 49 | 50 | def test_delaunay_triangulation_basic(self): 51 | pts = [ 52 | Point2D(0, 0), Point2D(1, 0), Point2D(0, 1), 53 | Point2D(1, 1), Point2D(0.5, 0.5) 54 | ] 55 | tris = triangulation.delaunay_triangulation_points_2d(pts) 56 | self.assertTrue(len(tris) > 0) 57 | for tri in tris: 58 | self.assertIsInstance(tri, Triangle) 59 | 60 | def test_delaunay_triangulation_too_few_points(self): 61 | pts = [Point2D(0, 0), Point2D(1, 0)] 62 | with self.assertRaises(ValueError): 63 | triangulation.delaunay_triangulation_points_2d(pts) 64 | 65 | def test_constrained_delaunay_triangulation_not_implemented(self): 66 | pts = [Point2D(0, 0), Point2D(1, 0), Point2D(0, 1)] 67 | poly = Polygon(pts) 68 | with self.assertRaises(NotImplementedError): 69 | triangulation.constrained_delaunay_triangulation(poly) 70 | 71 | def test_tetrahedralise_basic(self): 72 | pts = [ 73 | Point3D(0, 0, 0), 74 | Point3D(1, 0, 0), 75 | Point3D(0, 1, 0), 76 | Point3D(0, 0, 1), 77 | Point3D(1, 1, 1), 78 | ] 79 | tets = triangulation.tetrahedralise(pts) 80 | self.assertIsInstance(tets, list) 81 | self.assertGreater(len(tets), 0) 82 | for tet in tets: 83 | self.assertIsInstance(tet, tuple) 84 | self.assertEqual(len(tet), 4) 85 | for p in tet: 86 | self.assertIsInstance(p, Point3D) 87 | 88 | def test_tetrahedralise_too_few_points(self): 89 | pts = [Point3D(0, 0, 0), Point3D(1, 0, 0), Point3D(0, 1, 0)] 90 | with self.assertRaises(ValueError): 91 | triangulation.tetrahedralise(pts) 92 | 93 | 94 | if __name__ == "__main__": 95 | unittest.main() 96 | -------------------------------------------------------------------------------- /geo/primitives_2d/curve/base.py: -------------------------------------------------------------------------------- 1 | # geo/primitives_2d/curve/base.py 2 | 3 | """ 4 | Defines a base class for 2D curves in a parametric form C(t), typically with t ∈ [0, 1]. 5 | """ 6 | from abc import ABC, abstractmethod 7 | from typing import List, Sequence, Iterator 8 | 9 | from geo.core import Point2D, Vector2D 10 | from geo.core.precision import is_equal 11 | 12 | 13 | class Curve2D(ABC): 14 | """ 15 | Abstract base class for a 2D parametric curve. 16 | A curve is typically defined by C(t), where t is a parameter in [0, 1]. 17 | """ 18 | def __init__(self, control_points: Sequence[Point2D]): 19 | if not control_points: 20 | raise ValueError("A curve must have at least one control point.") 21 | for i, pt in enumerate(control_points): 22 | if not isinstance(pt, Point2D): 23 | raise TypeError(f"Expected Point2D at index {i}, got {type(pt).__name__}.") 24 | self.control_points: tuple[Point2D, ...] = tuple(control_points) 25 | 26 | @abstractmethod 27 | def point_at(self, t: float) -> Point2D: 28 | """ 29 | Calculates the point on the curve at parameter t. 30 | Args: 31 | t (float): The parameter (typically in the range [0, 1]). 32 | Returns: 33 | Point2D: The point on the curve. 34 | """ 35 | pass 36 | 37 | @abstractmethod 38 | def tangent_at(self, t: float) -> Vector2D: 39 | """ 40 | Calculates the (non-normalized) tangent vector to the curve at parameter t. 41 | If the tangent is undefined (e.g., cusp), a zero vector or exception may be returned/raised. 42 | 43 | Args: 44 | t (float): The parameter. 45 | 46 | Returns: 47 | Vector2D: The tangent vector. 48 | """ 49 | pass 50 | 51 | def derivative_at(self, t: float) -> Vector2D: 52 | """ 53 | Alias for tangent_at, representing the first derivative C'(t). 54 | """ 55 | return self.tangent_at(t) 56 | 57 | def length(self, t0: float = 0.0, t1: float = 1.0, num_segments: int = 100) -> float: 58 | """ 59 | Approximates the curve length from t0 to t1 using numerical integration. 60 | 61 | Args: 62 | t0 (float): Starting parameter. 63 | t1 (float): Ending parameter. 64 | num_segments (int): Number of segments for numerical approximation. 65 | 66 | Returns: 67 | float: Approximate curve length. 68 | 69 | Raises: 70 | ValueError: If num_segments is not positive. 71 | """ 72 | if is_equal(t0, t1): 73 | return 0.0 74 | if num_segments <= 0: 75 | raise ValueError("Number of segments must be positive for length calculation.") 76 | 77 | # Ensure t0 <= t1 78 | if t0 > t1: 79 | t0, t1 = t1, t0 80 | 81 | total_length = 0.0 82 | dt = (t1 - t0) / num_segments 83 | prev_point = self.point_at(t0) 84 | 85 | for i in range(1, num_segments + 1): 86 | current_t = t0 + i * dt 87 | current_point = self.point_at(current_t) 88 | total_length += prev_point.distance_to(current_point) 89 | prev_point = current_point 90 | 91 | return total_length 92 | 93 | def __len__(self) -> int: 94 | """ 95 | Returns the number of control points. 96 | """ 97 | return len(self.control_points) 98 | 99 | def __iter__(self) -> Iterator[Point2D]: 100 | """ 101 | Allows iteration over control points. 102 | """ 103 | return iter(self.control_points) 104 | 105 | def __repr__(self) -> str: 106 | return f"{self.__class__.__name__}(control_points={self.control_points})" -------------------------------------------------------------------------------- /tests/primitives_3d/test_polyhedra.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/primitives_3d/test_polyhedra.py 2 | # (2) python -m unittest tests/primitives_3d/test_polyhedra.py (verbose output) (auto add sys.path) 3 | 4 | import math 5 | import unittest 6 | import sys 7 | import os 8 | 9 | # For (1): Add the project root to sys.path so `geo` can be imported 10 | # For (2): Don't need 11 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 12 | 13 | from geo.primitives_3d.polyhedra import Polyhedron 14 | from geo.core import Point3D, Vector3D 15 | 16 | class TestPolyhedron(unittest.TestCase): 17 | 18 | def setUp(self): 19 | # Define a cube centered at origin with side length 2 20 | # Vertices of cube (8 points) 21 | self.cube_vertices = [ 22 | Point3D(-1, -1, -1), 23 | Point3D(1, -1, -1), 24 | Point3D(1, 1, -1), 25 | Point3D(-1, 1, -1), 26 | Point3D(-1, -1, 1), 27 | Point3D(1, -1, 1), 28 | Point3D(1, 1, 1), 29 | Point3D(-1, 1, 1) 30 | ] 31 | # Faces by vertex indices (each face has 4 vertices) 32 | self.cube_faces = [ 33 | [0, 1, 2, 3], # Bottom face (z = -1) 34 | [4, 5, 6, 7], # Top face (z = +1) 35 | [0, 1, 5, 4], # Front face (y = -1) 36 | [2, 3, 7, 6], # Back face (y = +1) 37 | [1, 2, 6, 5], # Right face (x = +1) 38 | [0, 3, 7, 4] # Left face (x = -1) 39 | ] 40 | self.cube = Polyhedron(self.cube_vertices, self.cube_faces) 41 | 42 | def test_basic_properties(self): 43 | self.assertEqual(self.cube.num_vertices, 8) 44 | self.assertEqual(self.cube.num_faces, 6) 45 | self.assertEqual(self.cube.num_edges, 12) # Cube has 12 edges 46 | 47 | def test_face_points(self): 48 | face0_points = self.cube.get_face_points(0) 49 | self.assertEqual(len(face0_points), 4) 50 | self.assertTrue(all(isinstance(p, Point3D) for p in face0_points)) 51 | 52 | def test_face_normal(self): 53 | # Bottom face normal points down (0,0,-1) 54 | normal = self.cube.get_face_normal(0) 55 | expected = Vector3D(0, 0, -1) 56 | self.assertAlmostEqual(normal.x, expected.x, places=6) 57 | self.assertAlmostEqual(normal.y, expected.y, places=6) 58 | self.assertAlmostEqual(normal.z, expected.z, places=6) 59 | 60 | # Top face normal points up (0,0,1) 61 | normal_top = self.cube.get_face_normal(1) 62 | expected_top = Vector3D(0, 0, 1) 63 | self.assertAlmostEqual(normal_top.x, expected_top.x, places=6) 64 | self.assertAlmostEqual(normal_top.y, expected_top.y, places=6) 65 | self.assertAlmostEqual(normal_top.z, expected_top.z, places=6) 66 | 67 | def test_surface_area(self): 68 | # Cube with side length 2 has surface area 6 * 2*2 = 24 69 | area = self.cube.surface_area() 70 | self.assertAlmostEqual(area, 24.0, places=6) 71 | 72 | def test_volume(self): 73 | # Cube side length 2 has volume 2^3 = 8 74 | vol = self.cube.volume() 75 | self.assertAlmostEqual(vol, 8.0, places=6) 76 | 77 | def test_contains_point_inside(self): 78 | inside_point = Point3D(0, 0, 0) 79 | self.assertTrue(self.cube.contains_point(inside_point)) 80 | 81 | def test_contains_point_outside(self): 82 | outside_point = Point3D(3, 0, 0) 83 | self.assertFalse(self.cube.contains_point(outside_point)) 84 | 85 | def test_contains_point_on_surface(self): 86 | surface_point = Point3D(1, 0, 0) # On right face plane 87 | self.assertTrue(self.cube.contains_point(surface_point)) 88 | 89 | def test_invalid_face_indices(self): 90 | # Creating a polyhedron with invalid face index should raise ValueError 91 | with self.assertRaises(ValueError): 92 | Polyhedron(self.cube_vertices, [[0, 1, 8]]) # 8 out of range 93 | 94 | def test_face_too_small(self): 95 | # Face with less than 3 vertices should raise error 96 | with self.assertRaises(ValueError): 97 | Polyhedron(self.cube_vertices, [[0, 1]]) # Only two vertices 98 | 99 | if __name__ == '__main__': 100 | unittest.main() 101 | -------------------------------------------------------------------------------- /geo/utils/validators.py: -------------------------------------------------------------------------------- 1 | # geo/utils/validators.py 2 | 3 | """ 4 | Input validation helper functions for the geometry package. 5 | """ 6 | from typing import Any, Sequence, Union, Type 7 | from collections.abc import Sequence as ABCSequence 8 | import numbers 9 | import math 10 | 11 | from geo.core import Point2D, Point3D 12 | 13 | 14 | def validate_non_negative(value: Union[int, float], name: str = "Value") -> None: 15 | """ 16 | Validates if a numeric value is non-negative. 17 | 18 | Args: 19 | value: The numeric value to check. 20 | name: The name of the value (for error messages). 21 | 22 | Raises: 23 | ValueError: If the value is negative. 24 | TypeError: If the value is not numeric. 25 | """ 26 | if not isinstance(value, numbers.Real): 27 | raise TypeError(f"{name} must be a numeric value (int or float). Got {type(value)}.") 28 | if math.isnan(value) or math.isinf(value): 29 | raise ValueError(f"{name} cannot be NaN or infinite. Got {value}.") 30 | if value < 0: 31 | raise ValueError(f"{name} cannot be negative. Got {value}.") 32 | 33 | 34 | def validate_positive(value: Union[int, float], name: str = "Value") -> None: 35 | """ 36 | Validates if a numeric value is strictly positive. 37 | 38 | Args: 39 | value: The numeric value to check. 40 | name: The name of the value (for error messages). 41 | 42 | Raises: 43 | ValueError: If the value is not positive. 44 | TypeError: If the value is not numeric. 45 | """ 46 | if not isinstance(value, numbers.Real): 47 | raise TypeError(f"{name} must be a numeric value (int or float). Got {type(value)}.") 48 | if math.isnan(value) or math.isinf(value): 49 | raise ValueError(f"{name} cannot be NaN or infinite. Got {value}.") 50 | if value <= 0: 51 | raise ValueError(f"{name} must be strictly positive. Got {value}.") 52 | 53 | 54 | def validate_list_of_points(points: Sequence[Any], 55 | min_points: int = 0, 56 | point_type: Union[Type[Point2D], Type[Point3D], None] = None, 57 | name: str = "Point list") -> None: 58 | """ 59 | Validates if the input is a sequence of Point2D or Point3D objects. 60 | 61 | Args: 62 | points: The sequence to validate. 63 | min_points: Minimum number of points required in the sequence. 64 | point_type: Expected type of points (Point2D or Point3D). If None, allows either. 65 | name: Name of the list (for error messages). 66 | 67 | Raises: 68 | TypeError: If `points` is not a sequence or contains non-Point objects. 69 | ValueError: If the number of points is less than `min_points`. 70 | """ 71 | if isinstance(points, str) or not isinstance(points, ABCSequence): 72 | raise TypeError(f"{name} must be a sequence (e.g., list or tuple), not a string or unrelated type. Got {type(points)}.") 73 | if len(points) < min_points: 74 | raise ValueError(f"{name} must contain at least {min_points} points. Got {len(points)}.") 75 | 76 | for i, p in enumerate(points): 77 | if point_type: 78 | if not isinstance(p, point_type): 79 | raise TypeError( 80 | f"Element {i} in {name} must be a {point_type.__name__}. Got {type(p)}." 81 | ) 82 | elif not isinstance(p, (Point2D, Point3D)): 83 | raise TypeError( 84 | f"Element {i} in {name} must be a Point2D or Point3D object. Got {type(p)}." 85 | ) 86 | 87 | 88 | def validate_polygon_vertices(vertices: Sequence[Any], 89 | point_type: Type[Point2D] = Point2D, 90 | name: str = "Polygon vertices") -> None: 91 | """ 92 | Validates vertices for a Polygon. Specifically for 2D polygons. 93 | 94 | Args: 95 | vertices: Sequence of potential vertices. 96 | point_type: Expected type of point (default Point2D). 97 | name: Name for error messages. 98 | 99 | Raises: 100 | TypeError: If vertices is not a sequence or elements are not of point_type. 101 | ValueError: If fewer than 3 vertices are provided. 102 | """ 103 | validate_list_of_points(vertices, min_points=3, point_type=point_type, name=name) 104 | -------------------------------------------------------------------------------- /tests/operations/test_intersections_2d.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/operations/test_intersections_2d.py 2 | # (2) python -m unittest tests/operations/test_intersections_2d.py (verbose output) (auto add sys.path) 3 | 4 | import unittest 5 | import math 6 | import os 7 | import sys 8 | 9 | # Add project root to sys.path for direct execution 10 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 11 | 12 | from geo.core import Point2D, Vector2D 13 | from geo.primitives_2d import Line2D, Segment2D, Polygon, Circle 14 | from geo.operations.intersections_2d import ( 15 | segment_segment_intersection_detail, 16 | line_polygon_intersections, 17 | segment_circle_intersections, 18 | ) 19 | 20 | class TestIntersections2D(unittest.TestCase): 21 | 22 | def test_segment_segment_intersection_detail_point(self): 23 | seg1 = Segment2D(Point2D(0, 0), Point2D(2, 2)) 24 | seg2 = Segment2D(Point2D(0, 2), Point2D(2, 0)) 25 | itype, result = segment_segment_intersection_detail(seg1, seg2) 26 | self.assertEqual(itype, "point") 27 | self.assertAlmostEqual(result.x, 1.0) 28 | self.assertAlmostEqual(result.y, 1.0) 29 | 30 | def test_segment_segment_intersection_detail_no_intersection(self): 31 | seg1 = Segment2D(Point2D(0, 0), Point2D(1, 1)) 32 | seg2 = Segment2D(Point2D(2, 2), Point2D(3, 3)) 33 | itype, result = segment_segment_intersection_detail(seg1, seg2) 34 | self.assertIn(itype, ("none", "collinear_no_overlap")) 35 | self.assertIsNone(result) 36 | 37 | def test_segment_segment_intersection_detail_overlap(self): 38 | seg1 = Segment2D(Point2D(0, 0), Point2D(3, 3)) 39 | seg2 = Segment2D(Point2D(1, 1), Point2D(4, 4)) 40 | itype, result = segment_segment_intersection_detail(seg1, seg2) 41 | self.assertEqual(itype, "overlap") 42 | self.assertIsInstance(result, tuple) 43 | self.assertTrue(all(isinstance(pt, Point2D) for pt in result)) 44 | 45 | def test_segment_segment_intersection_detail_collinear_no_overlap(self): 46 | seg1 = Segment2D(Point2D(0, 0), Point2D(1, 1)) 47 | seg2 = Segment2D(Point2D(2, 2), Point2D(3, 3)) 48 | itype, result = segment_segment_intersection_detail(seg1, seg2) 49 | self.assertEqual(itype, "collinear_no_overlap") 50 | self.assertIsNone(result) 51 | 52 | def test_line_polygon_intersections_simple_square(self): 53 | square = Polygon([ 54 | Point2D(0, 0), Point2D(2, 0), Point2D(2, 2), Point2D(0, 2) 55 | ]) 56 | line = Line2D(Point2D(1, -1), Vector2D(0, 1)) 57 | intersections = line_polygon_intersections(line, square) 58 | self.assertEqual(len(intersections), 2) 59 | self.assertTrue(any(abs(pt.y - 0) < 1e-7 for pt in intersections)) 60 | self.assertTrue(any(abs(pt.y - 2) < 1e-7 for pt in intersections)) 61 | 62 | def test_line_polygon_intersections_parallel_no_intersection(self): 63 | triangle = Polygon([ 64 | Point2D(0, 0), Point2D(1, 0), Point2D(0.5, 1) 65 | ]) 66 | line = Line2D(Point2D(0, 2), Vector2D(1, 0)) # Above triangle, parallel 67 | intersections = line_polygon_intersections(line, triangle) 68 | self.assertEqual(len(intersections), 0) 69 | 70 | def test_segment_circle_intersections_no_intersection(self): 71 | seg = Segment2D(Point2D(0, 0), Point2D(1, 0)) 72 | circle = Circle(Point2D(5, 5), 1) 73 | intersections = segment_circle_intersections(seg, circle) 74 | self.assertEqual(len(intersections), 0) 75 | 76 | def test_segment_circle_intersections_two_points(self): 77 | seg = Segment2D(Point2D(-2, 0), Point2D(2, 0)) 78 | circle = Circle(Point2D(0, 0), 1) 79 | intersections = segment_circle_intersections(seg, circle) 80 | self.assertEqual(len(intersections), 2) 81 | xs = sorted(pt.x for pt in intersections) 82 | self.assertAlmostEqual(xs[0], -1) 83 | self.assertAlmostEqual(xs[1], 1) 84 | 85 | def test_segment_circle_intersections_tangent(self): 86 | seg = Segment2D(Point2D(-1, 1), Point2D(1, 1)) 87 | circle = Circle(Point2D(0, 0), 1) 88 | intersections = segment_circle_intersections(seg, circle) 89 | self.assertEqual(len(intersections), 1) 90 | self.assertAlmostEqual(intersections[0].x, 0) 91 | self.assertAlmostEqual(intersections[0].y, 1) 92 | 93 | if __name__ == "__main__": 94 | unittest.main() 95 | -------------------------------------------------------------------------------- /tests/primitives_2d/curve/test_base.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/primitives_2d/curve/test_base.py 2 | # (2) python -m unittest tests/primitives_2d/curve/test_base.py (verbose output) (auto add sys.path) 3 | 4 | from __future__ import annotations 5 | 6 | import unittest 7 | import sys 8 | import os 9 | 10 | # For (1): Add the project root to sys.path so `geo` can be imported 11 | # For (2): Don't need 12 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../'))) 13 | 14 | from geo.primitives_2d.curve.base import Curve2D 15 | from geo.core import Point2D, Vector2D 16 | 17 | 18 | class DummyCurve(Curve2D): 19 | """ 20 | A simple linear curve for testing: C(t) = (1 - t) * P0 + t * P1 21 | """ 22 | 23 | def point_at(self, t: float) -> Point2D: 24 | p0, p1 = self.control_points 25 | x = (1 - t) * p0.x + t * p1.x 26 | y = (1 - t) * p0.y + t * p1.y 27 | return Point2D(x, y) 28 | 29 | def tangent_at(self, t: float) -> Vector2D: 30 | p0, p1 = self.control_points 31 | return Vector2D(p1.x - p0.x, p1.y - p0.y) 32 | 33 | 34 | class TestCurve2D(unittest.TestCase): 35 | 36 | def setUp(self): 37 | self.p0 = Point2D(0, 0) 38 | self.p1 = Point2D(3, 4) 39 | self.curve = DummyCurve([self.p0, self.p1]) 40 | 41 | def test_control_points_storage(self): 42 | self.assertEqual(len(self.curve), 2) 43 | self.assertEqual(self.curve.control_points[0], self.p0) 44 | 45 | def test_point_at(self): 46 | self.assertEqual(self.curve.point_at(0), self.p0) 47 | self.assertEqual(self.curve.point_at(1), self.p1) 48 | mid = self.curve.point_at(0.5) 49 | self.assertAlmostEqual(mid.x, 1.5) 50 | self.assertAlmostEqual(mid.y, 2.0) 51 | 52 | def test_tangent_at(self): 53 | tangent = self.curve.tangent_at(0.5) 54 | self.assertEqual(tangent.x, 3) 55 | self.assertEqual(tangent.y, 4) 56 | 57 | def test_derivative_alias(self): 58 | d = self.curve.derivative_at(0.3) 59 | self.assertEqual(d, self.curve.tangent_at(0.3)) 60 | 61 | def test_length(self): 62 | length = self.curve.length() 63 | self.assertAlmostEqual(length, 5.0, places=3) 64 | 65 | def test_length_partial(self): 66 | length_half = self.curve.length(0.0, 0.5) 67 | self.assertAlmostEqual(length_half, 2.5, delta=0.05) 68 | 69 | def test_length_reversed_bounds(self): 70 | length = self.curve.length(1.0, 0.0) 71 | self.assertAlmostEqual(length, 5.0, places=3) 72 | 73 | def test_length_same_bounds(self): 74 | self.assertEqual(self.curve.length(0.5, 0.5), 0.0) 75 | 76 | def test_repr(self): 77 | rep = repr(self.curve) 78 | self.assertIn("DummyCurve", rep) 79 | self.assertIn("control_points", rep) 80 | 81 | def test_invalid_constructor_empty(self): 82 | with self.assertRaises(ValueError): 83 | DummyCurve([]) 84 | 85 | def test_invalid_control_point_type(self): 86 | with self.assertRaises(TypeError): 87 | DummyCurve([self.p0, "not a point"]) 88 | 89 | def test_iter_and_len(self): 90 | pts = list(self.curve) 91 | self.assertEqual(pts, [self.p0, self.p1]) 92 | self.assertEqual(len(self.curve), 2) 93 | 94 | def test_length_invalid_segments(self): 95 | with self.assertRaises(ValueError): 96 | self.curve.length(num_segments=0) 97 | 98 | # ---------- Additional edge-case tests ---------- 99 | 100 | def test_point_at_out_of_bounds(self): 101 | p_neg = self.curve.point_at(-0.1) 102 | p_overshoot = self.curve.point_at(1.1) 103 | self.assertIsInstance(p_neg, Point2D) 104 | self.assertIsInstance(p_overshoot, Point2D) 105 | 106 | def test_zero_length_curve(self): 107 | curve = DummyCurve([Point2D(1, 1), Point2D(1, 1)]) 108 | self.assertAlmostEqual(curve.length(), 0.0) 109 | tangent = curve.tangent_at(0.5) 110 | self.assertEqual(tangent, Vector2D(0, 0)) 111 | 112 | def test_small_parameter_interval(self): 113 | l = self.curve.length(0.0, 1e-10, num_segments=10) 114 | self.assertAlmostEqual(l, 0.0, places=6) 115 | 116 | def test_length_high_resolution(self): 117 | l = self.curve.length(num_segments=10000) 118 | self.assertAlmostEqual(l, 5.0, places=4) 119 | 120 | def test_curve2d_is_abstract(self): 121 | with self.assertRaises(TypeError): 122 | Curve2D([self.p0]) # Cannot instantiate abstract class 123 | 124 | 125 | if __name__ == '__main__': 126 | unittest.main() 127 | -------------------------------------------------------------------------------- /tests/operations/test_measurements.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/operations/test_measurements.py 2 | # (2) python -m unittest tests/operations/test_measurements.py (verbose output) (auto add sys.path) 3 | 4 | import unittest 5 | import random 6 | import time 7 | import math 8 | import os 9 | import sys 10 | 11 | # Add project root to sys.path for direct execution 12 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 13 | 14 | 15 | from geo.core import Point2D, Vector2D, Point3D, Vector3D 16 | from geo.primitives_2d import Segment2D 17 | from geo.primitives_3d import Segment3D, Line3D 18 | from geo.operations.measurements import ( 19 | closest_point_on_segment_to_point, 20 | distance_segment_segment_2d, 21 | closest_points_segments_2d, 22 | signed_angle_between_vectors_2d, 23 | distance_point_line_3d, 24 | distance_point_plane, 25 | distance_line_line_3d 26 | ) 27 | from geo.primitives_3d import Plane as Plane3D 28 | 29 | 30 | class TestMeasurements(unittest.TestCase): 31 | def test_closest_point_on_segment_to_point(self): 32 | seg = Segment2D(Point2D(0, 0), Point2D(10, 0)) 33 | pt = Point2D(5, 5) 34 | expected = Point2D(5, 0) 35 | result = closest_point_on_segment_to_point(seg, pt) 36 | self.assertAlmostEqual(result.x, expected.x) 37 | self.assertAlmostEqual(result.y, expected.y) 38 | 39 | def test_distance_segment_segment_2d(self): 40 | seg1 = Segment2D(Point2D(0, 0), Point2D(1, 0)) 41 | seg2 = Segment2D(Point2D(0, 1), Point2D(1, 1)) 42 | dist = distance_segment_segment_2d(seg1, seg2) 43 | self.assertAlmostEqual(dist, 1.0) 44 | 45 | def test_closest_points_segments_2d(self): 46 | seg1 = Segment2D(Point2D(0, 0), Point2D(1, 0)) 47 | seg2 = Segment2D(Point2D(0, 1), Point2D(1, 1)) 48 | p1, p2 = closest_points_segments_2d(seg1, seg2) 49 | self.assertAlmostEqual(p1.distance_to(p2), 1.0) 50 | 51 | def test_signed_angle_between_vectors_2d(self): 52 | v1 = Vector2D(1, 0) 53 | v2 = Vector2D(0, 1) 54 | angle = signed_angle_between_vectors_2d(v1, v2) 55 | self.assertAlmostEqual(angle, 1.57079632679, places=5) # ~pi/2 56 | 57 | def test_distance_point_line_3d(self): 58 | line = Line3D(Point3D(0, 0, 0), Vector3D(1, 0, 0)) 59 | pt = Point3D(0, 1, 0) 60 | dist = distance_point_line_3d(pt, line) 61 | self.assertAlmostEqual(dist, 1.0) 62 | 63 | def test_distance_point_plane(self): 64 | plane = Plane3D(Point3D(0, 0, 0), Vector3D(0, 0, 1)) 65 | pt = Point3D(0, 0, 5) 66 | dist = distance_point_plane(pt, plane) 67 | self.assertAlmostEqual(dist, 5.0) 68 | 69 | def test_distance_line_line_3d(self): 70 | l1 = Line3D(Point3D(0, 0, 0), Vector3D(1, 0, 0)) 71 | l2 = Line3D(Point3D(0, 1, 1), Vector3D(0, 1, 0)) 72 | dist, cp1, cp2 = distance_line_line_3d(l1, l2) 73 | self.assertAlmostEqual(dist, 1.0) 74 | if cp1 and cp2: 75 | self.assertAlmostEqual(cp1.distance_to(cp2), dist) 76 | 77 | class TestMeasurementsEdgeCases(unittest.TestCase): 78 | def test_zero_length_segment(self): 79 | p = Point2D(1, 1) 80 | with self.assertRaises(ValueError): 81 | _ = Segment2D(p, p) # degenerate segment 82 | 83 | def test_line_with_zero_direction_raises(self): 84 | with self.assertRaises(ValueError): 85 | _ = Line3D(Point3D(0, 0, 0), Vector3D(0, 0, 0)) 86 | 87 | def test_closest_points_on_touching_segments(self): 88 | seg1 = Segment2D(Point2D(0, 0), Point2D(1, 0)) 89 | seg2 = Segment2D(Point2D(1, 0), Point2D(2, 0)) 90 | p1, p2 = closest_points_segments_2d(seg1, seg2) 91 | self.assertEqual(p1, Point2D(1, 0)) 92 | self.assertEqual(p2, Point2D(1, 0)) 93 | 94 | def test_parallel_lines_distance(self): 95 | l1 = Line3D(Point3D(0, 0, 0), Vector3D(1, 0, 0)) 96 | l2 = Line3D(Point3D(0, 1, 0), Vector3D(1, 0, 0)) 97 | dist, _, _ = distance_line_line_3d(l1, l2) 98 | self.assertAlmostEqual(dist, 1.0) 99 | 100 | class TestMeasurementsPerformance(unittest.TestCase): 101 | def test_closest_point_performance(self): 102 | seg = Segment3D(Point3D(0, 0, 0), Point3D(1000, 0, 0)) 103 | pt = Point3D(500, 10, 0) 104 | start_time = time.time() 105 | for _ in range(10000): 106 | _ = closest_point_on_segment_to_point(seg, pt) 107 | duration = time.time() - start_time 108 | self.assertLess(duration, 1.0) # Should run in under 1 second 109 | 110 | if __name__ == '__main__': 111 | unittest.main() 112 | -------------------------------------------------------------------------------- /tests/primitives_2d/test_polygon.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/primitives_2d/test_polygon.py 2 | # (2) python -m unittest tests/primitives_2d/test_polygon.py (verbose output) (auto add sys.path) 3 | 4 | from __future__ import annotations 5 | 6 | import math 7 | import unittest 8 | from typing import List 9 | import sys 10 | import os 11 | 12 | # For (1): Add the project root to sys.path so `geo` can be imported 13 | # For (2): Don't need 14 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 15 | 16 | from geo.primitives_2d import Polygon 17 | from geo.core import Point2D 18 | from geo.core.precision import DEFAULT_EPSILON, is_equal 19 | 20 | 21 | class TestPolygonConstruction(unittest.TestCase): 22 | """Validate constructor safeguards.""" 23 | 24 | def test_less_than_three_vertices_raises(self): 25 | with self.assertRaises(ValueError): 26 | Polygon([Point2D(0, 0), Point2D(1, 1)]) 27 | 28 | def test_duplicate_consecutive_vertices_collapsed(self): 29 | poly = Polygon([ 30 | Point2D(0, 0), Point2D(1, 0), Point2D(1, 0), Point2D(1, 1), Point2D(0, 1) 31 | ]) 32 | # Implementation may auto‑collapse duplicates or keep them but assure area ≈ 1 33 | self.assertAlmostEqual(abs(poly.area), 1.0, places=6) 34 | # Ensure number of *unique* vertices >= 4 for square 35 | unique_coords = {v.coords for v in poly.vertices} 36 | self.assertGreaterEqual(len(unique_coords), 4) 37 | 38 | 39 | class TestPolygonBasicMetrics(unittest.TestCase): 40 | def setUp(self): 41 | self.square = Polygon([ 42 | Point2D(0, 0), Point2D(1, 0), Point2D(1, 1), Point2D(0, 1) 43 | ]) 44 | self.triangle = Polygon([ 45 | Point2D(0, 0), Point2D(4, 0), Point2D(0, 3) 46 | ]) 47 | 48 | def test_area(self): 49 | self.assertAlmostEqual(self.square.area, 1.0) 50 | self.assertAlmostEqual(self.triangle.area, 6.0) 51 | 52 | def test_perimeter(self): 53 | if hasattr(self.square, "perimeter"): 54 | self.assertAlmostEqual(self.square.perimeter, 4.0) 55 | else: 56 | self.skipTest("Polygon.perimeter() not implemented") 57 | 58 | def test_edges_count(self): 59 | self.assertEqual(len(self.square.edges), len(self.square.vertices)) 60 | 61 | 62 | class TestPolygonContainment(unittest.TestCase): 63 | def setUp(self): 64 | self.poly = Polygon([ 65 | Point2D(0, 0), Point2D(5, 0), Point2D(5, 5), Point2D(0, 5) 66 | ]) 67 | 68 | def test_inside_point(self): 69 | self.assertTrue(self.poly.contains_point(Point2D(2.5, 2.5))) 70 | 71 | def test_outside_point(self): 72 | self.assertFalse(self.poly.contains_point(Point2D(6, 6))) 73 | 74 | def test_on_boundary(self): 75 | pt = Point2D(0, 2) 76 | self.assertTrue(self.poly.contains_point(pt)) 77 | 78 | def test_near_boundary_epsilon(self): 79 | near = Point2D(0 - DEFAULT_EPSILON / 2, 2) 80 | self.assertTrue(self.poly.contains_point(near)) 81 | 82 | 83 | class TestPolygonSelfIntersecting(unittest.TestCase): 84 | """Bow-tie polygon should be flagged as non‑simple or give |area|>0 but is_simple=False.""" 85 | 86 | def setUp(self): 87 | # Bow-tie / hour-glass crossing at centre 88 | self.bow = Polygon([ 89 | Point2D(0, 0), Point2D(2, 2), Point2D(0, 2), Point2D(2, 0) 90 | ]) 91 | 92 | @unittest.skipUnless(hasattr(Polygon, "is_simple"), "Polygon.is_simple() not implemented") 93 | def test_is_simple_false(self): 94 | self.assertFalse(self.bow.is_simple()) 95 | 96 | def test_area_absolute_value(self): 97 | # Shoelace algorithm gives signed area; magnitude should match two triangles (4) 98 | self.assertAlmostEqual(abs(self.bow.area), 2.0) 99 | 100 | 101 | class TestPolygonConvexity(unittest.TestCase): 102 | def setUp(self): 103 | self.heptagon = Polygon([ 104 | Point2D(math.cos(theta), math.sin(theta)) for theta in [i * 2 * math.pi / 7 for i in range(7)] 105 | ]) 106 | 107 | @unittest.skipUnless(hasattr(Polygon, "is_convex"), "Polygon.is_convex() not implemented") 108 | def test_is_convex_true(self): 109 | self.assertTrue(self.heptagon.is_convex()) 110 | 111 | 112 | class TestPolygonCollinearEdgeCases(unittest.TestCase): 113 | def test_collinear_vertices_area(self): 114 | poly = Polygon([ 115 | Point2D(0, 0), Point2D(1, 0), Point2D(2, 0), # collinear 116 | Point2D(2, 2), Point2D(0, 2) 117 | ]) 118 | # Collinear point should not alter area (expected rectangle 2×2) 119 | self.assertAlmostEqual(abs(poly.area), 4.0) 120 | 121 | 122 | if __name__ == "__main__": 123 | unittest.main() 124 | -------------------------------------------------------------------------------- /tests/utils/test_validators.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/utils/test_validators.py 2 | # (2) python -m unittest tests/utils/test_validators.py (verbose output) (auto add sys.path) 3 | 4 | import math 5 | import unittest 6 | import sys 7 | import os 8 | 9 | # For (1): Add the project root to sys.path so `geo` can be imported 10 | # For (2): Don't need 11 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 12 | 13 | from geo.utils.validators import ( 14 | validate_non_negative, 15 | validate_positive, 16 | validate_list_of_points, 17 | validate_polygon_vertices, 18 | ) 19 | from geo.core import Point2D, Point3D 20 | 21 | 22 | class TestValidators(unittest.TestCase): 23 | # ------------------------------------------------------------------ # 24 | # validate_non_negative # 25 | # ------------------------------------------------------------------ # 26 | def test_validate_non_negative_valid(self): 27 | # Zero and positive should pass 28 | validate_non_negative(0) 29 | validate_non_negative(1e9) 30 | validate_non_negative(3.14159) 31 | 32 | def test_validate_non_negative_errors(self): 33 | # Negative value 34 | with self.assertRaises(ValueError): 35 | validate_non_negative(-1, name="radius") 36 | 37 | # NaN / infinity 38 | for bad in (math.nan, math.inf, -math.inf): 39 | with self.subTest(bad=bad): 40 | with self.assertRaises(ValueError): 41 | validate_non_negative(bad) 42 | 43 | # Non-numeric types 44 | with self.assertRaises(TypeError): 45 | validate_non_negative("5") # type: ignore[arg-type] 46 | 47 | with self.assertRaises(TypeError): 48 | validate_non_negative([], name="value") # type: ignore[arg-type] 49 | 50 | # ------------------------------------------------------------------ # 51 | # validate_positive # 52 | # ------------------------------------------------------------------ # 53 | def test_validate_positive_valid(self): 54 | validate_positive(1) 55 | validate_positive(0.0001) 56 | 57 | def test_validate_positive_errors(self): 58 | for bad in (0, -0.1, -10): 59 | with self.subTest(bad=bad): 60 | with self.assertRaises(ValueError): 61 | validate_positive(bad) 62 | 63 | for bad in (math.nan, math.inf, -math.inf): 64 | with self.subTest(bad=bad): 65 | with self.assertRaises(ValueError): 66 | validate_positive(bad) 67 | 68 | with self.assertRaises(TypeError): 69 | validate_positive(None) # type: ignore[arg-type] 70 | 71 | # ------------------------------------------------------------------ # 72 | # validate_list_of_points # 73 | # ------------------------------------------------------------------ # 74 | def test_validate_list_of_points_valid(self): 75 | pts2 = [Point2D(0, 0), Point2D(1, 1)] 76 | validate_list_of_points(pts2, min_points=2) 77 | 78 | pts3 = [Point3D(0, 0, 0), Point3D(1, 0, 0), Point3D(0, 1, 0)] 79 | validate_list_of_points(pts3, min_points=3, point_type=Point3D) 80 | 81 | def test_validate_list_of_points_not_sequence(self): 82 | with self.assertRaises(TypeError): 83 | validate_list_of_points("not a list") # type: ignore[arg-type] 84 | 85 | def test_validate_list_of_points_too_few(self): 86 | with self.assertRaises(ValueError): 87 | validate_list_of_points([Point2D(0, 0)], min_points=2) 88 | 89 | def test_validate_list_of_points_wrong_type(self): 90 | pts_mixed = [Point2D(0, 0), "bad", Point2D(1, 1)] # type: ignore[list-item] 91 | with self.assertRaises(TypeError): 92 | validate_list_of_points(pts_mixed) 93 | 94 | pts_wrong = [Point2D(0, 0), Point2D(1, 1)] 95 | with self.assertRaises(TypeError): 96 | validate_list_of_points(pts_wrong, point_type=Point3D) 97 | 98 | # ------------------------------------------------------------------ # 99 | # validate_polygon_vertices # 100 | # ------------------------------------------------------------------ # 101 | def test_validate_polygon_vertices_valid(self): 102 | vertices = [Point2D(0, 0), Point2D(1, 0), Point2D(0, 1)] 103 | # should not raise 104 | validate_polygon_vertices(vertices) 105 | 106 | def test_validate_polygon_vertices_errors(self): 107 | # fewer than 3 vertices 108 | with self.assertRaises(ValueError): 109 | validate_polygon_vertices([Point2D(0, 0), Point2D(1, 0)]) 110 | 111 | # wrong point type 112 | bad = [Point3D(0, 0, 0), Point3D(1, 0, 0), Point3D(0, 1, 0)] 113 | with self.assertRaises(TypeError): 114 | validate_polygon_vertices(bad) # type: ignore[arg-type] 115 | 116 | 117 | if __name__ == "__main__": 118 | unittest.main() 119 | -------------------------------------------------------------------------------- /tests/operations/test_convex_hull.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/operations/test_convex_hull.py 2 | # (2) python -m unittest tests/operations/test_convex_hull.py (verbose output) (auto add sys.path) 3 | 4 | import unittest 5 | import os 6 | import sys 7 | import math 8 | import random 9 | import matplotlib.pyplot as plt 10 | 11 | # Add project root to sys.path for direct execution 12 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 13 | 14 | from geo.core import Point2D, Point3D 15 | from geo.operations.convex_hull import convex_hull_2d_monotone_chain, convex_hull_3d 16 | 17 | try: 18 | import scipy.spatial # ensure scipy is available for 3D tests 19 | SCIPY_AVAILABLE = True 20 | except ImportError: 21 | SCIPY_AVAILABLE = False 22 | 23 | class TestConvexHull2D(unittest.TestCase): 24 | 25 | def test_empty_input(self): 26 | self.assertEqual(convex_hull_2d_monotone_chain([]), []) 27 | 28 | def test_single_point(self): 29 | p = Point2D(0, 0) 30 | self.assertEqual(convex_hull_2d_monotone_chain([p]), [p]) 31 | 32 | def test_two_points(self): 33 | p1 = Point2D(0, 0) 34 | p2 = Point2D(1, 1) 35 | result = convex_hull_2d_monotone_chain([p1, p2]) 36 | self.assertEqual(result, [p1, p2]) 37 | 38 | def test_triangle(self): 39 | p1 = Point2D(0, 0) 40 | p2 = Point2D(1, 0) 41 | p3 = Point2D(0, 1) 42 | hull = convex_hull_2d_monotone_chain([p1, p2, p3]) 43 | self.assertEqual(set(hull), {p1, p2, p3}) 44 | 45 | def test_collinear_points(self): 46 | points = [Point2D(x, 0) for x in range(5)] 47 | hull = convex_hull_2d_monotone_chain(points) 48 | self.assertEqual(hull, [Point2D(0, 0), Point2D(4, 0)]) 49 | 50 | def test_square(self): 51 | square = [Point2D(0,0), Point2D(1,0), Point2D(1,1), Point2D(0,1)] 52 | hull = convex_hull_2d_monotone_chain(square) 53 | self.assertEqual(set(hull), set(square)) 54 | 55 | def test_duplicate_points(self): 56 | square = [Point2D(0,0), Point2D(1,0), Point2D(1,1), Point2D(0,1)] 57 | duplicate = square + [Point2D(1,0), Point2D(0,0)] 58 | hull = convex_hull_2d_monotone_chain(duplicate) 59 | self.assertEqual(set(hull), set(square)) 60 | 61 | 62 | @unittest.skipUnless(SCIPY_AVAILABLE, "Requires SciPy") 63 | class TestConvexHull3D(unittest.TestCase): 64 | 65 | def test_cube(self): 66 | cube = [ 67 | Point3D(0,0,0), Point3D(1,0,0), Point3D(1,1,0), Point3D(0,1,0), 68 | Point3D(0,0,1), Point3D(1,0,1), Point3D(1,1,1), Point3D(0,1,1) 69 | ] 70 | hull = convex_hull_3d(cube) 71 | self.assertEqual(len(hull.vertices), 8) 72 | self.assertGreaterEqual(hull.num_faces, 6) 73 | 74 | def test_tetrahedron(self): 75 | tetra = [ 76 | Point3D(0,0,0), Point3D(1,0,0), Point3D(0,1,0), Point3D(0,0,1) 77 | ] 78 | hull = convex_hull_3d(tetra) 79 | self.assertEqual(len(hull.vertices), 4) 80 | self.assertGreaterEqual(hull.num_faces, 4) 81 | 82 | 83 | class TestConvexHullExtended(unittest.TestCase): 84 | 85 | def generate_circle_points(self, num_points=100, radius=1.0, noise=0.0): 86 | return [ 87 | Point2D( 88 | radius * math.cos(theta) + random.uniform(-noise, noise), 89 | radius * math.sin(theta) + random.uniform(-noise, noise) 90 | ) for theta in [2 * math.pi * i / num_points for i in range(num_points)] 91 | ] 92 | 93 | def test_convex_hull_random_circle_points(self): 94 | points = self.generate_circle_points(num_points=100, radius=5.0, noise=0.1) 95 | hull = convex_hull_2d_monotone_chain(points) 96 | self.assertTrue(len(hull) > 0) 97 | 98 | # Visualize 99 | plt.figure() 100 | x = [p.x for p in points] 101 | y = [p.y for p in points] 102 | hx = [p.x for p in hull] + [hull[0].x] 103 | hy = [p.y for p in hull] + [hull[0].y] 104 | plt.plot(x, y, 'o', label='Points') 105 | plt.plot(hx, hy, 'r-', label='Convex Hull') 106 | plt.legend() 107 | plt.title("Convex Hull of Noisy Circle") 108 | plt.show() 109 | 110 | def test_convex_hull_random_cloud(self): 111 | random.seed(42) 112 | points = [Point2D(random.uniform(-10, 10), random.uniform(-10, 10)) for _ in range(200)] 113 | hull = convex_hull_2d_monotone_chain(points) 114 | self.assertTrue(len(hull) > 0) 115 | 116 | # Visualize 117 | plt.figure() 118 | x = [p.x for p in points] 119 | y = [p.y for p in points] 120 | hx = [p.x for p in hull] + [hull[0].x] 121 | hy = [p.y for p in hull] + [hull[0].y] 122 | plt.plot(x, y, 'o', label='Points') 123 | plt.plot(hx, hy, 'g-', label='Convex Hull') 124 | plt.legend() 125 | plt.title("Convex Hull of Random Cloud") 126 | plt.show() 127 | 128 | def test_convex_hull_3d_random_cube(self): 129 | try: 130 | points = [ 131 | Point3D(x, y, z) 132 | for x in (0, 1) for y in (0, 1) for z in (0, 1) 133 | ] 134 | hull = convex_hull_3d(points) 135 | self.assertTrue(hull is not None) 136 | self.assertTrue(hull.num_faces > 0) 137 | except ImportError: 138 | self.skipTest("SciPy not available for 3D convex hull") 139 | 140 | 141 | if __name__ == '__main__': 142 | unittest.main() 143 | -------------------------------------------------------------------------------- /tests/primitives_3d/test_cone.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/primitives_3d/test_cone.py 2 | # (2) python -m unittest tests/primitives_3d/test_cone.py (verbose output) (auto add sys.path) 3 | 4 | import math 5 | import unittest 6 | import sys 7 | import os 8 | 9 | # For (1): Add the project root to sys.path so `geo` can be imported 10 | # For (2): Don't need 11 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 12 | 13 | from geo.primitives_3d.cone import Cone 14 | from geo.core import Point3D, Vector3D 15 | from geo.primitives_3d.plane import Plane 16 | from geo.core.precision import is_equal, DEFAULT_EPSILON 17 | 18 | class TestCone(unittest.TestCase): 19 | 20 | def setUp(self): 21 | # Simple cone along z-axis from (0,0,0) base to (0,0,3) apex with radius 1 22 | self.apex = Point3D(0, 0, 3) 23 | self.base_center = Point3D(0, 0, 0) 24 | self.radius = 1.0 25 | self.cone = Cone(self.apex, self.base_center, self.radius) 26 | 27 | def test_cone_creation_valid(self): 28 | self.assertEqual(self.cone.apex, self.apex) 29 | self.assertEqual(self.cone.base_center, self.base_center) 30 | self.assertAlmostEqual(self.cone.base_radius, self.radius) 31 | self.assertAlmostEqual(self.cone.height, 3.0) 32 | self.assertTrue(isinstance(self.cone.axis_direction, Vector3D)) 33 | 34 | def test_cone_creation_invalid(self): 35 | # Apex == base_center with nonzero radius -> error 36 | with self.assertRaises(ValueError): 37 | Cone(self.base_center, self.base_center, 1.0) 38 | # Negative radius -> error 39 | with self.assertRaises(ValueError): 40 | Cone(self.apex, self.base_center, -0.5) 41 | # Apex == base_center with zero radius (point cone) allowed 42 | cone_point = Cone(self.base_center, self.base_center, 0.0) 43 | self.assertEqual(cone_point.height, 0.0) 44 | self.assertEqual(cone_point.base_radius, 0.0) 45 | 46 | def test_properties(self): 47 | # volume = (1/3)*pi*r^2*h = (1/3)*pi*1*1*3 = pi 48 | self.assertAlmostEqual(self.cone.volume, math.pi, places=7) 49 | # slant_height = sqrt(h^2 + r^2) = sqrt(9+1) = sqrt(10) 50 | self.assertAlmostEqual(self.cone.slant_height, math.sqrt(10), places=7) 51 | # lateral_surface_area = pi*r*slant_height 52 | self.assertAlmostEqual( 53 | self.cone.lateral_surface_area, math.pi * 1 * math.sqrt(10), places=7) 54 | # base_area = pi*r^2 55 | self.assertAlmostEqual(self.cone.base_area, math.pi * 1**2, places=7) 56 | # total_surface_area = base_area + lateral_surface_area 57 | self.assertAlmostEqual( 58 | self.cone.total_surface_area, 59 | self.cone.base_area + self.cone.lateral_surface_area, 60 | places=7) 61 | 62 | def test_get_base_plane(self): 63 | plane = self.cone.get_base_plane() 64 | self.assertIsInstance(plane, Plane) 65 | # Base plane normal should be opposite axis_direction 66 | self.assertTrue( 67 | is_equal(plane.normal.x, -self.cone.axis_direction.x, DEFAULT_EPSILON) and 68 | is_equal(plane.normal.y, -self.cone.axis_direction.y, DEFAULT_EPSILON) and 69 | is_equal(plane.normal.z, -self.cone.axis_direction.z, DEFAULT_EPSILON) 70 | ) 71 | 72 | def test_contains_point_inside(self): 73 | # Point exactly at apex 74 | self.assertTrue(self.cone.contains_point(self.apex)) 75 | # Point on axis halfway between base and apex 76 | mid_point = Point3D(0, 0, 1.5) 77 | self.assertTrue(self.cone.contains_point(mid_point)) 78 | # Point inside radius at mid height 79 | inside_point = Point3D(0.2, 0.2, 1.5) 80 | self.assertTrue(self.cone.contains_point(inside_point)) 81 | # Point on base circle 82 | base_edge_point = Point3D(1.0, 0, 0) 83 | self.assertTrue(self.cone.contains_point(base_edge_point)) 84 | # Point slightly outside radius at mid height 85 | outside_point = Point3D(1.0, 0, 1.5) 86 | self.assertFalse(self.cone.contains_point(outside_point)) 87 | 88 | def test_contains_point_outside(self): 89 | # Point below base plane 90 | below_base = Point3D(0, 0, -0.1) 91 | self.assertFalse(self.cone.contains_point(below_base)) 92 | # Point above apex 93 | above_apex = Point3D(0, 0, 3.1) 94 | self.assertFalse(self.cone.contains_point(above_apex)) 95 | # Far away point 96 | far_point = Point3D(10, 10, 10) 97 | self.assertFalse(self.cone.contains_point(far_point)) 98 | 99 | def test_equality_and_repr(self): 100 | cone2 = Cone(self.apex, self.base_center, self.radius) 101 | self.assertEqual(self.cone, cone2) 102 | self.assertIn("Cone", repr(self.cone)) 103 | 104 | def test_degenerate_point_cone_contains(self): 105 | point_cone = Cone(self.base_center, self.base_center, 0.0) 106 | self.assertTrue(point_cone.contains_point(self.base_center)) 107 | self.assertFalse(point_cone.contains_point(Point3D(0,0,0.1))) 108 | 109 | def test_degenerate_disk_cone_contains(self): 110 | # Apex == base_center, radius > 0 => should raise error on construction (your code) 111 | # But if modified to allow, test points on disk plane 112 | with self.assertRaises(ValueError): 113 | Cone(self.base_center, self.base_center, 1.0) 114 | 115 | if __name__ == "__main__": 116 | unittest.main() 117 | -------------------------------------------------------------------------------- /geo/primitives_2d/rectangle.py: -------------------------------------------------------------------------------- 1 | # geo/primitives_2d/rectangle.py 2 | 3 | """ 4 | Defines a Rectangle primitive in 2D space. 5 | """ 6 | 7 | import math 8 | from typing import List, Optional, Union 9 | 10 | from geo.core import Point2D, Vector2D 11 | from geo.core.precision import is_equal, DEFAULT_EPSILON 12 | from .polygon import Polygon 13 | 14 | class Rectangle(Polygon): 15 | """ 16 | Represents a rectangle in 2D space. 17 | Can be initialized with two opposite corner points, or with a corner, width, height, and angle. 18 | Inherits from Polygon. Vertices are ordered counter-clockwise by default. 19 | """ 20 | 21 | def __init__(self, p1: Point2D, p2_or_width: Union[Point2D, float], 22 | height: Optional[float] = None, angle_rad: float = 0.0): 23 | """ 24 | Initializes a Rectangle. 25 | 26 | Method 1: Two opposite corner points. 27 | Args: 28 | p1: First corner Point2D. 29 | p2_or_width: Opposite corner Point2D. 30 | height: Must be None. 31 | angle_rad: Must be 0.0 (ignored). 32 | 33 | Method 2: A corner point, width, height, and rotation angle. 34 | Args: 35 | p1: The bottom-left corner (before rotation) Point2D. 36 | p2_or_width: The width (float). 37 | height: The height (float). 38 | angle_rad: Rotation angle in radians, CCW from x-axis. 39 | 40 | Raises: 41 | ValueError: If arguments are inconsistent or width/height are non-positive. 42 | """ 43 | self.p1 = p1 # Store reference point for rotated case 44 | 45 | if isinstance(p2_or_width, Point2D) and height is None: 46 | # ── Axis-aligned rectangle from two opposite corners ── 47 | p3 = p2_or_width 48 | if p1 == p3: 49 | raise ValueError("Corner points of a rectangle cannot be identical.") 50 | 51 | min_x, max_x = (p1.x, p3.x) if p1.x < p3.x else (p3.x, p1.x) 52 | min_y, max_y = (p1.y, p3.y) if p1.y < p3.y else (p3.y, p1.y) 53 | 54 | bl = Point2D(min_x, min_y) 55 | br = Point2D(max_x, min_y) 56 | tr = Point2D(max_x, max_y) 57 | tl = Point2D(min_x, max_y) 58 | 59 | vertices = [bl, br, tr, tl] # CCW order 60 | super().__init__(vertices) 61 | 62 | self._width = max_x - min_x 63 | self._height = max_y - min_y 64 | self._angle_rad = 0.0 65 | 66 | elif isinstance(p2_or_width, (int, float)) and height is not None: 67 | # ── Rotated rectangle from base point, width, height, and angle ── 68 | width = float(p2_or_width) 69 | 70 | if width <= 0 or height <= 0: 71 | raise ValueError("Rectangle width and height must be positive.") 72 | 73 | self._width = width 74 | self._height = height 75 | self._angle_rad = angle_rad 76 | 77 | # Local rectangle before rotation 78 | local_p1 = Point2D(0, 0) 79 | local_p2 = Point2D(width, 0) 80 | local_p3 = Point2D(width, height) 81 | local_p4 = Point2D(0, height) 82 | 83 | vertices_local = [local_p1, local_p2, local_p3, local_p4] 84 | rotated_vertices = [] 85 | 86 | cos_a = math.cos(angle_rad) 87 | sin_a = math.sin(angle_rad) 88 | 89 | for p_local in vertices_local: 90 | rot_x = p_local.x * cos_a - p_local.y * sin_a 91 | rot_y = p_local.x * sin_a + p_local.y * cos_a 92 | final_x = rot_x + self.p1.x 93 | final_y = rot_y + self.p1.y 94 | rotated_vertices.append(Point2D(final_x, final_y)) 95 | 96 | super().__init__(rotated_vertices) 97 | 98 | else: 99 | raise ValueError("Invalid arguments for Rectangle constructor.") 100 | 101 | @property 102 | def width(self) -> float: 103 | """Returns the width of the rectangle.""" 104 | if hasattr(self, '_width'): 105 | return self._width 106 | return self.vertices[0].distance_to(self.vertices[1]) 107 | 108 | @property 109 | def height(self) -> float: 110 | """Returns the height of the rectangle.""" 111 | if hasattr(self, '_height'): 112 | return self._height 113 | return self.vertices[1].distance_to(self.vertices[2]) 114 | 115 | @property 116 | def angle(self) -> float: 117 | """Returns the rotation angle in radians from x-axis.""" 118 | if hasattr(self, '_angle_rad'): 119 | return self._angle_rad 120 | edge_vec = self.vertices[1] - self.vertices[0] 121 | return edge_vec.angle() if not edge_vec.is_zero_vector() else 0.0 122 | 123 | @property 124 | def area(self) -> float: 125 | """Returns the area of the rectangle.""" 126 | if hasattr(self, '_width') and hasattr(self, '_height'): 127 | return self._width * self._height 128 | return super().area # Shoelace method 129 | 130 | @property 131 | def diagonal_length(self) -> float: 132 | """Returns the diagonal length of the rectangle.""" 133 | if hasattr(self, '_width') and hasattr(self, '_height'): 134 | return math.hypot(self._width, self._height) 135 | return self.vertices[0].distance_to(self.vertices[2]) 136 | 137 | def is_square(self, epsilon: float = DEFAULT_EPSILON) -> bool: 138 | """Checks if the rectangle is a square.""" 139 | if self.num_vertices != 4: 140 | return False 141 | return is_equal(self.width, self.height, epsilon) 142 | -------------------------------------------------------------------------------- /geo/__init__.py: -------------------------------------------------------------------------------- 1 | # geo/__init__.py 2 | 3 | """ 4 | geo 5 | 6 | A Python package for computational geometry. 7 | Provides primitives, operations, and utilities for 2D and 3D geometric tasks. 8 | """ 9 | 10 | __version__ = "0.1.2" 11 | 12 | # Import key classes and functions from submodules for easier access 13 | # For example: from .core import Point2D, Vector3D 14 | # This makes them available as: import geo 15 | # then: geo.Point2D(...) 16 | 17 | # From core module 18 | from .core import ( 19 | DEFAULT_EPSILON, 20 | is_equal, 21 | is_zero, 22 | is_positive, 23 | is_negative, 24 | Point, Point2D, Point3D, 25 | Vector, Vector2D, Vector3D, 26 | translate, rotate_2d, rotate_3d, scale 27 | ) 28 | 29 | # From primitives_2d module 30 | from .primitives_2d import ( 31 | Line2D, Segment2D, Ray2D, 32 | Circle, 33 | Ellipse, 34 | Polygon, 35 | Triangle, 36 | Rectangle, 37 | Curve2D, # Base class for curves 38 | BezierCurve, 39 | SplineCurve, 40 | ) 41 | 42 | # From primitives_3d module 43 | from .primitives_3d import ( 44 | Plane, # Renamed to avoid conflict with other 'Plane' if any, or use Plane3D 45 | Line3D, Segment3D, Ray3D, 46 | Circle3D, Sphere, 47 | Cube, 48 | Cylinder, 49 | Cone, 50 | Polyhedron, 51 | ) 52 | 53 | # From operations module 54 | # Import specific, commonly used operations. Users can also import from submodules directly. 55 | from .operations import ( 56 | # 2D Intersections 57 | IntersectionType, 58 | segment_segment_intersection_detail, 59 | line_polygon_intersections, 60 | segment_contains_point_collinear, 61 | segment_circle_intersections, 62 | 63 | # Measurements 64 | closest_point_on_segment_to_point, 65 | distance_segment_segment_2d, 66 | closest_points_segments_2d, 67 | signed_angle_between_vectors_2d, 68 | distance_point_line_3d, 69 | distance_point_plane, 70 | distance_line_line_3d, 71 | 72 | # Containment 73 | check_point_left_of_line, 74 | is_polygon_simple, 75 | point_on_polygon_boundary, 76 | point_in_convex_polygon_2d, 77 | point_in_polyhedron_convex, 78 | 79 | # 3D Intersections 80 | sphere_sphere_intersection, 81 | plane_plane_intersection, 82 | line_triangle_intersection_moller_trumbore, 83 | SphereSphereIntersectionResult, 84 | AABB, 85 | 86 | # Convex Hull 87 | convex_hull_2d_monotone_chain, 88 | convex_hull_3d, 89 | plot_convex_hull_2d, 90 | plot_convex_hull_3d, 91 | 92 | # Triangulation 93 | triangulate_simple_polygon_ear_clipping, 94 | delaunay_triangulation_points_2d, 95 | constrained_delaunay_triangulation, 96 | tetrahedralise, 97 | 98 | # Boolean Operations (example) 99 | clip_polygon_sutherland_hodgman, 100 | polygon_union, 101 | polygon_intersection, 102 | polygon_difference, 103 | polyhedron_union, 104 | polyhedron_intersection, 105 | polyhedron_difference, 106 | ) 107 | 108 | # From utils module (if there are high-level utilities to expose) 109 | from .utils import ( 110 | validate_non_negative, 111 | validate_positive, 112 | validate_list_of_points, 113 | validate_polygon_vertices, 114 | parse_points_from_string, 115 | format_point_to_string, 116 | save_polyhedron_to_obj_simple, 117 | load_polyhedron_from_obj_simple, 118 | save_polygon2d_to_csv, 119 | load_polygon2d_from_csv, 120 | ) 121 | 122 | 123 | # Define __all__ for `from geo import *` 124 | # It's generally better for users to import specific items, but __all__ can be defined. 125 | __all__ = [ 126 | # Core 127 | 'DEFAULT_EPSILON', 'is_equal', 'is_zero', 'is_positive', 'is_negative', 128 | 'Point', 'Point2D', 'Point3D', 129 | 'Vector', 'Vector2D', 'Vector3D', 130 | 'translate', 'rotate_2d', 'rotate_3d', 'scale', 131 | 132 | # Primitives 2D 133 | 'Line2D', 'Segment2D', 'Ray2D', 134 | 'Circle', 'Ellipse', 'Polygon', 'Triangle', 'Rectangle', 135 | 'Curve2D', 'BezierCurve', 'SplineCurve', 136 | 137 | # Primitives 3D 138 | 'Plane', 'Line3D', 'Segment3D', 'Ray3D', 139 | 'Circle3D', 'Sphere', 'Cube', 'Cylinder', 'Cone', 'Polyhedron', 140 | 141 | # Operations 142 | 'IntersectionType', 'segment_segment_intersection_detail', 'line_polygon_intersections', 143 | 'segment_contains_point_collinear', 'segment_circle_intersections', 144 | 'closest_point_on_segment_to_point', 'distance_segment_segment_2d', 'closest_points_segments_2d', 145 | 'signed_angle_between_vectors_2d', 'distance_point_line_3d', 146 | 'distance_point_plane', 'distance_line_line_3d', 147 | 'check_point_left_of_line', 'is_polygon_simple', 'point_on_polygon_boundary', 148 | 'point_in_convex_polygon_2d', 'point_in_polyhedron_convex', 149 | 'sphere_sphere_intersection', 'plane_plane_intersection', 150 | 'line_triangle_intersection_moller_trumbore', 151 | 'SphereSphereIntersectionResult', 'AABB', 152 | 'convex_hull_2d_monotone_chain', 'convex_hull_3d', 153 | 'plot_convex_hull_2d', 'plot_convex_hull_3d', 154 | 'triangulate_simple_polygon_ear_clipping', 'delaunay_triangulation_points_2d', 155 | 'constrained_delaunay_triangulation', 'tetrahedralise', 156 | 'clip_polygon_sutherland_hodgman', 'polygon_union', 157 | 'polygon_intersection', 'polygon_difference', 158 | 'polyhedron_union', 'polyhedron_intersection', 'polyhedron_difference', 159 | 160 | # Utils 161 | 'validate_non_negative', 'validate_positive', 'validate_list_of_points', 162 | 'validate_polygon_vertices', 163 | 'parse_points_from_string', 'format_point_to_string', 164 | 'save_polyhedron_to_obj_simple', 'load_polyhedron_from_obj_simple', 165 | 'save_polygon2d_to_csv', 'load_polygon2d_from_csv', 166 | 167 | # Version 168 | '__version__', 169 | ] -------------------------------------------------------------------------------- /tests/primitives_3d/test_cube.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/primitives_3d/test_cube.py 2 | # (2) python -m unittest tests/primitives_3d/test_cube.py (verbose output) (auto add sys.path) 3 | 4 | import unittest 5 | import math 6 | import sys 7 | import os 8 | 9 | # For (1): Add the project root to sys.path so `geo` can be imported 10 | # For (2): Don't need 11 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 12 | 13 | from geo.core import Point3D, Vector3D 14 | from geo.primitives_3d.cube import Cube 15 | 16 | class TestCube(unittest.TestCase): 17 | 18 | def setUp(self): 19 | # Standard axis-aligned cube centered at origin, side 2 20 | self.axis_aligned_cube = Cube(center=Point3D(0, 0, 0), side_length=2) 21 | 22 | # Define rotated axes: 90 degree rotation around Z 23 | # New axes: x' = y, y' = -x, z' = z 24 | x_axis = Vector3D(0, 1, 0) 25 | y_axis = Vector3D(-1, 0, 0) 26 | z_axis = Vector3D(0, 0, 1) 27 | self.rotated_axes = [x_axis, y_axis, z_axis] 28 | 29 | self.oriented_cube = Cube(center=Point3D(1, 1, 1), side_length=2, axes=self.rotated_axes) 30 | 31 | def test_volume_surface_area(self): 32 | cube = self.axis_aligned_cube 33 | self.assertAlmostEqual(cube.volume, 8) 34 | self.assertAlmostEqual(cube.surface_area, 24) 35 | 36 | def test_vertices_count(self): 37 | self.assertEqual(len(self.axis_aligned_cube.vertices), 8) 38 | self.assertEqual(len(self.oriented_cube.vertices), 8) 39 | 40 | def test_vertices_positions_axis_aligned(self): 41 | cube = self.axis_aligned_cube 42 | hs = cube.half_side 43 | expected = [ 44 | Point3D(-hs, -hs, -hs), 45 | Point3D(-hs, -hs, hs), 46 | Point3D(-hs, hs, -hs), 47 | Point3D(-hs, hs, hs), 48 | Point3D( hs, -hs, -hs), 49 | Point3D( hs, -hs, hs), 50 | Point3D( hs, hs, -hs), 51 | Point3D( hs, hs, hs), 52 | ] 53 | for v, e in zip(cube.vertices, expected): 54 | self.assertAlmostEqual(v.x, e.x) 55 | self.assertAlmostEqual(v.y, e.y) 56 | self.assertAlmostEqual(v.z, e.z) 57 | 58 | def test_vertices_positions_oriented(self): 59 | cube = self.oriented_cube 60 | # Since cube is rotated 90 degrees about Z, vertices should match rotated positions 61 | # We'll check one vertex explicitly: 62 | # For example, vertex corresponding to (-hs, -hs, -hs) in local coords: 63 | hs = cube.half_side 64 | # local offset vector = (-hs * x_axis) + (-hs * y_axis) + (-hs * z_axis) 65 | offset = (cube.axes[0] * -hs) + (cube.axes[1] * -hs) + (cube.axes[2] * -hs) 66 | expected_vertex = cube.center + offset 67 | 68 | actual_vertex = cube.vertices[0] 69 | self.assertAlmostEqual(actual_vertex.x, expected_vertex.x) 70 | self.assertAlmostEqual(actual_vertex.y, expected_vertex.y) 71 | self.assertAlmostEqual(actual_vertex.z, expected_vertex.z) 72 | 73 | def test_contains_point_axis_aligned(self): 74 | cube = self.axis_aligned_cube 75 | # Inside points 76 | self.assertTrue(cube.contains_point(Point3D(0, 0, 0))) 77 | self.assertTrue(cube.contains_point(Point3D(1, 1, 1))) 78 | self.assertTrue(cube.contains_point(Point3D(-1, -1, -1))) 79 | # On boundary 80 | self.assertTrue(cube.contains_point(Point3D(1, 0, 0))) 81 | self.assertTrue(cube.contains_point(Point3D(0, -1, 0))) 82 | # Outside points 83 | self.assertFalse(cube.contains_point(Point3D(2, 0, 0))) 84 | self.assertFalse(cube.contains_point(Point3D(0, 0, -2))) 85 | 86 | def test_contains_point_oriented(self): 87 | cube = self.oriented_cube 88 | hs = cube.half_side 89 | 90 | # Point exactly at center 91 | self.assertTrue(cube.contains_point(cube.center)) 92 | 93 | # Points inside by local coordinates (+/- hs along axes) 94 | # Let's build a point inside: center + 0.5 * axes[0] + (-0.5) * axes[1] + 0 * axes[2] 95 | inside_point = cube.center + (cube.axes[0] * 0.5 * hs) + (cube.axes[1] * -0.5 * hs) 96 | self.assertTrue(cube.contains_point(inside_point)) 97 | 98 | # Point outside along one axis 99 | outside_point = cube.center + (cube.axes[0] * (hs + 0.1)) 100 | self.assertFalse(cube.contains_point(outside_point)) 101 | 102 | def test_equality(self): 103 | cube1 = Cube(center=Point3D(0, 0, 0), side_length=2) 104 | cube2 = Cube(center=Point3D(0, 0, 0), side_length=2) 105 | cube3 = Cube(center=Point3D(0, 0, 0), side_length=3) 106 | cube4 = Cube(center=Point3D(0, 0, 0), side_length=2, axes=self.rotated_axes) 107 | 108 | self.assertEqual(cube1, cube2) 109 | self.assertNotEqual(cube1, cube3) 110 | self.assertNotEqual(cube1, cube4) 111 | 112 | def test_invalid_axes(self): 113 | with self.assertRaises(ValueError): 114 | # Less than 3 axes 115 | Cube(center=Point3D(0, 0, 0), side_length=2, axes=[Vector3D(1,0,0)]) 116 | 117 | with self.assertRaises(ValueError): 118 | # Non-unit vector axes 119 | Cube(center=Point3D(0, 0, 0), side_length=2, axes=[ 120 | Vector3D(2, 0, 0), 121 | Vector3D(0, 1, 0), 122 | Vector3D(0, 0, 1), 123 | ]) 124 | 125 | with self.assertRaises(ValueError): 126 | # Non-orthogonal axes 127 | Cube(center=Point3D(0, 0, 0), side_length=2, axes=[ 128 | Vector3D(1, 0, 0), 129 | Vector3D(1, 0, 0), 130 | Vector3D(0, 0, 1), 131 | ]) 132 | 133 | 134 | if __name__ == "__main__": 135 | unittest.main() 136 | -------------------------------------------------------------------------------- /geo/operations/containment.py: -------------------------------------------------------------------------------- 1 | # geo/operations/containment.py 2 | 3 | """ 4 | Functions for 2D and 3D containment checks. 5 | Many basic containment checks are methods of the primitive classes. 6 | This module can house more complex checks or those involving multiple entities. 7 | """ 8 | from typing import List 9 | 10 | from geo.core import Point2D, Vector2D, Point3D 11 | from geo.core.precision import is_equal, is_zero, DEFAULT_EPSILON 12 | from geo.primitives_2d import Polygon, Segment2D, Line2D 13 | from geo.primitives_3d import Polyhedron, Plane as Plane3D 14 | from .intersections_2d import segment_segment_intersection_detail # For polygon simplicity check 15 | 16 | 17 | def check_point_left_of_line(point: Point2D, line_p1: Point2D, line_p2: Point2D) -> float: 18 | """ 19 | Returns > 0 if `point` is left of the directed line from line_p1 to line_p2, 20 | < 0 if right, and 0 if collinear. 21 | """ 22 | return (line_p2.x - line_p1.x) * (point.y - line_p1.y) - (line_p2.y - line_p1.y) * (point.x - line_p1.x) 23 | 24 | 25 | def is_polygon_simple(polygon: Polygon, epsilon: float = DEFAULT_EPSILON) -> bool: 26 | """ 27 | Checks if a polygon is simple (i.e., edges do not intersect except at shared vertices). 28 | """ 29 | num_edges = len(polygon.edges) 30 | if num_edges < 3: 31 | return True # Polygon with fewer than 3 edges trivially simple 32 | 33 | edges = polygon.edges 34 | 35 | for i in range(num_edges): 36 | edge1 = edges[i] 37 | for j in range(i + 1, num_edges): 38 | edge2 = edges[j] 39 | 40 | # Check adjacency: edges sharing exactly one vertex and consecutive in order 41 | is_adjacent = False 42 | # Shared vertices between edge1=(v_i, v_i+1), edge2=(v_j, v_j+1) 43 | shared_vertices = { 44 | polygon.vertices[i], 45 | polygon.vertices[(i + 1) % num_edges] 46 | } & { 47 | polygon.vertices[j], 48 | polygon.vertices[(j + 1) % num_edges] 49 | } 50 | if len(shared_vertices) == 1: 51 | # Adjacent if edges are consecutive in polygon indexing or first and last edge 52 | if j == (i + 1) % num_edges or (i == 0 and j == num_edges - 1): 53 | is_adjacent = True 54 | 55 | intersection_type, intersect_data = segment_segment_intersection_detail(edge1, edge2, epsilon) 56 | 57 | if is_adjacent: 58 | if intersection_type == "point": 59 | pt = intersect_data 60 | assert isinstance(pt, Point2D) 61 | # Intersection point must be the shared vertex 62 | shared_vertex = next(iter(shared_vertices)) 63 | if not pt == shared_vertex: 64 | # Adjacent edges intersecting at a point other than shared vertex means not simple 65 | return False 66 | elif intersection_type == "overlap": 67 | # Adjacent edges overlapping means degenerate polygon 68 | return False 69 | # "none" or "collinear_no_overlap" is okay for adjacent edges 70 | else: 71 | # Non-adjacent edges must not intersect or overlap 72 | if intersection_type in ("point", "overlap"): 73 | return False 74 | 75 | return True 76 | 77 | 78 | def point_on_polygon_boundary(point: Point2D, polygon: Polygon, epsilon: float = DEFAULT_EPSILON) -> bool: 79 | """ 80 | Returns True if point lies exactly on any edge of the polygon. 81 | """ 82 | if polygon.num_vertices < 2: 83 | return False 84 | 85 | for edge in polygon.edges: 86 | if edge.contains_point(point, epsilon): 87 | return True 88 | return False 89 | 90 | 91 | def point_in_convex_polygon_2d(point: Point2D, polygon: Polygon, epsilon: float = DEFAULT_EPSILON) -> bool: 92 | """ 93 | Checks if a point is inside or on the boundary of a convex polygon. 94 | Assumes vertices are ordered CCW. 95 | 96 | The point must be left of or on every directed edge. 97 | """ 98 | if polygon.num_vertices < 3: 99 | return False # Not a valid polygon for containment 100 | 101 | if point_on_polygon_boundary(point, polygon, epsilon): 102 | return True 103 | 104 | for i in range(polygon.num_vertices): 105 | p1 = polygon.vertices[i] 106 | p2 = polygon.vertices[(i + 1) % polygon.num_vertices] 107 | 108 | cross_val = check_point_left_of_line(point, p1, p2) 109 | if cross_val < -epsilon: # Point is to the right of an edge (outside) 110 | return False 111 | 112 | return True 113 | 114 | 115 | def point_in_polyhedron_convex(point: Point3D, polyhedron: Polyhedron, epsilon: float = DEFAULT_EPSILON) -> bool: 116 | """ 117 | Checks if a point is inside or on the boundary of a convex polyhedron. 118 | 119 | The point must lie on the non-positive side of all face planes (normals point outward). 120 | """ 121 | if polyhedron.num_faces == 0: 122 | return False 123 | 124 | for i in range(polyhedron.num_faces): 125 | face_points = polyhedron.get_face_points(i) 126 | if len(face_points) < 3: 127 | continue # Skip degenerate faces 128 | 129 | face_normal = polyhedron.get_face_normal(i) 130 | if face_normal.is_zero_vector(): 131 | # Degenerate face normal, skip or handle separately 132 | continue 133 | 134 | plane_of_face = Plane3D(face_points[0], face_normal) 135 | signed_dist = plane_of_face.signed_distance_to_point(point) 136 | if signed_dist > epsilon: # Outside face plane 137 | return False 138 | 139 | return True 140 | -------------------------------------------------------------------------------- /tests/primitives_3d/test_cylinder.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/primitives_3d/test_cylinder.py 2 | # (2) python -m unittest tests/primitives_3d/test_cylinder.py (verbose output) (auto add sys.path) 3 | 4 | import math 5 | import unittest 6 | import sys 7 | import os 8 | 9 | # For (1): Add the project root to sys.path so `geo` can be imported 10 | # For (2): Don't need 11 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 12 | 13 | from geo.core import Point3D, Vector3D 14 | from geo.primitives_3d.cylinder import Cylinder 15 | from geo.core.precision import DEFAULT_EPSILON, is_equal 16 | from geo.primitives_3d.plane import Plane 17 | 18 | 19 | class TestCylinder(unittest.TestCase): 20 | def setUp(self): 21 | self.base = Point3D(0, 0, 0) 22 | self.axis = Vector3D(0, 0, 1) 23 | self.radius = 2.0 24 | self.height = 5.0 25 | self.cylinder = Cylinder(self.base, self.axis, self.radius, self.height) 26 | 27 | def test_init_invalid(self): 28 | with self.assertRaises(ValueError): 29 | Cylinder(self.base, Vector3D(0, 0, 0), 1.0, 1.0) # zero axis vector 30 | 31 | with self.assertRaises(ValueError): 32 | Cylinder(self.base, self.axis, -1.0, 1.0) # negative radius 33 | 34 | with self.assertRaises(ValueError): 35 | Cylinder(self.base, self.axis, 1.0, -1.0) # negative height 36 | 37 | def test_properties(self): 38 | self.assertAlmostEqual(self.cylinder.volume, math.pi * self.radius ** 2 * self.height) 39 | self.assertAlmostEqual(self.cylinder.lateral_surface_area, 2 * math.pi * self.radius * self.height) 40 | self.assertAlmostEqual(self.cylinder.base_area, math.pi * self.radius ** 2) 41 | total_surface = 2 * self.cylinder.base_area + self.cylinder.lateral_surface_area 42 | self.assertAlmostEqual(self.cylinder.total_surface_area, total_surface) 43 | 44 | def test_top_center(self): 45 | expected_top = Point3D(0, 0, 5) 46 | self.assertEqual(self.cylinder.top_center, expected_top) 47 | 48 | def test_get_cap_planes(self): 49 | base_plane, top_plane = self.cylinder.get_cap_planes() 50 | self.assertIsInstance(base_plane, Plane) 51 | self.assertIsInstance(top_plane, Plane) 52 | # Base normal points opposite axis 53 | self.assertTrue(base_plane.normal == -self.axis.normalize()) 54 | # Top normal points along axis 55 | self.assertTrue(top_plane.normal == self.axis.normalize()) 56 | 57 | def test_contains_point_inside(self): 58 | # Inside point (center axis, halfway) 59 | p_inside = Point3D(0, 0, 2.5) 60 | self.assertTrue(self.cylinder.contains_point(p_inside)) 61 | 62 | def test_contains_point_on_surface(self): 63 | # Point exactly on lateral surface at mid height 64 | p_surface = Point3D(self.radius, 0, self.height / 2) 65 | self.assertTrue(self.cylinder.contains_point(p_surface)) 66 | 67 | def test_contains_point_outside_radius(self): 68 | p_outside = Point3D(self.radius + 1e-3, 0, self.height / 2) 69 | self.assertFalse(self.cylinder.contains_point(p_outside, epsilon=1e-5)) 70 | 71 | def test_contains_point_below_base(self): 72 | p_below = Point3D(0, 0, -0.1) 73 | self.assertFalse(self.cylinder.contains_point(p_below)) 74 | 75 | def test_contains_point_above_top(self): 76 | p_above = Point3D(0, 0, self.height + 0.1) 77 | self.assertFalse(self.cylinder.contains_point(p_above)) 78 | 79 | def test_equality(self): 80 | c2 = Cylinder(self.base, self.axis, self.radius, self.height) 81 | self.assertEqual(self.cylinder, c2) 82 | 83 | # Same cylinder but axis flipped and base and top swapped 84 | c3 = Cylinder(self.cylinder.top_center, -self.axis, self.radius, self.height) 85 | self.assertEqual(self.cylinder, c3) 86 | 87 | # Different radius 88 | c4 = Cylinder(self.base, self.axis, self.radius + 0.1, self.height) 89 | self.assertNotEqual(self.cylinder, c4) 90 | 91 | def test_distance_to_axis(self): 92 | p = Point3D(3, 0, 2) 93 | dist = self.cylinder.distance_to_axis(p) 94 | self.assertAlmostEqual(dist, 3) 95 | 96 | # Point on axis has zero distance 97 | p_on_axis = Point3D(0, 0, 1) 98 | self.assertAlmostEqual(self.cylinder.distance_to_axis(p_on_axis), 0) 99 | 100 | def test_get_lateral_surface_point(self): 101 | # Angle 0, height fraction 0 -> base edge on +perp1 102 | p0 = self.cylinder.get_lateral_surface_point(0, 0) 103 | self.assertTrue(is_equal(p0.z, 0)) 104 | dist0 = p0.distance_to(Point3D(0, 0, 0)) 105 | self.assertTrue(is_equal(dist0, self.radius)) 106 | 107 | # Angle pi/2, height fraction 1 -> top edge on +perp2 108 | p1 = self.cylinder.get_lateral_surface_point(math.pi / 2, 1) 109 | self.assertTrue(is_equal(p1.z, self.height)) 110 | dist1 = p1.distance_to(Point3D(0, 0, self.height)) 111 | self.assertTrue(is_equal(dist1, self.radius)) 112 | 113 | # Invalid height fraction < 0 or > 1 114 | with self.assertRaises(ValueError): 115 | self.cylinder.get_lateral_surface_point(0, -0.1) 116 | with self.assertRaises(ValueError): 117 | self.cylinder.get_lateral_surface_point(0, 1.1) 118 | 119 | def test_project_point_onto_axis(self): 120 | p = Point3D(1, 0, 3) 121 | proj = self.cylinder.project_point_onto_axis(p) 122 | # Projection is dot of vector (1,0,3) onto (0,0,1) = 3 123 | self.assertAlmostEqual(proj, 3) 124 | 125 | # Point below base 126 | p_below = Point3D(0, 0, -1) 127 | proj_below = self.cylinder.project_point_onto_axis(p_below) 128 | self.assertAlmostEqual(proj_below, -1) 129 | 130 | 131 | if __name__ == "__main__": 132 | unittest.main() 133 | -------------------------------------------------------------------------------- /geo/primitives_2d/curve/bezier.py: -------------------------------------------------------------------------------- 1 | # geo/primitives_2d/curve/bezier.py 2 | 3 | """ 4 | Bezier curves in 2-D. 5 | 6 | A Bezier curve of degree n is defined by its control points 7 | P₀ … Pₙ and is evaluated as 8 | 9 | B(t) = Σᵢ Pᵢ · Bᵢ,ₙ(t) 0 ≤ t ≤ 1, 10 | 11 | where Bᵢ,ₙ(t) are the Bernstein basis polynomials. Values of t 12 | outside [0, 1] produce an extrapolation of the curve. 13 | """ 14 | 15 | import math 16 | from typing import Sequence 17 | 18 | from geo.core import Point2D, Vector2D 19 | from geo.core.precision import is_equal 20 | from .base import Curve2D 21 | 22 | __all__ = ["BezierCurve"] 23 | 24 | 25 | # Helper 26 | def _bernstein(n: int, i: int, t: float) -> float: 27 | """ 28 | Bᵢ,ₙ(t) = C(n, i) · tⁱ · (1 - t)ⁿ⁻ⁱ 29 | Returns 0 when i ∉ [0, n] to simplify calling code. 30 | """ 31 | if i < 0 or i > n: 32 | return 0.0 33 | # math.comb already validates n ≥ i ≥ 0. 34 | coeff = math.comb(n, i) 35 | # No need to special-case t=0/1 – power terms are cheap & exact. 36 | return coeff * (t ** i) * ((1.0 - t) ** (n - i)) 37 | 38 | 39 | # Main class 40 | class BezierCurve(Curve2D): 41 | """ 42 | Arbitrary-degree 2-D Bezier curve. 43 | 44 | Parameters 45 | ---------- 46 | control_points : 47 | Iterable of :class: geo.core.Point2D. At least two are required 48 | (a single point would be a degenerate curve; for that, use 49 | :class: geo.primitives_2d.curve.base.Curve2D directly or a 50 | degenerate curve subclass). 51 | 52 | Notes 53 | ----- 54 | • Degree = len(control_points) - 1 55 | • point_at uses Bernstein polynomials by default; can switch 56 | to De Casteljau for improved numerical stability on very high 57 | degrees via the use_casteljau flag. 58 | """ 59 | 60 | def __init__(self, control_points: Sequence[Point2D]): 61 | if len(control_points) < 2: 62 | raise ValueError( 63 | "BezierCurve needs at least two control points " 64 | "to represent a curve (one would be a single point)." 65 | ) 66 | super().__init__(control_points) 67 | self.degree: int = len(self.control_points) - 1 68 | if self.degree == 0: 69 | # Degenerate curve – zero vector as a single-point curve 70 | self.control_points = (Point2D(0.0, 0.0), Point2D(0.0, 0.0)) 71 | 72 | def point_at(self, t: float, *, use_casteljau: bool = False) -> Point2D: 73 | """ 74 | Evaluate the curve at parameter t. 75 | 76 | Parameters 77 | ---------- 78 | t : float 79 | Parameter value. 0 ≤ t ≤ 1 is the usual domain; 80 | other values extrapolate linearly. 81 | use_casteljau : bool, default=False 82 | When True, uses the recursive De Casteljau algorithm, 83 | which is numerically stabler for high-degree curves. 84 | 85 | Returns 86 | ------- 87 | Point2D 88 | """ 89 | if self.degree == 1: 90 | # Early-exit – simple linear interpolation; avoids overhead 91 | p0, p1 = self.control_points 92 | return p0 * (1.0 - t) + p1 * t 93 | 94 | if use_casteljau: 95 | return self._point_at_casteljau(t) 96 | 97 | n = self.degree 98 | sx = sy = 0.0 99 | for i, p in enumerate(self.control_points): 100 | b = _bernstein(n, i, t) 101 | sx += p.x * b 102 | sy += p.y * b 103 | return Point2D(sx, sy) 104 | 105 | def tangent_at(self, t: float) -> Vector2D: 106 | """ 107 | First derivative B'(t). Returns the zero vector for a 108 | degenerate (degree 0) curve. 109 | 110 | The derivative of a Bezier curve is itself another Bezier curve 111 | of degree (n-1) whose control points are n·(Pᵢ₊₁ - Pᵢ). 112 | """ 113 | if self.degree == 0: 114 | return Vector2D(0.0, 0.0) 115 | 116 | if self.degree == 1: 117 | # Constant derivative – vector from P0 to P1 118 | p0, p1 = self.control_points 119 | return p1 - p0 120 | 121 | n = self.degree 122 | dx = dy = 0.0 123 | for i in range(n): 124 | delta = self.control_points[i + 1] - self.control_points[i] 125 | b = _bernstein(n - 1, i, t) 126 | dx += delta.x * b 127 | dy += delta.y * b 128 | return Vector2D(dx * n, dy * n) 129 | 130 | def __repr__(self) -> str: # pragma: no cover 131 | return ( 132 | f"{self.__class__.__name__}(degree={self.degree}, " 133 | f"control_points={self.control_points})" 134 | ) 135 | 136 | def _point_at_casteljau(self, t: float) -> Point2D: 137 | """ 138 | De Casteljau evaluation (O(n²) but numerically robust). 139 | """ 140 | if self.degree == 1: 141 | return self.point_at(t) # linear shortcut 142 | 143 | # Work on a mutable copy 144 | pts = list(self.control_points) 145 | n = self.degree 146 | for r in range(1, n + 1): 147 | for i in range(n - r + 1): 148 | pts[i] = pts[i] * (1.0 - t) + pts[i + 1] * t 149 | return pts[0] 150 | 151 | def derivative_curve(self) -> "BezierCurve": 152 | """ 153 | Returns the derivative of this Bezier curve as a new BezierCurve object. 154 | The resulting control points are vectors interpreted as new points. 155 | """ 156 | n = len(self.control_points) - 1 157 | deriv_points = [] 158 | for i in range(n): 159 | delta = self.control_points[i + 1] - self.control_points[i] 160 | # Convert the vector into a Point2D (starting from origin) 161 | deriv_point = Point2D(*((n * delta).components)) 162 | deriv_points.append(deriv_point) 163 | return BezierCurve(deriv_points) -------------------------------------------------------------------------------- /tests/primitives_2d/test_circle.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/primitives_2d/test_circle.py 2 | # (2) python -m unittest tests/primitives_2d/test_circle.py (verbose output) (auto add sys.path) 3 | 4 | import math 5 | import unittest 6 | import sys 7 | import os 8 | 9 | # For (1): Add the project root to sys.path so `geo` can be imported 10 | # For (2): Don't need 11 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 12 | 13 | from geo.primitives_2d import Circle, Line2D 14 | from geo.core import Point2D, Vector2D 15 | from geo.core.precision import DEFAULT_EPSILON, is_equal 16 | 17 | 18 | class TestCircleBasics(unittest.TestCase): 19 | """Constructor, repr, equality, basic properties.""" 20 | 21 | def test_constructor_negative_radius_raises(self): 22 | with self.assertRaises(ValueError): 23 | Circle(Point2D(0, 0), -1) 24 | 25 | def test_zero_radius(self): 26 | c = Circle(Point2D(1, 1), 0) 27 | self.assertEqual(c.area, 0.0) 28 | self.assertEqual(c.circumference, 0.0) 29 | # Only the centre is contained 30 | self.assertTrue(c.contains_point(Point2D(1, 1))) 31 | self.assertFalse(c.contains_point(Point2D(1, 1 + DEFAULT_EPSILON * 10))) 32 | # on_boundary should regard the centre as boundary when r==0 33 | self.assertTrue(c.on_boundary(Point2D(1, 1))) 34 | 35 | def test_area_circumference_formulae(self): 36 | r = 3.5 37 | c = Circle(Point2D(0, 0), r) 38 | self.assertAlmostEqual(c.area, math.pi * r ** 2) 39 | self.assertAlmostEqual(c.circumference, 2 * math.pi * r) 40 | 41 | def test_equality_with_tolerance(self): 42 | c1 = Circle(Point2D(0, 0), 2.0) 43 | c2 = Circle(Point2D(0, 0), 2.0 + DEFAULT_EPSILON / 2) 44 | self.assertEqual(c1, c2) 45 | 46 | 47 | class TestCircleContainment(unittest.TestCase): 48 | def setUp(self): 49 | self.circle = Circle(Point2D(0, 0), 5) 50 | 51 | def test_contains_point_inside(self): 52 | self.assertTrue(self.circle.contains_point(Point2D(3, 4))) # distance 5, on boundary 53 | self.assertTrue(self.circle.contains_point(Point2D(0, 0))) # centre 54 | self.assertTrue(self.circle.on_boundary(Point2D(3, 4))) 55 | 56 | def test_contains_point_outside(self): 57 | self.assertFalse(self.circle.contains_point(Point2D(6, 0))) 58 | # Just outside boundary but within epsilon → still True due to tolerance 59 | self.assertTrue(self.circle.contains_point(Point2D(5 + DEFAULT_EPSILON / 2, 0))) 60 | 61 | 62 | class TestCircleLineIntersection(unittest.TestCase): 63 | """Secant, tangent, and disjoint cases.""" 64 | 65 | def setUp(self): 66 | self.circle = Circle(Point2D(0, 0), 5) 67 | self.horizontal_center = Line2D(Point2D(-10, 0), Point2D(10, 0)) # Through centre 68 | self.horizontal_top_tangent = Line2D(Point2D(-10, 5), Vector2D(1, 0)) 69 | self.horizontal_above = Line2D(Point2D(-10, 6), Vector2D(1, 0)) 70 | 71 | def test_secant_two_points(self): 72 | pts = self.circle.intersection_with_line(self.horizontal_center) 73 | self.assertEqual(len(pts), 2) 74 | # Points should be (-5,0) and (5,0) regardless of ordering 75 | xs = sorted(p.x for p in pts) 76 | self.assertTrue(is_equal(xs[0], -5) and is_equal(xs[1], 5)) 77 | self.assertTrue(all(is_equal(p.y, 0) for p in pts)) 78 | 79 | def test_tangent_one_point(self): 80 | pts = self.circle.intersection_with_line(self.horizontal_top_tangent) 81 | self.assertEqual(len(pts), 1) 82 | self.assertAlmostEqual(pts[0].x, 0.0) 83 | self.assertAlmostEqual(pts[0].y, 5.0) 84 | 85 | def test_no_intersection_disjoint(self): 86 | self.assertEqual(self.circle.intersection_with_line(self.horizontal_above), []) 87 | 88 | 89 | class TestCircleCircleIntersection(unittest.TestCase): 90 | """Validate circle‑circle intersection helper in Circle.intersection_with_circle""" 91 | 92 | def test_two_points(self): 93 | c1 = Circle(Point2D(0, 0), 5) 94 | c2 = Circle(Point2D(8, 0), 5) 95 | pts = c1.intersection_with_circle(c2) 96 | self.assertEqual(len(pts), 2) 97 | # y coordinates should be symmetric 98 | self.assertAlmostEqual(pts[0].y, -pts[1].y) 99 | self.assertTrue(all(is_equal(p.x, 4) for p in pts)) 100 | 101 | def test_tangent_external(self): 102 | c1 = Circle(Point2D(0, 0), 5) 103 | c2 = Circle(Point2D(10, 0), 5) 104 | pts = c1.intersection_with_circle(c2) 105 | self.assertEqual(len(pts), 1) 106 | self.assertAlmostEqual(pts[0].x, 5.0) 107 | self.assertAlmostEqual(pts[0].y, 0.0) 108 | 109 | def test_tangent_internal(self): 110 | c1 = Circle(Point2D(0, 0), 5) 111 | c2 = Circle(Point2D(0, 0), 2) 112 | # Internal tangency when radius difference equals distance (here d=0, handled as containment) 113 | # Shift c2 a bit to make them internally tangent at (3,0) 114 | c2 = Circle(Point2D(3, 0), 2) 115 | pts = c1.intersection_with_circle(c2) 116 | self.assertEqual(len(pts), 1) 117 | self.assertAlmostEqual(pts[0].x, 5.0) 118 | self.assertAlmostEqual(pts[0].y, 0.0) 119 | 120 | def test_disjoint(self): 121 | c1 = Circle(Point2D(0, 0), 5) 122 | c2 = Circle(Point2D(20, 0), 5) 123 | self.assertEqual(c1.intersection_with_circle(c2), []) 124 | 125 | def test_contained_no_intersection(self): 126 | big = Circle(Point2D(0, 0), 5) 127 | small = Circle(Point2D(1, 1), 1) 128 | self.assertEqual(big.intersection_with_circle(small), []) 129 | 130 | def test_coincident_returns_empty(self): 131 | c1 = Circle(Point2D(0, 0), 5) 132 | c2 = Circle(Point2D(0, 0), 5) 133 | self.assertEqual(c1.intersection_with_circle(c2), []) 134 | 135 | def test_point_circle_tangent(self): 136 | point_circle = Circle(Point2D(5, 0), 0) 137 | big = Circle(Point2D(0, 0), 5) 138 | pts = big.intersection_with_circle(point_circle) 139 | self.assertEqual(pts, [Point2D(5, 0)]) 140 | 141 | 142 | if __name__ == "__main__": 143 | unittest.main() 144 | -------------------------------------------------------------------------------- /geo/operations/measurements.py: -------------------------------------------------------------------------------- 1 | # geo/operations/measurements.py 2 | """ 3 | High-level geometric measurement helpers that complement the methods 4 | already available on the primitive classes. 5 | """ 6 | from __future__ import annotations 7 | 8 | import math 9 | from typing import Optional, Sequence, Tuple, Union 10 | 11 | from geo.core import Point2D, Point3D, Vector2D, Vector3D 12 | from geo.core.precision import DEFAULT_EPSILON, is_equal, is_zero 13 | from geo.primitives_2d import Line2D, Segment2D 14 | from geo.primitives_3d import Line3D, Plane as Plane3D, Segment3D 15 | from geo.operations.intersections_2d import segment_segment_intersection_detail 16 | 17 | __all__: Sequence[str] = ( 18 | "closest_point_on_segment_to_point", 19 | "distance_segment_segment_2d", 20 | "closest_points_segments_2d", 21 | "signed_angle_between_vectors_2d", 22 | "distance_point_line_3d", 23 | "distance_point_plane", 24 | "distance_line_line_3d", 25 | ) 26 | 27 | 28 | # Generic helpers 29 | 30 | def _closest_point_parameter(p: Union[Point2D, Point3D], 31 | a: Union[Point2D, Point3D], 32 | b: Union[Point2D, Point3D]) -> float: 33 | """Parameter t in a + t·(b-a) of the orthogonal projection of p. 34 | Returns t without clamping to [0, 1]. 35 | """ 36 | ab = b - a # type: ignore[operator] 37 | if ab.is_zero_vector(DEFAULT_EPSILON): # degenerate segment 38 | return 0.0 39 | return (p - a).dot(ab) / ab.magnitude_squared() # type: ignore[operator] 40 | 41 | 42 | 43 | # 2‑D & 3‑D helpers 44 | 45 | def closest_point_on_segment_to_point( 46 | segment: Union[Segment2D, Segment3D], 47 | point: Union[Point2D, Point3D], 48 | *, 49 | epsilon: float = DEFAULT_EPSILON, 50 | ) -> Union[Point2D, Point3D]: 51 | """Return the point on *segment* closest to *point* (inclusive ends).""" 52 | t = _closest_point_parameter(point, segment.p1, segment.p2) 53 | if t <= 0.0: 54 | return segment.p1 55 | if t >= 1.0: 56 | return segment.p2 57 | return segment.p1 + (segment.p2 - segment.p1) * t # type: ignore[operator] 58 | 59 | 60 | def distance_segment_segment_2d( 61 | seg1: Segment2D, 62 | seg2: Segment2D, 63 | *, 64 | epsilon: float = DEFAULT_EPSILON, 65 | ) -> float: 66 | """Shortest distance between two *closed* 2D segments (0 if they touch).""" 67 | itype, _ = segment_segment_intersection_detail(seg1, seg2, epsilon) 68 | if itype in {"point", "overlap"}: 69 | return 0.0 70 | # endpoints to opposite segment distances 71 | dists = ( 72 | seg2.distance_to_point(seg1.p1), 73 | seg2.distance_to_point(seg1.p2), 74 | seg1.distance_to_point(seg2.p1), 75 | seg1.distance_to_point(seg2.p2), 76 | ) 77 | return min(dists) 78 | 79 | 80 | def closest_points_segments_2d( 81 | seg1: Segment2D, 82 | seg2: Segment2D, 83 | *, 84 | epsilon: float = DEFAULT_EPSILON, 85 | ) -> Tuple[Point2D, Point2D]: 86 | """Pair of closest points (p_on_seg1, p_on_seg2) for two 2D segments.""" 87 | itype, data = segment_segment_intersection_detail(seg1, seg2, epsilon) 88 | if itype == "point": 89 | assert isinstance(data, Point2D) 90 | return data, data 91 | if itype == "overlap": 92 | assert isinstance(data, tuple) and len(data) == 2 93 | return data[0], data[0] # any point on the overlap is equally valid 94 | 95 | # otherwise compute candidates (projection of each end onto the other seg) 96 | cands: list[Tuple[float, Point2D, Point2D]] = [] 97 | for p in (seg1.p1, seg1.p2): 98 | q = closest_point_on_segment_to_point(seg2, p, epsilon=epsilon) # type: ignore[arg-type] 99 | cands.append((p.distance_to(q), p, q)) 100 | for p in (seg2.p1, seg2.p2): 101 | q = closest_point_on_segment_to_point(seg1, p, epsilon=epsilon) # type: ignore[arg-type] 102 | cands.append((p.distance_to(q), q, p)) 103 | # return the minimum‑distance pair 104 | cands.sort(key=lambda t: t[0]) 105 | return cands[0][1], cands[0][2] 106 | 107 | 108 | def signed_angle_between_vectors_2d(v1: Vector2D, v2: Vector2D) -> float: 109 | """Signed angle v1 → v2 in radians (CCW positive, range (-π, π]).""" 110 | if v1.is_zero_vector(DEFAULT_EPSILON) or v2.is_zero_vector(DEFAULT_EPSILON): 111 | return 0.0 112 | dot = v1.dot(v2) 113 | cross = v1.x * v2.y - v1.y * v2.x 114 | return math.atan2(cross, dot) 115 | 116 | 117 | # Thin wrappers around primitive methods (3‑D) 118 | 119 | def distance_point_line_3d(point: Point3D, line: Line3D) -> float: 120 | """Alias for line.distance_to_point(point) kept for API symmetry.""" 121 | return line.distance_to_point(point) 122 | 123 | 124 | def distance_point_plane(point: Point3D, plane: Plane3D) -> float: 125 | """Alias for plane.distance_to_point(point) kept for API symmetry.""" 126 | return plane.distance_to_point(point) 127 | 128 | 129 | # 3‑D line‑line minimal distance 130 | 131 | def distance_line_line_3d( 132 | line1: Line3D, 133 | line2: Line3D, 134 | *, 135 | epsilon: float = DEFAULT_EPSILON, 136 | ) -> Tuple[float, Optional[Point3D], Optional[Point3D]]: 137 | """Shortest distance between two 3D infinite lines plus closest points. 138 | 139 | * Parallel lines - returns the perpendicular distance, points None. 140 | * Intersecting lines - distance 0, identical closest points. 141 | * Skew lines - distance > 0 and the unique closest points on each line. 142 | """ 143 | d1, d2 = line1.direction, line2.direction 144 | p1, p2 = line1.origin, line2.origin 145 | 146 | n = d1.cross(d2) 147 | if n.is_zero_vector(epsilon): # ‖d1×d2‖ ≈ 0 → parallel 148 | return line2.distance_to_point(p1), None, None 149 | 150 | w0 = p1 - p2 151 | denom = n.magnitude() 152 | distance = abs(w0.dot(n)) / denom 153 | 154 | # solve for the parameters of the closest points (see geometric derivation) 155 | b = d1.dot(d2) 156 | d = d1.dot(w0) 157 | e = d2.dot(w0) 158 | denom_param = 1.0 - b * b 159 | 160 | # numerical safety – denom_param should not be zero here (lines not parallel) 161 | if is_zero(denom_param, epsilon): 162 | return distance, None, None 163 | 164 | s = (b * e - d) / denom_param 165 | t = (e - b * d) / denom_param 166 | 167 | cp1 = p1 + d1 * s # type: ignore[operator] 168 | cp2 = p2 + d2 * t # type: ignore[operator] 169 | 170 | return distance, cp1, cp2 171 | -------------------------------------------------------------------------------- /tests/operations/test_containment.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/operations/test_containment.py 2 | # (2) python -m unittest tests/operations/test_containment.py (verbose output) (auto add sys.path) 3 | 4 | import unittest 5 | import os 6 | import sys 7 | 8 | # Add project root to sys.path for direct execution 9 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 10 | 11 | from geo.core.precision import DEFAULT_EPSILON 12 | from geo.core import Point2D, Point3D, Vector2D 13 | from geo.primitives_2d import Polygon, Segment2D 14 | from geo.primitives_3d import Polyhedron 15 | from geo.operations import containment 16 | 17 | class TestContainment(unittest.TestCase): 18 | 19 | def setUp(self): 20 | # Common points for tests 21 | self.p0 = Point2D(0, 0) 22 | self.p1 = Point2D(1, 0) 23 | self.p2 = Point2D(1, 1) 24 | self.p3 = Point2D(0, 1) 25 | self.p_outside = Point2D(2, 2) 26 | self.p_on_edge = Point2D(0.5, 0) 27 | self.p_vertex = Point2D(1, 0) 28 | 29 | # Square polygon CCW 30 | self.square = Polygon([self.p0, self.p1, self.p2, self.p3]) 31 | 32 | # Triangle polygon CCW 33 | self.triangle = Polygon([self.p0, self.p1, self.p2]) 34 | 35 | # Concave polygon (arrow shape) 36 | self.concave = Polygon([ 37 | Point2D(0,0), Point2D(2,1), Point2D(0,2), Point2D(1,1) 38 | ]) 39 | 40 | # --- check_point_left_of_line --- 41 | def test_check_point_left_of_line(self): 42 | val = containment.check_point_left_of_line(Point2D(0,1), Point2D(0,0), Point2D(1,0)) 43 | self.assertGreater(val, 0) 44 | 45 | val = containment.check_point_left_of_line(Point2D(0,-1), Point2D(0,0), Point2D(1,0)) 46 | self.assertLess(val, 0) 47 | 48 | val = containment.check_point_left_of_line(Point2D(0.5, 0), Point2D(0,0), Point2D(1,0)) 49 | self.assertAlmostEqual(val, 0) 50 | 51 | # --- is_polygon_simple --- 52 | def test_is_polygon_simple_simple_polygon(self): 53 | self.assertTrue(containment.is_polygon_simple(self.square)) 54 | self.assertTrue(containment.is_polygon_simple(self.triangle)) 55 | 56 | def test_is_polygon_simple_self_intersecting(self): 57 | bowtie = Polygon([Point2D(0,0), Point2D(2,2), Point2D(0,2), Point2D(2,0)]) 58 | self.assertFalse(containment.is_polygon_simple(bowtie)) 59 | 60 | def test_is_polygon_simple_adjacent_edges_touching_only_at_vertex(self): 61 | poly = Polygon([Point2D(0,0), Point2D(2,0), Point2D(1,1), Point2D(2,2), Point2D(0,2)]) 62 | self.assertTrue(containment.is_polygon_simple(poly)) 63 | 64 | def test_is_polygon_simple_overlapping_adjacent_edges(self): 65 | vertices = [Point2D(0,0), Point2D(2,0), Point2D(1,0), Point2D(0,1)] 66 | poly = Polygon(vertices) 67 | self.assertFalse(containment.is_polygon_simple(poly)) 68 | 69 | # --- point_on_polygon_boundary --- 70 | def test_point_on_polygon_boundary_true(self): 71 | self.assertTrue(containment.point_on_polygon_boundary(self.p_vertex, self.square)) 72 | self.assertTrue(containment.point_on_polygon_boundary(self.p_on_edge, self.square)) 73 | 74 | def test_point_on_polygon_boundary_false(self): 75 | self.assertFalse(containment.point_on_polygon_boundary(self.p_outside, self.square)) 76 | self.assertFalse(containment.point_on_polygon_boundary(Point2D(0.5, 0.5), self.square)) 77 | 78 | # --- point_in_convex_polygon_2d --- 79 | def test_point_in_convex_polygon_2d_inside(self): 80 | self.assertTrue(containment.point_in_convex_polygon_2d(Point2D(0.5,0.5), self.square)) 81 | 82 | def test_point_in_convex_polygon_2d_outside(self): 83 | self.assertFalse(containment.point_in_convex_polygon_2d(self.p_outside, self.square)) 84 | 85 | def test_point_in_convex_polygon_2d_on_edge(self): 86 | self.assertTrue(containment.point_in_convex_polygon_2d(self.p_on_edge, self.square)) 87 | 88 | def test_point_in_convex_polygon_2d_vertex(self): 89 | self.assertTrue(containment.point_in_convex_polygon_2d(self.p_vertex, self.square)) 90 | 91 | def test_point_in_convex_polygon_2d_concave_polygon(self): 92 | inside_concavity = Point2D(1, 1.2) 93 | self.assertFalse(containment.point_in_convex_polygon_2d(inside_concavity, self.concave)) 94 | 95 | # --- point_in_polyhedron_convex --- 96 | def test_point_in_polyhedron_convex_inside_and_outside(self): 97 | cube_vertices = [ 98 | Point3D(0,0,0), Point3D(1,0,0), Point3D(1,1,0), Point3D(0,1,0), 99 | Point3D(0,0,1), Point3D(1,0,1), Point3D(1,1,1), Point3D(0,1,1), 100 | ] 101 | 102 | class MockPolyhedron(Polyhedron): 103 | def __init__(self, verts): 104 | self._verts = verts 105 | def get_face_points(self, i): 106 | faces = [ 107 | [self._verts[0], self._verts[1], self._verts[2], self._verts[3]], 108 | [self._verts[4], self._verts[5], self._verts[6], self._verts[7]], 109 | [self._verts[0], self._verts[1], self._verts[5], self._verts[4]], 110 | [self._verts[1], self._verts[2], self._verts[6], self._verts[5]], 111 | [self._verts[2], self._verts[3], self._verts[7], self._verts[6]], 112 | [self._verts[3], self._verts[0], self._verts[4], self._verts[7]], 113 | ] 114 | return faces[i] 115 | @property 116 | def num_faces(self): 117 | return 6 118 | 119 | poly = MockPolyhedron(cube_vertices) 120 | 121 | from geo.primitives_3d import Plane as Plane3D 122 | 123 | orig_signed_distance = Plane3D.signed_distance_to_point 124 | def mock_signed_distance_to_point(self, point): 125 | if point.x < 0 or point.x > 1 or point.y < 0 or point.y > 1 or point.z < 0 or point.z > 1: 126 | return 1.0 127 | return -1.0 128 | 129 | Plane3D.signed_distance_to_point = mock_signed_distance_to_point 130 | 131 | inside_point = Point3D(0.5, 0.5, 0.5) 132 | outside_point = Point3D(1.5, 0.5, 0.5) 133 | 134 | self.assertTrue(containment.point_in_polyhedron_convex(inside_point, poly)) 135 | self.assertFalse(containment.point_in_polyhedron_convex(outside_point, poly)) 136 | 137 | Plane3D.signed_distance_to_point = orig_signed_distance 138 | 139 | 140 | if __name__ == '__main__': 141 | unittest.main() -------------------------------------------------------------------------------- /tests/primitives_3d/test_plane.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/primitives_3d/test_plane.py 2 | # (2) python -m unittest tests/primitives_3d/test_plane.py (verbose output) (auto add sys.path) 3 | 4 | import math 5 | import unittest 6 | import sys 7 | import os 8 | 9 | # For (1): Add the project root to sys.path so `geo` can be imported 10 | # For (2): Don't need 11 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 12 | 13 | from geo.primitives_3d.plane import Plane 14 | from geo.core import Point3D, Vector3D 15 | from geo.primitives_3d.line_3d import Line3D 16 | from geo.core.precision import DEFAULT_EPSILON 17 | 18 | class TestPlane(unittest.TestCase): 19 | 20 | def test_init_point_normal(self): 21 | p = Point3D(1, 2, 3) 22 | n = Vector3D(0, 0, 1) 23 | plane = Plane(p, n) 24 | self.assertEqual(plane.point_on_plane, p) 25 | self.assertEqual(plane.normal, n.normalize()) 26 | self.assertAlmostEqual(plane.d_coeff, n.dot(Vector3D(*p)), delta=DEFAULT_EPSILON) 27 | 28 | def test_init_three_points(self): 29 | p1 = Point3D(0, 0, 0) 30 | p2 = Point3D(1, 0, 0) 31 | p3 = Point3D(0, 1, 0) 32 | plane = Plane(p1, p2, p3) 33 | expected_normal = Vector3D(0, 0, 1) 34 | self.assertEqual(plane.normal, expected_normal) 35 | self.assertTrue(plane.contains_point(p1)) 36 | self.assertTrue(plane.contains_point(p2)) 37 | self.assertTrue(plane.contains_point(p3)) 38 | 39 | def test_init_collinear_points_raises(self): 40 | p1 = Point3D(0, 0, 0) 41 | p2 = Point3D(1, 1, 1) 42 | p3 = Point3D(2, 2, 2) 43 | with self.assertRaises(ValueError): 44 | Plane(p1, p2, p3) 45 | 46 | def test_init_zero_normal_raises(self): 47 | p = Point3D(0, 0, 0) 48 | zero_vector = Vector3D(0, 0, 0) 49 | with self.assertRaises(ValueError): 50 | Plane(p, zero_vector) 51 | 52 | def test_init_invalid_args_raises(self): 53 | p = Point3D(0, 0, 0) 54 | with self.assertRaises(TypeError): 55 | Plane(p, "invalid_arg") 56 | 57 | def test_repr_and_eq(self): 58 | p = Point3D(1, 2, 3) 59 | n = Vector3D(0, 0, 1) 60 | plane1 = Plane(p, n) 61 | plane2 = Plane(p, n) 62 | plane3 = Plane(p, -n) 63 | self.assertIn("Plane", repr(plane1)) 64 | self.assertEqual(plane1, plane2) 65 | self.assertEqual(plane1, plane3) 66 | self.assertNotEqual(plane1, "not_a_plane") 67 | 68 | def test_signed_distance_and_distance_to_point(self): 69 | p = Point3D(0, 0, 0) 70 | n = Vector3D(0, 0, 1) 71 | plane = Plane(p, n) 72 | 73 | point_above = Point3D(0, 0, 5) 74 | point_below = Point3D(0, 0, -5) 75 | point_on_plane = Point3D(1, 2, 0) 76 | 77 | self.assertAlmostEqual(plane.signed_distance_to_point(point_above), 5, delta=DEFAULT_EPSILON) 78 | self.assertAlmostEqual(plane.signed_distance_to_point(point_below), -5, delta=DEFAULT_EPSILON) 79 | self.assertAlmostEqual(plane.signed_distance_to_point(point_on_plane), 0, delta=DEFAULT_EPSILON) 80 | 81 | self.assertAlmostEqual(plane.distance_to_point(point_above), 5, delta=DEFAULT_EPSILON) 82 | self.assertAlmostEqual(plane.distance_to_point(point_below), 5, delta=DEFAULT_EPSILON) 83 | self.assertAlmostEqual(plane.distance_to_point(point_on_plane), 0, delta=DEFAULT_EPSILON) 84 | 85 | def test_contains_point(self): 86 | p = Point3D(0, 0, 0) 87 | n = Vector3D(0, 0, 1) 88 | plane = Plane(p, n) 89 | 90 | point_on_plane = Point3D(1, 1, 0) 91 | point_near_plane = Point3D(1, 1, 1e-12) 92 | point_off_plane = Point3D(1, 1, 1) 93 | 94 | self.assertTrue(plane.contains_point(point_on_plane)) 95 | self.assertTrue(plane.contains_point(point_near_plane, epsilon=1e-10)) 96 | self.assertFalse(plane.contains_point(point_off_plane)) 97 | 98 | def test_project_point(self): 99 | p = Point3D(0, 0, 0) 100 | n = Vector3D(0, 0, 1) 101 | plane = Plane(p, n) 102 | 103 | point = Point3D(1, 2, 3) 104 | projected = plane.project_point(point) 105 | self.assertTrue(plane.contains_point(projected)) 106 | self.assertAlmostEqual(projected.z, 0, delta=DEFAULT_EPSILON) 107 | self.assertAlmostEqual(projected.x, point.x, delta=DEFAULT_EPSILON) 108 | self.assertAlmostEqual(projected.y, point.y, delta=DEFAULT_EPSILON) 109 | 110 | def test_intersection_with_line_intersects(self): 111 | p = Point3D(0, 0, 0) 112 | n = Vector3D(0, 0, 1) 113 | plane = Plane(p, n) 114 | 115 | line_origin = Point3D(0, 0, 1) 116 | line_dir = Vector3D(0, 0, -1) 117 | line = Line3D(line_origin, line_dir) 118 | 119 | intersect_point = plane.intersection_with_line(line) 120 | self.assertIsNotNone(intersect_point) 121 | self.assertTrue(plane.contains_point(intersect_point)) 122 | self.assertAlmostEqual(intersect_point.z, 0, delta=DEFAULT_EPSILON) 123 | 124 | def test_intersection_with_line_parallel_no_intersect(self): 125 | p = Point3D(0, 0, 0) 126 | n = Vector3D(0, 0, 1) 127 | plane = Plane(p, n) 128 | 129 | line_origin = Point3D(0, 0, 1) 130 | line_dir = Vector3D(1, 0, 0) # Parallel to plane, in xy plane 131 | line = Line3D(line_origin, line_dir) 132 | 133 | intersect_point = plane.intersection_with_line(line) 134 | self.assertIsNone(intersect_point) 135 | 136 | def test_intersection_with_line_on_plane(self): 137 | p = Point3D(0, 0, 0) 138 | n = Vector3D(0, 0, 1) 139 | plane = Plane(p, n) 140 | 141 | line_origin = Point3D(1, 1, 0) 142 | line_dir = Vector3D(1, 0, 0) # Line lies on the plane 143 | line = Line3D(line_origin, line_dir) 144 | 145 | intersect_point = plane.intersection_with_line(line) 146 | self.assertIsNone(intersect_point) 147 | 148 | def test_get_coefficients(self): 149 | p = Point3D(1, 2, 3) 150 | n = Vector3D(0, 0, 1) 151 | plane = Plane(p, n) 152 | A, B, C, D = plane.get_coefficients() 153 | self.assertAlmostEqual(A, n.x, delta=DEFAULT_EPSILON) 154 | self.assertAlmostEqual(B, n.y, delta=DEFAULT_EPSILON) 155 | self.assertAlmostEqual(C, n.z, delta=DEFAULT_EPSILON) 156 | self.assertAlmostEqual(D, n.dot(Vector3D(*p)), delta=DEFAULT_EPSILON) 157 | 158 | if __name__ == "__main__": 159 | unittest.main() 160 | -------------------------------------------------------------------------------- /geo/primitives_3d/plane.py: -------------------------------------------------------------------------------- 1 | # geo/primitives_3d/plane.py 2 | 3 | """ 4 | Defines a Plane primitive in 3D space. 5 | """ 6 | 7 | # geo/primitives_3d/plane.py 8 | 9 | """ 10 | Defines a Plane primitive in 3D space. 11 | """ 12 | 13 | from typing import Optional, Union, Tuple, TYPE_CHECKING 14 | 15 | from geo.core import Point3D, Vector3D 16 | from geo.core.precision import is_equal, is_zero, DEFAULT_EPSILON 17 | 18 | if TYPE_CHECKING: 19 | from .line_3d import Line3D 20 | 21 | class Plane: 22 | """ 23 | Represents an infinite plane in 3D space. 24 | 25 | A plane can be defined by a point on the plane and a normal vector, 26 | or by three non-collinear points. 27 | 28 | The plane equation is: n · (P - P0) = 0, where n is the normal, 29 | P0 is a point on the plane, and P is any point (x,y,z) on the plane. 30 | 31 | This can be written as: 32 | Ax + By + Cz = D, 33 | where (A, B, C) is the normal vector n, 34 | and D = n · P0 (note: this is the plane constant in this form). 35 | """ 36 | 37 | def __init__( 38 | self, 39 | point_on_plane: Point3D, 40 | normal_or_p2: Union[Vector3D, Point3D], 41 | p3: Optional[Point3D] = None 42 | ): 43 | """ 44 | Initializes a Plane. 45 | 46 | Method 1: Point and Normal 47 | Args: 48 | point_on_plane: A Point3D on the plane. 49 | normal_or_p2: The normal Vector3D to the plane. 50 | p3: Must be None. 51 | 52 | Method 2: Three non-collinear points 53 | Args: 54 | point_on_plane (p1): The first Point3D. 55 | normal_or_p2 (p2): The second Point3D. 56 | p3: The third Point3D. 57 | 58 | Raises: 59 | ValueError: If the normal vector is zero, or if the three points are collinear. 60 | TypeError: If arguments are inconsistent. 61 | """ 62 | self.point_on_plane = point_on_plane 63 | 64 | if isinstance(normal_or_p2, Vector3D) and p3 is None: 65 | if normal_or_p2.is_zero_vector(): 66 | raise ValueError("Plane normal vector cannot be a zero vector.") 67 | self.normal = normal_or_p2.normalize() 68 | elif isinstance(normal_or_p2, Point3D) and isinstance(p3, Point3D): 69 | p1 = point_on_plane 70 | p2 = normal_or_p2 71 | # Calculate normal from three points p1, p2, p3 72 | v1 = p2 - p1 73 | v2 = p3 - p1 74 | calculated_normal = v1.cross(v2) 75 | if calculated_normal.is_zero_vector(): 76 | raise ValueError("The three points are collinear and cannot define a plane.") 77 | self.normal = calculated_normal.normalize() 78 | else: 79 | raise TypeError( 80 | "Invalid arguments for Plane constructor. " 81 | "Use (Point3D, Vector3D) or (Point3D, Point3D, Point3D)." 82 | ) 83 | 84 | # D constant for plane equation Ax + By + Cz = D 85 | # Note: Different from standard form Ax + By + Cz + D = 0, 86 | # here D = n · P0 87 | self.d_coeff = self.normal.dot( 88 | Vector3D(self.point_on_plane.x, self.point_on_plane.y, self.point_on_plane.z) 89 | ) 90 | 91 | def __repr__(self) -> str: 92 | return f"Plane(point_on_plane={self.point_on_plane}, normal={self.normal})" 93 | 94 | def __eq__(self, other: object) -> bool: 95 | if not isinstance(other, Plane): 96 | return False 97 | 98 | dot = self.normal.dot(other.normal) 99 | if not is_equal(abs(dot), 1.0): 100 | return False 101 | return other.contains_point(self.point_on_plane) 102 | 103 | def signed_distance_to_point(self, point: Point3D) -> float: 104 | """ 105 | Calculates the signed distance from a point to the plane. 106 | 107 | Distance = n · (P - P0) / |n|. Since |n|=1, Distance = n · (P - P0). 108 | Positive if the point is on the side of the normal, negative otherwise. 109 | """ 110 | vec_to_point = point - self.point_on_plane 111 | return self.normal.dot(vec_to_point) 112 | 113 | def distance_to_point(self, point: Point3D, epsilon: float = DEFAULT_EPSILON) -> float: 114 | """Calculates the shortest (unsigned) distance from a point to the plane.""" 115 | return abs(self.signed_distance_to_point(point)) 116 | 117 | def contains_point(self, point: Point3D, epsilon: float = DEFAULT_EPSILON) -> bool: 118 | """ 119 | Checks if a point lies on the plane. 120 | 121 | This is true if the signed distance from the point to the plane is close to zero. 122 | """ 123 | return is_zero(self.signed_distance_to_point(point), epsilon) 124 | 125 | def project_point(self, point: Point3D) -> Point3D: 126 | """ 127 | Projects a point onto the plane. 128 | 129 | P_proj = P - (n · (P - P0)) * n 130 | """ 131 | signed_dist = self.signed_distance_to_point(point) 132 | projected_point = point - (self.normal * signed_dist) 133 | return projected_point 134 | 135 | def intersection_with_line(self, line: 'Line3D') -> Optional[Point3D]: 136 | """ 137 | Calculates the intersection point of this plane with a Line3D. 138 | 139 | Args: 140 | line: The Line3D to intersect with. 141 | 142 | Returns: 143 | The intersection Point3D, or None if the line is parallel to the plane 144 | and not on the plane. If the line lies on the plane, it also returns None 145 | as there is no single intersection point (infinite intersections). 146 | """ 147 | n_dot_l_dir = self.normal.dot(line.direction) 148 | 149 | if is_zero(n_dot_l_dir): 150 | # Line is parallel to the plane. 151 | # Check if the line's origin point is on the plane. 152 | if self.contains_point(line.origin): 153 | return None # Line lies on the plane (infinite intersections) 154 | else: 155 | return None # Line is parallel and not on the plane (no intersection) 156 | 157 | n_dot_l0 = self.normal.dot( 158 | Vector3D(line.origin.x, line.origin.y, line.origin.z) 159 | ) 160 | t = (self.d_coeff - n_dot_l0) / n_dot_l_dir 161 | 162 | return line.point_at(t) 163 | 164 | def get_coefficients(self) -> Tuple[float, float, float, float]: 165 | """ 166 | Returns the coefficients (A, B, C, D) of the plane equation: 167 | Ax + By + Cz = D 168 | """ 169 | return (self.normal.x, self.normal.y, self.normal.z, self.d_coeff) 170 | -------------------------------------------------------------------------------- /geo/operations/triangulation.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions for polygon triangulation and other forms of triangulation. 3 | """ 4 | from typing import List, Sequence, Tuple 5 | 6 | from geo.core import Point2D, Point3D, Vector2D 7 | from geo.core.precision import is_zero, DEFAULT_EPSILON 8 | from geo.primitives_2d import Polygon, Triangle 9 | from .containment import check_point_left_of_line 10 | 11 | 12 | def triangulate_simple_polygon_ear_clipping(polygon: Polygon, ensure_ccw: bool = True) -> List[Triangle]: 13 | """ 14 | Triangulates a simple 2D polygon using the Ear Clipping (Ear Cutting) method. 15 | Assumes the polygon is simple (no self-intersections). 16 | Vertices should be ordered (e.g., counter-clockwise for this implementation). 17 | 18 | Args: 19 | polygon: The simple Polygon to triangulate. 20 | ensure_ccw: If True, ensures polygon vertices are CCW. If False, assumes they are. 21 | 22 | Returns: 23 | A list of Triangle objects that form the triangulation. 24 | Returns empty list if polygon has < 3 vertices or is degenerate. 25 | """ 26 | if polygon.num_vertices < 3: 27 | return [] 28 | 29 | original_vertices = list(polygon.vertices) 30 | 31 | if ensure_ccw: 32 | signed_area = polygon.signed_area() 33 | if is_zero(signed_area): 34 | return [] # Degenerate (collinear) 35 | if signed_area < 0: # Clockwise 36 | original_vertices.reverse() 37 | 38 | remaining_indices = list(range(len(original_vertices))) 39 | triangles: List[Triangle] = [] 40 | 41 | num_remaining = len(remaining_indices) 42 | current_iteration = 0 43 | max_iterations = num_remaining * num_remaining # Heuristic to avoid infinite loops 44 | 45 | while num_remaining > 2 and current_iteration < max_iterations: 46 | found_ear = False 47 | for i in range(num_remaining): 48 | idx_prev = remaining_indices[(i - 1) % num_remaining] 49 | idx_curr = remaining_indices[i] 50 | idx_next = remaining_indices[(i + 1) % num_remaining] 51 | 52 | p_prev = original_vertices[idx_prev] 53 | p_curr = original_vertices[idx_curr] 54 | p_next = original_vertices[idx_next] 55 | 56 | # Check convexity: cross product > epsilon means convex vertex for CCW polygon 57 | v_prev_curr = p_curr - p_prev 58 | v_curr_next = p_next - p_curr 59 | cross_product_z = v_prev_curr.x * v_curr_next.y - v_prev_curr.y * v_curr_next.x 60 | 61 | if cross_product_z <= DEFAULT_EPSILON: 62 | continue # Not a convex vertex or collinear 63 | 64 | # Check if any other vertex lies inside the ear triangle 65 | is_ear = True 66 | for j in range(num_remaining): 67 | idx_other = remaining_indices[j] 68 | if idx_other in (idx_prev, idx_curr, idx_next): 69 | continue 70 | p_other = original_vertices[idx_other] 71 | 72 | # Check if point p_other lies inside or on boundary of triangle 73 | # Using left-of-line tests 74 | if ( 75 | check_point_left_of_line(p_other, p_prev, p_curr) < -DEFAULT_EPSILON or 76 | check_point_left_of_line(p_other, p_curr, p_next) < -DEFAULT_EPSILON or 77 | check_point_left_of_line(p_other, p_next, p_prev) < -DEFAULT_EPSILON 78 | ): 79 | continue # Outside or on right side of at least one edge 80 | 81 | # p_other is inside or on boundary => not an ear 82 | is_ear = False 83 | break 84 | 85 | if is_ear: 86 | triangles.append(Triangle(p_prev, p_curr, p_next)) 87 | remaining_indices.pop(i) 88 | num_remaining -= 1 89 | found_ear = True 90 | break # Restart scan 91 | 92 | current_iteration += 1 93 | if not found_ear and num_remaining > 2: 94 | # Could not find an ear - polygon might be non-simple or numerical issues 95 | break 96 | 97 | # If original polygon was a triangle and no triangles were added, add it 98 | if len(original_vertices) == 3 and not triangles and not is_zero(Polygon(original_vertices).signed_area()): 99 | triangles.append(Triangle(*original_vertices)) 100 | 101 | return triangles 102 | 103 | 104 | def delaunay_triangulation_points_2d(points: Sequence[Point2D]) -> List[Triangle]: 105 | """ 106 | Computes the Delaunay triangulation of a set of 2D points. 107 | 108 | Args: 109 | points: Sequence of Point2D objects. 110 | 111 | Returns: 112 | List of Triangle objects forming the Delaunay triangulation. 113 | 114 | Raises: 115 | ValueError if fewer than 3 points are provided. 116 | """ 117 | from scipy.spatial import Delaunay 118 | 119 | if len(points) < 3: 120 | raise ValueError("At least 3 points are required for Delaunay triangulation.") 121 | 122 | coords = [(p.x, p.y) for p in points] 123 | delaunay = Delaunay(coords) 124 | 125 | triangles = [] 126 | for simplex in delaunay.simplices: 127 | tri = Triangle(points[simplex[0]], points[simplex[1]], points[simplex[2]]) 128 | triangles.append(tri) 129 | 130 | return triangles 131 | 132 | 133 | def constrained_delaunay_triangulation(polygon: Polygon) -> List[Triangle]: 134 | """ 135 | Placeholder for Constrained Delaunay Triangulation that preserves polygon edges. 136 | Not implemented. 137 | 138 | Args: 139 | polygon: Polygon to triangulate. 140 | 141 | Raises: 142 | NotImplementedError 143 | """ 144 | raise NotImplementedError("Constrained Delaunay Triangulation not implemented.") 145 | 146 | 147 | def tetrahedralise(points: Sequence[Point3D]) -> List[Tuple[Point3D, Point3D, Point3D, Point3D]]: 148 | """ 149 | Computes the 3D Delaunay tetrahedralization of a set of 3D points. 150 | 151 | Args: 152 | points: Sequence of Point3D objects. 153 | 154 | Returns: 155 | List of tetrahedra, each represented by a tuple of four Point3D vertices. 156 | 157 | Raises: 158 | ValueError if fewer than 4 points are provided. 159 | """ 160 | from scipy.spatial import Delaunay 161 | 162 | if len(points) < 4: 163 | raise ValueError("At least 4 points are required for 3D tetrahedralization.") 164 | 165 | coords = [(p.x, p.y, p.z) for p in points] 166 | delaunay = Delaunay(coords) 167 | 168 | tetrahedra = [] 169 | for simplex in delaunay.simplices: 170 | tetra = tuple(points[i] for i in simplex) 171 | tetrahedra.append(tetra) 172 | 173 | return tetrahedra 174 | -------------------------------------------------------------------------------- /geo/primitives_3d/cube.py: -------------------------------------------------------------------------------- 1 | # geo/primitives_3d/cube.py 2 | 3 | """ 4 | Defines a Cube primitive in 3D space. 5 | Supports both axis-aligned and non-axis-aligned cubes. 6 | A cube is a specific type of Polyhedron (hexahedron). 7 | """ 8 | 9 | import math 10 | from typing import List, Optional 11 | 12 | from geo.core import Point3D, Vector3D 13 | from .polyhedra import Polyhedron # To potentially represent it as a Polyhedron 14 | 15 | class Cube: 16 | """ 17 | Represents a cube in 3D space. 18 | 19 | - Axis-aligned cube defined by center and side_length. 20 | - Optionally, a rotation matrix (3 orthonormal Vector3D axes) can define 21 | a non-axis-aligned cube (oriented cube). 22 | """ 23 | 24 | def __init__( 25 | self, 26 | center: Point3D, 27 | side_length: float, 28 | axes: Optional[List[Vector3D]] = None, 29 | ): 30 | """ 31 | Initializes a Cube. 32 | 33 | Args: 34 | center: The center Point3D of the cube. 35 | side_length: The length of each side of the cube. Must be positive. 36 | axes: Optional list of 3 orthonormal Vector3D axes defining the cube orientation. 37 | If None, cube is axis-aligned with standard axes (x,y,z). 38 | 39 | Raises: 40 | ValueError: If side_length is non-positive or axes are invalid. 41 | """ 42 | if side_length <= 0: 43 | raise ValueError("Cube side length must be positive.") 44 | self.center = center 45 | self.side_length = side_length 46 | self.half_side = side_length / 2.0 47 | 48 | if axes is not None: 49 | if len(axes) != 3: 50 | raise ValueError("Axes must be a list of 3 Vector3D objects.") 51 | # Check orthonormality: each axis unit length and perpendicular 52 | for i in range(3): 53 | if not math.isclose(axes[i].magnitude(), 1.0, abs_tol=1e-9): 54 | raise ValueError("Axes must be unit vectors.") 55 | for j in range(i + 1, 3): 56 | if not math.isclose(axes[i].dot(axes[j]), 0.0, abs_tol=1e-9): 57 | raise ValueError("Axes must be mutually perpendicular.") 58 | self.axes = axes 59 | else: 60 | # Default to standard x,y,z axes unit vectors 61 | self.axes = [ 62 | Vector3D(1, 0, 0), 63 | Vector3D(0, 1, 0), 64 | Vector3D(0, 0, 1), 65 | ] 66 | 67 | # Compute vertices once for convenience 68 | self._vertices = self._compute_vertices() 69 | 70 | def __repr__(self) -> str: 71 | return f"Cube(center={self.center}, side_length={self.side_length}, axes={self.axes})" 72 | 73 | def __eq__(self, other: object) -> bool: 74 | if not isinstance(other, Cube): 75 | return False 76 | if not self.center == other.center: 77 | return False 78 | if not math.isclose(self.side_length, other.side_length, abs_tol=1e-9): 79 | return False 80 | # Compare axes component-wise with tolerance 81 | for a1, a2 in zip(self.axes, other.axes): 82 | for c1, c2 in zip((a1.x, a1.y, a1.z), (a2.x, a2.y, a2.z)): 83 | if not math.isclose(c1, c2, abs_tol=1e-9): 84 | return False 85 | return True 86 | 87 | @property 88 | def volume(self) -> float: 89 | """Calculates the volume of the cube.""" 90 | return self.side_length ** 3 91 | 92 | @property 93 | def surface_area(self) -> float: 94 | """Calculates the surface area of the cube.""" 95 | return 6 * (self.side_length ** 2) 96 | 97 | def _compute_vertices(self) -> List[Point3D]: 98 | """ 99 | Computes the 8 vertices of the cube based on center, half_side, and axes. 100 | 101 | Vertices are calculated by starting from the center and adding or subtracting half_side 102 | times each axis vector to reach corners. 103 | """ 104 | hs = self.half_side 105 | c = self.center 106 | ax, ay, az = self.axes 107 | 108 | # Each vertex corresponds to one combination of +/- half_side on each axis 109 | vertices = [] 110 | for dx in (-hs, hs): 111 | for dy in (-hs, hs): 112 | for dz in (-hs, hs): 113 | offset = (ax * dx) + (ay * dy) + (az * dz) 114 | vertex = c + offset 115 | vertices.append(vertex) 116 | return vertices 117 | 118 | @property 119 | def vertices(self) -> List[Point3D]: 120 | """Returns the precomputed vertices.""" 121 | return self._vertices 122 | 123 | @property 124 | def faces_as_vertex_indices(self) -> List[List[int]]: 125 | """ 126 | Returns the 6 faces of the cube, each as a list of vertex indices. 127 | Vertices are ordered CCW when viewed from outside. 128 | Vertex order in self.vertices is: 129 | index = (dx_index * 4) + (dy_index * 2) + dz_index 130 | with dx, dy, dz in (-hs, hs) ordering 131 | 132 | The cube is built as a rectangular parallelepiped, but the order is consistent. 133 | """ 134 | # The 8 vertices are ordered as: 135 | # 0: (-,-,-), 1: (-,-,+), 2: (-,+,-), 3: (-,+,+), 136 | # 4: (+,-,-), 5: (+,-,+), 6: (+,+,-), 7: (+,+,+) 137 | # But our code above generates in nested loops dx,dy,dz which is different order: 138 | # We'll reorder vertices to standard: 139 | # From our _compute_vertices order (dx, dy, dz): 140 | # Index in nested loops: 0..7 with dx varies slowest, dz fastest: 141 | # Actually, our loop is dx in (-hs,hs), dy in (-hs,hs), dz in (-hs,hs) 142 | # So order generated is: 143 | # 0:(-,-,-), 1:(-,-,+), 2:(-,+,-), 3:(-,+,+), 4:(+,-,-), 5:(+,-,+), 6:(+,+,-), 7:(+,+,+) 144 | # Which matches above. 145 | 146 | return [ 147 | [0, 4, 6, 2], # Left face (-x) 148 | [1, 3, 7, 5], # Right face (+x) 149 | [0, 1, 5, 4], # Bottom face (-y) 150 | [2, 6, 7, 3], # Top face (+y) 151 | [0, 2, 3, 1], # Back face (-z) 152 | [4, 5, 7, 6], # Front face (+z) 153 | ] 154 | 155 | def to_polyhedron(self) -> Polyhedron: 156 | """Converts this Cube to a Polyhedron object.""" 157 | return Polyhedron(self.vertices, self.faces_as_vertex_indices) 158 | 159 | def contains_point(self, point: Point3D, epsilon: float = 1e-9) -> bool: 160 | """ 161 | Checks if a point is inside or on the boundary of the cube. 162 | 163 | For axis-aligned cubes (default axes), this is a simple bounding box check. 164 | 165 | For oriented cubes, transform point into cube's local axes coords, 166 | then check if within [-half_side, half_side] along each axis. 167 | """ 168 | # Vector from center to point 169 | vec = point - self.center 170 | 171 | # Project vector onto each axis 172 | for axis in self.axes: 173 | dist = vec.dot(axis) 174 | if dist < -self.half_side - epsilon or dist > self.half_side + epsilon: 175 | return False 176 | return True 177 | -------------------------------------------------------------------------------- /geo/operations/convex_hull.py: -------------------------------------------------------------------------------- 1 | # geo/operations/convex_hull.py 2 | 3 | """ 4 | Robust 2D / 3D convex-hull utilities with optional visualisation helpers. 5 | 6 | Public API 7 | ========== 8 | convex_hull_2d_monotone_chain(points) → List[Point2D] 9 | convex_hull_3d(points) → Polyhedron | raises NotImplementedError 10 | plot_convex_hull_2d(points, hull=None, *, ax=None, show=True) 11 | plot_convex_hull_3d(points, hull=None, *, ax=None, show=True) 12 | 13 | Design notes 14 | ------------ 15 | * 2D - Andrew monotone chain O(n log n) with extra guards: 16 | • Deduplication of identical points. 17 | • Removal of on-edge collinear points inside edges (toggle via keep_collinear). 18 | • Graceful handling for < 3 unique points (returns copy of unique set). 19 | 20 | * 3D - Delegates to scipy.spatial.ConvexHull when available. 21 | The returned hull is converted to geo.primitives_3d.Polyhedron. 22 | If SciPy is missing an informative NotImplementedError is raised. 23 | 24 | * Visualisation - Lightweight wrappers around matplotlib 25 | (auto-installed in most scientific stacks). They are optional and only 26 | imported on demand - the core hull code has zero plotting deps. 27 | """ 28 | 29 | from __future__ import annotations 30 | 31 | from typing import Iterable, List, Sequence, Tuple, Optional 32 | 33 | from geo.core import Point2D, Point3D 34 | from geo.core.precision import DEFAULT_EPSILON, is_zero 35 | from geo.primitives_2d import Polygon 36 | from geo.primitives_3d import Polyhedron 37 | 38 | __all__ = [ 39 | "convex_hull_2d_monotone_chain", 40 | "convex_hull_3d", 41 | "plot_convex_hull_2d", 42 | "plot_convex_hull_3d", 43 | ] 44 | 45 | 46 | # Helpers 47 | 48 | def _cross(o: Point2D, a: Point2D, b: Point2D) -> float: 49 | """2D signed area of the triangle o-a-b (twice the actual area).""" 50 | return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x) 51 | 52 | 53 | def _dedup(points: Sequence[Point2D]) -> List[Point2D]: 54 | """Return points without duplicates (lexicographic order).""" 55 | seen: set[Tuple[float, float]] = set() 56 | out: List[Point2D] = [] 57 | for p in sorted(points, key=lambda q: (q.x, q.y)): 58 | key = (p.x, p.y) 59 | if key not in seen: 60 | seen.add(key) 61 | out.append(p) 62 | return out 63 | 64 | 65 | # 2‑D convex hull (Monotone Chain) 66 | 67 | 68 | def convex_hull_2d_monotone_chain( 69 | points: Sequence[Point2D], *, keep_collinear: bool = False 70 | ) -> List[Point2D]: 71 | """Andrew monotone-chain convex hull. 72 | 73 | Parameters 74 | ---------- 75 | points 76 | The input point cloud (any iterable). 77 | keep_collinear 78 | False (default) removes collinear points on edges of the hull, 79 | returning the minimal vertex set. True keeps them. 80 | """ 81 | uniq = _dedup(points) 82 | if len(uniq) <= 2: 83 | return uniq.copy() 84 | 85 | lower: List[Point2D] = [] 86 | for p in uniq: 87 | while len(lower) >= 2 and _cross(lower[-2], lower[-1], p) <= ( 88 | DEFAULT_EPSILON if keep_collinear else 0.0 89 | ): 90 | lower.pop() 91 | lower.append(p) 92 | 93 | upper: List[Point2D] = [] 94 | for p in reversed(uniq): 95 | while len(upper) >= 2 and _cross(upper[-2], upper[-1], p) <= ( 96 | DEFAULT_EPSILON if keep_collinear else 0.0 97 | ): 98 | upper.pop() 99 | upper.append(p) 100 | 101 | hull = lower[:-1] + upper[:-1] 102 | return hull 103 | 104 | 105 | # 3‑D convex hull (SciPy backend) 106 | 107 | try: 108 | from scipy.spatial import ConvexHull as _SciHull 109 | import numpy as _np 110 | 111 | _HAS_SCIPY = True 112 | except ModuleNotFoundError: # pragma: no cover – SciPy optional 113 | _HAS_SCIPY = False 114 | 115 | 116 | def _require_scipy() -> None: 117 | if not _HAS_SCIPY: 118 | raise NotImplementedError( 119 | "convex_hull_3d requires SciPy ≥1.3. Install via `pip install scipy`." 120 | ) 121 | 122 | 123 | def convex_hull_3d(points: Sequence[Point3D]) -> Polyhedron: 124 | """Compute the 3‑D convex hull using *SciPy*. 125 | 126 | Returns a :class:`~geo.primitives_3d.Polyhedron` whose vertices are the 127 | unique points of points and faces given by the SciPy simplices 128 | (oriented CCW seen from outside). 129 | """ 130 | _require_scipy() 131 | 132 | if len(points) < 4: 133 | raise ValueError("Need ≥4 non-coplanar points for a 3-D hull.") 134 | 135 | pts = _np.array([[p.x, p.y, p.z] for p in points]) 136 | hull = _SciHull(pts) 137 | 138 | vertices = [Point3D(*pts[i]) for i in hull.vertices] # unique vertices (order arbitrary) 139 | faces = [tuple(int(v) for v in simplex) for simplex in hull.simplices] 140 | return Polyhedron(vertices, faces) 141 | 142 | 143 | # Optional visualisation (matplotlib) 144 | 145 | def _mpl_axes_2d(ax=None): 146 | import matplotlib.pyplot as plt # lazy import 147 | 148 | if ax is None: 149 | fig, ax = plt.subplots() 150 | ax.set_aspect("equal", adjustable="box") 151 | return ax 152 | 153 | 154 | def plot_convex_hull_2d( 155 | points: Sequence[Point2D], 156 | hull: Optional[Sequence[Point2D]] = None, 157 | *, 158 | ax=None, 159 | show: bool = True, 160 | point_kwargs=None, 161 | hull_kwargs=None, 162 | ): 163 | """Quick matplotlib visualisation of a 2D hull.""" 164 | import matplotlib.pyplot as plt # lazy import 165 | 166 | point_kwargs = {"s": 20, "color": "tab:blue", "zorder": 2} | (point_kwargs or {}) 167 | hull_kwargs = {"linewidth": 1.5, "color": "tab:red", "zorder": 3} | (hull_kwargs or {}) 168 | 169 | ax = _mpl_axes_2d(ax) 170 | xs, ys = zip(*[(p.x, p.y) for p in points]) 171 | ax.scatter(xs, ys, **point_kwargs) 172 | 173 | if hull is None: 174 | hull = convex_hull_2d_monotone_chain(points) 175 | if hull: 176 | hx, hy = zip(*[(p.x, p.y) for p in hull + [hull[0]]]) 177 | ax.plot(hx, hy, **hull_kwargs) 178 | if show: 179 | plt.show() 180 | return ax 181 | 182 | 183 | def plot_convex_hull_3d( 184 | points: Sequence[Point3D], 185 | hull: Optional[Polyhedron] = None, 186 | *, 187 | ax=None, 188 | show: bool = True, 189 | ): 190 | """Matplotlib 3D plot of point cloud and its convex hull triangles.""" 191 | from mpl_toolkits.mplot3d import Axes3D # noqa: F401 – side-effect import 192 | import matplotlib.pyplot as plt # lazy import 193 | import matplotlib as mpl 194 | 195 | if hull is None: 196 | try: 197 | hull = convex_hull_3d(points) 198 | except NotImplementedError: 199 | raise # propagate if SciPy missing 200 | 201 | if ax is None: 202 | fig = plt.figure() 203 | ax = fig.add_subplot(111, projection="3d") 204 | 205 | # plot points 206 | xs, ys, zs = zip(*[(p.x, p.y, p.z) for p in points]) 207 | ax.scatter(xs, ys, zs, color="tab:blue", s=10) 208 | 209 | # plot hull faces 210 | verts = [(v.x, v.y, v.z) for v in hull.vertices] 211 | poly3d = [[verts[idx] for idx in face] for face in hull.faces] 212 | coll = mpl.art3d.Poly3DCollection(poly3d, alpha=0.2, facecolor="tab:red") 213 | ax.add_collection3d(coll) 214 | 215 | ax.set_box_aspect([1, 1, 1]) 216 | if show: 217 | plt.show() 218 | return ax 219 | -------------------------------------------------------------------------------- /tests/primitives_2d/curve/test_spline.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/primitives_2d/curve/test_spline.py 2 | # (2) python -m unittest tests/primitives_2d/curve/test_spline.py (verbose output) (auto add sys.path) 3 | 4 | import math 5 | import unittest 6 | import sys 7 | import os 8 | 9 | # For (1): Add the project root to sys.path so `geo` can be imported 10 | # For (2): Don't need 11 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../'))) 12 | 13 | from geo.primitives_2d.curve.spline import SplineCurve, _uniform_clamped_knots 14 | from geo.core import Point2D, Vector2D 15 | 16 | class TestSplineCurve(unittest.TestCase): 17 | 18 | def test_uniform_clamped_knots_basic(self): 19 | knots = _uniform_clamped_knots(4, 3) 20 | self.assertEqual(len(knots), 4 + 3 + 2) 21 | self.assertAlmostEqual(knots[0], 0.0) 22 | self.assertAlmostEqual(knots[-1], 1.0) 23 | self.assertTrue(all(knots[i] <= knots[i+1] for i in range(len(knots)-1))) 24 | 25 | def test_init_invalid_degree(self): 26 | pts = [Point2D(x, 0) for x in range(4)] 27 | with self.assertRaises(ValueError): 28 | SplineCurve(pts, degree=0) 29 | 30 | def test_init_insufficient_control_points(self): 31 | pts = [Point2D(0, 0), Point2D(1, 1)] 32 | with self.assertRaises(ValueError): 33 | SplineCurve(pts, degree=3) 34 | 35 | def test_init_invalid_knot_length(self): 36 | pts = [Point2D(x, 0) for x in range(5)] 37 | knots = [0, 0, 0, 1, 1] # too short for p=3, n=4 (need 9) 38 | with self.assertRaises(ValueError): 39 | SplineCurve(pts, degree=3, knots=knots) 40 | 41 | def test_init_nonmonotonic_knots(self): 42 | pts = [Point2D(x, 0) for x in range(5)] 43 | knots = [0, 0, 0, 0.5, 0.4, 1, 1, 1, 1] 44 | with self.assertRaises(ValueError): 45 | SplineCurve(pts, degree=3, knots=knots) 46 | 47 | def setUp(self): 48 | # Runs before each test 49 | self.pts = [Point2D(x, x*x) for x in range(5)] 50 | self.bs = SplineCurve(self.pts, degree=3) 51 | 52 | def test_find_span_basic(self): 53 | bs = self.bs 54 | # Lower and upper boundaries 55 | self.assertEqual(bs.find_span(bs.knots[bs.p]), bs.p) 56 | self.assertEqual(bs.find_span(bs.knots[bs.n + 1]), bs.n) 57 | 58 | # Pick a u strictly inside the first non-zero span (k_p , k_{p+1}) 59 | u_mid = (bs.knots[bs.p] + bs.knots[bs.p + 1]) / 2.0 60 | span = bs.find_span(u_mid) 61 | 62 | self.assertTrue(bs.knots[span] <= u_mid < bs.knots[span + 1]) 63 | # It should be the first non-zero span: index == p 64 | self.assertEqual(span, bs.p) 65 | 66 | def test_basis_functions_partition_of_unity(self): 67 | bs = self.bs 68 | u = (bs.knots[bs.p] + bs.knots[bs.n+1]) / 2 69 | i = bs.find_span(u) 70 | N = bs.basis_functions(i, u) 71 | self.assertEqual(len(N), bs.p + 1) 72 | self.assertAlmostEqual(sum(N), 1.0, places=12) 73 | self.assertTrue(all(x >= 0 for x in N)) 74 | 75 | def test_basis_function_derivatives_order_limits(self): 76 | bs = self.bs 77 | u = (bs.knots[bs.p] + bs.knots[bs.n+1]) / 2 78 | i = bs.find_span(u) 79 | ders = bs.basis_function_derivatives(i, u, d=5) 80 | # d capped at degree = 3, so expect 4 rows of derivatives (0 to 3) 81 | self.assertEqual(len(ders), bs.p + 1) 82 | self.assertAlmostEqual(sum(ders[0]), 1.0, places=12) 83 | 84 | def test_point_at_known_points(self): 85 | bs = self.bs 86 | start = bs.point_at(bs.knots[bs.p]) 87 | self.assertAlmostEqual(start.x, bs.control_points[0].x) 88 | self.assertAlmostEqual(start.y, bs.control_points[0].y) 89 | end = bs.point_at(bs.knots[bs.n+1]) 90 | self.assertAlmostEqual(end.x, bs.control_points[-1].x) 91 | self.assertAlmostEqual(end.y, bs.control_points[-1].y) 92 | mid_param = (bs.knots[bs.p] + bs.knots[bs.n+1]) / 2 93 | mid_point = bs.point_at(mid_param) 94 | self.assertIsInstance(mid_point, Point2D) 95 | 96 | def test_point_at_clamps_out_of_domain(self): 97 | bs = self.bs 98 | below = bs.point_at(bs.knots[bs.p] - 1) 99 | above = bs.point_at(bs.knots[bs.n+1] + 1) 100 | self.assertAlmostEqual(below.x, bs.control_points[0].x) 101 | self.assertAlmostEqual(above.x, bs.control_points[-1].x) 102 | 103 | def test_tangent_at_matches_numerical_derivative(self): 104 | bs = self.bs 105 | u = (bs.knots[bs.p] + bs.knots[bs.n+1]) / 2 106 | tangent = bs.tangent_at(u) 107 | eps = 1e-6 108 | p1 = bs.point_at(u - eps) 109 | p2 = bs.point_at(u + eps) 110 | dx = (p2.x - p1.x) / (2 * eps) 111 | dy = (p2.y - p1.y) / (2 * eps) 112 | self.assertAlmostEqual(tangent.x, dx, delta=abs(dx)*1e-3) 113 | self.assertAlmostEqual(tangent.y, dy, delta=abs(dy)*1e-3) 114 | 115 | def test_insert_knot_multiplicity_limit(self): 116 | bs = self.bs 117 | u = bs.knots[bs.p + 1] 118 | max_insert = bs.p - bs.knots.count(u) 119 | bs.insert_knot(u, max_insert) 120 | with self.assertRaises(ValueError): 121 | bs.insert_knot(u) 122 | 123 | def test_insert_knot_geometry_changes(self): 124 | pts = [Point2D(0, 0), Point2D(1, 2), Point2D(3, 3), Point2D(4, 0)] 125 | bs = SplineCurve(pts, degree=2) 126 | 127 | # Choose a knot inside a non-zero span (strictly between interior knots) 128 | u = (bs.knots[bs.p] + bs.knots[bs.p + 1]) / 2.0 129 | 130 | # Ensure it is not already repeated p times 131 | existing_multiplicity = bs.knots.count(u) 132 | self.assertLess(existing_multiplicity, bs.p, "Knot already has full multiplicity.") 133 | 134 | before_pts = list(bs.control_points) 135 | bs.insert_knot(u) 136 | after_pts = bs.control_points 137 | 138 | # After inserting once, should gain 1 control point 139 | self.assertEqual(len(after_pts), len(before_pts) + 1) 140 | 141 | # Knot vector should grow by 1 142 | self.assertEqual(len(bs.knots), len(before_pts) + bs.p + 2) 143 | 144 | pt_before = bs.point_at(u) 145 | self.assertIsInstance(pt_before, Point2D) 146 | 147 | def test_elevate_degree_increases_degree_and_points(self): 148 | pts = [Point2D(x, math.sin(x)) for x in range(5)] 149 | bs = SplineCurve(pts, degree=2) 150 | old_degree = bs.p 151 | old_n = bs.n 152 | old_pts = list(bs.control_points) 153 | bs.elevate_degree() 154 | self.assertEqual(bs.p, old_degree + 1) 155 | self.assertEqual(bs.n, len(bs.control_points) - 1) 156 | self.assertEqual(len(bs.control_points), old_n + 2) 157 | self.assertNotEqual(bs.control_points, old_pts) 158 | 159 | def test_to_bezier_segments_cubic_and_error(self): 160 | pts = [Point2D(x, math.cos(x)) for x in range(6)] 161 | bs = SplineCurve(pts, degree=3) 162 | segments = bs.to_bezier_segments() 163 | self.assertIsInstance(segments, list) 164 | self.assertTrue(all(len(seg) == 4 for seg in segments)) 165 | self.assertEqual(len(segments), bs.n - bs.p + 1) 166 | bs_deg2 = SplineCurve(pts, degree=2) 167 | with self.assertRaises(NotImplementedError): 168 | bs_deg2.to_bezier_segments() 169 | 170 | 171 | if __name__ == "__main__": 172 | unittest.main() 173 | -------------------------------------------------------------------------------- /tests/primitives_2d/test_line.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/primitives_2d/test_line.py 2 | # (2) python -m unittest tests/primitives_2d/test_line.py (verbose output) (auto add sys.path) 3 | 4 | from __future__ import annotations 5 | 6 | import math 7 | import unittest 8 | import sys 9 | import os 10 | 11 | # For (1): Add the project root to sys.path so `geo` can be imported 12 | # For (2): Don't need 13 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 14 | 15 | from geo.primitives_2d import Line2D, Segment2D, Ray2D 16 | from geo.core import Point2D, Vector2D 17 | from geo.core.precision import DEFAULT_EPSILON, is_equal 18 | 19 | # --------------------------------------------------------------------------- 20 | # LINE 2D 21 | # --------------------------------------------------------------------------- 22 | class TestLine2DConstruction(unittest.TestCase): 23 | """Constructor validation for Line2D.""" 24 | 25 | def test_two_point_constructor(self): 26 | p1, p2 = Point2D(0, 0), Point2D(1, 1) 27 | line = Line2D(p1, p2) 28 | self.assertEqual(line.p1, p1) 29 | self.assertTrue(is_equal(line.direction.x, 2 ** -0.5)) 30 | self.assertTrue(is_equal(line.direction.y, 2 ** -0.5)) 31 | 32 | def test_point_and_vector_constructor(self): 33 | p = Point2D(0, 0) 34 | v = Vector2D(0, 3) 35 | line = Line2D(p, v) 36 | self.assertEqual(line.p1, p) 37 | self.assertTrue(is_equal(line.direction.x, 0)) 38 | self.assertTrue(is_equal(line.direction.y, 1)) 39 | 40 | def test_identical_points_raises(self): 41 | p = Point2D(1, 1) 42 | with self.assertRaises(ValueError): 43 | Line2D(p, p) 44 | 45 | def test_zero_vector_raises(self): 46 | p = Point2D(0, 0) 47 | with self.assertRaises(ValueError): 48 | Line2D(p, Vector2D(0, 0)) 49 | 50 | 51 | class TestLine2DBehaviour(unittest.TestCase): 52 | """General behaviour and relations between lines.""" 53 | 54 | def setUp(self): 55 | self.line = Line2D(Point2D(0, 0), Point2D(2, 2)) # y = x 56 | 57 | def test_point_at(self): 58 | p = self.line.point_at(3.0) 59 | self.assertTrue(self.line.contains_point(p)) 60 | 61 | def test_contains_point_noise(self): 62 | noisy = Point2D(1 + 1e-10, 1 - 1e-10) 63 | self.assertTrue(self.line.contains_point(noisy)) 64 | 65 | def test_parallel_and_perpendicular(self): 66 | parallel = Line2D(Point2D(0, 1), Vector2D(1, 1)) 67 | perpendicular = Line2D(Point2D(0, 0), Vector2D(-1, 1)) # slope -1 68 | self.assertTrue(self.line.is_parallel_to(parallel)) 69 | self.assertTrue(self.line.is_perpendicular_to(perpendicular)) 70 | 71 | def test_intersection(self): 72 | other = Line2D(Point2D(0, 1), Vector2D(1, -1)) # y = -x + 1 73 | ip = self.line.intersection_with(other) 74 | self.assertEqual(ip, Point2D(0.5, 0.5)) 75 | 76 | def test_distance_to_point(self): 77 | d = self.line.distance_to_point(Point2D(1, 0)) 78 | self.assertAlmostEqual(d, 1 / math.sqrt(2), places=6) 79 | 80 | def test_near_parallel_tolerance(self): 81 | base = Line2D(Point2D(0, 0), Vector2D(1, 0)) 82 | off = Line2D(Point2D(0, 1), Vector2D(1, DEFAULT_EPSILON / 10)) 83 | self.assertTrue(base.is_parallel_to(off)) 84 | 85 | # --------------------------------------------------------------------------- 86 | # SEGMENT 2D 87 | # --------------------------------------------------------------------------- 88 | class TestSegment2DBasic(unittest.TestCase): 89 | """Constructor and basic properties.""" 90 | 91 | def setUp(self): 92 | self.seg = Segment2D(Point2D(0, 0), Point2D(3, 4)) # length 5 93 | 94 | def test_length_midpoint_direction(self): 95 | self.assertEqual(self.seg.length, 5) 96 | self.assertEqual(self.seg.midpoint, Point2D(1.5, 2)) 97 | self.assertEqual(self.seg.direction_vector, Vector2D(3, 4)) 98 | 99 | def test_contains_point(self): 100 | self.assertTrue(self.seg.contains_point(Point2D(1.5, 2))) 101 | self.assertFalse(self.seg.contains_point(Point2D(4, 5))) 102 | 103 | def test_to_line(self): 104 | line = self.seg.to_line() 105 | self.assertTrue(line.contains_point(self.seg.p1)) 106 | self.assertTrue(line.contains_point(self.seg.p2)) 107 | 108 | def test_zero_length_segment_raises(self): 109 | p = Point2D(1, 1) 110 | with self.assertRaises(ValueError): 111 | Segment2D(p, p) 112 | 113 | 114 | class TestSegment2DIntersections(unittest.TestCase): 115 | """Segment–segment intersection edge‑cases.""" 116 | 117 | def test_simple_cross(self): 118 | s1 = Segment2D(Point2D(0, 0), Point2D(3, 3)) 119 | s2 = Segment2D(Point2D(0, 3), Point2D(3, 0)) 120 | ip = s1.intersection_with_segment(s2) 121 | self.assertEqual(ip, Point2D(1.5, 1.5)) 122 | 123 | def test_collinear_overlap(self): 124 | s1 = Segment2D(Point2D(0, 0), Point2D(4, 0)) 125 | s2 = Segment2D(Point2D(2, 0), Point2D(6, 0)) 126 | 127 | from geo.operations import segment_segment_intersection_detail # type: ignore 128 | itype, result = segment_segment_intersection_detail(s1, s2) 129 | 130 | self.assertEqual(itype, "overlap") 131 | self.assertIsInstance(result, tuple) 132 | self.assertEqual(len(result), 2) 133 | self.assertTrue(all(isinstance(p, Point2D) for p in result)) 134 | 135 | expected = Segment2D(Point2D(2, 0), Point2D(4, 0)) 136 | self.assertTrue( 137 | (result[0] == expected.p1 and result[1] == expected.p2) or 138 | (result[0] == expected.p2 and result[1] == expected.p1), 139 | f"Expected overlapping segment {expected}, got {result}" 140 | ) 141 | 142 | def test_disjoint_parallel(self): 143 | s1 = Segment2D(Point2D(0, 0), Point2D(1, 0)) 144 | s2 = Segment2D(Point2D(0, 1), Point2D(1, 1)) 145 | self.assertIsNone(s1.intersection_with_segment(s2)) 146 | 147 | def setUp(self): 148 | # Example: horizontal segment from (0, 0) to (4, 0) 149 | self.seg = Segment2D(Point2D(0, 0), Point2D(4, 0)) 150 | 151 | def test_distance_to_point_inside_projection(self): 152 | p = Point2D(1, 2) 153 | d = self.seg.distance_to_point(p := Point2D(1, 2)) 154 | self.assertAlmostEqual(d, 2) 155 | 156 | # --------------------------------------------------------------------------- 157 | # RAY 2D 158 | # --------------------------------------------------------------------------- 159 | class TestRay2D(unittest.TestCase): 160 | """Construction and behaviour for Ray2D.""" 161 | 162 | def test_constructor_zero_vector_raises(self): 163 | with self.assertRaises(ValueError): 164 | Ray2D(Point2D(0, 0), Vector2D(0, 0)) 165 | 166 | def test_point_at(self): 167 | ray = Ray2D(Point2D(0, 0), Vector2D(1, 1)) 168 | p = ray.point_at(math.sqrt(2)) 169 | self.assertEqual(p, Point2D(1.0, 1.0)) 170 | 171 | def test_point_at_negative_raises(self): 172 | ray = Ray2D(Point2D(0, 0), Vector2D(1, 0)) 173 | with self.assertRaises(ValueError): 174 | ray.point_at(-0.1) 175 | 176 | def test_equality_normalises_direction(self): 177 | r1 = Ray2D(Point2D(0, 0), Vector2D(2, 0)) 178 | r2 = Ray2D(Point2D(0, 0), Vector2D(1, 0)) 179 | self.assertEqual(r1, r2) 180 | 181 | 182 | if __name__ == "__main__": 183 | unittest.main() 184 | -------------------------------------------------------------------------------- /tests/primitives_3d/test_line_3d.py: -------------------------------------------------------------------------------- 1 | # (1) python tests/primitives_3d/test_line_3d.py 2 | # (2) python -m unittest tests/primitives_3d/test_line_3d.py (verbose output) (auto add sys.path) 3 | 4 | import math 5 | import unittest 6 | import sys 7 | import os 8 | 9 | # For (1): Add the project root to sys.path so `geo` can be imported 10 | # For (2): Don't need 11 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) 12 | 13 | from geo.primitives_3d.line_3d import Line3D, Segment3D, Ray3D 14 | from geo.core import Point3D, Vector3D 15 | from geo.core.precision import is_equal, DEFAULT_EPSILON 16 | 17 | def points_equal(p1: Point3D, p2: Point3D, epsilon=DEFAULT_EPSILON) -> bool: 18 | return is_equal(p1.x, p2.x, epsilon) and is_equal(p1.y, p2.y, epsilon) and is_equal(p1.z, p2.z, epsilon) 19 | 20 | def vectors_equal(v1: Vector3D, v2: Vector3D, epsilon=DEFAULT_EPSILON) -> bool: 21 | return is_equal(v1.x, v2.x, epsilon) and is_equal(v1.y, v2.y, epsilon) and is_equal(v1.z, v2.z, epsilon) 22 | 23 | 24 | class TestLine3D(unittest.TestCase): 25 | 26 | def setUp(self): 27 | self.p1 = Point3D(0, 0, 0) 28 | self.p2 = Point3D(1, 1, 1) 29 | self.v1 = Vector3D(1, 1, 1) 30 | self.v_zero = Vector3D(0, 0, 0) 31 | 32 | def test_init_with_vector(self): 33 | line = Line3D(self.p1, self.v1) 34 | self.assertTrue(vectors_equal(line.direction, self.v1.normalize())) 35 | 36 | with self.assertRaises(ValueError): 37 | Line3D(self.p1, self.v_zero) 38 | 39 | with self.assertRaises(TypeError): 40 | Line3D(self.p1, 123) 41 | 42 | def test_init_with_two_points(self): 43 | line = Line3D(self.p1, self.p2) 44 | expected_dir = (self.p2 - self.p1).normalize() 45 | self.assertTrue(vectors_equal(line.direction, expected_dir)) 46 | 47 | with self.assertRaises(ValueError): 48 | Line3D(self.p1, self.p1) 49 | 50 | def test_equality(self): 51 | line1 = Line3D(self.p1, self.p2) 52 | line2 = Line3D(self.p2, self.p1) 53 | line3 = Line3D(self.p1, Vector3D(-1, -1, -1)) 54 | line4 = Line3D(Point3D(0, 1, 0), Vector3D(1, 1, 1)) 55 | 56 | self.assertEqual(line1, line2) 57 | self.assertEqual(line1, line3) 58 | self.assertNotEqual(line1, line4) 59 | 60 | def test_point_at(self): 61 | line = Line3D(self.p1, self.v1) 62 | p = line.point_at(2.0) 63 | expected = self.p1 + self.v1.normalize() * 2.0 64 | self.assertTrue(points_equal(p, expected)) 65 | 66 | def test_contains_point(self): 67 | line = Line3D(self.p1, self.v1) 68 | p_on_line = Point3D(2, 2, 2) 69 | p_not_on_line = Point3D(1, 0, 0) 70 | 71 | self.assertTrue(line.contains_point(p_on_line)) 72 | self.assertFalse(line.contains_point(p_not_on_line)) 73 | 74 | def test_distance_to_point(self): 75 | line = Line3D(self.p1, self.v1) 76 | p = Point3D(1, 0, 0) 77 | dist = line.distance_to_point(p) 78 | expected = ((p - self.p1).cross(self.v1.normalize())).magnitude() 79 | self.assertAlmostEqual(dist, expected, places=6) 80 | 81 | def test_project_point(self): 82 | line = Line3D(self.p1, self.v1) 83 | p = Point3D(1, 0, 0) 84 | proj = line.project_point(p) 85 | self.assertTrue(line.contains_point(proj)) 86 | dist_proj = proj.distance_to(p) 87 | for t in [-1, 0, 1, 2]: 88 | candidate = line.point_at(t) 89 | self.assertLessEqual(dist_proj, candidate.distance_to(p) + DEFAULT_EPSILON) 90 | 91 | 92 | class TestSegment3D(unittest.TestCase): 93 | 94 | def setUp(self): 95 | self.p1 = Point3D(0, 0, 0) 96 | self.p2 = Point3D(2, 0, 0) 97 | self.p_mid = Point3D(1, 0, 0) 98 | self.p_outside = Point3D(3, 0, 0) 99 | 100 | def test_init_invalid(self): 101 | with self.assertRaises(ValueError): 102 | Segment3D(self.p1, self.p1) 103 | 104 | def test_equality(self): 105 | seg1 = Segment3D(self.p1, self.p2) 106 | seg2 = Segment3D(self.p2, self.p1) 107 | seg3 = Segment3D(self.p1, Point3D(0, 1, 0)) 108 | 109 | self.assertEqual(seg1, seg2) 110 | self.assertNotEqual(seg1, seg3) 111 | 112 | def test_length_and_midpoint(self): 113 | seg = Segment3D(self.p1, self.p2) 114 | self.assertAlmostEqual(seg.length, 2.0, places=6) 115 | self.assertTrue(points_equal(seg.midpoint, self.p_mid)) 116 | 117 | def test_direction_vector(self): 118 | seg = Segment3D(self.p1, self.p2) 119 | expected_dir = Vector3D(2, 0, 0) 120 | self.assertTrue(vectors_equal(seg.direction_vector, expected_dir)) 121 | 122 | def test_to_line(self): 123 | seg = Segment3D(self.p1, self.p2) 124 | line = seg.to_line() 125 | self.assertTrue(line.contains_point(self.p1)) 126 | self.assertTrue(line.contains_point(self.p2)) 127 | 128 | def test_contains_point(self): 129 | seg = Segment3D(self.p1, self.p2) 130 | self.assertTrue(seg.contains_point(self.p_mid)) 131 | self.assertFalse(seg.contains_point(self.p_outside)) 132 | 133 | def test_distance_to_point(self): 134 | seg = Segment3D(self.p1, self.p2) 135 | p_above = Point3D(1, 1, 0) 136 | dist = seg.distance_to_point(p_above) 137 | self.assertAlmostEqual(dist, 1.0, places=6) 138 | 139 | p_before = Point3D(-1, 0, 0) 140 | dist_before = seg.distance_to_point(p_before) 141 | self.assertAlmostEqual(dist_before, 1.0, places=6) 142 | 143 | p_after = Point3D(3, 0, 0) 144 | dist_after = seg.distance_to_point(p_after) 145 | self.assertAlmostEqual(dist_after, 1.0, places=6) 146 | 147 | 148 | class TestRay3D(unittest.TestCase): 149 | 150 | def setUp(self): 151 | self.origin = Point3D(0, 0, 0) 152 | self.dir = Vector3D(1, 0, 0) 153 | self.zero_vec = Vector3D(0, 0, 0) 154 | 155 | def test_init(self): 156 | ray = Ray3D(self.origin, self.dir) 157 | self.assertTrue(vectors_equal(ray.direction, self.dir.normalize())) 158 | 159 | with self.assertRaises(ValueError): 160 | Ray3D(self.origin, self.zero_vec) 161 | 162 | def test_equality(self): 163 | ray1 = Ray3D(self.origin, self.dir) 164 | ray2 = Ray3D(self.origin, self.dir) 165 | ray3 = Ray3D(Point3D(1, 0, 0), self.dir) 166 | ray4 = Ray3D(self.origin, Vector3D(0, 1, 0)) 167 | 168 | self.assertEqual(ray1, ray2) 169 | self.assertNotEqual(ray1, ray3) 170 | self.assertNotEqual(ray1, ray4) 171 | 172 | def test_point_at(self): 173 | ray = Ray3D(self.origin, self.dir) 174 | p = ray.point_at(3.0) 175 | expected = self.origin + self.dir.normalize() * 3.0 176 | self.assertTrue(points_equal(p, expected)) 177 | 178 | with self.assertRaises(ValueError): 179 | ray.point_at(-1.0) 180 | 181 | def test_contains_point(self): 182 | ray = Ray3D(self.origin, self.dir) 183 | p_on_ray = Point3D(5, 0, 0) 184 | p_before_origin = Point3D(-1, 0, 0) 185 | p_not_on_ray = Point3D(0, 1, 0) 186 | 187 | self.assertTrue(ray.contains_point(p_on_ray)) 188 | self.assertFalse(ray.contains_point(p_before_origin)) 189 | self.assertFalse(ray.contains_point(p_not_on_ray)) 190 | 191 | def test_to_line(self): 192 | ray = Ray3D(self.origin, self.dir) 193 | line = ray.to_line() 194 | self.assertTrue(line.contains_point(self.origin)) 195 | self.assertTrue(vectors_equal(line.direction, self.dir.normalize())) 196 | 197 | 198 | if __name__ == "__main__": 199 | unittest.main() 200 | -------------------------------------------------------------------------------- /geo/primitives_2d/triangle.py: -------------------------------------------------------------------------------- 1 | # geo/primitives_2d/triangle.py 2 | 3 | """ 4 | Defines a Triangle primitive in 2D space. 5 | """ 6 | import math 7 | from typing import Tuple, List, Optional 8 | 9 | from geo.core import Point2D, Vector2D 10 | from geo.core.precision import is_equal, is_zero, DEFAULT_EPSILON 11 | from .polygon import Polygon 12 | from .line import Line2D, Segment2D 13 | from .circle import Circle 14 | 15 | 16 | class Triangle(Polygon): 17 | """ 18 | Represents a triangle in 2D space, defined by three vertices. 19 | Inherits from Polygon. 20 | """ 21 | def __init__(self, p1: Point2D, p2: Point2D, p3: Point2D): 22 | """ 23 | Initializes a Triangle. 24 | 25 | Args: 26 | p1, p2, p3: The three Point2D vertices of the triangle. 27 | 28 | Raises: 29 | ValueError: If the three points are collinear (form a degenerate triangle). 30 | """ 31 | super().__init__([p1, p2, p3]) 32 | # Check for collinearity (signed area would be zero) 33 | if is_zero(super().signed_area()): 34 | raise ValueError("Vertices are collinear, cannot form a non-degenerate triangle.") 35 | 36 | self.p1 = p1 37 | self.p2 = p2 38 | self.p3 = p3 39 | 40 | @property 41 | def area(self) -> float: 42 | """ 43 | Calculates the area of the triangle. 44 | Overrides Polygon.area for potential direct calculation, though Shoelace is fine. 45 | Area = 0.5 * |x1(y2−y3) + x2(y3−y1) + x3(y1−y2)| 46 | """ 47 | # Using the inherited Polygon.area which uses Shoelace formula is generally good. 48 | return super().area # Or implement the direct formula: 49 | # return 0.5 * abs(self.p1.x * (self.p2.y - self.p3.y) + \ 50 | # self.p2.x * (self.p3.y - self.p1.y) + \ 51 | # self.p3.x * (self.p1.y - self.p2.y)) 52 | 53 | @property 54 | def side_lengths(self) -> Tuple[float, float, float]: 55 | """Returns the lengths of the three sides (a, b, c). 56 | a: length of side opposite p1 (segment p2-p3) 57 | b: length of side opposite p2 (segment p1-p3) 58 | c: length of side opposite p3 (segment p1-p2) 59 | """ 60 | len_a = self.p2.distance_to(self.p3) # side opposite p1 61 | len_b = self.p1.distance_to(self.p3) # side opposite p2 62 | len_c = self.p1.distance_to(self.p2) # side opposite p3 63 | return len_a, len_b, len_c 64 | 65 | @property 66 | def angles_rad(self) -> Tuple[float, float, float]: 67 | """ 68 | Returns the three internal angles of the triangle in radians. 69 | (angle_at_p1, angle_at_p2, angle_at_p3) 70 | Uses the Law of Cosines: c^2 = a^2 + b^2 - 2ab*cos(C) 71 | => cos(C) = (a^2 + b^2 - c^2) / (2ab) 72 | """ 73 | a, b, c = self.side_lengths 74 | 75 | if is_zero(a) or is_zero(b) or is_zero(c): # Degenerate 76 | return (0.0, 0.0, 0.0) # Or handle error 77 | 78 | # Angle at p1 (opposite side a) 79 | cos_alpha = (b**2 + c**2 - a**2) / (2 * b * c) 80 | # Angle at p2 (opposite side b) 81 | cos_beta = (a**2 + c**2 - b**2) / (2 * a * c) 82 | # Angle at p3 (opposite side c) 83 | cos_gamma = (a**2 + b**2 - c**2) / (2 * a * b) 84 | 85 | # Clamp values to [-1, 1] due to potential floating point inaccuracies 86 | alpha = math.acos(max(-1.0, min(1.0, cos_alpha))) 87 | beta = math.acos(max(-1.0, min(1.0, cos_beta))) 88 | gamma = math.acos(max(-1.0, min(1.0, cos_gamma))) # Or gamma = math.pi - alpha - beta for precision 89 | 90 | return alpha, beta, gamma 91 | 92 | @property 93 | def angles_deg(self) -> Tuple[float, float, float]: 94 | """Returns the three internal angles in degrees.""" 95 | return tuple(math.degrees(rad) for rad in self.angles_rad) 96 | 97 | def is_equilateral(self, epsilon: float = DEFAULT_EPSILON) -> bool: 98 | """Checks if the triangle is equilateral.""" 99 | a, b, c = self.side_lengths 100 | return is_equal(a, b, epsilon) and is_equal(b, c, epsilon) 101 | 102 | def is_isosceles(self, epsilon: float = DEFAULT_EPSILON) -> bool: 103 | """Checks if the triangle is isosceles.""" 104 | if self.is_equilateral(epsilon): # Equilateral is also isosceles 105 | return True 106 | a, b, c = self.side_lengths 107 | return is_equal(a, b, epsilon) or \ 108 | is_equal(b, c, epsilon) or \ 109 | is_equal(a, c, epsilon) 110 | 111 | def is_right(self, epsilon: float = DEFAULT_EPSILON) -> bool: 112 | """Checks if the triangle is a right-angled triangle.""" 113 | angles = self.angles_rad 114 | right_angle = math.pi / 2 115 | return any(is_equal(angle, right_angle, epsilon) for angle in angles) 116 | 117 | @property 118 | def circumcircle(self) -> Optional[Circle]: 119 | """ 120 | Calculates the circumcircle of the triangle (the circle passing through all three vertices). 121 | Returns None if the triangle is degenerate (vertices are collinear), 122 | though the constructor should prevent this. 123 | """ 124 | # Using formula for circumcenter coordinates: 125 | # D = 2 * (x1(y2 - y3) + x2(y3 - y1) + x3(y1 - y2)) 126 | # This D is 4 * signed_area_of_triangle. If D is zero, points are collinear. 127 | 128 | D_val = 2 * (self.p1.x * (self.p2.y - self.p3.y) + \ 129 | self.p2.x * (self.p3.y - self.p1.y) + \ 130 | self.p3.x * (self.p1.y - self.p2.y)) 131 | 132 | if is_zero(D_val): 133 | return None # Collinear points, no unique circumcircle (or infinite radius) 134 | 135 | p1_sq = self.p1.x**2 + self.p1.y**2 136 | p2_sq = self.p2.x**2 + self.p2.y**2 137 | p3_sq = self.p3.x**2 + self.p3.y**2 138 | 139 | center_x = (1/D_val) * (p1_sq * (self.p2.y - self.p3.y) + \ 140 | p2_sq * (self.p3.y - self.p1.y) + \ 141 | p3_sq * (self.p1.y - self.p2.y)) 142 | 143 | center_y = (1/D_val) * (p1_sq * (self.p3.x - self.p2.x) + \ 144 | p2_sq * (self.p1.x - self.p3.x) + \ 145 | p3_sq * (self.p2.x - self.p1.x)) 146 | 147 | center = Point2D(center_x, center_y) 148 | radius = center.distance_to(self.p1) 149 | 150 | return Circle(center, radius) 151 | 152 | @property 153 | def incircle(self) -> Optional[Circle]: 154 | """ 155 | Calculates the incircle of the triangle (the largest circle contained within the triangle). 156 | Returns None if the triangle is degenerate. 157 | Incenter Ix = (a*x1 + b*x2 + c*x3) / (a+b+c) 158 | Incenter Iy = (a*y1 + b*y2 + c*y3) / (a+b+c) 159 | Inradius r = Area / s, where s is semi-perimeter (a+b+c)/2. 160 | """ 161 | a, b, c = self.side_lengths # a=p2p3, b=p1p3, c=p1p2 162 | perimeter_val = a + b + c 163 | 164 | if is_zero(perimeter_val): # Degenerate 165 | return None 166 | 167 | # Incenter coordinates 168 | # Note: a is length of side p2-p3 (opposite p1), b is p1-p3 (opposite p2), c is p1-p2 (opposite p3) 169 | center_x = (a * self.p1.x + b * self.p2.x + c * self.p3.x) / perimeter_val 170 | center_y = (a * self.p1.y + b * self.p2.y + c * self.p3.y) / perimeter_val 171 | center = Point2D(center_x, center_y) 172 | 173 | # Inradius 174 | area_val = self.area 175 | semi_perimeter = perimeter_val / 2.0 176 | if is_zero(semi_perimeter): # Degenerate 177 | return None 178 | 179 | radius = area_val / semi_perimeter 180 | 181 | return Circle(center, radius) --------------------------------------------------------------------------------