├── 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 | [](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 |
--------------------------------------------------------------------------------
/docs/_static/geologo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/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)
--------------------------------------------------------------------------------