diff --git a/.github/constraints/numpy-min.txt b/.github/constraints/numpy-min.txt new file mode 100644 index 000000000000..046f45bed53f --- /dev/null +++ b/.github/constraints/numpy-min.txt @@ -0,0 +1 @@ +numpy==1.26.0 diff --git a/.github/verify_python_wheel.py b/.github/verify_python_wheel.py new file mode 100644 index 000000000000..60bbf00d7dd8 --- /dev/null +++ b/.github/verify_python_wheel.py @@ -0,0 +1,32 @@ +"""Verify the contents required from a COMPAS Python wheel.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from zipfile import ZipFile + + +def find_wheel(path: Path) -> Path: + """Resolve exactly one wheel from a file or directory.""" + if path.is_file(): + return path + wheels = sorted(path.glob("*.whl")) + if len(wheels) != 1: + raise RuntimeError(f"Expected exactly one wheel in {path}, found {len(wheels)}.") + return wheels[0] + + +def verify_wheel(path: Path) -> None: + """Verify that a wheel contains the PEP 561 marker.""" + wheel = find_wheel(path) + with ZipFile(wheel) as archive: + names = set(archive.namelist()) + if "compas/py.typed" not in names: + raise RuntimeError(f"{wheel.name} does not contain compas/py.typed.") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + raise SystemExit("Usage: verify_python_wheel.py WHEEL_OR_DIRECTORY") + verify_wheel(Path(sys.argv[1])) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7930df021b6a..452045e4f530 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,7 +42,10 @@ jobs: - uses: compas-dev/compas-actions/setup-python@5c64e6e2b576a1232d28fed47d9478ca8eb63b2d # v2 candidate with: python-version: ${{ matrix.python }} - extras: dev + # Pyright covers the authoritative visualization examples as part of + # the public documentation contract, so their declared dependencies + # must be present in the typing environment. + extras: dev,viz - name: Check style run: uv run ruff check . - name: Check formatting @@ -51,6 +54,39 @@ jobs: run: uv run pyright - name: Run tests run: uv run pytest + - name: Build typed wheel + if: matrix.os == 'ubuntu-latest' && matrix.python == '3.12' + shell: bash + run: | + mkdir -p "${RUNNER_TEMP}/compas-source" "${RUNNER_TEMP}/compas-wheelhouse" + git archive HEAD | tar -xf - -C "${RUNNER_TEMP}/compas-source" + uv run python -m build \ + --wheel \ + --outdir "${RUNNER_TEMP}/compas-wheelhouse" \ + "${RUNNER_TEMP}/compas-source" + - name: Verify typed wheel + if: matrix.os == 'ubuntu-latest' && matrix.python == '3.12' + shell: bash + run: | + uv run python .github/verify_python_wheel.py "${RUNNER_TEMP}/compas-wheelhouse" + uv venv "${RUNNER_TEMP}/compas-wheel" + uv pip install \ + --python "${RUNNER_TEMP}/compas-wheel/bin/python" \ + "${RUNNER_TEMP}/compas-wheelhouse"/*.whl + ( + cd "${RUNNER_TEMP}" + "${GITHUB_WORKSPACE}/.venv/bin/pyright" \ + --pythonpath "${RUNNER_TEMP}/compas-wheel/bin/python" \ + "${GITHUB_WORKSPACE}/tests/typing/installed_consumer.py" + ) + - name: Upload verified candidate wheel + if: matrix.os == 'ubuntu-latest' && matrix.python == '3.12' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: candidate-linux-py312 + path: ${{ runner.temp }}/compas-wheelhouse/*.whl + if-no-files-found: error + retention-days: 14 numpy-compatibility: name: NumPy ${{ matrix.numpy }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 674efb497a08..7a551c39b20f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -* Added serializable strategies and structural validation for polygonization, ephemeral `Tessellation` buffers, mixed-face triangulation, and planar Delaunay triangulation. -* Added `polygonize(strategy)` and deterministic `tessellate(strategy=None)` operations for continuous geometry. Shape and surface defaults use `Native()`, `UGrid(64)`, or `UVGrid(64, 64)` according to parameter dimensionality. -* Added `FaceTriangulation` for mixed polygonal collections, explicit projected ear clipping for bounded near-planar polygons, and minimum-area dynamic programming for twisted 3D polygons. +* Added a verified Linux Python 3.12 candidate wheel artifact for framework-wide pre-release integration. +* Added the public `compas.typing` structural vocabulary with NumPy-aware `PointLike`, `VectorLike`, collection, and neutral `VerticesFaces` aliases. +* Added the PEP 561 `py.typed` marker and installed-wheel static-consumer verification. +* Added structural validation for polygonization, ephemeral `Tessellation` buffers, mixed-face triangulation, and planar Delaunay triangulation. +* Added producer-specific `to_polygonal_surface` and exactly composed `to_tessellation` conversions for structured shapes and untrimmed surfaces. +* Added named polygon triangulators, including explicit projected ear clipping for bounded near-planar polygons and minimum-area dynamic programming for twisted 3D polygons. * Added parameter-derived continuous AABBs for analytic shapes and surfaces, minimum-volume OBBs where established, and certified conservative control-net bounds for positive-weight NURBS surfaces. * Added `TOL.update()` method for explicit global state modification. * Added `TOL.temporary()` context manager for scoped changes. @@ -20,9 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* Migrated core geometry constructors and plugin contracts from private overlapping type aliases to semantic structural inputs, including copied NumPy coordinates and integral face indices. * Added a committed uv development lock, documented direct quality commands while retaining Invoke convenience tasks, and migrated CI, release, artifact, and documentation mechanics to immutably pinned shared actions. -* Standardized triangle-processing plugin inputs and outputs on the ephemeral `Tessellation` representation. See the geometry discretization and tessellation migration guide for the COMPAS 3 replacements. -* Renamed planar Delaunay operations to `triangulate_planar_points` and `triangulate_planar_domain`, with core-owned validation before backend dispatch. +* Standardized numerical triangle-mesh plugin inputs and outputs on structural `(vertices, triangles)` buffers. `Tessellation` remains an ephemeral discrete representation for geometry producers and scene handoff, but is not required at numerical backend boundaries. +* Defined core triangle-mesh Gaussian curvature as the dimensionless angle deficit, using $\pi - \sum_i \theta_i$ at boundary vertices and $2\pi - \sum_i \theta_i$ at interior vertices. +* Replaced strategy-selected planar Delaunay operations with `delaunay_triangulation`, `constrained_delaunay_triangulation`, and `conforming_delaunay_triangulation`, returning structural buffers with core-owned validation. * Replaced backend-specific `NurbsCurve` and `NurbsSurface` substitution with stable core wrappers and operation-level plugin contracts. * Modernized internal path, URL, text-stream, and binary-stream handling and made ownership of opened resources explicit. * Corrected variance, standard-deviation, Gram-Schmidt, determinant, inverse, matrix-decomposition, and projection edge-case behavior in `compas.linalg`. @@ -52,7 +57,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Consolidated best-fit algorithms in `bestfit.py`, removed `_numpy` public names, made numerical imports lazy, switched the canonical plane fit to PCA, and standardized fitted radii as scalar floats. * Consolidated hull and ICP algorithms under unsuffixed APIs, corrected convex-hull polyhedron construction and stable ICP returns, and moved optional Shapely polygon Boolean defaults into `booleans.py`. * Added deferred topology validation to `PlanarRegion` triangulation, with clear errors for invalid boundary loops, holes outside or touching the exterior, and overlapping, touching, or nested holes. -* Added a zero-copy, triangle-only `Tessellation` result with shared vertex indexing for triangles, normals, and texture coordinates. Continuous defining geometry uses explicit `polygonize(strategy)` for persistent `PolygonalSurface` results and fresh `tessellate(strategy=None)` for ephemeral triangle results; polygonal collections use composite `FaceTriangulation` policies. +* Added a zero-copy, triangle-only `Tessellation` result with shared vertex indexing for triangles, normals, and texture coordinates. Continuous structured geometry uses producer-specific conversion keywords, BReps use deflection keywords, and polygonal collections accept named or duck-typed triangulation callables. +* Changed `Mesh.triangulated()` to return an independent mesh of the same concrete type, preserving mesh data, existing keys and attributes, and editable topology while leaving other aggregate triangulation results as `Tessellation`. +* Removed the public tessellation and triangulation strategy hierarchies and the old `polygonize`/`tessellate` operation aliases in favor of one explicit public path per operation. * Defined discretization as the broad category of operations producing finite representations of continuous geometry, polygonization as the persistent `PolygonalSurface` operation, and tessellation as the ephemeral indexed-triangle operation. * Renamed the NURBS surface defining-point grid from `points` to `control_points`. * Made shape and surface strategy arguments non-mutating and added aligned analytic normals and normalized UV coordinates to surface tessellations. @@ -71,10 +78,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed -* Removed implicit and misspelled tessellation conversions, scalar resolution overrides, implicit quad splitting, and raw triangle-buffer plugin boundaries in favor of explicit strategies and result types. +* Removed the private `compas._typing` module and its ambiguous coordinate and tuple-valued mesh aliases. +* Removed implicit and misspelled tessellation conversions, scalar resolution overrides, and implicit quad splitting in favor of explicit producer controls, named triangulators, and result types. +* Removed the unsupported core triangle-mesh mean and principal curvature plugin contracts and the obsolete tessellation-specific pluggable decorator. * Removed stored shape and surface resolution state, derived discretization caches, generic sampled geometry properties, `Shape.to_vertices_and_faces()`, and `Mesh.from_shape()`. * Removed unused runtime tessellation capability discovery and speculative approximation-error schemas. -* Removed `Shape.to_polyhedron()` because `Polyhedron` represents a validated closed solid rather than the generic polygonal discretization of a shape. Use `polygonize(strategy)`, `tessellate(strategy)`, or convert the result to a mesh. +* Removed `Shape.to_polyhedron()` because `Polyhedron` represents a validated closed solid rather than the generic polygonal discretization of a shape. Use `to_polygonal_surface()`, `to_tessellation()`, or convert the result to a mesh. * Removed the private `compas._os` compatibility module and its obsolete Python 2, IronPython, CAD-host, Conda-bootstrapper, and Windows elevation helpers. * Removed the deprecated `compas.linalg.close` and `compas.linalg.allclose` helpers in favor of `TOL.is_close` and `TOL.is_allclose`. * Removed the `compas.rpc` package and the `compas_rpc` command-line entry point. diff --git a/docs/assets/examples/_plotter.py b/docs/assets/examples/_plotter.py index 442b678fa50f..425269566fe7 100644 --- a/docs/assets/examples/_plotter.py +++ b/docs/assets/examples/_plotter.py @@ -1,5 +1,3 @@ -# type: ignore - # import compas from compas_viewer.scene import Tag @@ -48,7 +46,8 @@ def viewer_add_face(): def viewer_add_halfvector(viewer, mesh: Mesh, halfedge, color=None, basewidth=0.01, arrowsize=5, distance=0.1): line: Line = mesh.edge_line(halfedge) - line = line.offset(line.length * distance) + offset = line.length * distance + line = Line(line.point_from_start(offset), line.point_from_end(offset)) color = color or Color.black() diff --git a/docs/assets/examples/basics.datastructures.cellnetworks.py b/docs/assets/examples/basics.datastructures.cellnetworks.py index d9896dfb79ac..828fae64e844 100644 --- a/docs/assets/examples/basics.datastructures.cellnetworks.py +++ b/docs/assets/examples/basics.datastructures.cellnetworks.py @@ -6,7 +6,7 @@ from compas.geometry import Line from compas.geometry import Polygon -cell_network: CellNetwork = CellNetwork.from_json(compas.get("cellnetwork_example.json")) # type: ignore +cell_network: CellNetwork = CellNetwork.from_json(compas.get("cellnetwork_example.json")) viewer = Viewer(show_grid=False) diff --git a/docs/assets/examples/basics.datastructures.meshes.py b/docs/assets/examples/basics.datastructures.meshes.py index f0b4a4fa2b43..f96e4c741551 100644 --- a/docs/assets/examples/basics.datastructures.meshes.py +++ b/docs/assets/examples/basics.datastructures.meshes.py @@ -1,5 +1,3 @@ -# type: ignore - from compas_viewer import Viewer from compas.colors import Color @@ -10,8 +8,9 @@ viewer = Viewer(viewmode="top", width=1600, height=900) red = Color.red() +light_gray = Color(0.95, 0.95, 0.95) -viewer.scene.add(mesh, facecolor=(0.95, 0.95, 0.95), lineswidth=2) +viewer.scene.add(mesh, facecolor=light_gray, lineswidth=2) viewer.scene.add(mesh.edge_line((30, 31)).translated([0, 0, 0.1]), linecolor=red, lineswidth=10) for edge in mesh.edge_loop((30, 31)): diff --git a/docs/assets/examples/basics.datastructures.volmeshes_example-1.py b/docs/assets/examples/basics.datastructures.volmeshes_example-1.py index 2fcaba2419b5..16336475484b 100644 --- a/docs/assets/examples/basics.datastructures.volmeshes_example-1.py +++ b/docs/assets/examples/basics.datastructures.volmeshes_example-1.py @@ -17,7 +17,7 @@ # Base Box # ============================================================================= -box = Box(10).to_mesh() +box = Box(10).to_polygonal_surface().to_mesh() # ============================================================================= # Cutting Planes diff --git a/docs/assets/examples/colors.py b/docs/assets/examples/colors.py index 7e23eb6198dc..0bf21ba685e9 100644 --- a/docs/assets/examples/colors.py +++ b/docs/assets/examples/colors.py @@ -1,5 +1,3 @@ -# type: ignore - from compas_viewer import Viewer from compas.colors import Color diff --git a/docs/assets/examples/colors_lightness.py b/docs/assets/examples/colors_lightness.py index 6fc2e0685f08..56a430ce01f7 100644 --- a/docs/assets/examples/colors_lightness.py +++ b/docs/assets/examples/colors_lightness.py @@ -1,5 +1,3 @@ -# type: ignore - from compas_viewer import Viewer from compas.colors import Color diff --git a/docs/assets/examples/colors_maps.py b/docs/assets/examples/colors_maps.py index 9c507975cd18..c37a65749265 100644 --- a/docs/assets/examples/colors_maps.py +++ b/docs/assets/examples/colors_maps.py @@ -1,5 +1,3 @@ -# type: ignore - from compas_viewer import Viewer from compas.colors import Color diff --git a/docs/assets/examples/colors_maps_from-one-color.py b/docs/assets/examples/colors_maps_from-one-color.py index 7110b7ae21a2..bf803270d972 100644 --- a/docs/assets/examples/colors_maps_from-one-color.py +++ b/docs/assets/examples/colors_maps_from-one-color.py @@ -1,5 +1,3 @@ -# type: ignore - from compas_viewer import Viewer from compas.colors import Color diff --git a/docs/assets/examples/colors_maps_from-rgb.py b/docs/assets/examples/colors_maps_from-rgb.py index e34eef1caf34..3c2ec93c7c4d 100644 --- a/docs/assets/examples/colors_maps_from-rgb.py +++ b/docs/assets/examples/colors_maps_from-rgb.py @@ -1,5 +1,3 @@ -# type: ignore - from compas_viewer import Viewer from compas.colors import ColorMap diff --git a/docs/assets/examples/colors_maps_from-rgb_simple.py b/docs/assets/examples/colors_maps_from-rgb_simple.py index 06565b88b011..40c5b97245a4 100644 --- a/docs/assets/examples/colors_maps_from-rgb_simple.py +++ b/docs/assets/examples/colors_maps_from-rgb_simple.py @@ -1,5 +1,3 @@ -# type: ignore - from compas_viewer import Viewer from compas.colors import ColorMap diff --git a/docs/assets/examples/colors_maps_from-three-colors.py b/docs/assets/examples/colors_maps_from-three-colors.py index 2b47024927e6..1e0fff6b7c57 100644 --- a/docs/assets/examples/colors_maps_from-three-colors.py +++ b/docs/assets/examples/colors_maps_from-three-colors.py @@ -1,5 +1,3 @@ -# type: ignore - from compas_viewer import Viewer from compas.colors import Color diff --git a/docs/assets/examples/colors_maps_from-two-colors.py b/docs/assets/examples/colors_maps_from-two-colors.py index 4840aead0a35..e7312e7176b6 100644 --- a/docs/assets/examples/colors_maps_from-two-colors.py +++ b/docs/assets/examples/colors_maps_from-two-colors.py @@ -1,5 +1,3 @@ -# type: ignore - from compas_viewer import Viewer from compas.colors import Color diff --git a/docs/assets/examples/colors_saturation.py b/docs/assets/examples/colors_saturation.py index 33a09d650b71..1f2b473550d1 100644 --- a/docs/assets/examples/colors_saturation.py +++ b/docs/assets/examples/colors_saturation.py @@ -1,5 +1,3 @@ -# type: ignore - from compas_viewer import Viewer from compas.colors import Color diff --git a/docs/assets/examples/quadmesh_planarize.py b/docs/assets/examples/quadmesh_planarize.py index d0543a3367a4..a18bd96b4184 100644 --- a/docs/assets/examples/quadmesh_planarize.py +++ b/docs/assets/examples/quadmesh_planarize.py @@ -1,13 +1,9 @@ -# type: ignore from compas_viewer import Viewer import compas from compas.colors import Color from compas.colors import ColorMap from compas.datastructures import Mesh - -# ! mesh_flatness is not available in compas.geometry -from compas.datastructures import mesh_flatness from compas.geometry import quadmesh_planarize mesh = Mesh.from_obj(compas.get("tubemesh.obj")) @@ -16,7 +12,7 @@ V2 = quadmesh_planarize((V, F), 500, 0.005) mesh = Mesh.from_vertices_and_faces(V2, F) -dev = mesh_flatness(mesh, maxdev=0.02) +dev = {face: mesh.face_flatness(face, maxdev=0.02) for face in mesh.faces()} cmap = ColorMap.from_two_colors(Color.white(), Color.blue()) facecolor = {} diff --git a/docs/assets/examples/scene.py b/docs/assets/examples/scene.py index 4a939a9f6392..8d79acc4625f 100644 --- a/docs/assets/examples/scene.py +++ b/docs/assets/examples/scene.py @@ -79,5 +79,5 @@ y += 5 -scene.print_hierarchy() +print(scene.tree.get_hierarchy_string()) scene.draw() diff --git a/docs/background/typing.md b/docs/background/typing.md new file mode 100644 index 000000000000..4b3404964b31 --- /dev/null +++ b/docs/background/typing.md @@ -0,0 +1,105 @@ +# Type Annotations + +COMPAS type annotations describe the values that an API can consume. They help +static type checkers find mistakes, but they do not add runtime validation or +conversion. + +## Numeric input families + +The public aliases in `compas.typing` cover the numeric families used by COMPAS APIs: + +- `RealInput` accepts the built-in `int` and `float` families and NumPy integer + and floating scalar families. +- `IntegralInput` accepts the built-in `int` family and the NumPy integer scalar + family. +- `NumericArray`, `FloatingArray`, and `IntegerArray` describe NumPy arrays by + scalar family. `Float64Array` and `Int32Array` describe the two cases where an + exact dtype is part of the contract. +- `FloatSequenceLike` describes a neutral indexed numeric sequence, such as a + quaternion. `PointLike` and `VectorLike` accept the same structure but state + whether an argument is positional or directional. +- `PointsLike`, `IndicesLike`, `PointGroupsLike`, and `IndexGroupsLike` combine + structural Python inputs with the corresponding NumPy array families. +- `VerticesFaces` is the single neutral structural `(vertices, faces)` tuple. + It can describe an input or result without requiring a `Mesh`, + `PolygonalSurface`, or `Tessellation` object. Whether faces must be triangles + is an operation contract, not part of the alias name. + +These aliases intentionally support duck typing. APIs should operate on the +capabilities they need instead of requiring a particular concrete container or +performing nominal type checks. Conversion belongs at an explicit boundary only +when an implementation requires a specific representation. + +Python's `bool` is a subclass of `int`, so static checkers include it in the +built-in integer family. NumPy's `bool_` is not a NumPy integer and is excluded. +An API that cannot meaningfully accept boolean values should reject them at its +own runtime boundary; the shared aliases do not add a blanket runtime check. + +Every member of `RealInput` supports `float(value)`. A coordinate element can +therefore be handed directly to an existing scalar setter that already +normalizes with `float(...)`, such as `point.x = point_like[0]`. This does not +authorize additional conversions in numerical functions: values continue to +participate in direct arithmetic where that is the established behavior. + +`CoordinateProtocol` includes length, iteration, integer indexing, and slicing. +A slice returns a neutral `Sequence`, because a two-component slice is not +necessarily a point or vector. `Point` and `Vector` explicitly implement this +protocol. `CoordinateProtocol` and `PointsProtocol` are runtime-checkable only +for existing structural equality behavior; the `Like` aliases are static +unions and must not be used as runtime validation gates. + +## Static types and runtime invariants + +An annotation cannot express every runtime requirement. For example, a numeric +array annotation does not specify that a coordinate has exactly three values, +that a collection has a particular rank, that every face of a +`VerticesFaces` has three indices, or that indices are within bounds. Such +requirements remain part of the documented API contract and should be checked +only where the operation needs them. + +Retained-buffer APIs keep the concrete type inferred for their primary buffers. +For example, `Tessellation` accepts Python structural buffers and NumPy numeric +arrays while its `vertices`, `triangles`, and `to_vertices_and_triangles()` +annotations preserve the concrete input buffer types. Optional aligned buffers +accept the same broad families without adding generic parameters. Nested +structural buffers are described by `IndicesProtocol`, `IndexGroupsProtocol`, and +`PointGroupsProtocol`, so compatible native wrappers do not have to inherit +from `Sequence`. Validation +uses the operations required by the invariants (`index` for triangle indices +and finiteness testing for coordinates); it does not copy, coerce, mutate, or +take ownership of supplied buffers. + +`Transformation` likewise retains a mutable nested-list matrix instead of +copying it. Python mutable lists are invariant, so a pre-bound +`list[list[int]]` or `list[list[numpy.float64]]` cannot be widened to the +matrix's writable element type soundly: a later floating-point assignment +would violate the caller's narrower annotation. Context-typed numeric literals +and core matrix results remain accepted. APIs using the neutral +`TransformationType` boundary also accept NumPy matrix arrays. Callers that +want retained mutable nested lists should annotate their elements with +`RealInput` or `float` at the ownership boundary. + +Copying constructors use the structural aliases when their existing ownership +path already consumes those values. `Plane`, `Line`, `Frame`, `Polyline`, +`Polygon`, and `Pointcloud` accept compatible Python and NumPy coordinates. +`PolygonalSurface` and `Polyhedron` additionally accept NumPy integral face +indices, normalize copied indices to Python `int`, and reject Python and NumPy +booleans as indices. No separate eager validation pass is implied. + +The former private `compas._typing` module and its overlapping coordinate and +mesh aliases were removed. Integrations should import the shared vocabulary +from `compas.typing`. + +## Static fixture convention + +Static typing behavior is characterized by `tests/typing/accepted.py` and +`tests/typing/expected_errors.py`. The accepted fixture must produce no Pyright +diagnostics. Every diagnostic in the negative fixture is declared on the same +line with an exact rule marker: + +```python +value: IntegralInput = 1.5 # expected-error: reportAssignmentType +``` + +The pytest harness compares the diagnostic file, line, and rule with these +markers. Missing, additional, or moved diagnostics therefore fail the tests. diff --git a/docs/background/workflow.md b/docs/background/workflow.md index 46d040b7e25a..132ee04188ec 100644 --- a/docs/background/workflow.md +++ b/docs/background/workflow.md @@ -127,3 +127,19 @@ The procedure for submitting a PR is the following. 6) Wait for the tests to pass and for the code to be reviewed. We review pull requests as soon as we can, typically within a week. If you get no review comments within two weeks, feel free to ask for feedback by adding a comment on your PR (this will notify maintainers). Thank you! + +## Candidate wheel artifacts + +The Linux Python 3.12 build is the producer for the core wheel used by the +framework-wide candidate integration gate. The build creates the wheel from a +clean `git archive`, verifies its contents and installed typing contract, and +then uploads that same file as the `candidate-linux-py312` Actions artifact. +The upload step does not rebuild the distribution and runs only after the +verification step succeeds. + +The artifact contains exactly one wheel and is retained for 14 days. Its +workflow run, source commit, artifact identifier, archive digest, wheel +filename, and wheel digest are recorded separately in the framework candidate +manifest. The framework gate accepts the artifact only when all recorded +provenance and digests match. Producing this artifact does not publish or +release COMPAS. diff --git a/docs/getting-started/advanced.serialisation.md b/docs/getting-started/advanced.serialisation.md index cdbfce85b429..b8a95ecb5b6b 100644 --- a/docs/getting-started/advanced.serialisation.md +++ b/docs/getting-started/advanced.serialisation.md @@ -94,10 +94,10 @@ The serialization mechanism applies recursively to nested structures of objects ```pycon >>> from compas.datastructures import Network, Mesh ->>> from compas.geometry import Point, Transformation, Box, Frame, Native +>>> from compas.geometry import Point, Transformation, Box, Frame >>> point = Point(0, 0, 0) >>> xform = Transformation() ->>> mesh = Box(Frame.worldXY(), 1, 1, 1).polygonize(Native()).to_mesh() +>>> mesh = Box(Frame.worldXY(), 1, 1, 1).to_polygonal_surface().to_mesh() >>> network = Network() >>> a = network.add_node(point=point) >>> b = network.add_node(transformation=xform) diff --git a/docs/getting-started/advanced.tessellation.md b/docs/getting-started/advanced.tessellation.md index 7c2679d494c4..6946fde0536b 100644 --- a/docs/getting-started/advanced.tessellation.md +++ b/docs/getting-started/advanced.tessellation.md @@ -1,244 +1,133 @@ # Geometry Discretization and Tessellation -COMPAS uses explicit verbs and result types for approximating continuous geometry. -The distinction matters because a sampled surface, a triangle buffer, and an editable -half-edge mesh have different guarantees and costs. - -## Terminology - -| Operation | Meaning | -| --- | --- | -| Sampling | Evaluate points, tangents, normals, or other quantities on continuous geometry. | -| Discretization | The broad category of operations that produce a finite representation of continuous geometry. | -| Polygonization | Apply `polygonize(strategy)` to continuous defining geometry and produce a persistent `PolygonalSurface`, retaining polygonal topology where possible. | -| Tessellation | Apply `tessellate(strategy)` to continuous defining geometry and produce an ephemeral, indexed `Tessellation` made exclusively of triangles. | -| Triangulation | Split existing polygonal faces, or construct a planar triangle complex from points and constraints. | -| Refinement | Add resolution while preserving the represented domain and constraints. | -| Remeshing | Reconstruct an existing mesh to improve element size, shape, or distribution. | - -Continuous defining geometry uses `polygonize(strategy)` and `tessellate()` or -`tessellate(strategy)`. Shape and Surface tessellation has a deterministic -default; polygonization and BRep tessellation require an explicit strategy. -Already polygonal geometry instead uses -`triangulate(strategy)` for in-place mutation or `triangulated(strategy)` for an -ephemeral triangle result. Refinement and remeshing are processing operations; -they are not synonyms for tessellation. +COMPAS separates structured polygonization, triangulation of existing discrete +geometry, and precision-controlled BRep tessellation. The operations produce +related representations, but their controls and guarantees are different. ## Choose the result representation | Type | Role | Serialized | Topology | | --- | --- | --- | --- | -| `Tessellation` | Short-lived rendering and triangle-processing input | No | Indexed triangles, optional aligned attributes and boundary polylines | -| `PolygonalSurface` | Lightweight persistent polygonal geometry | Yes | Indexed polygonal faces | -| `Mesh` | Persistent editable data structure | Yes | Half-edge topology and attributes | +| `Tessellation` | Short-lived rendering and triangle-processing result | No | Indexed triangles with optional aligned attributes and boundary polylines | +| `PolygonalSurface` | Lightweight persistent polygonal geometry | Yes | Indexed polygonal faces of arbitrary arity | +| `Mesh` | Persistent editable data structure | Yes | Half-edge topology, keys, and attributes | -Use `Tessellation` when the next operation consumes triangle buffers. Convert it -with `to_polygonal_surface()` or `to_mesh()` when an independent persistent object -is required. +## Structured shapes and surfaces -## Polygonize and tessellate a surface - -Strategies are immutable `compas.data.Data` configurations. Grid counts are positive -integers. Angular values use degrees at the public API boundary; linear values use -the model's units. +Shapes and untrimmed surfaces convert to a polygonal representation through +producer-owned resolution keywords. `nu` and `nv` count cells in the relevant +parameter directions; they are not geometric error bounds. ```python -from compas.geometry import FaceTriangulation from compas.geometry import PlanarSurface -from compas.geometry import UVGrid +from compas.geometry import Sphere surface = PlanarSurface(xsize=4.0, ysize=2.0) -strategy = UVGrid(u=8, v=4) +polygonal = surface.to_polygonal_surface(nu=8, nv=4) +triangles = surface.to_tessellation(nu=8, nv=4) -polygonal = surface.polygonize(strategy) -tessellation = surface.tessellate(strategy) -default_tessellation = surface.tessellate() # equivalent to UVGrid(64, 64) - -# Triangulation is also explicit when starting from polygonal geometry. -triangulated = polygonal.triangulated(FaceTriangulation()) +sphere = Sphere(1.0) +sphere_triangles = sphere.to_tessellation(nu=32, nv=16) ``` -The two triangle results need not have identical indexing. They do have the same -representation contract: exactly three valid indices per face and finite 3D vertex -coordinates. - -## Strategies +`to_tessellation(...)` uses exactly the vertices and triangle topology of +`to_polygonal_surface(...).triangulated()` with the same arguments. Surface +normals and texture coordinates are included when one aligned value exists for +each polygonized vertex. -`Native` requests fixed natural topology. `UGrid` and `UVGrid` request parameter-grid -sampling. `Deflection` supplies linear and angular targets, and `Adaptive` supplies -one or more of maximum deviation, normal angle, and edge length. A producer implements -only the strategies appropriate to its geometry. Producers validate the -supplied strategy directly and raise `TypeError` or `ValueError` when it is not -supported. Supported strategies are documented with each producer: +Default resolutions are: -```python -from compas.geometry import PlanarSurface -from compas.geometry import UVGrid - -surface = PlanarSurface() -result = surface.tessellate(UVGrid(16, 8)) -``` - -An omitted Shape or Surface tessellation strategy uses a fixed core default: - -| Geometry | Default strategy | +| Producer | Default controls | | --- | --- | -| `Box` | `Native()` | -| `Cylinder`, `Cone` | `UGrid(64)` | -| `Sphere`, `Torus`, `Capsule` | `UVGrid(64, 64)` | -| every `Surface` | `UVGrid(64, 64)` | +| `Box` | Natural six-face topology | +| `Cylinder`, `Cone` | `nu=64` | +| `Sphere`, `Torus`, `Capsule` | `nu=64, nv=64` | +| untrimmed `Surface` | `nu=64, nv=64` | -Grid values count cells, not sample points. Defaults are resolved for each -operation and are neither stored nor cached. `polygonize(strategy)` remains -explicit because its result is persistent. `Brep` and `BrepFace` tessellation -also remain explicit because their deflection targets have no universal default. +## Triangulate discrete geometry -Deflection and adaptive values are requests, not proof of achieved error. -Enriched results containing provenance and measured-error reports are deliberately -deferred; the current operation result is `Tessellation` itself. +Named functions select polygon algorithms. Aggregate `triangulated` methods +accept any callable with the same structural contract. An omitted selector or +explicit `None` uses `triangulate_polygon`. -## Triangulate polygonal geometry and planar domains - -`EarClipping` triangulates an existing planar polygon or `PlanarRegion` without -inserting free interior vertices. It supports holes and preserves the supplied -boundary vertices. Constrained Delaunay triangulation instead constructs a planar -domain triangulation and requires a plugin implementation of -`triangulate_planar_domain()`. +A callable returns exactly $n - 2$ nondegenerate triangles for an $n$-vertex +face. Indices refer to the ordered source vertices, every source boundary edge +appears once with the same direction, and every interior edge appears twice in +opposite directions. Aggregate operations validate this result before creating +or mutating topology. ```python -from compas.geometry import ConstrainedDelaunay -from compas.geometry import EarClipping -from compas.geometry import PlanarRegion -from compas.geometry import triangulate_planar_domain - -outer = [[0, 0, 0], [4, 0, 0], [4, 4, 0], [0, 4, 0]] -hole = [[1, 1, 0], [1, 3, 0], [3, 3, 0], [3, 1, 0]] - -region = PlanarRegion(outer, holes=[hole]) -local = region.triangulated(EarClipping()) - -# With a constrained-triangulation plugin such as compas_cgal installed: -domain = triangulate_planar_domain( - outer, - holes=[hole], - strategy=ConstrainedDelaunay(), -) -``` - -Use ear clipping for a faithful split of an existing face. Use constrained Delaunay -when constraints, interior points, or later quality refinement are part of the -problem. +from functools import partial -For a slightly warped polygon, projection must be explicit and bounded: +from compas.geometry import Polygon +from compas.geometry import triangulate_polygon_projected -```python -from compas.geometry import FaceTriangulation -from compas.geometry import PolygonalSurface -from compas.geometry import ProjectedEarClipping - -surface = PolygonalSurface( - [[0, 0, 0], [2, 0, 0.01], [2, 2, 0], [0, 2, -0.01]], - [[0, 1, 2, 3]], -) -strategy = FaceTriangulation( - quad=ProjectedEarClipping(max_deviation=0.01), - ngon=ProjectedEarClipping(max_deviation=0.01), -) -tessellation = surface.triangulated(strategy) +polygon = Polygon([[0, 0, 0], [2, 0, 0], [2, 2, 0.01], [0, 2, 0]]) +triangulator = partial(triangulate_polygon_projected, max_deviation=0.02) +result = polygon.triangulated(triangulator) ``` -`ProjectedEarClipping` uses a best-fit plane, rejects vertices beyond the maximum -deviation, and rejects degenerate or self-intersecting projections. It returns -indices into the original 3D vertices and never inserts vertices. +The public polygon operations are: -For a heavily twisted ordered 3D boundary that should not be projected, use the -minimum-area dynamic program explicitly: +- `triangulate_polygon` for the default arity-based behavior; +- `triangulate_quadrilateral` for explicit diagonal selection, with + `"shortest"` choosing the shortest valid interior diagonal; +- `triangulate_polygon_earclip` for planar polygons; +- `triangulate_polygon_projected` for bounded near-planar projection; and +- `triangulate_polygon_minimum_area` for a source-vertex minimum-area split. -```python -from compas.geometry import FaceTriangulation -from compas.geometry import MinimumAreaTriangulation -from compas.geometry import PolygonalSurface +`Mesh.triangulate()` modifies a mesh in place after staging every replacement. +`Mesh.triangulated()` returns an independent triangulated mesh of the same +concrete type, preserving the mesh data model. Numerical consumers extract its +structural buffers explicitly: -surface = PolygonalSurface( - [[0, 0, 0], [2, 0, 1], [3, 1, -1], [2, 3, 2], [0, 2, -1]], - [[0, 1, 2, 3, 4]], -) -strategy = FaceTriangulation( - quad=MinimumAreaTriangulation(), - ngon=MinimumAreaTriangulation(), -) -tessellation = surface.triangulated(strategy) +```python +triangle_mesh = mesh.triangulated() +vertices, triangles = triangle_mesh.to_vertices_and_faces() ``` -`MinimumAreaTriangulation` minimizes total triangle area using only the boundary -vertices. It takes $O(n^3)$ time and $O(n^2)$ space. The result is a -combinatorially manifold disk, but it is not guaranteed to be geometrically -intersection-free. [^barequet-sharir] - -[^barequet-sharir]: G. Barequet and M. Sharir, “Filling gaps in the boundary of a - polyhedron,” *Computer Aided Geometric Design* 12(2), 1995. - +`Polygon`, `PolygonalSurface`, and `Polyhedron` instead return an ephemeral +`Tessellation`. `PlanarRegion.triangulated()` also returns a `Tessellation` and +has no algorithm argument because its exterior-and-holes contract determines +the operation. -## Pass tessellations to geometry plugins +## Planar Delaunay operations -Triangle-processing plugin boundaries accept `Tessellation`, not raw -`(vertices, faces)` tuples or `Mesh`. This keeps one validation and ownership -contract across backends. +Planar point and domain functions return structural `(vertices, triangles)` +buffers. They accept Python and NumPy coordinate buffers without requiring a +COMPAS geometry representation. ```python -from compas.geometry import Native -from compas.geometry import Sphere -from compas.geometry import UVGrid -from compas.geometry import Box -from compas.geometry import boolean_difference_mesh_mesh - -box = Box(2.0).tessellate(Native()) -sphere = Sphere(1.25).tessellate(UVGrid(u=32, v=16)) -result = boolean_difference_mesh_mesh(box, sphere) -``` +from compas.geometry import constrained_delaunay_triangulation +from compas.geometry import delaunay_triangulation -The selected plugin remains authoritative for its algorithm. The same rule applies -to CGAL and libigl triangle-processing operations: callers pass a validated -`Tessellation`, and receive the documented result type. +points = [[0, 0, 0], [2, 0, 0], [2, 1, 0], [0, 1, 0]] +vertices, triangles = delaunay_triangulation(points) -## Trims, seams, singularities, and validation +boundary = [[0, 0, 0], [4, 0, 0], [4, 4, 0], [0, 4, 0]] +vertices, triangles = constrained_delaunay_triangulation(boundary) +``` -A tessellator must respect trimmed domains. BRep tessellations may expose sampled -trim and edge boundaries through `Tessellation.polylines`. Periodic seams and -parametric singularities may require duplicate vertices. Normals and texture -coordinates share vertex indexing, so a producer duplicates a vertex at a normal or -texture seam rather than introducing a second index buffer. +Inputs lie in world XY unless an explicit frame is supplied. The constrained +and conforming operations require a plugin. Conforming triangulation alone may +insert Steiner vertices and accepts the optional positive finite +`max_edge_length` refinement target. -Construction validates coordinate dimensions and finiteness, triangle arity and -index range, and alignment of normals and texture coordinates. Domain operations -add their own geometric checks: planarity, simple boundary loops, valid hole -containment, and non-intersection where applicable. +## BRep tessellation -`Tessellation` retains supplied buffers without copying or converting them. This -permits Python sequences, NumPy arrays, and compatible native buffers. Producers own -buffers they create; callers must treat all buffers as immutable for the lifetime of -the tessellation. `to_polygonal_surface()` and `to_mesh()` create independent -COMPAS objects. +BReps and trimmed BRep faces expose only precision-controlled tessellation: -## Migration from pre-3.0 APIs +```python +tessellation = brep.to_tessellation( + linear_deflection=0.01, + angular_deflection=15.0, +) +``` -COMPAS 3 intentionally has no compatibility aliases for the removed legacy -conversion methods. Make the desired operation and representation explicit, -and provide a strategy whenever the default tessellation density is not -appropriate. +Linear deflection uses model units and angular deflection uses degrees. Omitted +values resolve from `TOL.lineardeflection` and the degree equivalent of +`TOL.angulardeflection` before plugin dispatch. These values are requested +approximation controls, not proof that a backend achieved an error bound. -| Removed or old pattern | COMPAS 3 replacement | -| --- | --- | -| `to_tessellation()`, `to_tesselation()`, `compute_tessellation()` | `tessellate()` or `tessellate(strategy)` | -| `to_viewmesh()` for display triangles | `tessellate()` or `tessellate(strategy)` | -| `to_polygonal_surface()` or backend-specific mesh conversion from continuous geometry | `polygonize(strategy)` | -| Positional `u`, `v`, or resolution arguments | `UGrid(u)` or `UVGrid(u, v)` | -| Backend-specific tolerance arguments | `Deflection(linear, angular)` or a supported `Adaptive(...)` | -| `quads_to_triangles()` and implicit diagonal selection | `triangulate(FaceTriangulation(...))` or `triangulated(...)` | -| `to_vertices_and_faces(triangulated=True)` | `triangulated(strategy).to_vertices_and_triangles()` | -| Raw `(vertices, faces)` triangle-plugin input | `Tessellation(vertices, triangles)` | -| Planar Delaunay entry points | `triangulate_planar_points(..., strategy=Delaunay())` or `triangulate_planar_domain(...)` | - -In-place `triangulate(strategy)` returns `None`. Non-mutating -`triangulated(strategy)` returns a `Tessellation`. Polygonization and tessellation -never imply remeshing or refinement. +BReps do not provide `to_polygonal_surface`: their trimmed faces have no single +uniform parameter-grid structure to preserve. diff --git a/mkdocs.yml b/mkdocs.yml index bc1f90333edc..9b5eb2fc2f90 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -191,6 +191,7 @@ nav: - Development Setup: background/setup.md - Development Workflow: background/workflow.md - Contributing Code: background/code.md + - Type Annotations: background/typing.md - Documentation: background/documentation.md - Plugins: background/plugins.md - Extensions: background/extensions.md diff --git a/pyproject.toml b/pyproject.toml index 9275e14d8781..faa804118f21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ dependencies = [ "mapbox-earcut", "networkx >= 3.0", - "numpy >= 1.15.4", + "numpy >= 1.26", "scipy >= 1.1", "shapely", ] @@ -42,7 +42,7 @@ dev = [ "bump-my-version", "compas_invocations2", "invoke >=0.14", - "pyright", + "pyright == 1.1.414", "pytest", "pytest-cov", "pytest-dependency", @@ -92,6 +92,7 @@ version = { attr = "compas.__version__" } where = ["src"] [tool.setuptools.package-data] +compas = ["py.typed"] "compas.colors.cmcrameri" = ["*.txt"] "compas.data.samples" = ["*.obj", "*.stl", "*.off", "*.ply", "*.json"] @@ -169,5 +170,5 @@ docstring-code-format = true docstring-code-line-length = "dynamic" [tool.pyright] -include = ["src/compas"] +include = ["src/compas", "docs/assets/examples"] pythonVersion = "3.12" diff --git a/src/compas/_typing.py b/src/compas/_typing.py deleted file mode 100644 index 665420177946..000000000000 --- a/src/compas/_typing.py +++ /dev/null @@ -1,44 +0,0 @@ -from collections.abc import Iterator -from collections.abc import Sequence -from os import PathLike -from typing import Protocol -from typing import runtime_checkable - -FilePath = str | PathLike[str] - - -class FloatSequence(Protocol): - def __len__(self) -> int: ... - - def __getitem__(self, key: int) -> float: ... - - def __iter__(self) -> Iterator[float]: ... - - -FloatSequenceType = FloatSequence | Sequence[float] -RawCoordinateType = list[float] | tuple[float, float, float] - - -@runtime_checkable -class Coordinate(FloatSequence, Protocol): - pass - - -CoordinateType = Coordinate | Sequence[float] - - -@runtime_checkable -class Coordinates(Protocol): - def __len__(self) -> int: ... - - def __getitem__(self, key: int) -> CoordinateType: ... - - def __iter__(self) -> Iterator[CoordinateType]: ... - - -CoordinatesType = Coordinates | Sequence[CoordinateType] - -SphereType = tuple[CoordinateType, float] -MeshType = tuple[CoordinatesType, Sequence[Sequence[int]]] -RayType = tuple[CoordinateType, CoordinateType] -RayMeshHit = tuple[int, float, float, float] diff --git a/src/compas/data/data.py b/src/compas/data/data.py index a6b255dd8ca8..05468500b04c 100644 --- a/src/compas/data/data.py +++ b/src/compas/data/data.py @@ -7,7 +7,7 @@ from uuid import uuid4 import compas -from compas._typing import FilePath +from compas.typing import FilePath JSONFile = FilePath | TextIO diff --git a/src/compas/data/json.py b/src/compas/data/json.py index bca15fa582b1..d960053624e0 100644 --- a/src/compas/data/json.py +++ b/src/compas/data/json.py @@ -6,9 +6,9 @@ from typing import cast from compas import _iotools -from compas._typing import FilePath from compas.data import DataDecoder from compas.data import DataEncoder +from compas.typing import FilePath _JSON_CONTENT_FILENAME = "content.json" JSONFile = FilePath | TextIO diff --git a/src/compas/datastructures/cell_network/cell_network.py b/src/compas/datastructures/cell_network/cell_network.py index 5c51b4895c1d..6eaad18bb5c1 100644 --- a/src/compas/datastructures/cell_network/cell_network.py +++ b/src/compas/datastructures/cell_network/cell_network.py @@ -43,12 +43,13 @@ from compas.linalg.vectors import scale_vector from compas.linalg.vectors import subtract_vectors from compas.tolerance import TOL +from compas.typing import RealInput from .types import AttributeDict from .types import Cell from .types import Edge from .types import Face -from .types import PointCoordinates +from .types import PointLike from .types import Vertex _MISSING = object() @@ -269,7 +270,7 @@ def from_obj(cls, filepath: Any, precision: int | None = None) -> Self: @classmethod def from_vertices_and_cells( cls, - vertices: Sequence[PointCoordinates], + vertices: Sequence[PointLike], cells: Sequence[Sequence[Sequence[Vertex]]], ) -> Self: """Construct a cell network object from vertices and cells. @@ -1884,7 +1885,7 @@ def vertices_coordinates(self, vertices: Iterable[Vertex], axes: str = "xyz") -> Returns ------- - list of list[float] + list[list[float]] Coordinates of the vertices. """ @@ -4652,7 +4653,7 @@ def cell_polyhedron(self, cell: Cell) -> Polyhedron: vertices, faces = self.cell_to_vertices_and_faces(cell) return Polyhedron(vertices, faces) - def cell_volume(self, cell: Cell) -> float: + def cell_volume(self, cell: Cell) -> RealInput: """Compute the volume of a cell. Parameters diff --git a/src/compas/datastructures/cell_network/types.py b/src/compas/datastructures/cell_network/types.py index cf7dd5bd9584..88357727a089 100644 --- a/src/compas/datastructures/cell_network/types.py +++ b/src/compas/datastructures/cell_network/types.py @@ -1,9 +1,12 @@ -from collections.abc import Sequence from typing import Any +from typing import TypeAlias + +from compas.typing import PointLike as _PointLike + +PointLike: TypeAlias = _PointLike Vertex = int Face = int Cell = int Edge = tuple[Vertex, Vertex] AttributeDict = dict[str, Any] -PointCoordinates = Sequence[float] diff --git a/src/compas/datastructures/graph/graph.py b/src/compas/datastructures/graph/graph.py index d2095b7422f3..fd5647db22c6 100644 --- a/src/compas/datastructures/graph/graph.py +++ b/src/compas/datastructures/graph/graph.py @@ -35,6 +35,7 @@ from compas.topology import astar_shortest_path from compas.topology import breadth_first_traverse from compas.topology import connected_components +from compas.typing import PointLike from .duality import graph_find_cycles from .operations.join import graph_join_edges @@ -338,13 +339,13 @@ def from_nodes_and_edges( return graph @classmethod - def from_pointcloud(cls, cloud: Iterable[Sequence[float]], degree: int = 3) -> Self: + def from_pointcloud(cls, cloud: Iterable[PointLike], degree: int = 3) -> Self: """Construct a graph from random connections between the points of a pointcloud. Parameters ---------- cloud - A pointcloud object. + A point collection or iterator of XYZ point coordinates. degree The number of connections per node. @@ -462,10 +463,10 @@ def to_networkx(self) -> Any: import networkx as nx G = nx.DiGraph() - G.graph["name"] = self.name # type: ignore + G.graph["name"] = self.name for node, attr in self.nodes(data=True): - G.add_node(node, **attr) # type: ignore + G.add_node(node, **attr) for edge, attr in self.edges(data=True): G.add_edge(*edge, **attr) diff --git a/src/compas/datastructures/mesh/conway.py b/src/compas/datastructures/mesh/conway.py index c55bfa68bec1..93713a73726a 100644 --- a/src/compas/datastructures/mesh/conway.py +++ b/src/compas/datastructures/mesh/conway.py @@ -12,10 +12,10 @@ if TYPE_CHECKING: from .mesh import Mesh -MeshType = TypeVar("MeshType", bound="Mesh") +MeshT = TypeVar("MeshT", bound="Mesh") -def mesh_conway_dual(mesh: MeshType) -> MeshType: +def mesh_conway_dual(mesh: MeshT) -> MeshT: """Generates the dual mesh from a seed mesh. Parameters @@ -61,7 +61,7 @@ def mesh_conway_dual(mesh: MeshType) -> MeshType: return cls.from_vertices_and_faces(vertices, faces) -def mesh_conway_join(mesh: MeshType) -> MeshType: +def mesh_conway_join(mesh: MeshT) -> MeshT: """Generates the join mesh from a seed mesh. Parameters @@ -115,7 +115,7 @@ def mesh_conway_join(mesh: MeshType) -> MeshType: return join_mesh -def mesh_conway_ambo(mesh: MeshType) -> MeshType: +def mesh_conway_ambo(mesh: MeshT) -> MeshT: """Generates the ambo mesh from a seed mesh. Parameters @@ -153,7 +153,7 @@ def mesh_conway_ambo(mesh: MeshType) -> MeshType: return mesh_conway_dual(mesh_conway_join(mesh)) -def mesh_conway_kis(mesh: MeshType) -> MeshType: +def mesh_conway_kis(mesh: MeshT) -> MeshT: """Generates the kis mesh from a seed mesh. Parameters @@ -198,7 +198,7 @@ def mesh_conway_kis(mesh: MeshType) -> MeshType: return cls.from_vertices_and_faces(vertices, faces) -def mesh_conway_needle(mesh: MeshType) -> MeshType: +def mesh_conway_needle(mesh: MeshT) -> MeshT: """Generates the needle mesh from a seed mesh. Parameters @@ -236,7 +236,7 @@ def mesh_conway_needle(mesh: MeshType) -> MeshType: return mesh_conway_kis(mesh_conway_dual(mesh)) -def mesh_conway_zip(mesh: MeshType) -> MeshType: +def mesh_conway_zip(mesh: MeshT) -> MeshT: """Generates the zip mesh from a seed mesh. Parameters @@ -274,7 +274,7 @@ def mesh_conway_zip(mesh: MeshType) -> MeshType: return mesh_conway_dual(mesh_conway_kis(mesh)) -def mesh_conway_truncate(mesh: MeshType) -> MeshType: +def mesh_conway_truncate(mesh: MeshT) -> MeshT: """Generates the truncate mesh from a seed mesh. Parameters @@ -313,7 +313,7 @@ def mesh_conway_truncate(mesh: MeshType) -> MeshType: return mesh_conway_dual(mesh_conway_kis(mesh_conway_dual(mesh))) -def mesh_conway_ortho(mesh: MeshType) -> MeshType: +def mesh_conway_ortho(mesh: MeshT) -> MeshT: """Generates the ortho mesh from a seed mesh. Parameters @@ -351,7 +351,7 @@ def mesh_conway_ortho(mesh: MeshType) -> MeshType: return mesh_conway_join(mesh_conway_join(mesh)) -def mesh_conway_expand(mesh: MeshType) -> MeshType: +def mesh_conway_expand(mesh: MeshT) -> MeshT: """Generates the expand mesh from a seed mesh. Parameters @@ -389,7 +389,7 @@ def mesh_conway_expand(mesh: MeshType) -> MeshType: return mesh_conway_ambo(mesh_conway_ambo(mesh)) -def mesh_conway_gyro(mesh: MeshType) -> MeshType: +def mesh_conway_gyro(mesh: MeshT) -> MeshT: """Generates the gyro mesh from a seed mesh. Parameters @@ -448,7 +448,7 @@ def mesh_conway_gyro(mesh: MeshType) -> MeshType: return cls.from_vertices_and_faces(vertices, faces) -def mesh_conway_snub(mesh: MeshType) -> MeshType: +def mesh_conway_snub(mesh: MeshT) -> MeshT: """Generates the snub mesh from a seed mesh. Parameters @@ -486,7 +486,7 @@ def mesh_conway_snub(mesh: MeshType) -> MeshType: return mesh_conway_dual(mesh_conway_gyro(mesh_conway_dual(mesh))) -def mesh_conway_meta(mesh: MeshType) -> MeshType: +def mesh_conway_meta(mesh: MeshT) -> MeshT: """Generates the meta mesh from a seed mesh. Parameters @@ -524,7 +524,7 @@ def mesh_conway_meta(mesh: MeshType) -> MeshType: return mesh_conway_kis(mesh_conway_join(mesh)) -def mesh_conway_bevel(mesh: MeshType) -> MeshType: +def mesh_conway_bevel(mesh: MeshT) -> MeshT: """Generates the bevel mesh from a seed mesh. Parameters diff --git a/src/compas/datastructures/mesh/duality.py b/src/compas/datastructures/mesh/duality.py index c271b9cf8143..47b4aee1dc1f 100644 --- a/src/compas/datastructures/mesh/duality.py +++ b/src/compas/datastructures/mesh/duality.py @@ -5,16 +5,16 @@ if TYPE_CHECKING: from .mesh import Mesh -MeshType = TypeVar("MeshType", bound="Mesh") -DualMeshType = TypeVar("DualMeshType", bound="Mesh") +MeshT = TypeVar("MeshT", bound="Mesh") +DualVerticesFaces = TypeVar("DualVerticesFaces", bound="Mesh") @overload -def mesh_dual(mesh: MeshType, cls: None = None, include_boundary: bool = False) -> MeshType: ... +def mesh_dual(mesh: MeshT, cls: None = None, include_boundary: bool = False) -> MeshT: ... @overload -def mesh_dual(mesh: "Mesh", cls: type[DualMeshType], include_boundary: bool = False) -> DualMeshType: ... +def mesh_dual(mesh: "Mesh", cls: type[DualVerticesFaces], include_boundary: bool = False) -> DualVerticesFaces: ... def mesh_dual(mesh: "Mesh", cls: type["Mesh"] | None = None, include_boundary: bool = False) -> "Mesh": @@ -45,12 +45,11 @@ def mesh_dual(mesh: "Mesh", cls: type["Mesh"] | None = None, include_boundary: b -------- >>> import compas >>> from compas.datastructures import Mesh - >>> from compas.geometry import FaceTriangulation >>> mesh = Mesh.from_obj(compas.get("faces.obj")) >>> mesh.delete_face(11) >>> mesh.delete_face(6) >>> mesh.delete_face(7) - >>> mesh.triangulate(FaceTriangulation()) + >>> mesh.triangulate() >>> mesh = mesh.subdivided("corner") >>> dual = mesh.dual(include_boundary=True) diff --git a/src/compas/datastructures/mesh/mesh.py b/src/compas/datastructures/mesh/mesh.py index a9f8e63fd90c..c492ea853de0 100644 --- a/src/compas/datastructures/mesh/mesh.py +++ b/src/compas/datastructures/mesh/mesh.py @@ -25,7 +25,6 @@ from itertools import product from math import pi from random import sample -from typing import TYPE_CHECKING from typing import Any from typing import Literal from typing import Self @@ -69,6 +68,9 @@ from compas.geometry import normal_polygon from compas.geometry import oriented_bounding_box from compas.geometry import transform_points +from compas.geometry.triangulation import Triangulator +from compas.geometry.triangulation import _validate_triangulation +from compas.geometry.triangulation import triangulate_polygon from compas.itertools import linspace from compas.itertools import pairwise from compas.itertools import window @@ -85,13 +87,13 @@ from compas.topology import breadth_first_traverse from compas.topology import connected_components from compas.topology import unify_cycles +from compas.typing import IndexGroupsLike +from compas.typing import IndicesLike +from compas.typing import PointsLike +from compas.typing import RealInput from .duality import mesh_dual from .operations.collapse import mesh_collapse_edge - -if TYPE_CHECKING: - from compas.geometry import FaceTriangulation - from compas.geometry import Tessellation from .operations.merge import mesh_merge_faces from .operations.split import mesh_split_edge from .operations.split import mesh_split_face @@ -105,7 +107,7 @@ from .types import AttributeDict from .types import Edge from .types import Face -from .types import PointCoordinates +from .types import PointLike from .types import Vertex _MISSING = object() @@ -366,7 +368,7 @@ def from_off(cls, filepath: Any) -> Self: @classmethod def from_lines( cls, - lines: Iterable[Sequence[PointCoordinates]], + lines: Iterable[Sequence[PointLike]], delete_boundary_face: bool = False, precision: int | None = None, ) -> Self: @@ -405,8 +407,8 @@ def from_lines( @classmethod def from_polylines( cls, - boundary_polylines: list[list[PointCoordinates]], - other_polylines: list[list[PointCoordinates]], + boundary_polylines: list[list[PointLike]], + other_polylines: list[list[PointLike]], ) -> Self: """Construct mesh from polylines. @@ -479,8 +481,8 @@ def from_polylines( @classmethod def from_vertices_and_faces( cls, - vertices: Sequence[Iterable[float]] | Mapping[Vertex, Iterable[float]], - faces: Sequence[Sequence[Vertex]] | Mapping[Face, Sequence[Vertex]], + vertices: PointsLike | Mapping[Vertex, PointLike | Iterable[float]], + faces: IndexGroupsLike | Mapping[Face, IndicesLike], ) -> Self: """Construct a mesh object from a list of vertices and faces. @@ -510,10 +512,10 @@ def from_vertices_and_faces( if isinstance(faces, Mapping): for fkey, face_vertices in faces.items(): - mesh.add_face(face_vertices, fkey) + mesh.add_face(list(face_vertices), fkey) else: for face in iter(faces): - mesh.add_face(face) + mesh.add_face(list(face)) return mesh @@ -536,7 +538,7 @@ def from_polyhedron(cls, f: int) -> Self: return cls.from_vertices_and_faces(p.vertices, p.faces) @classmethod - def from_points(cls, points: Sequence[PointCoordinates]) -> Self: + def from_points(cls, points: Sequence[PointLike]) -> Self: """Construct a mesh from a planar Delaunay triangulation of points. Parameters @@ -551,16 +553,15 @@ def from_points(cls, points: Sequence[PointCoordinates]) -> Self: A mesh object. """ - from compas.geometry import Delaunay - from compas.geometry import triangulate_planar_points + from compas.geometry import delaunay_triangulation - tessellation = triangulate_planar_points(points, strategy=Delaunay()) - return cls.from_vertices_and_faces(tessellation.vertices, tessellation.triangles) + vertices, faces = delaunay_triangulation(points) + return cls.from_vertices_and_faces(vertices, faces) @classmethod def from_polygons( cls, - polygons: Iterable[Sequence[PointCoordinates]], + polygons: Iterable[Sequence[PointLike]], precision: int | None = None, ) -> Self: """Construct a mesh from a series of polygons. @@ -683,33 +684,31 @@ def to_vertices_and_faces(self) -> tuple[list[list[float]], list[list[int]]]: faces = [[vertex_index[vertex] for vertex in self.face_vertices(face)] for face in self.faces()] return vertices, faces - def triangulated(self, strategy: "FaceTriangulation") -> "Tessellation": - """Triangulate the faces without modifying the mesh. + def triangulated(self, triangulator: Triangulator | None = None) -> Self: + """Return an independent triangulated copy of the mesh. Parameters ---------- - strategy - The mixed-face triangulation strategy. + triangulator + A callable that maps each face's ordered vertices to triangle + indices local to that face. It should return a complete, + nondegenerate triangulation with the winding of the source + boundary. If `None`, use `triangulate_polygon`. Returns ------- - Tessellation + Self + A triangulated mesh of the same concrete type. - """ - from compas.geometry import FaceTriangulation - from compas.geometry import Tessellation - from compas.geometry.triangulation import triangulate_polygon + Notes + ----- + The returned mesh follows standard `Data.copy` identity semantics and + therefore has a new GUID. The source mesh is not modified. - if not isinstance(strategy, FaceTriangulation): - raise TypeError("strategy should be a FaceTriangulation.") - vertex_index = self.vertex_index() - vertices = [self.vertex_coordinates(vertex) for vertex in self.vertices()] - triangles = [] - for face in self.faces(): - face_vertices = self.face_vertices(face) - local = triangulate_polygon([self.vertex_coordinates(vertex) for vertex in face_vertices], strategy) - triangles.extend([[vertex_index[face_vertices[index]] for index in triangle] for triangle in local]) - return Tessellation(vertices, triangles) + """ + mesh = self.copy() + mesh.triangulate(triangulator) + return mesh def to_points(self) -> list[list[float]]: """Convert the mesh to a collection of points. @@ -1056,7 +1055,7 @@ def add_vertex( def add_face( self, - vertices: Sequence[Vertex], + vertices: IndicesLike, fkey: Face | None = None, attr_dict: Mapping[str, Any] | None = None, **kwattr: Any, @@ -1097,9 +1096,9 @@ def add_face( highest integer key value, then the highest integer value is updated accordingly. """ + vertices = [int(key) for key in vertices] if vertices[-1] == vertices[0]: vertices = vertices[:-1] - vertices = [int(key) for key in vertices] vertices[:] = [u for u, v in pairwise(vertices + vertices[:1]) if u != v] if len(vertices) < 3: return @@ -1155,9 +1154,8 @@ def join(self, other: "Mesh", weld: bool = False, precision: int | None = None) >>> b = Box.from_width_height_depth(1, 1, 1) >>> T = Translation.from_vector([2, 0, 0]) >>> b.transform(T) - >>> from compas.geometry import Native - >>> a = a.polygonize(Native()).to_mesh() - >>> b = b.polygonize(Native()).to_mesh() + >>> a = a.to_polygonal_surface().to_mesh() + >>> b = b.to_polygonal_surface().to_mesh() >>> a.number_of_vertices() 8 >>> a.number_of_faces() @@ -1328,7 +1326,7 @@ def insert_vertex( self, fkey: Face, key: Vertex | None = None, - xyz: PointCoordinates | None = None, + xyz: PointLike | None = None, return_fkeys: bool = False, ) -> Vertex | tuple[Vertex, list[Face]]: """Insert a vertex in the specified face. @@ -2118,7 +2116,7 @@ def vertex_attributes( self, key: Vertex, names: Sequence[str] | None = None, - values: Sequence[Any] | None = None, + values: Iterable[Any] | None = None, ) -> Any: """Get or set multiple attributes of a vertex. @@ -3287,38 +3285,44 @@ def remove_duplicate_vertices(self, precision: int | None = None) -> None: if u not in self.halfedge[v]: self.halfedge[v][u] = None - def triangulate(self, strategy: "FaceTriangulation") -> None: + def triangulate(self, triangulator: Triangulator | None = None) -> None: """Triangulate the faces in place. Parameters ---------- - strategy - The mixed-face triangulation strategy. + triangulator + A callable that maps each face's ordered vertices to triangle + indices local to that face. It should return a complete, + nondegenerate triangulation with the winding of the source + boundary. If `None`, use `triangulate_polygon`. Notes ----- - Generated faces inherit the attributes of their source face. Vertex - keys are preserved; face keys are not. + Existing triangular faces retain their keys and attributes. Generated + faces inherit the attributes of their source face, but their keys and + iteration order are not guaranteed. Vertex keys and attributes on + pre-existing edges are preserved; new diagonals receive the default + edge attributes. """ - from compas.geometry import FaceTriangulation - from compas.geometry.triangulation import triangulate_polygon - - if not isinstance(strategy, FaceTriangulation): - raise TypeError("strategy should be a FaceTriangulation.") + if triangulator is None: + triangulator = triangulate_polygon replacements = [] for face in list(self.faces()): vertices = self.face_vertices(face) if len(vertices) == 3: continue - local = triangulate_polygon([self.vertex_coordinates(vertex) for vertex in vertices], strategy) + points = [self.vertex_coordinates(vertex) for vertex in vertices] + local = _validate_triangulation(triangulator(points), points) triangles = [[vertices[index] for index in triangle] for triangle in local] replacements.append((face, triangles, dict(self.face_attributes(face)))) + edgedata = self.edgedata.copy() for face, triangles, attributes in replacements: self.delete_face(face) for triangle in triangles: self.add_face(triangle, attr_dict=attributes) + self.edgedata.update(edgedata) # only reason this is here and not on the halfedge is because of the spatial tree def unify_cycles( @@ -4283,7 +4287,7 @@ def area(self) -> float: """ return sum(self.face_area(fkey) for fkey in self.faces()) - def volume(self, copy: bool = True, unify_cycles: bool = True) -> float | None: + def volume(self, copy: bool = True, unify_cycles: bool = True) -> RealInput | None: """Calculate the volume of the mesh. Parameters @@ -4337,11 +4341,8 @@ def volume(self, copy: bool = True, unify_cycles: bool = True) -> float | None: if unify_cycles: mesh_to_use.unify_cycles() - # Use built-in triangulation to get triangulated faces - from compas.geometry import FaceTriangulation - - tessellation = mesh_to_use.triangulated(FaceTriangulation()) - vertices, faces = tessellation.to_vertices_and_triangles() + triangle_mesh = mesh_to_use.triangulated() + vertices, faces = triangle_mesh.to_vertices_and_faces() volume = 0.0 for face in faces: @@ -4355,7 +4356,7 @@ def volume(self, copy: bool = True, unify_cycles: bool = True) -> float | None: return abs(volume) - def centroid(self) -> list[float]: + def centroid(self) -> list[RealInput]: """Calculate the mesh centroid. Returns @@ -4369,7 +4370,7 @@ def centroid(self) -> list[float]: 1.0 / self.area(), ) - def normal(self) -> list[float]: + def normal(self) -> list[RealInput]: """Calculate the average mesh normal. Returns @@ -4463,7 +4464,7 @@ def vertices_points(self, vertices: Iterable[Vertex]) -> list[Point]: """ return [self.vertex_point(vertex) for vertex in vertices] - def set_vertex_point(self, vertex: Vertex, point: PointCoordinates) -> None: + def set_vertex_point(self, vertex: Vertex, point: PointLike) -> None: """Set the point of a vertex. Parameters @@ -5211,7 +5212,7 @@ def vertices_on_boundaries(self) -> list[list[Vertex]]: key = vertices_all[0] def length(boundary: list[Vertex]) -> float: - return sum(self.edge_length(edge) for edge in pairwise(boundary + boundary[:1])) # type: ignore + return sum(self.edge_length(edge) for edge in pairwise(boundary + boundary[:1])) return sorted(boundaries, key=length, reverse=True) diff --git a/src/compas/datastructures/mesh/slice.py b/src/compas/datastructures/mesh/slice.py index 406361632070..a01291897bde 100644 --- a/src/compas/datastructures/mesh/slice.py +++ b/src/compas/datastructures/mesh/slice.py @@ -2,6 +2,8 @@ from typing import Generic from typing import TypeVar +import numpy as np + from compas.geometry import Plane from compas.geometry import intersection_segment_plane from compas.linalg.vectors import dot_vectors @@ -13,10 +15,10 @@ if TYPE_CHECKING: from .mesh import Mesh -MeshType = TypeVar("MeshType", bound="Mesh") +MeshT = TypeVar("MeshT", bound="Mesh") -def mesh_slice_plane(mesh: MeshType, plane: Plane) -> tuple[MeshType, MeshType] | None: +def mesh_slice_plane(mesh: MeshT, plane: Plane) -> tuple[MeshT, MeshT] | None: """Slice a mesh with a plane and construct the resulting submeshes. Parameters @@ -48,11 +50,10 @@ def mesh_slice_plane(mesh: MeshType, plane: Plane) -> tuple[MeshType, MeshType] -------- >>> from compas.geometry import Plane >>> from compas.geometry import Box - >>> from compas.geometry import Native >>> from compas.datastructures import Mesh >>> plane = Plane((0, 0, 0), (1, 0, 0)) >>> box = Box.from_width_height_depth(1, 1, 1) - >>> mesh = box.polygonize(Native()).to_mesh() + >>> mesh = box.to_polygonal_surface().to_mesh() >>> result = mesh_slice_plane(mesh, plane) >>> len(result) == 2 True @@ -64,12 +65,12 @@ def mesh_slice_plane(mesh: MeshType, plane: Plane) -> tuple[MeshType, MeshType] return intersection.split() -class IntersectionMeshPlane(Generic[MeshType]): +class IntersectionMeshPlane(Generic[MeshT]): # ============================================================================= # Dunder methods # ============================================================================= - def __init__(self, mesh: MeshType, plane: Plane): + def __init__(self, mesh: MeshT, plane: Plane): self.mesh = mesh self.plane = plane self._intersections: list[Vertex] = [] @@ -80,7 +81,7 @@ def __init__(self, mesh: MeshType, plane: Plane): # ============================================================================= @property - def meshtype(self) -> type[MeshType]: + def meshtype(self) -> type[MeshT]: return type(self.mesh) @property @@ -108,7 +109,7 @@ def is_mesh_closed(self) -> bool: return self.mesh.is_closed() @property - def positive(self) -> MeshType | None: + def positive(self) -> MeshT | None: if self.is_none: return vertices = [] @@ -127,7 +128,7 @@ def positive(self) -> MeshType | None: return mesh @property - def negative(self) -> MeshType | None: + def negative(self) -> MeshT | None: if self.is_none: return vertices = [] @@ -160,7 +161,7 @@ def is_positive(self, key: Vertex) -> bool: return True return False - def is_negative(self, key: Vertex) -> bool: + def is_negative(self, key: Vertex) -> bool | np.bool_: o = self.plane.point n = self.plane.normal if key in self.intersections: @@ -196,7 +197,7 @@ def intersect(self) -> None: vertex_intersections.append(v) self._intersections = intersections - def split(self) -> tuple[MeshType, MeshType]: + def split(self) -> tuple[MeshT, MeshT]: for fkey in list(self.mesh.faces()): split = [key for key in self.mesh.face_vertices(fkey) if key in self.intersections] if len(split) == 2: diff --git a/src/compas/datastructures/mesh/subdivision.py b/src/compas/datastructures/mesh/subdivision.py index 85beb1b28932..516f8e8ab689 100644 --- a/src/compas/datastructures/mesh/subdivision.py +++ b/src/compas/datastructures/mesh/subdivision.py @@ -19,7 +19,7 @@ if TYPE_CHECKING: from .mesh import Mesh -MeshType = TypeVar("MeshType", bound="Mesh") +MeshT = TypeVar("MeshT", bound="Mesh") SubdivisionScheme = Literal["tri", "quad", "corner", "catmullclark", "doosabin", "frames", "loop"] @@ -60,7 +60,7 @@ def insert_vertex(self, fkey): return SubdMesh -def mesh_fast_copy(other: MeshType) -> MeshType: +def mesh_fast_copy(other: MeshT) -> MeshT: SubdMesh = subd_factory(type(other)) subd = SubdMesh() subd.vertex = deepcopy(other.vertex) @@ -82,7 +82,7 @@ def mesh_fast_copy(other: MeshType) -> MeshType: # any subd algorithm should return a new subd mesh, leaving the control mesh intact -def mesh_subdivide(mesh: MeshType, scheme: SubdivisionScheme = "catmullclark", **options: Any) -> MeshType: +def mesh_subdivide(mesh: MeshT, scheme: SubdivisionScheme = "catmullclark", **options: Any) -> MeshT: """Subdivide the input mesh. Parameters @@ -123,7 +123,7 @@ def mesh_subdivide(mesh: MeshType, scheme: SubdivisionScheme = "catmullclark", * raise ValueError(f"Subdivision scheme is not supported: {scheme}") -def mesh_subdivide_tri(mesh: MeshType, k: int = 1) -> MeshType: +def mesh_subdivide_tri(mesh: MeshT, k: int = 1) -> MeshT: """Subdivide a mesh using simple insertion of vertices. Parameters @@ -141,10 +141,9 @@ def mesh_subdivide_tri(mesh: MeshType, k: int = 1) -> MeshType: Examples -------- >>> from compas.geometry import Box - >>> from compas.geometry import Native >>> from compas.datastructures import Mesh >>> box = Box.from_corner_corner_height([0.0, 0.0, 0.0], [1.0, 1.0, 0.0], 1.0) - >>> mesh = box.polygonize(Native()).to_mesh() + >>> mesh = box.to_polygonal_surface().to_mesh() >>> k = 2 >>> subd = mesh_subdivide_tri(mesh, k=k) >>> mesh is subd @@ -164,7 +163,7 @@ def mesh_subdivide_tri(mesh: MeshType, k: int = 1) -> MeshType: return cls.__from_data__(subd.__data__) -def mesh_subdivide_quad(mesh: MeshType, k: int = 1) -> MeshType: +def mesh_subdivide_quad(mesh: MeshT, k: int = 1) -> MeshT: """Subdivide a mesh such that all faces are quads. Parameters @@ -187,10 +186,9 @@ def mesh_subdivide_quad(mesh: MeshType, k: int = 1) -> MeshType: Examples -------- >>> from compas.geometry import Box - >>> from compas.geometry import Native >>> from compas.datastructures import Mesh >>> box = Box.from_corner_corner_height([0.0, 0.0, 0.0], [1.0, 1.0, 0.0], 1.0) - >>> mesh = box.polygonize(Native()).to_mesh() + >>> mesh = box.to_polygonal_surface().to_mesh() >>> k = 2 >>> subd = mesh_subdivide_quad(mesh, k=k) >>> mesh is subd @@ -229,7 +227,7 @@ def mesh_subdivide_quad(mesh: MeshType, k: int = 1) -> MeshType: return subd2 -def mesh_subdivide_corner(mesh: MeshType, k: int = 1) -> MeshType: +def mesh_subdivide_corner(mesh: MeshT, k: int = 1) -> MeshT: """Subdivide a mesh by cutting corners. Parameters @@ -273,10 +271,10 @@ def mesh_subdivide_corner(mesh: MeshType, k: int = 1) -> MeshType: def mesh_subdivide_catmullclark( - mesh: MeshType, + mesh: MeshT, k: int = 1, fixed: Collection[Vertex] | None = None, -) -> MeshType: +) -> MeshT: """Subdivide a mesh using the Catmull-Clark algorithm. Parameters @@ -314,10 +312,9 @@ def mesh_subdivide_catmullclark( Examples -------- >>> from compas.geometry import Box - >>> from compas.geometry import Native >>> from compas.datastructures import Mesh >>> box = Box.from_corner_corner_height([0.0, 0.0, 0.0], [1.0, 1.0, 0.0], 1.0) - >>> mesh = box.polygonize(Native()).to_mesh() + >>> mesh = box.to_polygonal_surface().to_mesh() >>> k = 2 >>> subd = mesh_subdivide_catmullclark(mesh, k=k) >>> mesh is subd @@ -337,11 +334,10 @@ def mesh_subdivide_catmullclark( to a number higher than the number of subdivision steps. >>> from compas.geometry import Box - >>> from compas.geometry import Native >>> from compas.linalg import dot_vectors >>> from compas.datastructures import Mesh - >>> cage = Box.from_width_height_depth(1, 1, 1).polygonize(Native()).to_mesh() + >>> cage = Box.from_width_height_depth(1, 1, 1).to_polygonal_surface().to_mesh() >>> cage.update_default_edge_attributes({"crease": 0}) >>> top = sorted(cage.faces(), key=lambda face: dot_vectors(cage.face_normal(face), [0, 0, 1]))[-1] >>> cage.edges_attribute("crease", 5, keys=list(cage.face_halfedges(top))) @@ -467,10 +463,10 @@ def mesh_subdivide_catmullclark( def mesh_subdivide_doosabin( - mesh: MeshType, + mesh: MeshT, k: int = 1, fixed: Collection[Vertex] | None = None, -) -> MeshType: +) -> MeshT: """Subdivide a mesh following the doo-sabin scheme. Parameters @@ -495,10 +491,9 @@ def mesh_subdivide_doosabin( Examples -------- >>> from compas.geometry import Box - >>> from compas.geometry import Native >>> from compas.datastructures import Mesh >>> box = Box.from_corner_corner_height([0.0, 0.0, 0.0], [1.0, 1.0, 0.0], 1.0) - >>> mesh = box.polygonize(Native()).to_mesh() + >>> mesh = box.to_polygonal_surface().to_mesh() >>> k = 2 >>> subd = mesh_subdivide_doosabin(mesh, k=k) >>> mesh is subd @@ -587,10 +582,10 @@ def mesh_subdivide_doosabin( def mesh_subdivide_frames( - mesh: MeshType, + mesh: MeshT, offset: float | Mapping[Face, float], add_windows: bool = False, -) -> MeshType: +) -> MeshT: """Subdivide a mesh by creating offset frames and windows on its faces. Parameters @@ -660,10 +655,10 @@ def mesh_subdivide_frames( def trimesh_subdivide_loop( - mesh: MeshType, + mesh: MeshT, k: int = 1, fixed: Collection[Vertex] | None = None, -) -> MeshType: +) -> MeshT: """Subdivide a triangle mesh using the Loop algorithm. Parameters @@ -691,12 +686,10 @@ def trimesh_subdivide_loop( Triangulate the faces. >>> from compas.geometry import Box - >>> from compas.geometry import FaceTriangulation - >>> from compas.geometry import Native >>> from compas.datastructures import Mesh >>> box = Box.from_corner_corner_height([0.0, 0.0, 0.0], [1.0, 1.0, 0.0], 1.0) - >>> mesh = box.polygonize(Native()).to_mesh() - >>> mesh.triangulate(FaceTriangulation()) + >>> mesh = box.to_polygonal_surface().to_mesh() + >>> mesh.triangulate() Subdivide 2 times. diff --git a/src/compas/datastructures/mesh/types.py b/src/compas/datastructures/mesh/types.py index de87f8f96454..f18f8425282e 100644 --- a/src/compas/datastructures/mesh/types.py +++ b/src/compas/datastructures/mesh/types.py @@ -1,8 +1,11 @@ -from collections.abc import Sequence from typing import Any +from typing import TypeAlias + +from compas.typing import PointLike as _PointLike + +PointLike: TypeAlias = _PointLike Vertex = int Face = int Edge = tuple[Vertex, Vertex] AttributeDict = dict[str, Any] -PointCoordinates = Sequence[float] diff --git a/src/compas/datastructures/volmesh/types.py b/src/compas/datastructures/volmesh/types.py index 9d0c69af8fd0..7f6685bab690 100644 --- a/src/compas/datastructures/volmesh/types.py +++ b/src/compas/datastructures/volmesh/types.py @@ -1,5 +1,10 @@ from collections.abc import Sequence from typing import Any +from typing import TypeAlias + +from compas.typing import PointLike as _PointLike + +PointLike: TypeAlias = _PointLike Vertex = int Halfface = int @@ -7,6 +12,5 @@ Cell = int Edge = tuple[Vertex, Vertex] AttributeDict = dict[str, Any] -PointCoordinates = Sequence[float] FaceVertices = Sequence[Vertex] CellFaces = Sequence[FaceVertices] diff --git a/src/compas/datastructures/volmesh/volmesh.py b/src/compas/datastructures/volmesh/volmesh.py index 3220af76664d..f29f3a00438b 100644 --- a/src/compas/datastructures/volmesh/volmesh.py +++ b/src/compas/datastructures/volmesh/volmesh.py @@ -63,7 +63,7 @@ from .types import Face from .types import FaceVertices from .types import Halfface -from .types import PointCoordinates +from .types import PointLike from .types import Vertex _MISSING = object() @@ -366,7 +366,7 @@ def from_obj(cls, filepath: Any, precision: int | None = None) -> Self: @classmethod def from_vertices_and_cells( cls, - vertices: Sequence[PointCoordinates] | Mapping[Vertex, PointCoordinates], + vertices: Sequence[PointLike] | Mapping[Vertex, PointLike], cells: Sequence[CellFaces], ) -> Self: """Construct a volmesh object from vertices and cells. @@ -993,7 +993,7 @@ def delete_vertex(self, vertex: Vertex) -> None: Parameters ---------- - vertex : int + vertex The identifier of the vertex. Returns @@ -1906,7 +1906,7 @@ def vertex_coordinates(self, vertex: Vertex, axes: str = "xyz") -> list[float]: ---------- vertex : int The identifier of the vertex. - axes : str, optional + axes The axes alon which to take the coordinates. Should be a combination of x, y, and z. @@ -1917,7 +1917,9 @@ def vertex_coordinates(self, vertex: Vertex, axes: str = "xyz") -> list[float]: See Also -------- - vertex_point, vertex_laplacian, vertex_neighborhood_centroid + [`vertex_point`][compas.datastructures.VolMesh.vertex_point] + [`vertex_laplacian`][compas.datastructures.VolMesh.vertex_laplacian] + [`vertex_neighborhood_centroid`][compas.datastructures.VolMesh.vertex_neighborhood_centroid] """ return [self._vertex[vertex][axis] for axis in axes] diff --git a/src/compas/files/gltf/gltf_conversions.py b/src/compas/files/gltf/gltf_conversions.py index 5d76cfa7817a..ea1630636ee4 100644 --- a/src/compas/files/gltf/gltf_conversions.py +++ b/src/compas/files/gltf/gltf_conversions.py @@ -324,9 +324,7 @@ def mesh_to_gltf_primitive(mesh: Mesh) -> PrimitiveData: Converted primitive. """ - from compas.geometry import FaceTriangulation - - vertices, faces = mesh.triangulated(FaceTriangulation()).to_vertices_and_triangles() + vertices, faces = mesh.triangulated().to_vertices_and_faces() indices = [index for face in faces for index in face] primitive = PrimitiveData({"POSITION": vertices}, indices, mode=MODE_TRIANGLES) texture_coordinates = mesh.vertices_attribute("texture_coordinate") diff --git a/src/compas/files/gltf/gltf_document.py b/src/compas/files/gltf/gltf_document.py index b76d165016e1..c0b86e8019c9 100644 --- a/src/compas/files/gltf/gltf_document.py +++ b/src/compas/files/gltf/gltf_document.py @@ -1,7 +1,6 @@ from copy import deepcopy from typing import TYPE_CHECKING from typing import Any -from typing import cast from compas.files.gltf.data_classes import AnimationData from compas.files.gltf.data_classes import CameraData @@ -317,19 +316,19 @@ def update_scene_transforms_and_positions(self, scene: GLTFScene) -> None: origin = [0, 0, 0] for node_key in scene.children: node = self.nodes[node_key] - node.transform = node.matrix or node.get_matrix_from_trs() - node.position = transform_points([origin], node.transform)[0] - queue = [node_key] + node_transform = node.matrix or node.get_matrix_from_trs() + node.transform = node_transform + node.position = transform_points([origin], node_transform)[0] + queue = [(node_key, node_transform)] while queue: - cur_key = queue.pop(0) + cur_key, parent_transform = queue.pop(0) cur = self.nodes[cur_key] for child_key in cur.children: child = self.nodes[child_key] - # Every node is assigned a transform before it enters the queue. - parent_transform = cast(list[list[float]], cur.transform) - child.transform = multiply_matrices(parent_transform, child.matrix or child.get_matrix_from_trs()) - child.position = transform_points([origin], child.transform)[0] - queue.append(child_key) + child_transform = multiply_matrices(parent_transform, child.matrix or child.get_matrix_from_trs()) + child.transform = child_transform + child.position = transform_points([origin], child_transform)[0] + queue.append((child_key, child_transform)) def get_node_faces(self, node: GLTFNode) -> list[tuple[int, ...]] | None: """Return the faces of the mesh attached to a node. diff --git a/src/compas/files/gltf/gltf_node.py b/src/compas/files/gltf/gltf_node.py index b7d2d92ab734..66bbc8515ec8 100644 --- a/src/compas/files/gltf/gltf_node.py +++ b/src/compas/files/gltf/gltf_node.py @@ -9,6 +9,7 @@ from compas.linalg.transformations import matrix_from_quaternion from compas.linalg.transformations import matrix_from_scale_factors from compas.linalg.transformations import matrix_from_translation +from compas.typing import RealInput if TYPE_CHECKING: from compas.datastructures import Mesh @@ -16,6 +17,8 @@ from .gltf_document import GLTFDocument from .gltf_mesh import GLTFMesh +type Matrix = list[list[float]] | list[list[RealInput]] + class GLTFNode: """Object representing the COMPAS consumable part of a glTF node. @@ -87,8 +90,8 @@ def __init__( self._mesh_key = None self.weights: list[float] | None = None - self.position: list[float] | None = None - self.transform: list[list[float]] | None = None + self.position: list[RealInput] | None = None + self.transform: Matrix | None = None self._key = None self._camera = None @@ -192,13 +195,13 @@ def scale(self, value: list[float] | None) -> None: self._scale = value @property - def matrix(self) -> list[list[float]] | None: + def matrix(self) -> Matrix | None: if not (self.translation or self.rotation or self.scale or self._matrix): return identity_matrix(4) return self._matrix @matrix.setter - def matrix(self, value: list[list[float]] | None) -> None: + def matrix(self, value: Matrix | None) -> None: if value is None: self._matrix = value return @@ -232,7 +235,7 @@ def faces(self): # Methods # ============================================================================= - def get_matrix_from_trs(self) -> list[list[float]]: + def get_matrix_from_trs(self) -> Matrix: """Compose the node's translation, rotation, and scale into a matrix. Returns diff --git a/src/compas/geometry/__init__.py b/src/compas/geometry/__init__.py index 7cac437f3576..6eb50c0f23bb 100644 --- a/src/compas/geometry/__init__.py +++ b/src/compas/geometry/__init__.py @@ -231,8 +231,9 @@ ) from .quadmesh_planarize import quadmesh_planarize from .triangulation_delaunay import ( - triangulate_planar_domain, - triangulate_planar_points, + conforming_delaunay_triangulation, + constrained_delaunay_triangulation, + delaunay_triangulation, ) from .triangulation_earclip import earclip_polygon from .trimesh import ( @@ -247,8 +248,6 @@ trimesh_isolines, trimesh_lscm, trimesh_massmatrix, - trimesh_mean_curvature, - trimesh_principal_curvature, trimesh_pull_points, trimesh_remesh, trimesh_remesh_along_isoline, @@ -277,23 +276,13 @@ from .line import Line from .polyline import Polyline from .pointcloud import Pointcloud -from .tessellation import Adaptive -from .tessellation import Deflection -from .tessellation import Native from .tessellation import Tessellation -from .tessellation import TessellationStrategy -from .tessellation import UGrid -from .tessellation import UVGrid -from .triangulation import ConformingDelaunay -from .triangulation import ConstrainedDelaunay -from .triangulation import Delaunay -from .triangulation import DiagonalSplit -from .triangulation import EarClipping -from .triangulation import FaceTriangulation -from .triangulation import MinimumAreaTriangulation -from .triangulation import ProjectedEarClipping -from .triangulation import TriangulationQuality -from .triangulation import TriangulationStrategy +from .triangulation import Triangulator +from .triangulation import triangulate_polygon +from .triangulation import triangulate_polygon_earclip +from .triangulation import triangulate_polygon_minimum_area +from .triangulation import triangulate_polygon_projected +from .triangulation import triangulate_quadrilateral from .curves.curve import Curve from .curves.circle import Circle @@ -535,8 +524,9 @@ "offset_polyline", "offset_polygon", "quadmesh_planarize", - "triangulate_planar_domain", - "triangulate_planar_points", + "conforming_delaunay_triangulation", + "constrained_delaunay_triangulation", + "delaunay_triangulation", "earclip_polygon", "trimesh_cotangent_laplacian_matrix", "trimesh_descent", @@ -549,8 +539,6 @@ "trimesh_isolines", "trimesh_lscm", "trimesh_massmatrix", - "trimesh_mean_curvature", - "trimesh_principal_curvature", "trimesh_pull_points", "trimesh_remesh", "trimesh_remesh_along_isoline", @@ -578,23 +566,13 @@ "Polyline", "Pointcloud", "IntersectionResult", - "Adaptive", - "Deflection", - "Native", "Tessellation", - "TessellationStrategy", - "UGrid", - "UVGrid", - "ConformingDelaunay", - "ConstrainedDelaunay", - "Delaunay", - "DiagonalSplit", - "EarClipping", - "FaceTriangulation", - "MinimumAreaTriangulation", - "ProjectedEarClipping", - "TriangulationQuality", - "TriangulationStrategy", + "Triangulator", + "triangulate_polygon", + "triangulate_polygon_earclip", + "triangulate_polygon_minimum_area", + "triangulate_polygon_projected", + "triangulate_quadrilateral", "Curve", "Circle", "Ellipse", diff --git a/src/compas/geometry/_core/angles.py b/src/compas/geometry/_core/angles.py index 336b43ecf155..88f571d52921 100644 --- a/src/compas/geometry/_core/angles.py +++ b/src/compas/geometry/_core/angles.py @@ -3,7 +3,6 @@ from math import degrees from math import pi -from compas._typing import CoordinateType from compas.linalg.vectors import cross_vectors from compas.linalg.vectors import dot_vectors from compas.linalg.vectors import dot_vectors_xy @@ -12,9 +11,11 @@ from compas.linalg.vectors import subtract_vectors from compas.linalg.vectors import subtract_vectors_xy from compas.tolerance import TOL +from compas.typing import PointLike +from compas.typing import VectorLike -def angle_vectors(u: CoordinateType, v: CoordinateType, deg: bool = False, tol: float | None = None) -> float: +def angle_vectors(u: VectorLike, v: VectorLike, deg: bool = False, tol: float | None = None) -> float: """Compute the smallest angle between two vectors. Parameters @@ -73,9 +74,9 @@ def angle_vectors(u: CoordinateType, v: CoordinateType, deg: bool = False, tol: def angle_vectors_signed( - u: CoordinateType, - v: CoordinateType, - normal: CoordinateType, + u: VectorLike, + v: VectorLike, + normal: VectorLike, deg: bool = False, tol: float | None = None, ) -> float: @@ -167,7 +168,7 @@ def angle_vectors_projected( return angle_vectors_signed(u_cross, v_cross, normal, deg, tol) -def angle_vectors_xy(u: Sequence[float], v: Sequence[float], deg: bool = False, tol: float | None = None) -> float: +def angle_vectors_xy(u: VectorLike, v: VectorLike, deg: bool = False, tol: float | None = None) -> float: """Compute the smallest angle between the XY components of two vectors. Parameters @@ -199,7 +200,7 @@ def angle_vectors_xy(u: Sequence[float], v: Sequence[float], deg: bool = False, return acos(a) -def angle_points(a: CoordinateType, b: CoordinateType, c: CoordinateType, deg: bool = False) -> float: +def angle_points(a: PointLike, b: PointLike, c: PointLike, deg: bool = False) -> float: r"""Compute the smallest angle between the vectors defined by three points. Parameters @@ -273,7 +274,7 @@ def angle_points_xy(a: Sequence[float], b: Sequence[float], c: Sequence[float], return angle_vectors_xy(u, v, deg) -def angles_vectors(u: CoordinateType, v: CoordinateType, deg: bool = False) -> tuple[float, float]: +def angles_vectors(u: VectorLike, v: VectorLike, deg: bool = False) -> tuple[float, float]: """Compute the the 2 angles formed by a pair of vectors. Parameters @@ -298,7 +299,7 @@ def angles_vectors(u: CoordinateType, v: CoordinateType, deg: bool = False) -> t return a, pi * 2 - a -def angles_vectors_xy(u: Sequence[float], v: Sequence[float], deg: bool = False) -> tuple[float, float]: +def angles_vectors_xy(u: VectorLike, v: VectorLike, deg: bool = False) -> tuple[float, float]: """Compute the angles between the XY components of two vectors. Parameters diff --git a/src/compas/geometry/_core/centroids.py b/src/compas/geometry/_core/centroids.py index f3a73053ffb1..ac925f66eeb3 100644 --- a/src/compas/geometry/_core/centroids.py +++ b/src/compas/geometry/_core/centroids.py @@ -1,8 +1,7 @@ from collections.abc import Sequence from math import fabs +from operator import index -from compas._typing import CoordinatesType -from compas._typing import CoordinateType from compas.itertools import pairwise from compas.linalg.vectors import add_vectors from compas.linalg.vectors import cross_vectors @@ -14,9 +13,13 @@ from compas.linalg.vectors import subtract_vectors from compas.linalg.vectors import subtract_vectors_xy from compas.linalg.vectors import sum_vectors +from compas.typing import PointLike +from compas.typing import PointsLike +from compas.typing import RealInput +from compas.typing import VerticesFaces -def midpoint_point_point(a: Sequence[float], b: Sequence[float]) -> list[float]: +def midpoint_point_point(a: PointLike, b: PointLike) -> list[RealInput]: """Compute the midpoint of two points. Parameters @@ -35,7 +38,7 @@ def midpoint_point_point(a: Sequence[float], b: Sequence[float]) -> list[float]: return [0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1]), 0.5 * (a[2] + b[2])] -def midpoint_point_point_xy(a: Sequence[float], b: Sequence[float]) -> list[float]: +def midpoint_point_point_xy(a: PointLike, b: PointLike) -> list[RealInput]: """Compute the midpoint of two points lying in the XY-plane. Parameters @@ -54,7 +57,7 @@ def midpoint_point_point_xy(a: Sequence[float], b: Sequence[float]) -> list[floa return [0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1]), 0.0] -def midpoint_line(line: Sequence[Sequence[float]]) -> list[float]: +def midpoint_line(line: PointsLike) -> list[RealInput]: """Compute the midpoint of a line defined by two points. Parameters @@ -76,7 +79,7 @@ def midpoint_line(line: Sequence[Sequence[float]]) -> list[float]: return midpoint_point_point(*line) -def midpoint_line_xy(line: Sequence[Sequence[float]]) -> list[float]: +def midpoint_line_xy(line: PointsLike) -> list[RealInput]: """Compute the midpoint of a line defined by two points. Parameters @@ -98,7 +101,7 @@ def midpoint_line_xy(line: Sequence[Sequence[float]]) -> list[float]: return midpoint_point_point_xy(*line) -def centroid_points(points: CoordinatesType) -> list[float]: +def centroid_points(points: PointsLike) -> list[RealInput]: """Compute the centroid of a set of points. Parameters @@ -128,7 +131,7 @@ def centroid_points(points: CoordinatesType) -> list[float]: return [sum(x) / p, sum(y) / p, sum(z) / p] -def centroid_points_weighted(points: Sequence[Sequence[float]], weights: Sequence[float]) -> list[float]: +def centroid_points_weighted(points: Sequence[Sequence[float]], weights: Sequence[float]) -> list[RealInput]: """Compute the weighted centroid of a set of points. The weights can be any between minus and plus infinity. Parameters @@ -149,7 +152,7 @@ def centroid_points_weighted(points: Sequence[Sequence[float]], weights: Sequenc return vector -def centroid_points_xy(points: Sequence[Sequence[float]]) -> list[float]: +def centroid_points_xy(points: PointsLike) -> list[RealInput]: """Compute the centroid of a set of points lying in the XY-plane. Parameters @@ -179,7 +182,7 @@ def centroid_points_xy(points: Sequence[Sequence[float]]) -> list[float]: return [sum(x) / p, sum(y) / p, 0.0] -def centroid_polygon(polygon: CoordinatesType) -> CoordinateType: +def centroid_polygon(polygon: PointsLike) -> PointLike: r"""Compute the centroid of the surface of a polygon. Parameters @@ -189,7 +192,7 @@ def centroid_polygon(polygon: CoordinatesType) -> CoordinateType: Returns ------- - CoordinateType + PointLike The XYZ coordinates of the centroid. Raises @@ -285,7 +288,7 @@ def centroid_polygon(polygon: CoordinatesType) -> CoordinateType: return [cx / A2, cy / A2, cz / A2] -def centroid_polygon_xy(polygon: Sequence[Sequence[float]]) -> list[float]: +def centroid_polygon_xy(polygon: PointsLike) -> list[RealInput]: r"""Compute the centroid of the surface of a polygon projected to the XY plane. Parameters @@ -384,7 +387,7 @@ def centroid_polygon_xy(polygon: Sequence[Sequence[float]]) -> list[float]: return [cx / A2, cy / A2, 0.0] -def centroid_polygon_vertices(polygon: Sequence[Sequence[float]]) -> list[float]: +def centroid_polygon_vertices(polygon: PointsLike) -> list[RealInput]: """Compute the centroid of the vertices of a polygon. Parameters @@ -401,7 +404,7 @@ def centroid_polygon_vertices(polygon: Sequence[Sequence[float]]) -> list[float] return centroid_points(polygon) -def centroid_polygon_vertices_xy(polygon: Sequence[Sequence[float]]) -> list[float]: +def centroid_polygon_vertices_xy(polygon: PointsLike) -> list[RealInput]: """Compute the centroid of the vertices of a polygon projected to the XY plane. Parameters @@ -490,7 +493,7 @@ def centroid_polygon_edges_xy(polygon: Sequence[Sequence[float]]) -> list[float] return [cx / L, cy / L, 0.0] -def centroid_polyhedron(polyhedron: tuple[list[Sequence[float]], Sequence[list[int]]]) -> list[float]: +def centroid_polyhedron(polyhedron: VerticesFaces) -> list[RealInput]: """Compute the center of mass of a polyhedron. Parameters @@ -529,18 +532,14 @@ def centroid_polyhedron(polyhedron: tuple[list[Sequence[float]], Sequence[list[i ez = [0.0, 0.0, 1.0] for face in faces: - if len(face) == 3: - triangles = [face] + indices = [index(vertex) for vertex in face] + if len(indices) == 3: + triangles = [(vertices[indices[0]], vertices[indices[1]], vertices[indices[2]])] else: - centroid = centroid_points([vertices[index] for index in face]) - w = len(vertices) - vertices.append(centroid) - triangles = [[w, u, v] for u, v in pairwise(face + face[0:1])] - - for triangle in triangles: - a = vertices[triangle[0]] - b = vertices[triangle[1]] - c = vertices[triangle[2]] + centroid = centroid_points([vertices[i] for i in indices]) + triangles = [(centroid, vertices[u], vertices[v]) for u, v in pairwise(indices + indices[0:1])] + + for a, b, c in triangles: ab = subtract_vectors(b, a) ac = subtract_vectors(c, a) n = cross_vectors(ab, ac) diff --git a/src/compas/geometry/_core/distance.py b/src/compas/geometry/_core/distance.py index 7f9acb1705a7..d811fce1205b 100644 --- a/src/compas/geometry/_core/distance.py +++ b/src/compas/geometry/_core/distance.py @@ -10,8 +10,6 @@ from numpy.typing import ArrayLike from numpy.typing import NDArray -from compas._typing import CoordinatesType -from compas._typing import CoordinateType from compas.itertools import pairwise from compas.linalg.vectors import add_vectors from compas.linalg.vectors import add_vectors_xy @@ -29,9 +27,12 @@ from compas.linalg.vectors import vector_component from compas.linalg.vectors import vector_component_xy from compas.tolerance import TOL +from compas.typing import PointLike +from compas.typing import PointsLike +from compas.typing import RealInput -def distance_point_point(a: CoordinateType, b: CoordinateType) -> float: +def distance_point_point(a: PointLike, b: PointLike) -> float: """Compute the distance between two points. Parameters @@ -60,7 +61,7 @@ def distance_point_point(a: CoordinateType, b: CoordinateType) -> float: return length_vector(ab) -def distance_point_point_xy(a: CoordinateType, b: CoordinateType) -> float: +def distance_point_point_xy(a: PointLike, b: PointLike) -> float: """Compute the distance between points a and b, assuming they lie in the XY plane. Parameters @@ -91,7 +92,7 @@ def distance_point_point_xy(a: CoordinateType, b: CoordinateType) -> float: return length_vector_xy(ab) -def distance_point_point_sqrd(a: CoordinateType, b: CoordinateType) -> float: +def distance_point_point_sqrd(a: PointLike, b: PointLike) -> RealInput: """Compute the squared distance between points `a` and `b`. Parameters @@ -120,7 +121,7 @@ def distance_point_point_sqrd(a: CoordinateType, b: CoordinateType) -> float: return length_vector_sqrd(ab) -def distance_point_point_sqrd_xy(a: Sequence[float], b: Sequence[float]) -> float: +def distance_point_point_sqrd_xy(a: PointLike, b: PointLike) -> RealInput: """Compute the squared distance between points a and b lying in the XY plane. Parameters @@ -151,7 +152,7 @@ def distance_point_point_sqrd_xy(a: Sequence[float], b: Sequence[float]) -> floa return length_vector_sqrd_xy(ab) -def distance_point_line(point: CoordinateType, line: CoordinatesType) -> float: +def distance_point_line(point: PointLike, line: PointsLike) -> float: """Compute the distance between a point and a line. Parameters @@ -186,7 +187,7 @@ def distance_point_line(point: CoordinateType, line: CoordinatesType) -> float: return length / length_ab -def distance_point_line_xy(point: CoordinateType, line: CoordinatesType) -> float: +def distance_point_line_xy(point: PointLike, line: PointsLike) -> float: """Compute the distance between a point and a line, assuming they lie in the XY-plane. Parameters @@ -221,7 +222,7 @@ def distance_point_line_xy(point: CoordinateType, line: CoordinatesType) -> floa return length / length_ab -def distance_point_line_sqrd(point: Sequence[float], line: Sequence[Sequence[float]]) -> float: +def distance_point_line_sqrd(point: PointLike, line: PointsLike) -> RealInput: """Compute the squared distance between a point and a line. Parameters @@ -254,7 +255,7 @@ def distance_point_line_sqrd(point: Sequence[float], line: Sequence[Sequence[flo return length / length_ab -def distance_point_line_sqrd_xy(point: Sequence[float], line: Sequence[Sequence[float]]) -> float: +def distance_point_line_sqrd_xy(point: PointLike, line: PointsLike) -> RealInput: """Compute the squared distance between a point and a line lying in the XY-plane. Parameters @@ -289,7 +290,7 @@ def distance_point_line_sqrd_xy(point: Sequence[float], line: Sequence[Sequence[ return length / length_ab -def distance_point_plane(point: CoordinateType, plane: CoordinatesType) -> float: +def distance_point_plane(point: PointLike, plane: PointsLike) -> float: r"""Compute the distance from a point to a plane defined by origin point and normal. Parameters @@ -338,7 +339,7 @@ def distance_point_plane(point: CoordinateType, plane: CoordinatesType) -> float return fabs(distance_point_plane_signed(point, plane)) -def distance_point_plane_signed(point: CoordinateType, plane: CoordinatesType) -> float: +def distance_point_plane_signed(point: PointLike, plane: PointsLike) -> RealInput: r"""Compute the signed distance from a point to a plane defined by origin point and normal. Parameters @@ -389,7 +390,7 @@ def distance_point_plane_signed(point: CoordinateType, plane: CoordinatesType) - return dot_vectors(vector, normal) -def distance_line_line(l1: Sequence[Sequence[float]], l2: Sequence[Sequence[float]], tol: float | None = None) -> float: +def distance_line_line(l1: PointsLike, l2: PointsLike, tol: float | None = None) -> float: r"""Compute the shortest distance between two lines. Parameters @@ -437,7 +438,7 @@ def distance_line_line(l1: Sequence[Sequence[float]], l2: Sequence[Sequence[floa # ============================================================================== -def sort_points(point: CoordinateType, cloud: CoordinatesType) -> list[tuple[float, CoordinateType, int]]: +def sort_points(point: PointLike, cloud: PointsLike) -> list[tuple[RealInput, PointLike, int]]: """Sorts points of a pointcloud based on their distance from a given point. Parameters @@ -463,7 +464,7 @@ def sort_points(point: CoordinateType, cloud: CoordinatesType) -> list[tuple[flo return sorted(zip(minsq, cloud, range(len(cloud))), key=lambda x: x[0]) -def sort_points_xy(point: Sequence[float], cloud: Sequence[Sequence[float]]) -> list[tuple[float, Sequence[float], int]]: +def sort_points_xy(point: PointLike, cloud: PointsLike) -> list[tuple[RealInput, PointLike, int]]: """Sorts points of a pointcloud based on their distance from a given point, assuming all points lie in the XY plane. @@ -490,7 +491,7 @@ def sort_points_xy(point: Sequence[float], cloud: Sequence[Sequence[float]]) -> return sorted(zip(minsq, cloud, range(len(cloud))), key=lambda x: x[0]) -def closest_point_in_cloud(point: CoordinateType, cloud: CoordinatesType) -> tuple[float, CoordinateType, int]: +def closest_point_in_cloud(point: PointLike, cloud: PointsLike) -> tuple[float, PointLike, int]: """Calculates the closest point in a pointcloud. Parameters @@ -586,7 +587,7 @@ def closest_points_in_cloud_numpy( return indices -def closest_point_in_cloud_xy(point: Sequence[float], cloud: Sequence[Sequence[float]]) -> tuple[float, Sequence[float], int]: +def closest_point_in_cloud_xy(point: PointLike, cloud: PointsLike) -> tuple[float, PointLike, int]: """Calculates the closest point in a list of points in the XY-plane. Parameters @@ -611,7 +612,7 @@ def closest_point_in_cloud_xy(point: Sequence[float], cloud: Sequence[Sequence[f return sqrt(d), xyz, index -def closest_point_on_line(point: CoordinateType, line: CoordinatesType) -> list[float]: +def closest_point_on_line(point: PointLike, line: PointsLike) -> list[RealInput]: """Computes closest point on line to a given point. Parameters @@ -638,7 +639,7 @@ def closest_point_on_line(point: CoordinateType, line: CoordinatesType) -> list[ return add_vectors(a, c) -def closest_point_on_line_xy(point: Sequence[float], line: Sequence[Sequence[float]]) -> list[float]: +def closest_point_on_line_xy(point: PointLike, line: PointsLike) -> list[RealInput]: """Compute closest point on line (continuous) to a given point lying in the XY-plane. Parameters @@ -661,7 +662,7 @@ def closest_point_on_line_xy(point: Sequence[float], line: Sequence[Sequence[flo return add_vectors_xy(a, c) -def closest_point_on_segment(point: CoordinateType, segment: CoordinatesType) -> CoordinateType: +def closest_point_on_segment(point: PointLike, segment: PointsLike) -> PointLike: """Computes closest point on line segment (p1, p2) to test point. Parameters @@ -689,7 +690,7 @@ def closest_point_on_segment(point: CoordinateType, segment: CoordinatesType) -> return p -def closest_point_on_segment_xy(point: Sequence[float], segment: Sequence[Sequence[float]]) -> list[float]: +def closest_point_on_segment_xy(point: PointLike, segment: PointsLike) -> list[RealInput]: """Compute closest point on a line segment to a given point lying in the XY-plane. Parameters @@ -717,7 +718,7 @@ def closest_point_on_segment_xy(point: Sequence[float], segment: Sequence[Sequen return p -def closest_point_on_polyline(point: CoordinateType, polyline: CoordinatesType) -> CoordinateType: +def closest_point_on_polyline(point: PointLike, polyline: PointsLike) -> PointLike: """Find the closest point on a polyline to a given point. Parameters @@ -742,7 +743,7 @@ def closest_point_on_polyline(point: CoordinateType, polyline: CoordinatesType) return closest_point_in_cloud(point, cloud)[1] -def closest_point_on_polyline_xy(point: Sequence[float], polyline: Sequence[Sequence[float]]) -> Sequence[float]: +def closest_point_on_polyline_xy(point: PointLike, polyline: PointsLike) -> list[RealInput]: """Compute closest point on a polyline to a given point, assuming they both lie in the XY-plane. @@ -757,7 +758,7 @@ def closest_point_on_polyline_xy(point: Sequence[float], polyline: Sequence[Sequ Returns ------- - Sequence[float] + list[RealInput] XYZ coordinates of closest point in the XY plane. """ @@ -766,10 +767,11 @@ def closest_point_on_polyline_xy(point: Sequence[float], polyline: Sequence[Sequ for segment in pairwise(polyline): cloud.append(closest_point_on_segment_xy(point, segment)) - return closest_point_in_cloud_xy(point, cloud)[1] + index = closest_point_in_cloud_xy(point, cloud)[2] + return cloud[index] -def closest_point_on_polygon_xy(point: Sequence[float], polygon: Sequence[Sequence[float]]) -> Sequence[float]: +def closest_point_on_polygon_xy(point: PointLike, polygon: PointsLike) -> list[RealInput]: """Compute closest point on a polygon to a given point lying in the XY-plane. Parameters @@ -784,7 +786,7 @@ def closest_point_on_polygon_xy(point: Sequence[float], polygon: Sequence[Sequen Returns ------- - Sequence[float] + list[RealInput] XYZ coordinates of closest point in the XY plane. """ @@ -793,10 +795,11 @@ def closest_point_on_polygon_xy(point: Sequence[float], polygon: Sequence[Sequen segment = polygon[i - 1], polygon[i] points.append(closest_point_on_segment_xy(point, segment)) - return closest_point_in_cloud_xy(point, points)[1] + index = closest_point_in_cloud_xy(point, points)[2] + return points[index] -def closest_point_on_plane(point: Sequence[float], plane: Sequence[Sequence[float]]) -> list[float]: +def closest_point_on_plane(point: PointLike, plane: PointsLike) -> list[RealInput]: """Compute closest point on a plane to a given point. Parameters diff --git a/src/compas/geometry/_core/intersections.py b/src/compas/geometry/_core/intersections.py index 1e08db35a482..9c0cb9929ed9 100644 --- a/src/compas/geometry/_core/intersections.py +++ b/src/compas/geometry/_core/intersections.py @@ -3,9 +3,6 @@ from math import sqrt from typing import Literal -from compas._typing import CoordinatesType -from compas._typing import CoordinateType -from compas._typing import SphereType from compas.linalg.vectors import add_vectors from compas.linalg.vectors import cross_vectors from compas.linalg.vectors import dot_vectors @@ -16,6 +13,10 @@ from compas.linalg.vectors import subtract_vectors from compas.linalg.vectors import subtract_vectors_xy from compas.tolerance import TOL +from compas.typing import PointLike +from compas.typing import PointsLike +from compas.typing import RealInput +from compas.typing import SphereData from .distance import distance_point_point from .predicates_3 import is_point_in_triangle @@ -34,22 +35,20 @@ "intersection_sphere_sphere", ] -PointCoordinates = tuple[float, float, float] +type PointTuple = tuple[RealInput, RealInput, RealInput] SphereSphereIntersection = ( - tuple[Literal["point"], list[float]] | tuple[Literal["circle"], tuple[list[float], float, list[float]]] | tuple[Literal["sphere"], tuple[list[float], float]] + tuple[Literal["point"], list[RealInput]] | tuple[Literal["circle"], tuple[list[RealInput], float, list[RealInput]]] | tuple[Literal["sphere"], tuple[list[RealInput], float]] ) CircleCircleXYIntersection = ( - tuple[Literal["point"], PointCoordinates] - | tuple[Literal["points"], tuple[PointCoordinates, PointCoordinates]] - | tuple[Literal["circle"], tuple[PointCoordinates, float, PointCoordinates]] + tuple[Literal["point"], PointTuple] | tuple[Literal["points"], tuple[PointTuple, PointTuple]] | tuple[Literal["circle"], tuple[PointTuple, RealInput, PointTuple]] ) def closest_points_line_line( - line1: Sequence[CoordinateType], - line2: Sequence[CoordinateType], + line1: Sequence[PointLike], + line2: Sequence[PointLike], tol: float | None = None, -) -> tuple[list[float] | None, list[float] | None]: +) -> tuple[list[RealInput] | None, list[RealInput] | None]: a, b = line1 c, d = line2 ab = subtract_vectors(b, a) @@ -67,10 +66,10 @@ def closest_points_line_line( def intersection_line_plane( - line: Sequence[CoordinateType], - plane: Sequence[CoordinateType], + line: PointsLike, + plane: PointsLike, tol: float | None = None, -) -> list[float] | None: +) -> list[RealInput] | None: a, b = line o, n = plane ab = subtract_vectors(b, a) @@ -85,10 +84,10 @@ def intersection_line_plane( def intersection_line_line_xy( - line1: CoordinatesType, - line2: CoordinatesType, + line1: PointsLike, + line2: PointsLike, tol: float | None = None, -) -> PointCoordinates | None: +) -> PointTuple | None: a, b = line1 c, d = line2 x1, y1 = a[0], a[1] @@ -108,8 +107,8 @@ def intersection_line_line_xy( def intersection_circle_circle_xy( - circle1: tuple[CoordinatesType, float], - circle2: tuple[CoordinatesType, float], + circle1: tuple[PointsLike, RealInput], + circle2: tuple[PointsLike, RealInput], tol: float | None = None, ) -> CircleCircleXYIntersection | None: plane1, radius1 = circle1 @@ -157,9 +156,9 @@ def intersection_circle_circle_xy( def intersection_ellipse_line_xy( ellipse: tuple[float, float], - line: CoordinatesType, + line: PointsLike, tol: float | None = None, -) -> PointCoordinates | tuple[PointCoordinates, PointCoordinates] | None: +) -> PointTuple | tuple[PointTuple, PointTuple] | None: x1, y1 = line[0][0], line[0][1] x2, y2 = line[1][0], line[1][1] major, minor = ellipse @@ -189,10 +188,10 @@ def intersection_ellipse_line_xy( def intersection_plane_plane( - plane1: Sequence[CoordinateType], - plane2: Sequence[CoordinateType], + plane1: Sequence[PointLike], + plane2: Sequence[PointLike], tol: float | None = None, -) -> tuple[list[float], list[float]] | None: +) -> tuple[list[RealInput], list[RealInput]] | None: o1, n1 = plane1 o2, n2 = plane2 direction = cross_vectors(n1, n2) @@ -208,8 +207,8 @@ def intersection_plane_plane( def intersection_sphere_sphere( - sphere1: SphereType, - sphere2: SphereType, + sphere1: SphereData, + sphere2: SphereData, tol: float | None = None, ) -> SphereSphereIntersection | None: center1, radius1 = sphere1 @@ -243,10 +242,10 @@ def intersection_sphere_sphere( def intersection_sphere_line( - sphere: SphereType, - line: Sequence[CoordinateType], + sphere: SphereData, + line: Sequence[PointLike], tol: float | None = None, -) -> PointCoordinates | tuple[PointCoordinates, PointCoordinates] | None: +) -> PointTuple | tuple[PointTuple, PointTuple] | None: start, end = line center, radius = sphere @@ -298,10 +297,10 @@ def intersection_sphere_line( def intersection_plane_circle( - plane: Sequence[CoordinateType], - circle: tuple[Sequence[CoordinateType], float], + plane: Sequence[PointLike], + circle: tuple[Sequence[PointLike], RealInput], tol: float | None = None, -) -> PointCoordinates | tuple[PointCoordinates, PointCoordinates] | None: +) -> PointTuple | tuple[PointTuple, PointTuple] | None: circle_plane, circle_radius = circle line = intersection_plane_plane(plane, circle_plane, tol=tol) if line is None: @@ -310,10 +309,10 @@ def intersection_plane_circle( def intersection_segment_plane( - segment: Sequence[CoordinateType], - plane: Sequence[CoordinateType], + segment: Sequence[PointLike], + plane: Sequence[PointLike], tol: float | None = None, -) -> list[float] | None: +) -> list[RealInput] | None: a, b = segment o, n = plane ab = subtract_vectors(b, a) @@ -330,10 +329,10 @@ def intersection_segment_plane( def intersection_line_triangle( - line: Sequence[CoordinateType], - triangle: Sequence[CoordinateType], + line: PointsLike, + triangle: PointsLike, tol: float | None = None, -) -> list[float] | None: +) -> list[RealInput] | None: a, b, c = triangle ab = subtract_vectors(b, a) ac = subtract_vectors(c, a) diff --git a/src/compas/geometry/_core/normals.py b/src/compas/geometry/_core/normals.py index 094866536a79..0a107515abc0 100644 --- a/src/compas/geometry/_core/normals.py +++ b/src/compas/geometry/_core/normals.py @@ -1,17 +1,16 @@ -from collections.abc import Sequence - -from compas._typing import CoordinatesType from compas.linalg.vectors import cross_vectors from compas.linalg.vectors import cross_vectors_xy from compas.linalg.vectors import length_vector from compas.linalg.vectors import normalize_vector from compas.linalg.vectors import subtract_vectors from compas.linalg.vectors import subtract_vectors_xy +from compas.typing import PointsLike +from compas.typing import RealInput from .centroids import centroid_points -def normal_polygon(polygon: CoordinatesType, unitized: bool = True) -> list[float]: +def normal_polygon(polygon: PointsLike, unitized: bool = True) -> list[RealInput]: """Compute the normal of a polygon defined by a sequence of points. Parameters @@ -71,7 +70,7 @@ def normal_polygon(polygon: CoordinatesType, unitized: bool = True) -> list[floa return normalize_vector([nx, ny, nz]) -def normal_triangle(triangle: CoordinatesType, unitized: bool = True) -> list[float]: +def normal_triangle(triangle: PointsLike, unitized: bool = True) -> list[RealInput]: """Compute the normal vector of a triangle. Parameters @@ -110,7 +109,7 @@ def normal_triangle(triangle: CoordinatesType, unitized: bool = True) -> list[fl return [lvec * n[0], lvec * n[1], lvec * n[2]] -def normal_triangle_xy(triangle: Sequence[Sequence[float]], unitized: bool = True) -> list[float]: +def normal_triangle_xy(triangle: PointsLike, unitized: bool = True) -> list[RealInput]: """Compute the normal vector of a triangle assumed to lie in the XY plane. Parameters diff --git a/src/compas/geometry/_core/predicates_2.py b/src/compas/geometry/_core/predicates_2.py index abf04ad05d01..4f53d54cbf48 100644 --- a/src/compas/geometry/_core/predicates_2.py +++ b/src/compas/geometry/_core/predicates_2.py @@ -1,12 +1,14 @@ from collections.abc import Sequence from typing import Any -from compas._typing import CoordinatesType -from compas._typing import CoordinateType +import numpy as np + from compas.geometry import closest_point_on_segment_xy from compas.geometry import distance_point_line_xy from compas.geometry import distance_point_point_xy from compas.tolerance import TOL +from compas.typing import PointLike +from compas.typing import PointsLike # ============================================================================= # ============================================================================= @@ -25,7 +27,7 @@ # ============================================================================= -def is_ccw_xy(a: CoordinateType, b: CoordinateType, c: CoordinateType, colinear: bool = False) -> bool: +def is_ccw_xy(a: PointLike, b: PointLike, c: PointLike, colinear: bool = False) -> bool | np.bool_: """Determine if c is on the left of ab when looking from a to b, and assuming that all points lie in the XY plane. @@ -146,7 +148,7 @@ def is_colinear_xy(a: Sequence[float], b: Sequence[float], c: Sequence[float]) - # ============================================================================= -def is_polygon_convex_xy(polygon: Sequence[Sequence[float]], colinear: bool = False) -> bool: +def is_polygon_convex_xy(polygon: PointsLike, colinear: bool = False) -> bool: """Determine if the polygon is convex on the XY-plane. Parameters @@ -195,7 +197,7 @@ def is_polygon_convex_xy(polygon: Sequence[Sequence[float]], colinear: bool = Fa # ============================================================================= -def is_point_on_line_xy(point: CoordinateType, line: CoordinatesType, tol: float | None = None) -> bool: +def is_point_on_line_xy(point: PointLike, line: PointsLike, tol: float | None = None) -> bool | np.bool_: """Determine if a point lies on a line on the XY-plane. Parameters @@ -223,7 +225,7 @@ def is_point_on_line_xy(point: CoordinateType, line: CoordinatesType, tol: float return TOL.is_zero(distance_point_line_xy(point, line), tol) -def is_point_on_segment_xy(point: CoordinateType, segment: CoordinatesType, tol: float | None = None) -> bool: +def is_point_on_segment_xy(point: PointLike, segment: PointsLike, tol: float | None = None) -> bool: """Determine if a point lies on a given line segment on the XY-plane. Parameters @@ -357,7 +359,7 @@ def is_point_in_triangle_xy(point: Sequence[float], triangle: Sequence[Sequence[ return True -def is_point_in_convex_polygon_xy(point: CoordinateType, polygon: CoordinatesType) -> bool: +def is_point_in_convex_polygon_xy(point: PointLike, polygon: PointsLike) -> bool: """Determine if a point is in the interior of a convex polygon lying on the XY-plane. Parameters @@ -398,7 +400,7 @@ def is_point_in_convex_polygon_xy(point: CoordinateType, polygon: CoordinatesTyp return True -def is_point_in_polygon_xy(point: CoordinateType, polygon: CoordinatesType) -> bool: +def is_point_in_polygon_xy(point: PointLike, polygon: PointsLike) -> bool: """Determine if a point is in the interior of a polygon lying on the XY-plane. Parameters @@ -475,7 +477,7 @@ def is_point_in_circle_xy(point: Sequence[float], circle: Sequence[Any]) -> bool return False -def is_polygon_in_polygon_xy(polygon1: Sequence[Sequence[float]], polygon2: Sequence[Sequence[float]]) -> bool: +def is_polygon_in_polygon_xy(polygon1: PointsLike, polygon2: PointsLike) -> bool: """Determine if a polygon is in the interior of another polygon on the XY-plane. Parameters @@ -553,7 +555,7 @@ def is_intersection_line_line_xy(l1: Sequence[Sequence[float]], l2: Sequence[Seq raise NotImplementedError -def is_intersection_segment_segment_xy(ab: Sequence[Sequence[float]], cd: Sequence[Sequence[float]]) -> bool: +def is_intersection_segment_segment_xy(ab: PointsLike, cd: PointsLike) -> bool | np.bool_: """Determines if two segments, ab and cd, intersect. Parameters @@ -567,7 +569,7 @@ def is_intersection_segment_segment_xy(ab: Sequence[Sequence[float]], cd: Sequen Returns ------- - bool + bool | numpy.bool_ True if the segments intersect. False otherwise. diff --git a/src/compas/geometry/_core/predicates_3.py b/src/compas/geometry/_core/predicates_3.py index fbc69eab28c5..6a2fab95b9a3 100644 --- a/src/compas/geometry/_core/predicates_3.py +++ b/src/compas/geometry/_core/predicates_3.py @@ -2,8 +2,8 @@ from itertools import chain from typing import Any -from compas._typing import CoordinatesType -from compas._typing import CoordinateType +import numpy as np + from compas.geometry import area_triangle from compas.geometry import centroid_points from compas.geometry import closest_point_on_segment @@ -17,6 +17,9 @@ from compas.linalg.vectors import length_vector from compas.linalg.vectors import subtract_vectors from compas.tolerance import TOL +from compas.typing import PointLike +from compas.typing import PointsLike +from compas.typing import VectorLike # ============================================================================= # ============================================================================= @@ -35,7 +38,7 @@ # ============================================================================= -def is_colinear(a: CoordinateType, b: CoordinateType, c: CoordinateType, tol: float | None = None) -> bool: +def is_colinear(a: PointLike, b: PointLike, c: PointLike, tol: float | None = None) -> bool | np.bool_: """Determine if three points are colinear. Parameters @@ -65,7 +68,7 @@ def is_colinear(a: CoordinateType, b: CoordinateType, c: CoordinateType, tol: fl return TOL.is_zero(area_triangle([a, b, c]), tol) -def is_colinear_line_line(line1: Sequence[Sequence[float]], line2: Sequence[Sequence[float]], tol: float | None = None) -> bool: +def is_colinear_line_line(line1: Sequence[Sequence[float]], line2: Sequence[Sequence[float]], tol: float | None = None) -> bool | np.bool_: """Determine if two lines are colinear. Parameters @@ -95,7 +98,7 @@ def is_colinear_line_line(line1: Sequence[Sequence[float]], line2: Sequence[Sequ return is_colinear(a, b, c, tol) and is_colinear(a, b, d, tol) -def is_coplanar(points: CoordinatesType, tol: float | None = None) -> bool: +def is_coplanar(points: PointsLike, tol: float | None = None) -> bool: """Determine if the points are coplanar. Parameters @@ -161,7 +164,7 @@ def is_coplanar(points: CoordinatesType, tol: float | None = None) -> bool: # ============================================================================= -def is_parallel_vector_vector(u: Sequence[float], v: Sequence[float], tol: float | None = None) -> bool: +def is_parallel_vector_vector(u: VectorLike, v: VectorLike, tol: float | None = None) -> bool | np.bool_: """Determine if two vectors are parallel. Parameters @@ -203,7 +206,7 @@ def is_parallel_vector_vector(u: Sequence[float], v: Sequence[float], tol: float return TOL.is_zero(length_vector(cross_vectors(u, v)), tol) -def is_parallel_line_line(line1: Sequence[Sequence[float]], line2: Sequence[Sequence[float]], tol: float | None = None) -> bool: +def is_parallel_line_line(line1: Sequence[Sequence[float]], line2: Sequence[Sequence[float]], tol: float | None = None) -> bool | np.bool_: """Determine if two lines are parallel. Parameters @@ -236,7 +239,7 @@ def is_parallel_line_line(line1: Sequence[Sequence[float]], line2: Sequence[Sequ return is_parallel_vector_vector(ab, cd, tol) -def is_parallel_plane_plane(plane1: Sequence[Sequence[float]], plane2: Sequence[Sequence[float]], tol: float | None = None) -> bool: +def is_parallel_plane_plane(plane1: Sequence[Sequence[float]], plane2: Sequence[Sequence[float]], tol: float | None = None) -> bool | np.bool_: """Determine if two planes are parallel. Parameters @@ -264,7 +267,7 @@ def is_parallel_plane_plane(plane1: Sequence[Sequence[float]], plane2: Sequence[ return is_parallel_vector_vector(plane1[1], plane2[1], tol) -def is_perpendicular_vector_vector(u: Sequence[float], v: Sequence[float], tol: float | None = None) -> bool: +def is_perpendicular_vector_vector(u: VectorLike, v: VectorLike, tol: float | None = None) -> bool | np.bool_: """Determine if two vectors are perpendicular. Parameters @@ -300,7 +303,7 @@ def is_perpendicular_vector_vector(u: Sequence[float], v: Sequence[float], tol: return TOL.is_zero(dot_vectors(u, v), tol) -def is_perpendicular_line_line(line1: Sequence[Sequence[float]], line2: Sequence[Sequence[float]], tol: float | None = None) -> bool: +def is_perpendicular_line_line(line1: Sequence[Sequence[float]], line2: Sequence[Sequence[float]], tol: float | None = None) -> bool | np.bool_: """Determine if two lines are perpendicular. Parameters @@ -332,7 +335,7 @@ def is_perpendicular_line_line(line1: Sequence[Sequence[float]], line2: Sequence return is_perpendicular_vector_vector(ab, cd, tol) -def is_perpendicular_plane_plane(plane1: Sequence[Sequence[float]], plane2: Sequence[Sequence[float]], tol: float | None = None) -> bool: +def is_perpendicular_plane_plane(plane1: Sequence[Sequence[float]], plane2: Sequence[Sequence[float]], tol: float | None = None) -> bool | np.bool_: """Determine if two planes are perpendicular. Parameters @@ -377,7 +380,7 @@ def is_perpendicular_plane_plane(plane1: Sequence[Sequence[float]], plane2: Sequ # ============================================================================= -def is_polygon_convex(polygon: CoordinatesType) -> bool: +def is_polygon_convex(polygon: PointsLike) -> bool: """Determine if a polygon is convex. Parameters @@ -478,7 +481,7 @@ def is_polyhedron_convex(polyhedron: Sequence[Any]) -> bool: # ============================================================================= -def is_point_on_plane(point: CoordinateType, plane: CoordinatesType, tol: float | None = None) -> bool: +def is_point_on_plane(point: PointLike, plane: PointsLike, tol: float | None = None) -> bool | np.bool_: """Determine if a point lies on a plane. Parameters @@ -518,7 +521,7 @@ def is_point_on_plane(point: CoordinateType, plane: CoordinatesType, tol: float # ============================================================================= -def is_point_on_line(point: CoordinateType, line: CoordinatesType, tol: float | None = None) -> bool: +def is_point_on_line(point: PointLike, line: PointsLike, tol: float | None = None) -> bool | np.bool_: """Determine if a point lies on a line. Parameters @@ -541,7 +544,7 @@ def is_point_on_line(point: CoordinateType, line: CoordinatesType, tol: float | return TOL.is_zero(distance_point_line(point, line), tol) -def is_point_on_segment(point: CoordinateType, segment: CoordinatesType, tol: float | None = None) -> bool: +def is_point_on_segment(point: PointLike, segment: PointsLike, tol: float | None = None) -> bool: """Determine if a point lies on a given line segment. Parameters @@ -578,7 +581,7 @@ def is_point_on_segment(point: CoordinateType, segment: CoordinatesType, tol: fl return False -def is_point_on_polyline(point: CoordinateType, polyline: CoordinatesType, tol: float | None = None) -> bool: +def is_point_on_polyline(point: PointLike, polyline: PointsLike, tol: float | None = None) -> bool: """Determine if a point is on a polyline. Parameters @@ -609,7 +612,7 @@ def is_point_on_polyline(point: CoordinateType, polyline: CoordinatesType, tol: return False -def is_point_on_circle(point: Sequence[float], circle: Sequence[Any], tol: float | None = None) -> bool: +def is_point_on_circle(point: Sequence[float], circle: Sequence[Any], tol: float | None = None) -> bool | np.bool_: """Determine if a point lies on a circle. Parameters @@ -668,7 +671,7 @@ def is_point_on_circle(point: Sequence[float], circle: Sequence[Any], tol: float # ============================================================================= -def is_point_in_circle(point: CoordinateType, circle: Sequence[Any], tol: float | None = None) -> bool: +def is_point_in_circle(point: PointLike, circle: Sequence[Any], tol: float | None = None) -> bool | np.bool_: """Determine if a point lies in a circle. Parameters @@ -691,7 +694,7 @@ def is_point_in_circle(point: CoordinateType, circle: Sequence[Any], tol: float return False -def is_point_in_triangle(point: CoordinateType, triangle: CoordinatesType, tol: float | None = None) -> bool: +def is_point_in_triangle(point: PointLike, triangle: PointsLike, tol: float | None = None) -> bool: """Determine if a point is in the interior of a triangle. Parameters @@ -713,7 +716,7 @@ def is_point_in_triangle(point: CoordinateType, triangle: CoordinatesType, tol: """ - def is_on_same_side(p1: CoordinateType, p2: CoordinateType, segment: CoordinatesType) -> bool: + def is_on_same_side(p1: PointLike, p2: PointLike, segment: PointsLike) -> bool: a, b = segment v = subtract_vectors(b, a) c1 = cross_vectors(v, subtract_vectors(p1, a)) @@ -768,7 +771,7 @@ def is_point_in_polygon(point: Sequence[float], polygon: Sequence[Sequence[float # ============================================================================= -def is_point_in_sphere(point: Sequence[float], sphere: Sequence[Any], tol: float | None = None) -> bool: +def is_point_in_sphere(point: Sequence[float], sphere: Sequence[Any], tol: float | None = None) -> bool | np.bool_: """Determine if a point lies in a sphere. Parameters @@ -834,7 +837,7 @@ def is_point_in_polyhedron(point: Sequence[float], polyhedron: Sequence[Any], to return all(is_point_behind_plane(point, plane, tol=tol) for plane in planes) -def is_point_infrontof_plane(point: Sequence[float], plane: Sequence[Sequence[float]], tol: float | None = None) -> bool: +def is_point_infrontof_plane(point: Sequence[float], plane: Sequence[Sequence[float]], tol: float | None = None) -> bool | np.bool_: """Determine if a point lies in front of a plane. Parameters @@ -856,7 +859,7 @@ def is_point_infrontof_plane(point: Sequence[float], plane: Sequence[Sequence[fl return TOL.is_positive(dot_vectors(subtract_vectors(point, plane[0]), plane[1]), tol) -def is_point_behind_plane(point: CoordinateType, plane: CoordinatesType, tol: float | None = None) -> bool: +def is_point_behind_plane(point: PointLike, plane: PointsLike, tol: float | None = None) -> bool | np.bool_: """Determine if a point lies behind a plane. Parameters diff --git a/src/compas/geometry/_core/size.py b/src/compas/geometry/_core/size.py index 64925176edda..c6f8dafb5fef 100644 --- a/src/compas/geometry/_core/size.py +++ b/src/compas/geometry/_core/size.py @@ -1,7 +1,7 @@ from collections.abc import Sequence from math import fabs +from operator import index -from compas._typing import CoordinatesType from compas.itertools import pairwise from compas.linalg.vectors import cross_vectors from compas.linalg.vectors import cross_vectors_xy @@ -9,6 +9,9 @@ from compas.linalg.vectors import length_vector from compas.linalg.vectors import subtract_vectors from compas.linalg.vectors import subtract_vectors_xy +from compas.typing import PointsLike +from compas.typing import RealInput +from compas.typing import VerticesFaces from .centroids import centroid_points from .centroids import centroid_points_xy @@ -16,7 +19,7 @@ from .normals import normal_triangle_xy -def area_triangle(triangle: CoordinatesType) -> float: +def area_triangle(triangle: PointsLike) -> float: """Compute the area of a triangle defined by three points. Parameters @@ -62,7 +65,7 @@ def area_triangle_xy(triangle: Sequence[Sequence[float]]) -> float: return 0.5 * length_vector(normal_triangle_xy(triangle, False)) -def area_polygon(polygon: CoordinatesType) -> float: +def area_polygon(polygon: PointsLike) -> float: """Compute the area of a polygon. Parameters @@ -136,7 +139,7 @@ def area_polygon_xy(polygon: Sequence[Sequence[float]]) -> float: return fabs(a) -def volume_polyhedron(polyhedron: tuple[list[list[float]], Sequence[list[int]]]) -> float: +def volume_polyhedron(polyhedron: VerticesFaces) -> RealInput: r"""Compute the volume of a polyhedron represented by a closed mesh. Parameters @@ -179,22 +182,23 @@ def volume_polyhedron(polyhedron: tuple[list[list[float]], Sequence[list[int]]]) V = 0 for vertices in faces: - if len(vertices) == 3: - triangles = [vertices] - else: - centroid = centroid_points([xyz[i] for i in vertices]) - i = len(xyz) - xyz.append(centroid) - triangles = [] - for u, v in pairwise(vertices + vertices[0:1]): - triangles.append([i, u, v]) - - for u, v, w in triangles: - a = xyz[u] - b = xyz[v] - c = xyz[w] + indices = [index(vertex) for vertex in vertices] + if len(indices) == 3: + a = xyz[indices[0]] + b = xyz[indices[1]] + c = xyz[indices[2]] ab = subtract_vectors(b, a) ac = subtract_vectors(c, a) n = cross_vectors(ab, ac) V += dot_vectors(a, n) + else: + centroid = centroid_points([xyz[i] for i in indices]) + for u, v in pairwise(indices + indices[0:1]): + a = centroid + b = xyz[u] + c = xyz[v] + ab = subtract_vectors(b, a) + ac = subtract_vectors(c, a) + n = cross_vectors(ab, ac) + V += dot_vectors(a, n) return V / 6.0 diff --git a/src/compas/geometry/_core/transformations.py b/src/compas/geometry/_core/transformations.py index 47272aaf724d..98b2935c9ecf 100644 --- a/src/compas/geometry/_core/transformations.py +++ b/src/compas/geometry/_core/transformations.py @@ -2,8 +2,6 @@ from collections.abc import Iterable from collections.abc import Sequence -from compas._typing import CoordinatesType -from compas._typing import CoordinateType from compas.linalg.matrices import multiply_matrices from compas.linalg.matrices import multiply_matrix_vector from compas.linalg.matrices import transpose_matrix @@ -22,6 +20,10 @@ from compas.linalg.vectors import subtract_vectors_xy from compas.linalg.vectors import vector_component from compas.linalg.vectors import vector_component_xy +from compas.typing import PointLike +from compas.typing import PointsLike +from compas.typing import RealInput +from compas.typing import VectorLike from .angles import angle_vectors from .distance import closest_point_on_line @@ -35,7 +37,7 @@ # this function will not always work # it is also a duplicate of stuff found in matrices and frame -def local_axes(a: Sequence[float], b: Sequence[float], c: Sequence[float]) -> tuple[list[float], list[float], list[float]]: +def local_axes(a: PointLike, b: PointLike, c: PointLike) -> tuple[list[RealInput], list[RealInput], list[RealInput]]: # Raw coordinate sequences do not necessarily support vector subtraction. u = subtract_vectors(b, a) v = subtract_vectors(c, a) @@ -44,7 +46,7 @@ def local_axes(a: Sequence[float], b: Sequence[float], c: Sequence[float]) -> tu return normalize_vector(u), normalize_vector(v), normalize_vector(w) -def orthonormalize_axes(xaxis: CoordinateType, yaxis: CoordinateType) -> tuple[CoordinateType, CoordinateType]: +def orthonormalize_axes(xaxis: VectorLike, yaxis: VectorLike) -> tuple[list[RealInput], list[RealInput]]: """Corrects xaxis and yaxis to be unit vectors and orthonormal. Parameters @@ -56,7 +58,7 @@ def orthonormalize_axes(xaxis: CoordinateType, yaxis: CoordinateType) -> tuple[C Returns ------- - tuple[Sequence[float], Sequence[float]] + tuple[list[RealInput], list[RealInput]] The corrected x and y axes, in that order. Raises @@ -76,8 +78,12 @@ def orthonormalize_axes(xaxis: CoordinateType, yaxis: CoordinateType) -> tuple[C True """ - xaxis_normalized = normalize_vector(xaxis) - yaxis_normalized = normalize_vector(yaxis) + xaxis_length = norm_vector(xaxis) + yaxis_length = norm_vector(yaxis) + if not xaxis_length or not yaxis_length: + raise ValueError("Xaxis and yaxis cannot span a plane.") + xaxis_normalized = scale_vector(xaxis, 1.0 / xaxis_length) + yaxis_normalized = scale_vector(yaxis, 1.0 / yaxis_length) zaxis = cross_vectors(xaxis_normalized, yaxis_normalized) if not norm_vector(zaxis): raise ValueError("Xaxis and yaxis cannot span a plane.") @@ -85,7 +91,7 @@ def orthonormalize_axes(xaxis: CoordinateType, yaxis: CoordinateType) -> tuple[C return xaxis_normalized, yaxis_orthogonal -def homogenize(xyz: Iterable[Iterable[float]], w: float = 1.0) -> list[list[float]]: +def homogenize(xyz: PointsLike, w: float = 1.0) -> list[list[RealInput]]: """Homogenise a list of vectors. Parameters @@ -98,7 +104,7 @@ def homogenize(xyz: Iterable[Iterable[float]], w: float = 1.0) -> list[list[floa Returns ------- - list[list[float]] + list[list[RealInput]] Homogenised data. Notes @@ -117,7 +123,7 @@ def homogenize(xyz: Iterable[Iterable[float]], w: float = 1.0) -> list[list[floa return [[x * w, y * w, z * w, w] if w else [x, y, z, 0.0] for x, y, z in xyz] -def dehomogenize(xyzw: Sequence[Sequence[float]]) -> list[list[float]]: +def dehomogenize(xyzw: Sequence[Sequence[RealInput]]) -> list[list[RealInput]]: """Dehomogenise a list of vectors. Parameters @@ -127,7 +133,7 @@ def dehomogenize(xyzw: Sequence[Sequence[float]]) -> list[list[float]]: Returns ------- - list[list[float]] + list[list[RealInput]] Dehomogenised vectors. """ @@ -135,8 +141,8 @@ def dehomogenize(xyzw: Sequence[Sequence[float]]) -> list[list[float]]: def homogenize_and_flatten_frames( - frames: Sequence[Sequence[Sequence[float]]], -) -> list[list[float]]: + frames: Sequence[Sequence[Sequence[RealInput]]], +) -> list[list[RealInput]]: """Homogenize a list of frames and flatten the 3D list into a 2D list. Parameters @@ -145,7 +151,7 @@ def homogenize_and_flatten_frames( Returns ------- - list[list[float]] + list[list[RealInput]] A list with 3 entries per frame: a homogenized point, and two homogenized vectors. Examples @@ -157,15 +163,15 @@ def homogenize_and_flatten_frames( """ - def homogenize_frame(frame: Sequence[Sequence[float]]) -> list[list[float]]: + def homogenize_frame(frame: Sequence[Sequence[RealInput]]) -> list[list[RealInput]]: return homogenize([frame[0]], w=1.0) + homogenize([frame[1], frame[2]], w=0.0) return [v for frame in frames for v in homogenize_frame(frame)] def dehomogenize_and_unflatten_frames( - points_and_vectors: Sequence[Sequence[float]], -) -> list[list[list[float]]]: + points_and_vectors: Sequence[Sequence[RealInput]], +) -> list[list[list[RealInput]]]: """Dehomogenize a list of vectors and unflatten the 2D list into a 3D list. Parameters @@ -175,7 +181,7 @@ def dehomogenize_and_unflatten_frames( Returns ------- - list[list[list[float]]] + list[list[list[RealInput]]] The dehmogenized frame data. Examples @@ -194,7 +200,7 @@ def dehomogenize_and_unflatten_frames( # ============================================================================== -def transform_points(points: Iterable[Iterable[float]], T: Iterable[Iterable[float]]) -> list[list[float]]: +def transform_points(points: PointsLike, T: Iterable[Iterable[RealInput]]) -> list[list[RealInput]]: """Transform multiple points with one transformation matrix. Parameters @@ -206,7 +212,7 @@ def transform_points(points: Iterable[Iterable[float]], T: Iterable[Iterable[flo Returns ------- - list[list[float]] + list[list[RealInput]] Transformed points. Examples @@ -219,7 +225,7 @@ def transform_points(points: Iterable[Iterable[float]], T: Iterable[Iterable[flo return dehomogenize(multiply_matrices(homogenize(points, w=1.0), transpose_matrix(T))) -def transform_vectors(vectors: CoordinatesType, T: Iterable[Iterable[float]]) -> list[list[float]]: +def transform_vectors(vectors: PointsLike, T: Iterable[Iterable[RealInput]]) -> list[list[RealInput]]: """Transform multiple vectors with one transformation matrix. Parameters @@ -231,7 +237,7 @@ def transform_vectors(vectors: CoordinatesType, T: Iterable[Iterable[float]]) -> Returns ------- - list[list[float]] + list[list[RealInput]] Transformed vectors. Examples @@ -244,7 +250,7 @@ def transform_vectors(vectors: CoordinatesType, T: Iterable[Iterable[float]]) -> return dehomogenize(multiply_matrices(homogenize(vectors, w=0.0), transpose_matrix(T))) -def transform_frames(frames: Sequence[Sequence[Sequence[float]]], T: Sequence[Sequence[float]]) -> list[list[list[float]]]: +def transform_frames(frames: Sequence[Sequence[Sequence[RealInput]]], T: Sequence[Sequence[RealInput]]) -> list[list[list[RealInput]]]: """Transform multiple frames with one transformation matrix. Parameters @@ -256,7 +262,7 @@ def transform_frames(frames: Sequence[Sequence[Sequence[float]]], T: Sequence[Se Returns ------- - list[list[list[float]]] + list[list[list[RealInput]]] Transformed frames. Examples @@ -273,7 +279,7 @@ def transform_frames(frames: Sequence[Sequence[Sequence[float]]], T: Sequence[Se return dehomogenize_and_unflatten_frames(multiply_matrices(points_and_vectors, transpose_matrix(T))) -def world_to_local_coordinates(frame: Sequence[Sequence[float]], xyz: Sequence[Sequence[float]]) -> list[list[float]]: +def world_to_local_coordinates(frame: Sequence[Sequence[RealInput]], xyz: Sequence[Sequence[RealInput]]) -> list[list[RealInput]]: """Convert global coordinates to local coordinates. Parameters @@ -285,7 +291,7 @@ def world_to_local_coordinates(frame: Sequence[Sequence[float]], xyz: Sequence[S Returns ------- - list[list[float]] + list[list[RealInput]] The coordinates of the given points in the local coordinate system. Examples @@ -301,7 +307,7 @@ def world_to_local_coordinates(frame: Sequence[Sequence[float]], xyz: Sequence[S return transform_points(xyz, T) -def local_to_world_coordinates(frame: Sequence[Sequence[float]], xyz: Sequence[Sequence[float]]) -> list[list[float]]: +def local_to_world_coordinates(frame: Sequence[Sequence[RealInput]], xyz: Sequence[Sequence[RealInput]]) -> list[list[RealInput]]: """Convert local coordinates to global coordinates. Parameters @@ -313,7 +319,7 @@ def local_to_world_coordinates(frame: Sequence[Sequence[float]], xyz: Sequence[S Returns ------- - list[list[float]] + list[list[RealInput]] The coordinates of the given points in the local coordinate system. Examples @@ -334,7 +340,7 @@ def local_to_world_coordinates(frame: Sequence[Sequence[float]], xyz: Sequence[S # ============================================================================== -def translate_points(points: Sequence[Sequence[float]], vector: Sequence[float]) -> list[list[float]]: +def translate_points(points: PointsLike, vector: VectorLike) -> list[list[RealInput]]: """Translate points. Parameters @@ -346,14 +352,14 @@ def translate_points(points: Sequence[Sequence[float]], vector: Sequence[float]) Returns ------- - list[list[float]] + list[list[RealInput]] The translated points. """ return [add_vectors(point, vector) for point in points] -def translate_points_xy(points: Sequence[Sequence[float]], vector: Sequence[float]) -> list[list[float]]: +def translate_points_xy(points: PointsLike, vector: VectorLike) -> list[list[RealInput]]: """Translate points and in the XY plane. Parameters @@ -365,7 +371,7 @@ def translate_points_xy(points: Sequence[Sequence[float]], vector: Sequence[floa Returns ------- - list[list[float]] + list[list[RealInput]] The translated points in the XY plane (Z=0). """ @@ -377,7 +383,7 @@ def translate_points_xy(points: Sequence[Sequence[float]], vector: Sequence[floa # ============================================================================== -def scale_points(points: Sequence[Sequence[float]], scale: float) -> list[list[float]]: +def scale_points(points: PointsLike, scale: RealInput) -> list[list[RealInput]]: """Scale points. Parameters @@ -389,7 +395,7 @@ def scale_points(points: Sequence[Sequence[float]], scale: float) -> list[list[f Returns ------- - list[list[float]] + list[list[RealInput]] The scaled points. """ @@ -397,7 +403,7 @@ def scale_points(points: Sequence[Sequence[float]], scale: float) -> list[list[f return transform_points(points, T) -def scale_points_xy(points: Sequence[Sequence[float]], scale: float) -> list[list[float]]: +def scale_points_xy(points: PointsLike, scale: RealInput) -> list[list[RealInput]]: """Scale points in the XY plane. Parameters @@ -409,7 +415,7 @@ def scale_points_xy(points: Sequence[Sequence[float]], scale: float) -> list[lis Returns ------- - list[list[float]] + list[list[RealInput]] The scaled points in the XY plane (Z=0). """ @@ -423,11 +429,11 @@ def scale_points_xy(points: Sequence[Sequence[float]], scale: float) -> list[lis def rotate_points( - points: Sequence[Sequence[float]], + points: PointsLike, angle: float, - axis: Sequence[float] | None = None, - origin: Sequence[float] | None = None, -) -> list[list[float]]: + axis: VectorLike | None = None, + origin: PointLike | None = None, +) -> list[list[RealInput]]: """Rotates points around an arbitrary axis in 3D. Parameters @@ -445,7 +451,7 @@ def rotate_points( Returns ------- - list[list[float]] + list[list[RealInput]] The rotated points Notes @@ -467,7 +473,7 @@ def rotate_points( return points -def rotate_points_xy(points: Sequence[Sequence[float]], angle: float, origin: Sequence[float] | None = None) -> list[list[float]]: +def rotate_points_xy(points: PointsLike, angle: float, origin: PointLike | None = None) -> list[list[RealInput]]: """Rotates points in the XY plane around the Z axis at a specific origin. Parameters @@ -482,7 +488,7 @@ def rotate_points_xy(points: Sequence[Sequence[float]], angle: float, origin: Se Returns ------- - list[list[float]] + list[list[RealInput]] The rotated points in the XY plane (Z=0). """ @@ -506,7 +512,7 @@ def rotate_points_xy(points: Sequence[Sequence[float]], angle: float, origin: Se # ============================================================================== -def mirror_vector_vector(v1: Sequence[float], v2: Sequence[float]) -> list[float]: +def mirror_vector_vector(v1: VectorLike, v2: VectorLike) -> list[RealInput]: """Mirrors vector about vector. Parameters @@ -533,7 +539,7 @@ def mirror_vector_vector(v1: Sequence[float], v2: Sequence[float]) -> list[float return subtract_vectors(v1, scale_vector(v2, 2 * dot_vectors(v1, v2))) -def mirror_point_point(point: Sequence[float], mirror: Sequence[float]) -> list[float]: +def mirror_point_point(point: PointLike, mirror: PointLike) -> list[RealInput]: """Mirror a point about a point. Parameters @@ -552,7 +558,7 @@ def mirror_point_point(point: Sequence[float], mirror: Sequence[float]) -> list[ return add_vectors(mirror, subtract_vectors(mirror, point)) -def mirror_point_point_xy(point: Sequence[float], mirror: Sequence[float]) -> list[float]: +def mirror_point_point_xy(point: PointLike, mirror: PointLike) -> list[RealInput]: """Mirror a point about a point. Parameters @@ -571,7 +577,7 @@ def mirror_point_point_xy(point: Sequence[float], mirror: Sequence[float]) -> li return add_vectors_xy(mirror, subtract_vectors_xy(mirror, point)) -def mirror_points_point(points: Sequence[Sequence[float]], mirror: Sequence[float]) -> list[list[float]]: +def mirror_points_point(points: PointsLike, mirror: PointLike) -> list[list[RealInput]]: """Mirror multiple points about a point. Parameters @@ -583,14 +589,14 @@ def mirror_points_point(points: Sequence[Sequence[float]], mirror: Sequence[floa Returns ------- - list[list[float]] + list[list[RealInput]] The mirrored points, with Z=0. """ return [mirror_point_point(point, mirror) for point in points] -def mirror_points_point_xy(points: Sequence[Sequence[float]], mirror: Sequence[float]) -> list[list[float]]: +def mirror_points_point_xy(points: PointsLike, mirror: PointLike) -> list[list[RealInput]]: """Mirror multiple points about a point. Parameters @@ -602,14 +608,14 @@ def mirror_points_point_xy(points: Sequence[Sequence[float]], mirror: Sequence[f Returns ------- - list[list[float]] + list[list[RealInput]] The mirrored points, with Z=0. """ return [mirror_point_point_xy(point, mirror) for point in points] -def mirror_point_line(point: Sequence[float], line: Sequence[Sequence[float]]) -> list[float]: +def mirror_point_line(point: PointLike, line: PointsLike) -> list[RealInput]: """Mirror a point about a line. Parameters @@ -629,7 +635,7 @@ def mirror_point_line(point: Sequence[float], line: Sequence[Sequence[float]]) - return add_vectors(closest, subtract_vectors(closest, point)) -def mirror_point_line_xy(point: Sequence[float], line: Sequence[Sequence[float]]) -> list[float]: +def mirror_point_line_xy(point: PointLike, line: PointsLike) -> list[RealInput]: """Mirror a point about a line. Parameters @@ -650,7 +656,7 @@ def mirror_point_line_xy(point: Sequence[float], line: Sequence[Sequence[float]] return add_vectors_xy(closest, subtract_vectors_xy(closest, point)) -def mirror_points_line(points: Sequence[Sequence[float]], line: Sequence[Sequence[float]]) -> list[list[float]]: +def mirror_points_line(points: PointsLike, line: PointsLike) -> list[list[RealInput]]: """Mirror a point about a line. Parameters @@ -662,14 +668,14 @@ def mirror_points_line(points: Sequence[Sequence[float]], line: Sequence[Sequenc Returns ------- - list[list[float]] + list[list[RealInput]] The mirrored points. """ return [mirror_point_line(point, line) for point in points] -def mirror_points_line_xy(points: Sequence[Sequence[float]], line: Sequence[Sequence[float]]) -> list[list[float]]: +def mirror_points_line_xy(points: PointsLike, line: PointsLike) -> list[list[RealInput]]: """Mirror a point about a line. Parameters @@ -681,14 +687,14 @@ def mirror_points_line_xy(points: Sequence[Sequence[float]], line: Sequence[Sequ Returns ------- - list[list[float]] + list[list[RealInput]] The mirrored points. """ return [mirror_point_line_xy(point, line) for point in points] -def mirror_point_plane(point: Sequence[float], plane: Sequence[Sequence[float]]) -> list[float]: +def mirror_point_plane(point: PointLike, plane: PointsLike) -> list[RealInput]: """Mirror a point about a plane. Parameters @@ -708,7 +714,7 @@ def mirror_point_plane(point: Sequence[float], plane: Sequence[Sequence[float]]) return add_vectors(closest, subtract_vectors(closest, point)) -def mirror_points_plane(points: Sequence[Sequence[float]], plane: Sequence[Sequence[float]]) -> list[list[float]]: +def mirror_points_plane(points: PointsLike, plane: PointsLike) -> list[list[RealInput]]: """Mirror a point about a plane. Parameters @@ -720,7 +726,7 @@ def mirror_points_plane(points: Sequence[Sequence[float]], plane: Sequence[Seque Returns ------- - list[list[float]] + list[list[RealInput]] The mirrored points. """ @@ -734,7 +740,7 @@ def mirror_points_plane(points: Sequence[Sequence[float]], plane: Sequence[Seque # ============================================================================== -def project_point_plane(point: Sequence[float], plane: Sequence[Sequence[float]]) -> list[float]: +def project_point_plane(point: PointLike, plane: PointsLike) -> list[RealInput]: """Project a point onto a plane. Parameters @@ -782,7 +788,7 @@ def project_point_plane(point: Sequence[float], plane: Sequence[Sequence[float]] return subtract_vectors(point, snormal) -def project_points_plane(points: Sequence[Sequence[float]], plane: Sequence[Sequence[float]]) -> list[list[float]]: +def project_points_plane(points: PointsLike, plane: PointsLike) -> list[list[RealInput]]: """Project multiple points onto a plane. Parameters @@ -794,7 +800,7 @@ def project_points_plane(points: Sequence[Sequence[float]], plane: Sequence[Sequ Returns ------- - list[list[float]] + list[list[RealInput]] The projected points. See Also @@ -805,7 +811,7 @@ def project_points_plane(points: Sequence[Sequence[float]], plane: Sequence[Sequ return [project_point_plane(point, plane) for point in points] -def project_point_line(point: Sequence[float], line: Sequence[Sequence[float]]) -> list[float]: +def project_point_line(point: PointLike, line: PointsLike) -> list[RealInput]: """Project a point onto a line. Parameters @@ -837,7 +843,7 @@ def project_point_line(point: Sequence[float], line: Sequence[Sequence[float]]) return add_vectors(a, c) -def project_point_line_xy(point: Sequence[float], line: Sequence[Sequence[float]]) -> list[float]: +def project_point_line_xy(point: PointLike, line: PointsLike) -> list[RealInput]: """Project a point onto a line in the XY plane. Parameters @@ -868,7 +874,7 @@ def project_point_line_xy(point: Sequence[float], line: Sequence[Sequence[float] return add_vectors_xy(a, c) -def project_points_line(points: Sequence[Sequence[float]], line: Sequence[Sequence[float]]) -> list[list[float]]: +def project_points_line(points: PointsLike, line: PointsLike) -> list[list[RealInput]]: """Project points onto a line. Parameters @@ -880,7 +886,7 @@ def project_points_line(points: Sequence[Sequence[float]], line: Sequence[Sequen Returns ------- - list[list[float]] + list[list[RealInput]] XYZ coordinates of the projected points. Notes @@ -895,7 +901,7 @@ def project_points_line(points: Sequence[Sequence[float]], line: Sequence[Sequen return [project_point_line(point, line) for point in points] -def project_points_line_xy(points: Sequence[Sequence[float]], line: Sequence[Sequence[float]]) -> list[list[float]]: +def project_points_line_xy(points: PointsLike, line: PointsLike) -> list[list[RealInput]]: """Project points onto a line in the XY plane. Parameters @@ -907,7 +913,7 @@ def project_points_line_xy(points: Sequence[Sequence[float]], line: Sequence[Seq Returns ------- - list[list[float]] + list[list[RealInput]] XYZ coordinates of the projected points, with Z=0. Notes @@ -928,10 +934,10 @@ def project_points_line_xy(points: Sequence[Sequence[float]], line: Sequence[Seq def reflect_line_plane( - line: Sequence[Sequence[float]], - plane: Sequence[Sequence[float]], + line: PointsLike, + plane: PointsLike, tol: float | None = None, -) -> tuple[list[float], list[float]] | None: +) -> tuple[list[RealInput], list[RealInput]] | None: """Bounce a line of a reflection plane. Parameters @@ -983,10 +989,10 @@ def reflect_line_plane( def reflect_line_triangle( - line: Sequence[Sequence[float]], - triangle: Sequence[Sequence[float]], + line: PointsLike, + triangle: PointsLike, tol: float | None = None, -) -> tuple[list[float], list[float]] | None: +) -> tuple[list[RealInput], list[RealInput]] | None: """Bounce a line of a reflection triangle. Parameters @@ -1049,10 +1055,10 @@ def reflect_line_triangle( def orient_points( - points: Sequence[Sequence[float]], - reference_plane: Sequence[Sequence[float]], - target_plane: Sequence[Sequence[float]], -) -> list[list[float]]: + points: PointsLike, + reference_plane: PointsLike, + target_plane: PointsLike, +) -> list[list[RealInput]]: """Orient points from one plane to another. Parameters @@ -1066,7 +1072,7 @@ def orient_points( Returns ------- - list[list[float]] + list[list[RealInput]] XYZ coordinates of the oriented points. Notes diff --git a/src/compas/geometry/_core/transformations_numpy.py b/src/compas/geometry/_core/transformations_numpy.py index 5b7a2e0ef285..042a78d7fbb2 100644 --- a/src/compas/geometry/_core/transformations_numpy.py +++ b/src/compas/geometry/_core/transformations_numpy.py @@ -1,3 +1,4 @@ +from collections.abc import Iterable from collections.abc import Sequence from typing import Any @@ -11,8 +12,10 @@ from numpy.typing import NDArray from scipy.linalg import solve +from compas.typing import RealInput -def transform_points_numpy(points: ArrayLike, T: ArrayLike) -> NDArray[Any]: + +def transform_points_numpy(points: ArrayLike, T: Iterable[Iterable[RealInput]] | ArrayLike) -> NDArray[Any]: """Transform multiple points with one Transformation using numpy. Parameters diff --git a/src/compas/geometry/_tessellation_plugin.py b/src/compas/geometry/_tessellation_plugin.py deleted file mode 100644 index 4ee8a2710473..000000000000 --- a/src/compas/geometry/_tessellation_plugin.py +++ /dev/null @@ -1,40 +0,0 @@ -from collections.abc import Callable -from functools import wraps -from inspect import signature -from typing import ParamSpec -from typing import TypeVar - -from compas.plugins import pluggable - -P = ParamSpec("P") -R = TypeVar("R") - - -def tessellation_pluggable( - *, - category: str, - inputs: tuple[str, ...], - result: bool = False, -) -> Callable[[Callable[P, R]], Callable[P, R]]: - """Decorate a pluggable with the canonical tessellation boundary.""" - - def decorator(func: Callable[P, R]) -> Callable[P, R]: - extension = pluggable(category=category)(func) - func_signature = signature(func) - - @wraps(func) - def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: - from .tessellation import Tessellation - - arguments = func_signature.bind(*args, **kwargs).arguments - for name in inputs: - if not isinstance(arguments[name], Tessellation): - raise TypeError(f"{name} should be a Tessellation.") - value = extension(*args, **kwargs) - if result and not isinstance(value, Tessellation): - raise TypeError(f"{func.__name__} should return a Tessellation.") - return value - - return wrapper - - return decorator diff --git a/src/compas/geometry/_typing.py b/src/compas/geometry/_typing.py index 1c09eae14d5a..1e6e99772eaa 100644 --- a/src/compas/geometry/_typing.py +++ b/src/compas/geometry/_typing.py @@ -1,13 +1,16 @@ from collections.abc import Sequence import compas.geometry -from compas._typing import CoordinateType +from compas.typing import FloatSequenceLike +from compas.typing import NumericArray +from compas.typing import PointsLike +from compas.typing import RealInput -type LineType = compas.geometry.Line | Sequence[CoordinateType] -type PlaneType = compas.geometry.Plane | Sequence[CoordinateType] -CircleType = tuple[PlaneType, float] -type PolygonType = compas.geometry.Polygon | Sequence[CoordinateType] -type PolylineType = compas.geometry.Polyline | Sequence[CoordinateType] -type QuaternionType = compas.geometry.Quaternion | Sequence[float] -type TriangleType = compas.geometry.Polygon | Sequence[CoordinateType] -type TransformationType = compas.geometry.Transformation | Sequence[Sequence[float]] +type LineType = compas.geometry.Line | PointsLike +type PlaneType = compas.geometry.Plane | PointsLike +CircleType = tuple[PlaneType, RealInput] +type PolygonType = compas.geometry.Polygon | PointsLike +type PolylineType = compas.geometry.Polyline | PointsLike +type QuaternionType = compas.geometry.Quaternion | FloatSequenceLike +type TriangleType = compas.geometry.Polygon | PointsLike +type TransformationType = compas.geometry.Transformation | Sequence[Sequence[RealInput]] | NumericArray diff --git a/src/compas/geometry/bbox.py b/src/compas/geometry/bbox.py index 887eebcc0c58..65413ea070ca 100644 --- a/src/compas/geometry/bbox.py +++ b/src/compas/geometry/bbox.py @@ -4,15 +4,15 @@ from typing import Literal from typing import overload -from compas._typing import CoordinatesType -from compas._typing import CoordinateType from compas.plugins import pluggable +from compas.typing import PointLike +from compas.typing import PointsLike if TYPE_CHECKING: from numpy.typing import NDArray -def bounding_box(points: Iterable[CoordinateType]) -> list[list[float]]: +def bounding_box(points: Iterable[PointLike]) -> list[list[float]]: """Compute the axis-aligned bounding box of a collection of points. Parameters @@ -50,7 +50,7 @@ def bounding_box(points: Iterable[CoordinateType]) -> list[list[float]]: ] -def bounding_box_xy(points: Iterable[CoordinateType]) -> list[list[float]]: +def bounding_box_xy(points: Iterable[PointLike]) -> list[list[float]]: """Compute the axis-aligned bounding box of points in the XY plane. Parameters @@ -87,7 +87,7 @@ def bounding_box_xy(points: Iterable[CoordinateType]) -> list[list[float]]: @pluggable(category="geometry") -def oriented_bounding_box(points: CoordinatesType, tol: float | None = None) -> list[list[float]]: +def oriented_bounding_box(points: PointsLike, tol: float | None = None) -> list[list[float]]: """Compute the minimum-volume oriented bounding box of a collection of points. Parameters @@ -152,7 +152,7 @@ def oriented_bounding_box(points: CoordinatesType, tol: float | None = None) -> return local_to_world_coordinates_numpy(frame, aligned).tolist() -def oriented_bounding_box_xy(points: CoordinatesType) -> list[list[float]]: +def oriented_bounding_box_xy(points: PointsLike) -> list[list[float]]: """Compute the minimum-area oriented bounding box of points in the XY plane. Parameters diff --git a/src/compas/geometry/bestfit.py b/src/compas/geometry/bestfit.py index 76ff355c7e15..c2f66971ca54 100644 --- a/src/compas/geometry/bestfit.py +++ b/src/compas/geometry/bestfit.py @@ -1,12 +1,12 @@ from typing import TYPE_CHECKING -from compas._typing import CoordinatesType +from compas.typing import PointsLike if TYPE_CHECKING: from numpy.typing import NDArray -def bestfit_line(points: CoordinatesType) -> "tuple[NDArray, NDArray]": +def bestfit_line(points: PointsLike) -> "tuple[NDArray, NDArray]": """Fit a line to points using principal component analysis. Parameters @@ -31,7 +31,7 @@ def bestfit_line(points: CoordinatesType) -> "tuple[NDArray, NDArray]": return origin, axes[0] -def bestfit_plane(points: CoordinatesType) -> "tuple[NDArray, NDArray]": +def bestfit_plane(points: PointsLike) -> "tuple[NDArray, NDArray]": """Fit a plane to points using principal component analysis. Parameters @@ -56,7 +56,7 @@ def bestfit_plane(points: CoordinatesType) -> "tuple[NDArray, NDArray]": return origin, axes[2] -def bestfit_frame(points: CoordinatesType) -> "tuple[NDArray, NDArray, NDArray]": +def bestfit_frame(points: PointsLike) -> "tuple[NDArray, NDArray, NDArray]": """Fit a frame to points using principal component analysis. Parameters @@ -81,7 +81,7 @@ def bestfit_frame(points: CoordinatesType) -> "tuple[NDArray, NDArray, NDArray]" return origin, axes[0], axes[1] -def bestfit_circle(points: CoordinatesType) -> "tuple[NDArray, NDArray, float]": +def bestfit_circle(points: PointsLike) -> "tuple[NDArray, NDArray, float]": """Fit a circle to points using nonlinear least squares. Parameters @@ -138,7 +138,7 @@ def residuals(center: "NDArray") -> "NDArray": return xyz, axes[2], radius -def bestfit_sphere(points: CoordinatesType) -> tuple[list[float], float]: +def bestfit_sphere(points: PointsLike) -> tuple[list[float], float]: """Fit a sphere to points using linear least squares. Parameters diff --git a/src/compas/geometry/booleans.py b/src/compas/geometry/booleans.py index 3e66465f6deb..39e94b2651c4 100644 --- a/src/compas/geometry/booleans.py +++ b/src/compas/geometry/booleans.py @@ -1,12 +1,9 @@ from typing import TYPE_CHECKING from typing import Any -from compas._typing import CoordinatesType -from compas.plugins import PluginNotInstalledError from compas.plugins import pluggable - -from ._tessellation_plugin import tessellation_pluggable -from .tessellation import Tessellation +from compas.typing import PointsLike +from compas.typing import VerticesFaces if TYPE_CHECKING: from .planar_region import PlanarRegion @@ -17,7 +14,7 @@ def _xy_coordinates(points: Any) -> list[list[float]]: return [[point[0], point[1]] for point in points] -def _shapely_polygon(region: "PlanarRegion | Polygon | CoordinatesType") -> Any: +def _shapely_polygon(region: "PlanarRegion | Polygon | PointsLike") -> Any: from shapely.geometry import Polygon # pyright: ignore[reportMissingModuleSource] outer = getattr(region, "outer", None) @@ -50,21 +47,20 @@ def polygons_of(item: Any) -> list[Any]: ] -@tessellation_pluggable(category="booleans", inputs=("A", "B"), result=True) -def boolean_union_mesh_mesh(A: "Tessellation", B: "Tessellation") -> "Tessellation": +def boolean_union_mesh_mesh(A: VerticesFaces, B: VerticesFaces) -> VerticesFaces: """Compute the boolean union of two triangle meshes. Parameters ---------- A - The first triangle tessellation. + The first triangle mesh as vertices and triangles. B - The second triangle tessellation. + The second triangle mesh as vertices and triangles. Returns ------- - Tessellation - The triangle geometry of the boolean union. + tuple + The vertices and triangles of the Boolean union. See Also -------- @@ -74,41 +70,43 @@ def boolean_union_mesh_mesh(A: "Tessellation", B: "Tessellation") -> "Tessellati Notes ----- This function requires a Boolean plugin, such as `compas_cgal`. + Input validity is a precondition of the selected backend. Core dispatches + the structural buffers without converting or pre-validating them. Examples -------- - >>> from compas.geometry import Box, Native, Sphere, UVGrid + >>> from compas.geometry import Box, Sphere >>> from compas.geometry import boolean_union_mesh_mesh # doctest: +SKIP - >>> from compas.geometry import trimesh_remesh # doctest: +SKIP - >>> box = Box.from_width_height_depth(2, 2, 2) - >>> A = box.tessellate(Native()) + >>> A = box.to_tessellation().to_vertices_and_triangles() >>> sphere = Sphere.from_point_and_radius([1, 1, 1], 1) - >>> B = sphere.tessellate(UVGrid(30, 30)) - - >>> B = trimesh_remesh(B, 0.3, 10) # doctest: +SKIP + >>> B = sphere.to_tessellation(nu=30, nv=30).to_vertices_and_triangles() >>> union = boolean_union_mesh_mesh(A, B) # doctest: +SKIP """ - raise PluginNotInstalledError("No plugin was found for the boolean_union_mesh_mesh pluggable. A plugin is available in compas_cgal...") + return _boolean_union_mesh_mesh(A, B) + + +@pluggable(category="booleans") +def _boolean_union_mesh_mesh(A: VerticesFaces, B: VerticesFaces) -> VerticesFaces: + raise NotImplementedError -@tessellation_pluggable(category="booleans", inputs=("A", "B"), result=True) -def boolean_difference_mesh_mesh(A: "Tessellation", B: "Tessellation") -> "Tessellation": +def boolean_difference_mesh_mesh(A: VerticesFaces, B: VerticesFaces) -> VerticesFaces: """Compute the boolean difference of two triangle meshes. Parameters ---------- A - The first triangle tessellation. + The first triangle mesh as vertices and triangles. B - The second triangle tessellation. + The second triangle mesh as vertices and triangles. Returns ------- - Tessellation - The triangle geometry of the boolean difference. + tuple + The vertices and triangles of the Boolean difference. See Also -------- @@ -118,26 +116,32 @@ def boolean_difference_mesh_mesh(A: "Tessellation", B: "Tessellation") -> "Tesse Notes ----- This function requires a Boolean plugin, such as `compas_cgal`. + Input validity is a precondition of the selected backend. Core dispatches + the structural buffers without converting or pre-validating them. """ - raise PluginNotInstalledError("No plugin was found for the boolean_difference_mesh_mesh pluggable. A plugin is available in compas_cgal...") + return _boolean_difference_mesh_mesh(A, B) -@tessellation_pluggable(category="booleans", inputs=("A", "B"), result=True) -def boolean_intersection_mesh_mesh(A: "Tessellation", B: "Tessellation") -> "Tessellation": +@pluggable(category="booleans") +def _boolean_difference_mesh_mesh(A: VerticesFaces, B: VerticesFaces) -> VerticesFaces: + raise NotImplementedError + + +def boolean_intersection_mesh_mesh(A: VerticesFaces, B: VerticesFaces) -> VerticesFaces: """Compute the boolean intersection of two triangle meshes. Parameters ---------- A - The first triangle tessellation. + The first triangle mesh as vertices and triangles. B - The second triangle tessellation. + The second triangle mesh as vertices and triangles. Returns ------- - Tessellation - The triangle geometry of the boolean intersection. + tuple + The vertices and triangles of the Boolean intersection. See Also -------- @@ -147,13 +151,20 @@ def boolean_intersection_mesh_mesh(A: "Tessellation", B: "Tessellation") -> "Tes Notes ----- This function requires a Boolean plugin, such as `compas_cgal`. + Input validity is a precondition of the selected backend. Core dispatches + the structural buffers without converting or pre-validating them. """ - raise PluginNotInstalledError("No plugin was found for the boolean_intersection_mesh_mesh pluggable. A plugin is available in compas_cgal...") + return _boolean_intersection_mesh_mesh(A, B) + + +@pluggable(category="booleans") +def _boolean_intersection_mesh_mesh(A: VerticesFaces, B: VerticesFaces) -> VerticesFaces: + raise NotImplementedError @pluggable(category="booleans") -def boolean_union_polygon_polygon(A: "PlanarRegion | Polygon | CoordinatesType", B: "PlanarRegion | Polygon | CoordinatesType") -> list["PlanarRegion"]: +def boolean_union_polygon_polygon(A: "PlanarRegion | Polygon | PointsLike", B: "PlanarRegion | Polygon | PointsLike") -> list["PlanarRegion"]: """Compute the Boolean union of two planar regions. The polygons are assumed to lie in the XY plane. Z coordinates are ignored. @@ -187,7 +198,7 @@ def boolean_union_polygon_polygon(A: "PlanarRegion | Polygon | CoordinatesType", @pluggable(category="booleans") -def boolean_difference_polygon_polygon(A: "PlanarRegion | Polygon | CoordinatesType", B: "PlanarRegion | Polygon | CoordinatesType") -> list["PlanarRegion"]: +def boolean_difference_polygon_polygon(A: "PlanarRegion | Polygon | PointsLike", B: "PlanarRegion | Polygon | PointsLike") -> list["PlanarRegion"]: """Compute the Boolean difference of two planar regions. The polygons are assumed to lie in the XY plane. Z coordinates are ignored. @@ -221,7 +232,7 @@ def boolean_difference_polygon_polygon(A: "PlanarRegion | Polygon | CoordinatesT @pluggable(category="booleans") -def boolean_symmetric_difference_polygon_polygon(A: "PlanarRegion | Polygon | CoordinatesType", B: "PlanarRegion | Polygon | CoordinatesType") -> list["PlanarRegion"]: +def boolean_symmetric_difference_polygon_polygon(A: "PlanarRegion | Polygon | PointsLike", B: "PlanarRegion | Polygon | PointsLike") -> list["PlanarRegion"]: """Compute the Boolean symmetric difference of two planar regions. The polygons are assumed to lie in the XY plane. Z coordinates are ignored. @@ -255,7 +266,7 @@ def boolean_symmetric_difference_polygon_polygon(A: "PlanarRegion | Polygon | Co @pluggable(category="booleans") -def boolean_intersection_polygon_polygon(A: "PlanarRegion | Polygon | CoordinatesType", B: "PlanarRegion | Polygon | CoordinatesType") -> list["PlanarRegion"]: +def boolean_intersection_polygon_polygon(A: "PlanarRegion | Polygon | PointsLike", B: "PlanarRegion | Polygon | PointsLike") -> list["PlanarRegion"]: """Compute the Boolean intersection of two planar regions. The polygons are assumed to lie in the XY plane. Z coordinates are ignored. diff --git a/src/compas/geometry/brep/brep.py b/src/compas/geometry/brep/brep.py index 1f90cf5bcd9e..ded07d81b608 100644 --- a/src/compas/geometry/brep/brep.py +++ b/src/compas/geometry/brep/brep.py @@ -1,4 +1,5 @@ from collections.abc import Sequence +from math import degrees from os import PathLike from typing import TYPE_CHECKING from typing import Any @@ -8,6 +9,7 @@ from compas.data import Data from compas.geometry import Geometry +from compas.tolerance import TOL if TYPE_CHECKING: from compas.datastructures import Mesh @@ -20,13 +22,11 @@ from compas.geometry import Cone from compas.geometry import Curve from compas.geometry import Cylinder - from compas.geometry import Deflection from compas.geometry import Frame from compas.geometry import Line from compas.geometry import Plane from compas.geometry import Point from compas.geometry import Polygon - from compas.geometry import PolygonalSurface from compas.geometry import Polyline from compas.geometry import Sphere from compas.geometry import Surface @@ -39,7 +39,6 @@ from .operations import brep_area from .operations import brep_cap_planar_holes from .operations import brep_centroid -from .operations import brep_classification from .operations import brep_contains from .operations import brep_contours from .operations import brep_copy @@ -47,27 +46,45 @@ from .operations import brep_cull_unused_faces from .operations import brep_cull_unused_loops from .operations import brep_cull_unused_vertices +from .operations import brep_edges +from .operations import brep_faces from .operations import brep_fillet from .operations import brep_fix from .operations import brep_flip from .operations import brep_frame from .operations import brep_heal +from .operations import brep_is_closed +from .operations import brep_is_compound +from .operations import brep_is_compoundsolid +from .operations import brep_is_convex +from .operations import brep_is_infinite +from .operations import brep_is_manifold +from .operations import brep_is_orientable +from .operations import brep_is_shell +from .operations import brep_is_solid +from .operations import brep_is_surface +from .operations import brep_is_valid +from .operations import brep_loops from .operations import brep_make_solid from .operations import brep_obb +from .operations import brep_orientation from .operations import brep_overlap -from .operations import brep_polygonize from .operations import brep_rebuild from .operations import brep_sew +from .operations import brep_shells from .operations import brep_slice +from .operations import brep_solids from .operations import brep_split -from .operations import brep_tessellate from .operations import brep_to_data from .operations import brep_to_iges from .operations import brep_to_step from .operations import brep_to_stl -from .operations import brep_topology +from .operations import brep_to_tessellation from .operations import brep_transform from .operations import brep_trimmed +from .operations import brep_trims +from .operations import brep_type +from .operations import brep_vertices from .operations import brep_volume from .operations import from_boolean_difference from .operations import from_boolean_intersection @@ -130,6 +147,12 @@ class Brep(Geometry): name The optional object name. + Notes + ----- + Query properties are implemented independently by the active backend. A + backend raises :class:`NotImplementedError` for a property whose documented + meaning it cannot provide; it does not substitute a false or empty value. + """ # ========================================================================== @@ -232,67 +255,67 @@ def native_brep(self) -> Any: @property def orientation(self) -> Literal[0, 1, 2, 3]: """The orientation as a `BrepOrientation` value.""" - return brep_classification(self)["orientation"] + return brep_orientation(self) @property def type(self) -> Literal[0, 1, 2, 3, 4, 5, 6, 7, 8]: """The topological type as a `BrepType` value.""" - return brep_classification(self)["type"] + return brep_type(self) @property def is_valid(self) -> bool: """Whether the Brep is valid according to the backend.""" - return brep_classification(self)["is_valid"] + return brep_is_valid(self) @property def is_orientable(self) -> bool: """Whether the Brep is orientable.""" - return brep_classification(self)["is_orientable"] + return brep_is_orientable(self) @property def is_closed(self) -> bool: """Whether the Brep has no naked boundaries.""" - return brep_classification(self)["is_closed"] + return brep_is_closed(self) @property def is_infinite(self) -> bool: """Whether the Brep represents an unbounded shape.""" - return brep_classification(self)["is_infinite"] + return brep_is_infinite(self) @property def is_convex(self) -> bool: """Whether the Brep is convex.""" - return brep_classification(self)["is_convex"] + return brep_is_convex(self) @property def is_manifold(self) -> bool: """Whether the Brep is manifold.""" - return brep_classification(self)["is_manifold"] + return brep_is_manifold(self) @property def is_surface(self) -> bool: """Whether the Brep represents a single surface.""" - return brep_classification(self)["is_surface"] + return brep_is_surface(self) @property def is_compound(self) -> bool: """Whether the Brep is a compound.""" - return brep_classification(self)["is_compound"] + return brep_is_compound(self) @property def is_compoundsolid(self) -> bool: """Whether the Brep is a compound solid.""" - return brep_classification(self)["is_compoundsolid"] + return brep_is_compoundsolid(self) @property def is_shell(self) -> bool: """Whether the Brep is a shell.""" - return brep_classification(self)["is_shell"] + return brep_is_shell(self) @property def is_solid(self) -> bool: """Whether the Brep is a solid.""" - return brep_classification(self)["is_solid"] + return brep_is_solid(self) # ============================================================================== # Geometric Components @@ -320,37 +343,37 @@ def surfaces(self) -> list["Surface | Plane | Cylinder | Cone | Sphere | Torus"] @property def vertices(self) -> list["BrepVertex"]: """The topological vertices.""" - return brep_topology(self)["vertices"] + return brep_vertices(self) @property def edges(self) -> list["BrepEdge"]: """The topological edges.""" - return brep_topology(self)["edges"] + return brep_edges(self) @property def trims(self) -> list["BrepTrim"]: """The parametric trims.""" - return brep_topology(self)["trims"] + return brep_trims(self) @property def loops(self) -> list["BrepLoop"]: """The boundary loops.""" - return brep_topology(self)["loops"] + return brep_loops(self) @property def faces(self) -> list["BrepFace"]: """The topological faces.""" - return brep_topology(self)["faces"] + return brep_faces(self) @property def shells(self) -> list["Brep"]: """The shells contained in the Brep.""" - return brep_topology(self)["shells"] + return brep_shells(self) @property def solids(self) -> list["Brep"]: """The solids contained in the Brep.""" - return brep_topology(self)["solids"] + return brep_solids(self) # ============================================================================== # Geometric Properties @@ -859,13 +882,22 @@ def to_stl(self, filepath: str | PathLike[str]) -> bool: """ return brep_to_stl(self, filepath) - def tessellate(self, strategy: "Deflection") -> "Tessellation": + def to_tessellation( + self, + *, + linear_deflection: float | None = None, + angular_deflection: float | None = None, + ) -> "Tessellation": """Create a triangle tessellation of the trimmed shape. Parameters ---------- - strategy - The linear and angular deflection targets. + linear_deflection + The positive linear deflection in model units. If `None`, use + `TOL.lineardeflection`. + angular_deflection + The positive angular deflection in degrees, no greater than 180. + If `None`, use the degree equivalent of `TOL.angulardeflection`. Returns ------- @@ -873,31 +905,13 @@ def tessellate(self, strategy: "Deflection") -> "Tessellation": The ephemeral triangle representation. """ - from compas.geometry import Deflection - - if not isinstance(strategy, Deflection): - raise TypeError("Brep tessellation requires a Deflection strategy.") - return brep_tessellate(self, strategy) - - def polygonize(self, strategy: "Deflection") -> "PolygonalSurface": - """Create a polygonal representation of the trimmed shape. - - Parameters - ---------- - strategy - The linear and angular deflection targets. - - Returns - ------- - PolygonalSurface - The fresh polygonal representation. - - """ - from compas.geometry import Deflection - - if not isinstance(strategy, Deflection): - raise TypeError("Brep polygonization requires a Deflection strategy.") - return brep_polygonize(self, strategy) + linear = TOL.lineardeflection if linear_deflection is None else linear_deflection + angular = degrees(TOL.angulardeflection) if angular_deflection is None else angular_deflection + return brep_to_tessellation( + self, + linear_deflection=linear, + angular_deflection=angular, + ) # ============================================================================== # Relationships diff --git a/src/compas/geometry/brep/edge.py b/src/compas/geometry/brep/edge.py index 73d42711c80c..ab7db9e1ee7e 100644 --- a/src/compas/geometry/brep/edge.py +++ b/src/compas/geometry/brep/edge.py @@ -4,8 +4,8 @@ from typing import Literal from typing import Self -from compas._typing import CoordinatesType from compas.data import Data +from compas.typing import PointsLike if TYPE_CHECKING: from compas.geometry import Bezier @@ -147,7 +147,7 @@ def from_vertices(cls, vertices: Sequence["BrepVertex"]) -> Self: raise NotImplementedError @classmethod - def from_points(cls, points: CoordinatesType) -> Self: + def from_points(cls, points: PointsLike) -> Self: """Construct an edge from two points. Parameters diff --git a/src/compas/geometry/brep/face.py b/src/compas/geometry/brep/face.py index bc80fed22aaf..5ae03a32f97d 100644 --- a/src/compas/geometry/brep/face.py +++ b/src/compas/geometry/brep/face.py @@ -14,12 +14,10 @@ from compas.geometry import BrepVertex from compas.geometry import Cone from compas.geometry import Cylinder - from compas.geometry import Deflection from compas.geometry import Frame from compas.geometry import NurbsSurface from compas.geometry import Plane from compas.geometry import Point - from compas.geometry import PolygonalSurface from compas.geometry import Sphere from compas.geometry import Surface from compas.geometry import Tessellation @@ -291,13 +289,33 @@ def to_nurbs(self) -> "NurbsSurface": """Return the face surface as a NURBS surface.""" raise NotImplementedError - def tessellate(self, strategy: "Deflection") -> "Tessellation": - """Create a triangle tessellation respecting this face's trims.""" - return self.as_brep().tessellate(strategy) + def to_tessellation( + self, + *, + linear_deflection: float | None = None, + angular_deflection: float | None = None, + ) -> "Tessellation": + """Create a triangle tessellation respecting this face's trims. + + Parameters + ---------- + linear_deflection + The positive linear deflection in model units. If `None`, use + `TOL.lineardeflection` through the containing BRep operation. + angular_deflection + The positive angular deflection in degrees, no greater than 180. + If `None`, use the degree equivalent of `TOL.angulardeflection`. - def polygonize(self, strategy: "Deflection") -> "PolygonalSurface": - """Create a polygonal representation respecting this face's trims.""" - return self.as_brep().polygonize(strategy) + Returns + ------- + Tessellation + The indexed triangle representation. + + """ + return self.as_brep().to_tessellation( + linear_deflection=linear_deflection, + angular_deflection=angular_deflection, + ) # ========================================================================== # Methods diff --git a/src/compas/geometry/brep/operations.py b/src/compas/geometry/brep/operations.py index 4fdc49b51776..d69f4bec5880 100644 --- a/src/compas/geometry/brep/operations.py +++ b/src/compas/geometry/brep/operations.py @@ -1,11 +1,14 @@ """Pluggable operations for the backend-neutral Brep wrapper.""" from collections.abc import Sequence +from functools import wraps +from math import isfinite +from numbers import Real from os import PathLike from typing import TYPE_CHECKING from typing import Any from typing import Literal -from typing import TypedDict +from typing import Protocol from compas.plugins import PluginNotInstalledError from compas.plugins import pluggable @@ -16,12 +19,10 @@ from compas.geometry import Cone from compas.geometry import Curve from compas.geometry import Cylinder - from compas.geometry import Deflection from compas.geometry import Frame from compas.geometry import Plane from compas.geometry import Point from compas.geometry import Polygon - from compas.geometry import PolygonalSurface from compas.geometry import Polyline from compas.geometry import Sphere from compas.geometry import Surface @@ -38,34 +39,43 @@ from .vertex import BrepVertex -class BrepClassification(TypedDict): - """Backend-independent classification values for a Brep.""" - - orientation: Literal[0, 1, 2, 3] - type: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8] - is_valid: bool - is_orientable: bool - is_closed: bool - is_infinite: bool - is_convex: bool - is_manifold: bool - is_surface: bool - is_compound: bool - is_compoundsolid: bool - is_shell: bool - is_solid: bool - - -class BrepTopology(TypedDict): - """Backend-independent topology extracted from a Brep.""" - - vertices: list["BrepVertex"] - edges: list["BrepEdge"] - trims: list["BrepTrim"] - loops: list["BrepLoop"] - faces: list["BrepFace"] - shells: list["Brep"] - solids: list["Brep"] +class _BrepTessellator(Protocol): + def __call__( + self, + brep: "Brep", + *, + linear_deflection: float, + angular_deflection: float, + ) -> "Tessellation": ... + + +def _validate_brep_tessellation_control(value: object, name: str, *, maximum: float | None = None) -> float: + if not isinstance(value, Real) or isinstance(value, bool): + raise TypeError(f"{name} should be a real number.") + checked = float(value) + if not isfinite(checked) or checked <= 0 or maximum is not None and checked > maximum: + requirement = "positive and finite" if maximum is None else f"positive, finite, and no greater than {maximum:g}" + raise ValueError(f"{name} should be {requirement}.") + return checked + + +def _guard_brep_tessellation_controls(operation: _BrepTessellator) -> _BrepTessellator: + @wraps(operation) + def guarded( + brep: "Brep", + *, + linear_deflection: float, + angular_deflection: float, + ) -> "Tessellation": + linear = _validate_brep_tessellation_control(linear_deflection, "linear_deflection") + angular = _validate_brep_tessellation_control(angular_deflection, "angular_deflection", maximum=180) + return operation( + brep, + linear_deflection=linear, + angular_deflection=angular, + ) + + return guarded @pluggable(category="brep-factories") @@ -214,7 +224,7 @@ def brep_centroid(brep: "Brep") -> "Point": @pluggable(category="brep-queries") -def brep_classification(brep: "Brep") -> BrepClassification: +def brep_orientation(brep: "Brep") -> Literal[0, 1, 2, 3]: raise PluginNotInstalledError @@ -229,7 +239,97 @@ def brep_obb(brep: "Brep") -> "Box": @pluggable(category="brep-queries") -def brep_topology(brep: "Brep") -> BrepTopology: +def brep_type(brep: "Brep") -> Literal[0, 1, 2, 3, 4, 5, 6, 7, 8]: + raise PluginNotInstalledError + + +@pluggable(category="brep-queries") +def brep_is_valid(brep: "Brep") -> bool: + raise PluginNotInstalledError + + +@pluggable(category="brep-queries") +def brep_is_orientable(brep: "Brep") -> bool: + raise PluginNotInstalledError + + +@pluggable(category="brep-queries") +def brep_is_closed(brep: "Brep") -> bool: + raise PluginNotInstalledError + + +@pluggable(category="brep-queries") +def brep_is_infinite(brep: "Brep") -> bool: + raise PluginNotInstalledError + + +@pluggable(category="brep-queries") +def brep_is_convex(brep: "Brep") -> bool: + raise PluginNotInstalledError + + +@pluggable(category="brep-queries") +def brep_is_manifold(brep: "Brep") -> bool: + raise PluginNotInstalledError + + +@pluggable(category="brep-queries") +def brep_is_surface(brep: "Brep") -> bool: + raise PluginNotInstalledError + + +@pluggable(category="brep-queries") +def brep_is_compound(brep: "Brep") -> bool: + raise PluginNotInstalledError + + +@pluggable(category="brep-queries") +def brep_is_compoundsolid(brep: "Brep") -> bool: + raise PluginNotInstalledError + + +@pluggable(category="brep-queries") +def brep_is_shell(brep: "Brep") -> bool: + raise PluginNotInstalledError + + +@pluggable(category="brep-queries") +def brep_is_solid(brep: "Brep") -> bool: + raise PluginNotInstalledError + + +@pluggable(category="brep-queries") +def brep_vertices(brep: "Brep") -> list["BrepVertex"]: + raise PluginNotInstalledError + + +@pluggable(category="brep-queries") +def brep_edges(brep: "Brep") -> list["BrepEdge"]: + raise PluginNotInstalledError + + +@pluggable(category="brep-queries") +def brep_trims(brep: "Brep") -> list["BrepTrim"]: + raise PluginNotInstalledError + + +@pluggable(category="brep-queries") +def brep_loops(brep: "Brep") -> list["BrepLoop"]: + raise PluginNotInstalledError + + +@pluggable(category="brep-queries") +def brep_faces(brep: "Brep") -> list["BrepFace"]: + raise PluginNotInstalledError + + +@pluggable(category="brep-queries") +def brep_shells(brep: "Brep") -> list["Brep"]: + raise PluginNotInstalledError + + +@pluggable(category="brep-queries") +def brep_solids(brep: "Brep") -> list["Brep"]: raise PluginNotInstalledError @@ -248,19 +348,17 @@ def brep_contours(brep: "Brep", planes: Sequence["Plane"]) -> list[list["Polylin raise PluginNotInstalledError +@_guard_brep_tessellation_controls @pluggable(category="brep-operations") -def brep_tessellate( +def brep_to_tessellation( brep: "Brep", - strategy: "Deflection", + *, + linear_deflection: float, + angular_deflection: float, ) -> "Tessellation": raise PluginNotInstalledError -@pluggable(category="brep-operations") -def brep_polygonize(brep: "Brep", strategy: "Deflection") -> "PolygonalSurface": - raise PluginNotInstalledError - - @pluggable(category="brep-operations") def brep_cap_planar_holes(brep: "Brep", tolerance: float | None = None) -> "Brep": raise PluginNotInstalledError diff --git a/src/compas/geometry/contours.py b/src/compas/geometry/contours.py index e271e8cb74b1..01e5ad187ae7 100644 --- a/src/compas/geometry/contours.py +++ b/src/compas/geometry/contours.py @@ -1,7 +1,7 @@ from collections.abc import Sequence from typing import TYPE_CHECKING -from compas._typing import CoordinatesType +from compas.typing import PointsLike if TYPE_CHECKING: from numpy.typing import NDArray @@ -108,7 +108,7 @@ def _contours_from_grid(x: "NDArray", y: "NDArray", values: "NDArray", level: fl def scalarfield_contours( - xy: CoordinatesType, + xy: PointsLike, s: Sequence[float], levels: int | Sequence[float] = 50, density: int = 100, diff --git a/src/compas/geometry/curves/arc.py b/src/compas/geometry/curves/arc.py index bf52f3d2cf21..0d6dd124b65a 100644 --- a/src/compas/geometry/curves/arc.py +++ b/src/compas/geometry/curves/arc.py @@ -4,6 +4,8 @@ from typing import Any from typing import Self +import numpy as np + from compas.geometry import Circle from compas.geometry import Frame from compas.geometry import Point @@ -210,17 +212,17 @@ def circumference(self) -> float: return self.diameter * pi @property - def is_circle(self) -> bool: + def is_circle(self) -> bool | np.bool_: """Whether the arc covers a complete circle.""" return TOL.is_close(abs(self.angle), PI2) @property - def is_closed(self) -> bool: + def is_closed(self) -> bool | np.bool_: """Whether the arc covers a complete circle.""" return self.is_circle @property - def is_periodic(self) -> bool: + def is_periodic(self) -> bool | np.bool_: """Whether the arc covers a complete circle.""" return self.is_circle diff --git a/src/compas/geometry/curves/bezier.py b/src/compas/geometry/curves/bezier.py index ec0227c03e41..e2716657323b 100644 --- a/src/compas/geometry/curves/bezier.py +++ b/src/compas/geometry/curves/bezier.py @@ -3,10 +3,10 @@ from math import factorial from typing import Any -from compas._typing import CoordinateType from compas.geometry import Frame from compas.geometry import Point from compas.geometry import Vector +from compas.typing import PointLike from .._typing import TransformationType from .curve import Curve @@ -159,7 +159,7 @@ class Bezier(Curve): def __data__(self) -> dict[str, Any]: return {"points": [point.__data__ for point in self.points]} - def __init__(self, points: Sequence[CoordinateType], name: str | None = None) -> None: + def __init__(self, points: Sequence[PointLike], name: str | None = None) -> None: super().__init__(name=name) self._points: list[Point] = [] self.points = points @@ -207,7 +207,7 @@ def points(self) -> list[Point]: return self._points @points.setter - def points(self, points: Sequence[CoordinateType]) -> None: + def points(self, points: Sequence[PointLike]) -> None: if len(points) < 2: raise ValueError("A Bezier curve requires at least two control points.") self._points = [Point(point[0], point[1], point[2]) for point in points] diff --git a/src/compas/geometry/curves/circle.py b/src/compas/geometry/curves/circle.py index e33db4b2098c..c151f7209ca1 100644 --- a/src/compas/geometry/curves/circle.py +++ b/src/compas/geometry/curves/circle.py @@ -8,11 +8,12 @@ from typing import Self from typing import overload -from compas._typing import CoordinateType from compas.geometry import Frame from compas.geometry import Plane from compas.geometry import Point from compas.geometry import Vector +from compas.typing import PointLike +from compas.typing import RealInput from .conic import Conic @@ -68,7 +69,7 @@ def __data__(self) -> dict[str, Any]: def __from_data__(cls, data: dict[str, Any]) -> Self: return cls(radius=data["radius"], frame=Frame.__from_data__(data["frame"])) - def __init__(self, radius: float, frame: Frame | None = None, name: str | None = None) -> None: + def __init__(self, radius: RealInput, frame: Frame | None = None, name: str | None = None) -> None: super().__init__(frame=frame, name=name) self._radius: float | None = None self.radius = radius @@ -109,7 +110,7 @@ def center(self) -> Point: return self.frame.point @center.setter - def center(self, point: CoordinateType) -> None: + def center(self, point: PointLike) -> None: self.frame.point = point @property @@ -120,7 +121,7 @@ def radius(self) -> float: return self._radius @radius.setter - def radius(self, radius: float) -> None: + def radius(self, radius: RealInput) -> None: if radius <= 0: raise ValueError("The radius of a circle must be positive.") self._radius = float(radius) @@ -164,7 +165,7 @@ def is_periodic(self) -> bool: # ========================================================================== @classmethod - def from_point_and_radius(cls, point: CoordinateType, radius: float) -> Self: + def from_point_and_radius(cls, point: PointLike, radius: RealInput) -> Self: """Construct a circle from a point and a radius. Parameters @@ -195,7 +196,7 @@ def from_point_and_radius(cls, point: CoordinateType, radius: float) -> Self: return cls(frame=frame, radius=radius) @classmethod - def from_plane_and_radius(cls, plane: Plane, radius: float) -> Self: + def from_plane_and_radius(cls, plane: Plane, radius: RealInput) -> Self: """Construct a circle from a plane and a radius. Parameters @@ -227,7 +228,7 @@ def from_plane_and_radius(cls, plane: Plane, radius: float) -> Self: return cls(frame=frame, radius=radius) @classmethod - def from_three_points(cls, a: CoordinateType, b: CoordinateType, c: CoordinateType) -> Self: + def from_three_points(cls, a: PointLike, b: PointLike, c: PointLike) -> Self: """Construct a circle from three points. Parameters @@ -286,7 +287,7 @@ def from_three_points(cls, a: CoordinateType, b: CoordinateType, c: CoordinateTy return cls.from_plane_and_radius(plane, radius) @classmethod - def from_points(cls, points: Sequence[CoordinateType]) -> Self: + def from_points(cls, points: Sequence[PointLike]) -> Self: """Construct a circle from a list of at least three points. Parameters @@ -463,12 +464,12 @@ def tangent_at(self, t: float, world: bool = True) -> Vector: return vector @overload - def closest_point(self, point: CoordinateType, return_parameter: Literal[False] = False) -> Point: ... + def closest_point(self, point: PointLike, return_parameter: Literal[False] = False) -> Point: ... @overload - def closest_point(self, point: CoordinateType, return_parameter: Literal[True]) -> tuple[Point, float]: ... + def closest_point(self, point: PointLike, return_parameter: Literal[True]) -> tuple[Point, float]: ... - def closest_point(self, point: CoordinateType, return_parameter: bool = False) -> Point | tuple[Point, float]: + def closest_point(self, point: PointLike, return_parameter: bool = False) -> Point | tuple[Point, float]: """Compute the closest point on the circle to a given point. Parameters @@ -511,7 +512,7 @@ def closest_point(self, point: CoordinateType, return_parameter: bool = False) - return closest, parameter % 1.0 return closest - def contains_point(self, point: CoordinateType, tol: float = 1e-6, dmax: float = 1e-6) -> bool: + def contains_point(self, point: PointLike, tol: float = 1e-6, dmax: float = 1e-6) -> bool: """Verify that the circle contains a given point. Parameters diff --git a/src/compas/geometry/curves/curve.py b/src/compas/geometry/curves/curve.py index 376b94facc6a..170d8865cadc 100644 --- a/src/compas/geometry/curves/curve.py +++ b/src/compas/geometry/curves/curve.py @@ -5,8 +5,8 @@ from typing import TypeVar from typing import overload -from compas._typing import CoordinateType -from compas._typing import FilePath +import numpy as np + from compas.geometry import Frame from compas.geometry import Geometry from compas.geometry import Plane @@ -14,6 +14,8 @@ from compas.itertools import linspace from compas.plugins import PluginNotInstalledError from compas.plugins import pluggable +from compas.typing import FilePath +from compas.typing import PointLike from .._typing import TransformationType @@ -176,12 +178,12 @@ def length(self) -> float: raise NotImplementedError @property - def is_closed(self) -> bool: + def is_closed(self) -> bool | np.bool_: """Whether the curve is closed.""" raise NotImplementedError @property - def is_periodic(self) -> bool: + def is_periodic(self) -> bool | np.bool_: """Whether the curve is periodic.""" raise NotImplementedError @@ -568,12 +570,12 @@ def reversed(self) -> Self: return copy @overload - def closest_point(self, point: CoordinateType, return_parameter: Literal[False] = False) -> "Point": ... + def closest_point(self, point: PointLike, return_parameter: Literal[False] = False) -> "Point": ... @overload - def closest_point(self, point: CoordinateType, return_parameter: Literal[True]) -> tuple["Point", float]: ... + def closest_point(self, point: PointLike, return_parameter: Literal[True]) -> tuple["Point", float]: ... - def closest_point(self, point: CoordinateType, return_parameter: bool = False) -> "Point | tuple[Point, float]": + def closest_point(self, point: PointLike, return_parameter: bool = False) -> "Point | tuple[Point, float]": """Compute the closest point on the curve to a given point. Parameters diff --git a/src/compas/geometry/curves/ellipse.py b/src/compas/geometry/curves/ellipse.py index 015b1b422d61..695e21182760 100644 --- a/src/compas/geometry/curves/ellipse.py +++ b/src/compas/geometry/curves/ellipse.py @@ -5,11 +5,11 @@ from typing import Any from typing import Self -from compas._typing import CoordinateType from compas.geometry import Frame from compas.geometry import Plane from compas.geometry import Point from compas.geometry import Vector +from compas.typing import PointLike from ..line import Line from .conic import Conic @@ -120,7 +120,7 @@ def center(self) -> Point: return self.frame.point @center.setter - def center(self, point: CoordinateType) -> None: + def center(self, point: PointLike) -> None: self.frame.point = point @property @@ -249,7 +249,7 @@ def is_periodic(self) -> bool: # ========================================================================== @classmethod - def from_point_major_minor(cls, point: CoordinateType, major: float, minor: float) -> Self: + def from_point_major_minor(cls, point: PointLike, major: float, minor: float) -> Self: """Construct a ellipse from a point and major and minor axis lengths. Parameters diff --git a/src/compas/geometry/curves/hyperbola.py b/src/compas/geometry/curves/hyperbola.py index d04e61b78d11..afbf7b1c4e94 100644 --- a/src/compas/geometry/curves/hyperbola.py +++ b/src/compas/geometry/curves/hyperbola.py @@ -6,10 +6,10 @@ from typing import Literal from typing import Self -from compas._typing import CoordinateType from compas.geometry import Frame from compas.geometry import Point from compas.geometry import Vector +from compas.typing import PointLike from ..line import Line from .conic import Conic @@ -112,7 +112,7 @@ def center(self) -> Point: return self.frame.point @center.setter - def center(self, point: CoordinateType) -> None: + def center(self, point: PointLike) -> None: self.frame.point = point @property diff --git a/src/compas/geometry/curves/line.py b/src/compas/geometry/curves/line.py index d05817b87bc8..dc28b4bbf2ca 100644 --- a/src/compas/geometry/curves/line.py +++ b/src/compas/geometry/curves/line.py @@ -4,12 +4,14 @@ from typing import Self from typing import overload -from compas._typing import CoordinateType from compas.geometry import Frame from compas.geometry import Line from compas.geometry import Point from compas.geometry import Vector from compas.itertools import linspace +from compas.typing import PointLike +from compas.typing import RealInput +from compas.typing import VectorLike from .._typing import TransformationType from .curve import Curve @@ -55,7 +57,7 @@ def __data__(self) -> dict[str, Any]: def __from_data__(cls, data: dict[str, Any]) -> Self: return cls(data["start"], data["end"]) - def __init__(self, start: CoordinateType, end: CoordinateType, name: str | None = None) -> None: + def __init__(self, start: PointLike, end: PointLike, name: str | None = None) -> None: super().__init__(name=name) self._line = Line(start, end) @@ -88,7 +90,7 @@ def start(self) -> Point: return self._line.start @start.setter - def start(self, point: CoordinateType) -> None: + def start(self, point: PointLike) -> None: self._line.start = point @property @@ -97,7 +99,7 @@ def end(self) -> Point: return self._line.end @end.setter - def end(self, point: CoordinateType) -> None: + def end(self, point: PointLike) -> None: self._line.end = point @property @@ -138,7 +140,7 @@ def from_line(cls, line: Line) -> Self: return cls(line.start, line.end) @classmethod - def from_point_and_vector(cls, point: CoordinateType, vector: CoordinateType) -> Self: + def from_point_and_vector(cls, point: PointLike, vector: VectorLike) -> Self: """Construct a linear curve from a point and vector. Parameters @@ -157,7 +159,7 @@ def from_point_and_vector(cls, point: CoordinateType, vector: CoordinateType) -> return cls.from_line(Line.from_point_and_vector(point, vector)) @classmethod - def from_point_direction_length(cls, point: CoordinateType, direction: CoordinateType, length: float) -> Self: + def from_point_direction_length(cls, point: PointLike, direction: VectorLike, length: float) -> Self: """Construct a linear curve from a point, direction, and length. Parameters @@ -188,7 +190,7 @@ def to_line(self) -> Line: """ return Line(self.start, self.end) - def point_at(self, t: float, world: bool = True) -> Point: + def point_at(self, t: RealInput, world: bool = True) -> Point: """Compute the point at a normalized parameter. Parameters @@ -233,12 +235,12 @@ def reverse(self) -> None: self._line.flip() @overload - def closest_point(self, point: CoordinateType, return_parameter: Literal[False] = False) -> Point: ... + def closest_point(self, point: PointLike, return_parameter: Literal[False] = False) -> Point: ... @overload - def closest_point(self, point: CoordinateType, return_parameter: Literal[True]) -> tuple[Point, float]: ... + def closest_point(self, point: PointLike, return_parameter: Literal[True]) -> tuple[Point, float]: ... - def closest_point(self, point: CoordinateType, return_parameter: bool = False) -> Point | tuple[Point, float]: + def closest_point(self, point: PointLike, return_parameter: bool = False) -> Point | tuple[Point, float]: """Compute the closest point on the finite curve. Parameters @@ -310,6 +312,6 @@ def transform(self, transformation: TransformationType) -> None: self._line.transform(transformation) @staticmethod - def _validate_parameter(t: float) -> None: + def _validate_parameter(t: RealInput) -> None: if t < 0.0 or t > 1.0: raise ValueError("The parameter should be in the domain [0, 1].") diff --git a/src/compas/geometry/curves/nurbs.py b/src/compas/geometry/curves/nurbs.py index 7ac21e9eee8f..62830563bead 100644 --- a/src/compas/geometry/curves/nurbs.py +++ b/src/compas/geometry/curves/nurbs.py @@ -5,8 +5,8 @@ from typing import Literal from typing import overload -from compas._typing import CoordinateType -from compas._typing import FilePath +from compas.typing import FilePath +from compas.typing import PointLike from .._typing import TransformationType from .curve import Curve @@ -286,7 +286,7 @@ def from_ellipse(cls, ellipse: "Ellipse") -> "NurbsCurve": return NurbsCurve.from_parameters(points=points, weights=weights, knots=knots, multiplicities=mults, degree=2) @classmethod - def from_interpolation(cls, points: Sequence[CoordinateType], precision: float = 1e-3) -> "NurbsCurve": + def from_interpolation(cls, points: Sequence[PointLike], precision: float = 1e-3) -> "NurbsCurve": """Construct a NURBS curve by interpolating a set of points. Parameters @@ -361,7 +361,7 @@ def from_native(cls, curve: object) -> "NurbsCurve": @classmethod def from_parameters( cls, - points: Sequence[CoordinateType], + points: Sequence[PointLike], weights: Sequence[float], knots: Sequence[float], multiplicities: Sequence[int], @@ -404,7 +404,7 @@ def from_parameters( return from_parameters(points, weights, knots, multiplicities, degree, is_periodic=is_periodic) @classmethod - def from_points(cls, points: Sequence[CoordinateType], degree: int = 3, is_periodic: bool = False) -> "NurbsCurve": + def from_points(cls, points: Sequence[PointLike], degree: int = 3, is_periodic: bool = False) -> "NurbsCurve": """Construct a NURBS curve from control points. Parameters @@ -513,12 +513,12 @@ def reverse(self) -> None: curve_reverse(self) @overload - def closest_point(self, point: CoordinateType, return_parameter: Literal[False] = False) -> "Point": ... + def closest_point(self, point: PointLike, return_parameter: Literal[False] = False) -> "Point": ... @overload - def closest_point(self, point: CoordinateType, return_parameter: Literal[True]) -> tuple["Point", float]: ... + def closest_point(self, point: PointLike, return_parameter: Literal[True]) -> tuple["Point", float]: ... - def closest_point(self, point: CoordinateType, return_parameter: bool = False) -> "Point | tuple[Point, float]": + def closest_point(self, point: PointLike, return_parameter: bool = False) -> "Point | tuple[Point, float]": """Compute the closest point on the curve.""" return curve_closest_point(self, point, return_parameter=return_parameter) diff --git a/src/compas/geometry/curves/nurbs_operations.py b/src/compas/geometry/curves/nurbs_operations.py index c0c6b53942f8..3c20f6556dfa 100644 --- a/src/compas/geometry/curves/nurbs_operations.py +++ b/src/compas/geometry/curves/nurbs_operations.py @@ -3,11 +3,11 @@ from collections.abc import Sequence from typing import TYPE_CHECKING -from compas._typing import CoordinateType -from compas._typing import FilePath from compas.geometry._typing import TransformationType from compas.plugins import PluginNotInstalledError from compas.plugins import pluggable +from compas.typing import FilePath +from compas.typing import PointLike if TYPE_CHECKING: from compas.geometry import Arc @@ -23,7 +23,7 @@ def from_arc(arc: "Arc") -> "NurbsCurve": @pluggable(category="nurbscurve-factories") -def from_interpolation(points: Sequence[CoordinateType], precision: float = 1e-3) -> "NurbsCurve": +def from_interpolation(points: Sequence[PointLike], precision: float = 1e-3) -> "NurbsCurve": raise PluginNotInstalledError @@ -34,7 +34,7 @@ def from_native(native_curve: object) -> "NurbsCurve": @pluggable(category="nurbscurve-factories") def from_parameters( - points: Sequence[CoordinateType], + points: Sequence[PointLike], weights: Sequence[float], knots: Sequence[float], multiplicities: Sequence[int], @@ -45,7 +45,7 @@ def from_parameters( @pluggable(category="nurbscurve-factories") -def from_points(points: Sequence[CoordinateType], degree: int = 3, is_periodic: bool = False) -> "NurbsCurve": +def from_points(points: Sequence[PointLike], degree: int = 3, is_periodic: bool = False) -> "NurbsCurve": raise PluginNotInstalledError @@ -140,7 +140,7 @@ def curve_reverse(curve: "NurbsCurve") -> None: @pluggable(category="nurbscurve-operations") -def curve_closest_point(curve: "NurbsCurve", point: CoordinateType, return_parameter: bool = False) -> "Point | tuple[Point, float]": +def curve_closest_point(curve: "NurbsCurve", point: PointLike, return_parameter: bool = False) -> "Point | tuple[Point, float]": raise PluginNotInstalledError diff --git a/src/compas/geometry/frame.py b/src/compas/geometry/frame.py index 7b8490164f3a..9aeb88331904 100644 --- a/src/compas/geometry/frame.py +++ b/src/compas/geometry/frame.py @@ -5,8 +5,6 @@ from typing import TypeVar from typing import overload -from compas._typing import Coordinates -from compas._typing import CoordinateType from compas.geometry import Geometry from compas.geometry import Transformation from compas.itertools import linspace @@ -22,6 +20,9 @@ from compas.linalg.vectors import argmax from compas.linalg.vectors import cross_vectors from compas.linalg.vectors import subtract_vectors +from compas.typing import PointLike +from compas.typing import PointsProtocol +from compas.typing import VectorLike from ._typing import PlaneType from ._typing import QuaternionType @@ -164,9 +165,9 @@ def __from_data__(cls, data: dict[str, Sequence[float]]) -> Self: def __init__( self, - point: CoordinateType, - xaxis: CoordinateType | None = None, - yaxis: CoordinateType | None = None, + point: PointLike, + xaxis: VectorLike | None = None, + yaxis: VectorLike | None = None, name: str | None = None, ) -> None: super().__init__(name=name) @@ -206,7 +207,7 @@ def __getitem__(self, key: int) -> Point | Vector: return self.yaxis raise KeyError - def __setitem__(self, key: int, value: CoordinateType) -> None: + def __setitem__(self, key: int, value: PointLike) -> None: if key == 0: self.point = value return @@ -222,7 +223,7 @@ def __iter__(self) -> Iterator[Point | Vector]: return iter((self.point, self.xaxis, self.yaxis)) def __eq__(self, other: object) -> bool: - if not isinstance(other, Coordinates): + if not isinstance(other, PointsProtocol): return False if len(self) != len(other): return False @@ -258,7 +259,7 @@ def point(self) -> Point: return self._point @point.setter - def point(self, point: CoordinateType) -> None: + def point(self, point: PointLike) -> None: self._point = Point(point[0], point[1], point[2]) @property @@ -287,7 +288,7 @@ def xaxis(self) -> Vector: return self._xaxis @xaxis.setter - def xaxis(self, vector: CoordinateType) -> None: + def xaxis(self, vector: VectorLike) -> None: xaxis = Vector(vector[0], vector[1], vector[2]) if not xaxis.length: raise ValueError("The X axis cannot be a zero vector.") @@ -329,7 +330,7 @@ def yaxis(self) -> Vector: return self._yaxis @yaxis.setter - def yaxis(self, vector: CoordinateType) -> None: + def yaxis(self, vector: VectorLike) -> None: yaxis = Vector(vector[0], vector[1], vector[2]) if not yaxis.length: raise ValueError("The Y axis cannot be a zero vector.") @@ -475,7 +476,7 @@ def worldYZ(cls) -> Self: return cls._from_trusted_data((0, 0, 0), (0, 1, 0), (0, 0, 1)) @classmethod - def from_points(cls, point: CoordinateType, point_xaxis: CoordinateType, point_xyplane: CoordinateType) -> Self: + def from_points(cls, point: PointLike, point_xaxis: PointLike, point_xyplane: PointLike) -> Self: """Constructs a frame from 3 points. Parameters @@ -509,7 +510,7 @@ def from_points(cls, point: CoordinateType, point_xaxis: CoordinateType, point_x return cls(point, xaxis, yaxis) @classmethod - def from_rotation(cls, rotation: Transformation, point: CoordinateType = (0, 0, 0)) -> Self: + def from_rotation(cls, rotation: Transformation, point: PointLike = (0, 0, 0)) -> Self: """Constructs a frame from a Rotation. Parameters @@ -640,7 +641,7 @@ def from_list(cls, values: MutableSequence[float]) -> Self: return cls.from_matrix(matrix) @classmethod - def from_quaternion(cls, quaternion: QuaternionType, point: CoordinateType = (0, 0, 0)) -> Self: + def from_quaternion(cls, quaternion: QuaternionType, point: PointLike = (0, 0, 0)) -> Self: """Construct a frame from a rotation represented by quaternion coefficients. Parameters @@ -670,7 +671,7 @@ def from_quaternion(cls, quaternion: QuaternionType, point: CoordinateType = (0, return cls._from_trusted_data(point, xaxis, yaxis) @classmethod - def from_axis_angle_vector(cls, axis_angle_vector: CoordinateType, point: CoordinateType = (0, 0, 0)) -> Self: + def from_axis_angle_vector(cls, axis_angle_vector: VectorLike, point: PointLike = (0, 0, 0)) -> Self: """Construct a frame from an axis-angle vector representing the rotation. Parameters @@ -706,7 +707,7 @@ def from_euler_angles( euler_angles: Sequence[float], static: bool = True, axes: str = "xyz", - point: CoordinateType = (0, 0, 0), + point: PointLike = (0, 0, 0), ) -> Self: """Construct a frame from a rotation represented by Euler angles. @@ -939,12 +940,12 @@ def euler_angles(self, static: bool = True, axes: str = "xyz") -> list[float]: return euler_angles_from_matrix(R, static, axes) @overload - def to_local_coordinates(self, obj_in_wcf: CoordinateType) -> Point: ... + def to_local_coordinates(self, obj_in_wcf: GeometryType) -> GeometryType: ... @overload - def to_local_coordinates(self, obj_in_wcf: GeometryType) -> GeometryType: ... + def to_local_coordinates(self, obj_in_wcf: PointLike) -> Point: ... - def to_local_coordinates(self, obj_in_wcf: CoordinateType | GeometryType) -> Point | GeometryType: + def to_local_coordinates(self, obj_in_wcf: PointLike | Geometry) -> Point | Geometry: """Returns the object's coordinates in the local coordinate system of the frame. Parameters @@ -979,12 +980,12 @@ def to_local_coordinates(self, obj_in_wcf: CoordinateType | GeometryType) -> Poi return Point(obj_in_wcf[0], obj_in_wcf[1], obj_in_wcf[2]).transformed(T) @overload - def to_world_coordinates(self, obj_in_lcf: CoordinateType) -> Point: ... + def to_world_coordinates(self, obj_in_lcf: GeometryType) -> GeometryType: ... @overload - def to_world_coordinates(self, obj_in_lcf: GeometryType) -> GeometryType: ... + def to_world_coordinates(self, obj_in_lcf: PointLike) -> Point: ... - def to_world_coordinates(self, obj_in_lcf: CoordinateType | GeometryType) -> Point | GeometryType: + def to_world_coordinates(self, obj_in_lcf: PointLike | Geometry) -> Point | Geometry: """Returns the object's coordinates in the global coordinate frame. Parameters @@ -1062,9 +1063,9 @@ def transform(self, transformation: TransformationType) -> None: @classmethod def _from_trusted_data( cls, - point: CoordinateType, - xaxis: CoordinateType, - yaxis: CoordinateType, + point: PointLike, + xaxis: VectorLike, + yaxis: VectorLike, name: str | None = None, ) -> Self: """Construct a frame by copying coordinates known to be valid. diff --git a/src/compas/geometry/geometry.py b/src/compas/geometry/geometry.py index 7f3c1cc916d1..710360944e37 100644 --- a/src/compas/geometry/geometry.py +++ b/src/compas/geometry/geometry.py @@ -1,10 +1,9 @@ -from collections.abc import Sequence from typing import TYPE_CHECKING from typing import Self from compas.data import Data - -from ._typing import CoordinateType +from compas.typing import PointLike +from compas.typing import VectorLike if TYPE_CHECKING: from compas.geometry import Box @@ -163,7 +162,7 @@ def scaled(self, x: float, y: float | None = None, z: float | None = None) -> Se geometry.scale(x=x, y=y, z=z) return geometry - def translate(self, vector: Sequence[float]) -> None: + def translate(self, vector: VectorLike) -> None: """Translate the geometry. Parameters @@ -183,7 +182,7 @@ def translate(self, vector: Sequence[float]) -> None: self.transform(Translation.from_vector(vector)) - def translated(self, vector: Sequence[float]) -> Self: + def translated(self, vector: VectorLike) -> Self: """Returns a translated copy of this geometry. Parameters @@ -208,7 +207,7 @@ def translated(self, vector: Sequence[float]) -> Self: geometry.translate(vector) return geometry - def rotate(self, angle: float, axis: Sequence[float] | None = None, point: CoordinateType | None = None) -> None: + def rotate(self, angle: float, axis: VectorLike | None = None, point: PointLike | None = None) -> None: """Rotate the geometry. Parameters @@ -237,7 +236,7 @@ def rotate(self, angle: float, axis: Sequence[float] | None = None, point: Coord self.transform(Rotation.from_axis_and_angle(axis, angle, point)) - def rotated(self, angle: float, axis: Sequence[float] | None = None, point: CoordinateType | None = None) -> Self: + def rotated(self, angle: float, axis: VectorLike | None = None, point: PointLike | None = None) -> Self: """Returns a rotated copy of this geometry. Parameters diff --git a/src/compas/geometry/hull.py b/src/compas/geometry/hull.py index 602351bae3cf..8bbbe9cb811b 100644 --- a/src/compas/geometry/hull.py +++ b/src/compas/geometry/hull.py @@ -1,12 +1,12 @@ from typing import TYPE_CHECKING -from compas._typing import CoordinatesType +from compas.typing import PointsLike if TYPE_CHECKING: from numpy.typing import NDArray -def convex_hull(points: CoordinatesType) -> "tuple[NDArray, NDArray]": +def convex_hull(points: PointsLike) -> "tuple[NDArray, NDArray]": """Compute the convex hull of points in three-dimensional space. Parameters @@ -42,7 +42,7 @@ def convex_hull(points: CoordinatesType) -> "tuple[NDArray, NDArray]": return hull.vertices, hull.simplices -def convex_hull_xy(points: CoordinatesType) -> "tuple[NDArray, NDArray]": +def convex_hull_xy(points: PointsLike) -> "tuple[NDArray, NDArray]": """Compute the convex hull of points in the XY plane. Parameters diff --git a/src/compas/geometry/icp.py b/src/compas/geometry/icp.py index 987694efcc07..de39d2a4ba7d 100644 --- a/src/compas/geometry/icp.py +++ b/src/compas/geometry/icp.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING -from compas._typing import CoordinatesType +from compas.typing import PointsLike if TYPE_CHECKING: from numpy.typing import NDArray @@ -31,7 +31,7 @@ def _bestfit_transform(source: "NDArray", target: "NDArray") -> "NDArray": return transformation -def icp(source: CoordinatesType, target: CoordinatesType, tol: float | None = None, maxiter: int = 100) -> "tuple[NDArray, NDArray]": +def icp(source: PointsLike, target: PointsLike, tol: float | None = None, maxiter: int = 100) -> "tuple[NDArray, NDArray]": """Align two point clouds using the iterative closest point method. Parameters @@ -77,9 +77,9 @@ def icp(source: CoordinatesType, target: CoordinatesType, tol: float | None = No origin, axes, _ = pca(source_points) # NumPy rows satisfy the coordinate protocol at runtime, but NumPy's # overloaded indexing stubs cannot express that structural compatibility. - source_frame = Frame(origin, axes[0], axes[1]) # pyright: ignore[reportArgumentType] + source_frame = Frame(origin, axes[0], axes[1]) origin, axes, _ = pca(target_points) - target_frame = Frame(origin, axes[0], axes[1]) # pyright: ignore[reportArgumentType] + target_frame = Frame(origin, axes[0], axes[1]) initial = Transformation.from_frame_to_frame(source_frame, target_frame) source_points = transform_points_numpy(source_points, initial.matrix) diff --git a/src/compas/geometry/interpolation.py b/src/compas/geometry/interpolation.py index 9b00a226c733..50381554ac13 100644 --- a/src/compas/geometry/interpolation.py +++ b/src/compas/geometry/interpolation.py @@ -1,16 +1,17 @@ from collections.abc import Sequence -from compas._typing import CoordinatesType -from compas._typing import CoordinateType from compas.itertools import normalize_values from compas.linalg.vectors import add_vectors from compas.linalg.vectors import dot_vectors from compas.linalg.vectors import scale_vector from compas.linalg.vectors import subtract_vectors from compas.linalg.vectors import sum_vectors +from compas.typing import PointLike +from compas.typing import PointsLike +from compas.typing import RealInput -def barycentric_coordinates(point: CoordinateType, triangle: Sequence[CoordinateType]) -> list[float]: +def barycentric_coordinates(point: PointLike, triangle: Sequence[PointLike]) -> list[RealInput]: """Compute the barycentric coordinates of a point with respect to a triangle. Parameters @@ -43,10 +44,10 @@ def barycentric_coordinates(point: CoordinateType, triangle: Sequence[Coordinate def discrete_coons_patch( - ab: CoordinatesType | None, - bc: CoordinatesType | None, - dc: CoordinatesType | None, - ad: CoordinatesType | None, + ab: PointsLike | None, + bc: PointsLike | None, + dc: PointsLike | None, + ad: PointsLike | None, ) -> tuple[list[list[float]], list[list[int]]]: """Construct a discrete Coons patch from three or four boundary polylines. @@ -125,7 +126,7 @@ def discrete_coons_patch( return vertices, faces -def tween_points(points1: CoordinatesType, points2: CoordinatesType, num: int) -> list[list[list[float]]]: +def tween_points(points1: PointsLike, points2: PointsLike, num: int) -> list[list[list[RealInput]]]: """Compute evenly spaced interpolations between two point sets. Parameters @@ -153,11 +154,11 @@ def tween_points(points1: CoordinatesType, points2: CoordinatesType, num: int) - def tween_points_distance( - points1: CoordinatesType, - points2: CoordinatesType, + points1: PointsLike, + points2: PointsLike, dist: float, index: int | None = None, -) -> list[list[float]]: +) -> list[list[RealInput]]: """Interpolate two point sets at a specified reference-point distance. Parameters diff --git a/src/compas/geometry/intersections.py b/src/compas/geometry/intersections.py index ec85cc073a35..a27ee6fda1a9 100644 --- a/src/compas/geometry/intersections.py +++ b/src/compas/geometry/intersections.py @@ -1,14 +1,17 @@ from itertools import chain from typing import TYPE_CHECKING -from compas._typing import CoordinatesType -from compas._typing import RayMeshHit -from compas._typing import RayType -from compas._typing import SphereType from compas.itertools import pairwise from compas.plugins import PluginNotInstalledError from compas.plugins import pluggable from compas.tolerance import TOL +from compas.typing import PointGroupsLike +from compas.typing import PointsLike +from compas.typing import RayData +from compas.typing import RayMeshHit +from compas.typing import RealInput +from compas.typing import SphereData +from compas.typing import VerticesFaces from ._core.intersections import closest_points_line_line as _closest_points_line_line_raw from ._core.intersections import intersection_circle_circle_xy as _intersection_circle_circle_xy @@ -23,14 +26,12 @@ from ._core.intersections import intersection_sphere_sphere as _intersection_sphere_sphere from ._core.predicates_2 import is_point_on_segment_xy from ._core.predicates_3 import is_point_on_segment -from ._tessellation_plugin import tessellation_pluggable from ._typing import CircleType from ._typing import LineType from ._typing import PlaneType from ._typing import PolylineType from ._typing import TriangleType from .intersection import IntersectionResult -from .tessellation import Tessellation if TYPE_CHECKING: from .brep.brep import Brep @@ -319,7 +320,7 @@ def intersection_polyline_plane( if expected_number_of_intersections is not None and expected_number_of_intersections < 0: raise ValueError("The expected number of intersections cannot be negative.") origin, normal = plane - intersections: list[list[float]] = [] + intersections: list[list[RealInput]] = [] for segment in pairwise(polyline): if expected_number_of_intersections is not None and len(intersections) >= expected_number_of_intersections: break @@ -447,8 +448,8 @@ def intersection_plane_plane_plane( def intersection_sphere_sphere( - sphere1: SphereType, - sphere2: SphereType, + sphere1: SphereData, + sphere2: SphereData, tol: float | None = None, ) -> "IntersectionResult[Point | Circle | Sphere]": """Compute the intersection of two spheres. @@ -536,7 +537,7 @@ def intersection_segment_polyline( def intersection_sphere_line( - sphere: SphereType, + sphere: SphereData, line: LineType, tol: float | None = None, ) -> "IntersectionResult[Point]": @@ -657,21 +658,20 @@ def intersection_plane_circle( ) -@tessellation_pluggable(category="intersections", inputs=("A", "B")) -def intersection_mesh_mesh(A: "Tessellation", B: "Tessellation") -> list[CoordinatesType]: +def intersection_mesh_mesh(A: VerticesFaces, B: VerticesFaces) -> PointGroupsLike: """Compute the intersection of two meshes. Parameters ---------- A - The first triangle tessellation. + The first triangle mesh as vertices and triangles. B - The second triangle tessellation. + The second triangle mesh as vertices and triangles. Returns ------- - list[CoordinatesType] - The intersection polylines. + PointGroupsLike + The intersection polyline coordinate buffers. Raises ------ @@ -679,11 +679,15 @@ def intersection_mesh_mesh(A: "Tessellation", B: "Tessellation") -> list[Coordin If no intersection plugin is available. """ + return _intersection_mesh_mesh(A, B) + + +@pluggable(category="intersections") +def _intersection_mesh_mesh(A: VerticesFaces, B: VerticesFaces) -> PointGroupsLike: raise PluginNotInstalledError -@tessellation_pluggable(category="intersections", inputs=("mesh",)) -def intersection_ray_mesh(ray: RayType, mesh: "Tessellation") -> list[RayMeshHit]: +def intersection_ray_mesh(ray: RayData, mesh: VerticesFaces) -> list[RayMeshHit]: """Compute the intersection(s) between a ray and a mesh. Parameters @@ -691,11 +695,11 @@ def intersection_ray_mesh(ray: RayType, mesh: "Tessellation") -> list[RayMeshHit ray The ray origin and direction vector. mesh - The triangle tessellation. + The triangle mesh as vertices and triangles. Returns ------- - list[RayMeshHit] + list[tuple[int, float, float, float]] For every hit, the intersected face index, the `u` and `v` barycentric coordinates, and the distance from the ray origin. @@ -705,6 +709,11 @@ def intersection_ray_mesh(ray: RayType, mesh: "Tessellation") -> list[RayMeshHit If no intersection plugin is available. """ + return _intersection_ray_mesh(ray, mesh) + + +@pluggable(category="intersections") +def _intersection_ray_mesh(ray: RayData, mesh: VerticesFaces) -> list[RayMeshHit]: raise PluginNotInstalledError @@ -776,7 +785,7 @@ def intersection_line_segment_xy( def intersection_line_box_xy( line: LineType, - box: CoordinatesType, + box: PointsLike, tol: float | None = None, ) -> "IntersectionResult[Point]": """Compute the intersection between a line and a box in the XY plane. @@ -815,7 +824,7 @@ def intersection_line_box_xy( def intersection_polyline_box_xy( polyline: PolylineType, - box: CoordinatesType, + box: PointsLike, tol: float | None = None, ) -> "IntersectionResult[Point]": """Compute the intersection between a polyline and a box in the XY plane. diff --git a/src/compas/geometry/kdtree.py b/src/compas/geometry/kdtree.py index 34f165ffbc7b..152182afefb9 100644 --- a/src/compas/geometry/kdtree.py +++ b/src/compas/geometry/kdtree.py @@ -2,7 +2,8 @@ from collections.abc import Sequence from typing import NamedTuple -from compas._typing import CoordinateType +from compas.typing import PointLike +from compas.typing import RealInput from ._core.distance import distance_point_point_sqrd @@ -10,7 +11,7 @@ class Node(NamedTuple): """A node in a `KDTree`.""" - point: CoordinateType + point: PointLike axis: int label: int left: "Node | None" @@ -20,9 +21,9 @@ class Node(NamedTuple): class Neighbor(NamedTuple): """A nearest-neighbor search result.""" - point: CoordinateType + point: PointLike label: int - distance: float + distance: RealInput class KDTree: @@ -56,7 +57,7 @@ class KDTree: # Dunder methods # ========================================================================== - def __init__(self, objects: Sequence[CoordinateType] | None = None) -> None: + def __init__(self, objects: Sequence[PointLike] | None = None) -> None: self.root: Node | None = None if objects: self.root = self.build([(point, index) for index, point in enumerate(objects)]) @@ -65,7 +66,7 @@ def __init__(self, objects: Sequence[CoordinateType] | None = None) -> None: # Methods # ========================================================================== - def build(self, objects: list[tuple[CoordinateType, int]], axis: int = 0) -> Node | None: + def build(self, objects: list[tuple[PointLike, int]], axis: int = 0) -> Node | None: """Build a tree from labeled points. Parameters @@ -99,7 +100,7 @@ def build(self, objects: list[tuple[CoordinateType, int]], axis: int = 0) -> Nod def nearest_neighbor( self, - point: CoordinateType, + point: PointLike, exclude: Collection[int] | None = None, ) -> Neighbor | None: """Find the nearest non-excluded point. @@ -144,7 +145,7 @@ def search(node: Node | None) -> None: return None return Neighbor(best.point, best.label, best.distance**0.5) - def nearest_neighbors(self, point: CoordinateType, number: int, distance_sort: bool = False) -> list[Neighbor]: + def nearest_neighbors(self, point: PointLike, number: int, distance_sort: bool = False) -> list[Neighbor]: """Find up to a requested number of nearest neighbors. Parameters diff --git a/src/compas/geometry/line.py b/src/compas/geometry/line.py index 9447f9039a6e..67efb3d5de8c 100644 --- a/src/compas/geometry/line.py +++ b/src/compas/geometry/line.py @@ -3,9 +3,11 @@ from typing import Self from typing import overload -from compas._typing import Coordinates -from compas._typing import CoordinateType from compas.linalg.vectors import add_vectors +from compas.typing import PointLike +from compas.typing import PointsProtocol +from compas.typing import RealInput +from compas.typing import VectorLike from ._typing import TransformationType from .geometry import Geometry @@ -82,7 +84,7 @@ def __data__(self) -> dict[str, list[float]]: # Dunder methods # ========================================================================== - def __init__(self, start: CoordinateType, end: CoordinateType, name: str | None = None) -> None: + def __init__(self, start: PointLike, end: PointLike, name: str | None = None) -> None: super().__init__(name=name) self._point: Point | None = None self._vector: Vector | None = None @@ -104,7 +106,7 @@ def __getitem__(self, key: int) -> Point: return self.end raise KeyError - def __setitem__(self, key: int, value: CoordinateType) -> None: + def __setitem__(self, key: int, value: PointLike) -> None: if key == 0: self.start = value elif key == 1: @@ -119,7 +121,7 @@ def __len__(self) -> int: return 2 def __eq__(self, other: object) -> bool: - if not isinstance(other, Coordinates): + if not isinstance(other, PointsProtocol): return False if len(other) != 2: return False @@ -158,7 +160,7 @@ def point(self) -> Point: return self._point @point.setter - def point(self, point: CoordinateType) -> None: + def point(self, point: PointLike) -> None: self._point = Point(point[0], point[1], point[2]) @property @@ -190,7 +192,7 @@ def vector(self) -> Vector: return self._vector @vector.setter - def vector(self, vector: CoordinateType) -> None: + def vector(self, vector: VectorLike) -> None: self._vector = Vector(vector[0], vector[1], vector[2]) self._direction = None @@ -256,7 +258,7 @@ def start(self) -> Point: return self.point @start.setter - def start(self, point: CoordinateType) -> None: + def start(self, point: PointLike) -> None: self.point = point @property @@ -283,7 +285,7 @@ def end(self) -> Point: return self.start + self.vector @end.setter - def end(self, point: CoordinateType) -> None: + def end(self, point: PointLike) -> None: end = Point(point[0], point[1], point[2]) self._vector = Vector.from_start_end(self.start, end) self._direction = None @@ -305,7 +307,7 @@ def midpoint(self) -> Point: # ========================================================================== @classmethod - def from_point_and_vector(cls, point: CoordinateType, vector: CoordinateType) -> Self: + def from_point_and_vector(cls, point: PointLike, vector: VectorLike) -> Self: """Construct a line from a point and a vector. Parameters @@ -337,7 +339,7 @@ def from_point_and_vector(cls, point: CoordinateType, vector: CoordinateType) -> return cls(point, add_vectors(point, vector)) @classmethod - def from_point_direction_length(cls, point: CoordinateType, direction: CoordinateType, length: float) -> Self: + def from_point_direction_length(cls, point: PointLike, direction: VectorLike, length: float) -> Self: """Construct a line from a point, a direction and a length. Parameters @@ -376,7 +378,7 @@ def from_point_direction_length(cls, point: CoordinateType, direction: Coordinat # Methods # ========================================================================== - def point_at(self, t: float) -> Point: + def point_at(self, t: RealInput) -> Point: """Construct a point along the line at a fractional position. Parameters @@ -450,12 +452,12 @@ def point_from_end(self, distance: float) -> Point: return point @overload - def closest_point(self, point: CoordinateType, return_parameter: Literal[False] = False) -> Point: ... + def closest_point(self, point: PointLike, return_parameter: Literal[False] = False) -> Point: ... @overload - def closest_point(self, point: CoordinateType, return_parameter: Literal[True]) -> tuple[Point, float]: ... + def closest_point(self, point: PointLike, return_parameter: Literal[True]) -> tuple[Point, float]: ... - def closest_point(self, point: CoordinateType, return_parameter: bool = False) -> Point | tuple[Point, float]: + def closest_point(self, point: PointLike, return_parameter: bool = False) -> Point | tuple[Point, float]: """Compute the closest point on the line to a given point. Parameters diff --git a/src/compas/geometry/offset.py b/src/compas/geometry/offset.py index 2ebedb14fce2..f0cf206c6fb5 100644 --- a/src/compas/geometry/offset.py +++ b/src/compas/geometry/offset.py @@ -2,8 +2,8 @@ from collections.abc import Sequence from typing import Any -from compas._typing import CoordinatesType -from compas._typing import CoordinateType +import numpy as np + from compas.data.validators import is_item_iterable from compas.geometry import centroid_points from compas.geometry import is_colinear @@ -15,6 +15,9 @@ from compas.linalg.vectors import normalize_vector from compas.linalg.vectors import scale_vector from compas.linalg.vectors import subtract_vectors +from compas.typing import PointsLike +from compas.typing import RealInput +from compas.typing import VectorLike from ._core.intersections import closest_points_line_line from ._typing import LineType @@ -25,7 +28,7 @@ _OffsetDistance = float | Sequence[float | Sequence[float]] -def intersect_lines(l1: LineType, l2: LineType, tol: float | None) -> list[float] | None: +def intersect_lines(l1: LineType, l2: LineType, tol: float | None) -> list[RealInput] | None: a, b = l1 c, d = l2 x1, x2 = closest_points_line_line((a, b), (c, d), tol) @@ -33,8 +36,8 @@ def intersect_lines(l1: LineType, l2: LineType, tol: float | None) -> list[float return centroid_points([x1, x2]) -def intersect_lines_colinear(l1: LineType, l2: LineType, tol: float | None) -> list[float] | None: - def are_segments_colinear(l1: LineType, l2: LineType, tol: float | None) -> bool: +def intersect_lines_colinear(l1: LineType, l2: LineType, tol: float | None) -> list[RealInput] | None: + def are_segments_colinear(l1: LineType, l2: LineType, tol: float | None) -> bool | np.bool_: a, b = l1 d, c = l2 return is_colinear(a, b, c, tol) @@ -43,7 +46,7 @@ def are_segments_colinear(l1: LineType, l2: LineType, tol: float | None) -> bool return centroid_points([l1[1], l2[0]]) -def intersect(l1: LineType, l2: LineType, tol: float | None) -> list[float]: +def intersect(l1: LineType, l2: LineType, tol: float | None) -> list[RealInput]: supported_funcs = [intersect_lines, intersect_lines_colinear] for func in supported_funcs: point = func(l1, l2, tol) @@ -54,11 +57,11 @@ def intersect(l1: LineType, l2: LineType, tol: float | None) -> list[float]: def offset_segments( - point_list: CoordinatesType, + point_list: PointsLike, distances: Iterable[_LineDistance], - normal: CoordinateType, -) -> list[tuple[list[float], list[float]]]: - segments: list[tuple[list[float], list[float]]] = [] + normal: VectorLike, +) -> list[tuple[list[RealInput], list[RealInput]]]: + segments: list[tuple[list[RealInput], list[RealInput]]] = [] for line, distance in zip(pairwise(point_list), distances): segments.append(offset_line(line, distance, normal)) return segments @@ -67,8 +70,8 @@ def offset_segments( def offset_line( line: LineType, distance: _LineDistance, - normal: CoordinateType = (0.0, 0.0, 1.0), -) -> tuple[list[float], list[float]]: + normal: VectorLike = (0.0, 0.0, 1.0), +) -> tuple[list[RealInput], list[RealInput]]: """Offset a line by a distance. Parameters @@ -120,7 +123,7 @@ def offset_polygon( polygon: PolygonType, distance: _OffsetDistance, tol: float | None = None, -) -> list[list[float]]: +) -> list[list[RealInput]]: """Offset a polygon (closed) by a distance. Parameters @@ -186,9 +189,9 @@ def offset_polygon( def offset_polyline( polyline: PolylineType, distance: _OffsetDistance, - normal: CoordinateType = (0.0, 0.0, 1.0), + normal: VectorLike = (0.0, 0.0, 1.0), tol: float | None = None, -) -> list[list[float]]: +) -> list[list[RealInput]]: """Offset a polyline by a distance. Parameters diff --git a/src/compas/geometry/pca.py b/src/compas/geometry/pca.py index 29fb4cf4358b..9157d457e9b1 100644 --- a/src/compas/geometry/pca.py +++ b/src/compas/geometry/pca.py @@ -4,10 +4,10 @@ from numpy.typing import ArrayLike from numpy.typing import NDArray - from compas._typing import CoordinatesType + from compas.typing import PointsLike -def pca(data: "CoordinatesType | ArrayLike") -> "tuple[NDArray, NDArray, NDArray]": +def pca(data: "PointsLike | ArrayLike") -> "tuple[NDArray, NDArray, NDArray]": """Compute the principal components of a dataset. Parameters diff --git a/src/compas/geometry/planar_region.py b/src/compas/geometry/planar_region.py index 6415c34bf72f..7e92e7a2e134 100644 --- a/src/compas/geometry/planar_region.py +++ b/src/compas/geometry/planar_region.py @@ -2,10 +2,10 @@ from typing import TYPE_CHECKING from typing import Self -from compas._typing import CoordinatesType from compas.geometry import Geometry from compas.geometry import Polygon from compas.geometry import is_coplanar +from compas.typing import PointsLike from .triangulation_earclip import _earclip_boundaries @@ -16,7 +16,6 @@ from compas.geometry import PolygonalSurface from compas.geometry import Tessellation from compas.geometry import Transformation - from compas.geometry import TriangulationStrategy class PlanarRegion(Geometry): @@ -44,11 +43,10 @@ class PlanarRegion(Geometry): -------- >>> outer = [[0, 0, 0], [4, 0, 0], [4, 4, 0], [0, 4, 0]] >>> hole = [[1, 1, 0], [1, 3, 0], [3, 3, 0], [3, 1, 0]] - >>> from compas.geometry import EarClipping >>> region = PlanarRegion(outer, holes=[hole]) >>> region.area() 12.0 - >>> region.triangulated(EarClipping()).number_of_triangles + >>> region.triangulated().number_of_triangles 8 """ @@ -74,8 +72,8 @@ def __from_data__(cls, data: dict) -> Self: def __init__( self, - outer: Polygon | CoordinatesType, - holes: Sequence[Polygon | CoordinatesType] | None = None, + outer: Polygon | PointsLike, + holes: Sequence[Polygon | PointsLike] | None = None, name: str | None = None, ) -> None: super().__init__(name=name) @@ -124,26 +122,17 @@ def vertices(self) -> list[list[float]]: # Conversions # ============================================================================= - def triangulated(self, strategy: "TriangulationStrategy") -> "Tessellation": + def triangulated(self) -> "Tessellation": """Triangulate the planar region without modifying it. - Parameters - ---------- - strategy - The face triangulation strategy. Planar regions support - [`EarClipping`][compas.geometry.EarClipping]. - Returns ------- Tessellation The indexed triangular representation. """ - from compas.geometry import EarClipping from compas.geometry import Tessellation - if not isinstance(strategy, EarClipping): - raise TypeError("PlanarRegion supports only the EarClipping strategy.") vertices, triangles = _earclip_boundaries(self.outer, self.holes) return Tessellation(vertices, triangles) @@ -155,9 +144,7 @@ def to_polygonal_surface(self) -> "PolygonalSurface": PolygonalSurface """ - from compas.geometry import EarClipping - - return self.triangulated(EarClipping()).to_polygonal_surface() + return self.triangulated().to_polygonal_surface() def to_mesh(self) -> "Mesh": """Convert the region to a triangulated half-edge mesh. @@ -167,9 +154,7 @@ def to_mesh(self) -> "Mesh": Mesh """ - from compas.geometry import EarClipping - - return self.triangulated(EarClipping()).to_mesh() + return self.triangulated().to_mesh() # ============================================================================= # Methods @@ -222,7 +207,7 @@ def transform(self, transformation: "Transformation") -> None: # ============================================================================= @staticmethod - def _copy_boundary(boundary: Polygon | CoordinatesType) -> Polygon: + def _copy_boundary(boundary: Polygon | PointsLike) -> Polygon: points = boundary.points if isinstance(boundary, Polygon) else boundary return Polygon(points) diff --git a/src/compas/geometry/plane.py b/src/compas/geometry/plane.py index e4882c3336e4..c4692f0f47b2 100644 --- a/src/compas/geometry/plane.py +++ b/src/compas/geometry/plane.py @@ -4,13 +4,17 @@ from typing import TYPE_CHECKING from typing import Self -from compas._typing import Coordinates -from compas._typing import CoordinatesType -from compas._typing import CoordinateType +import numpy as np + from compas.geometry import Geometry from compas.geometry import bestfit_plane from compas.linalg.vectors import cross_vectors from compas.tolerance import TOL +from compas.typing import PointLike +from compas.typing import PointsLike +from compas.typing import PointsProtocol +from compas.typing import RealInput +from compas.typing import VectorLike from ._typing import TransformationType from .point import Point @@ -92,7 +96,7 @@ def __data__(self) -> dict[str, list[float]]: # Dunder methods # ========================================================================== - def __init__(self, point: CoordinateType, normal: CoordinateType, name: str | None = None) -> None: + def __init__(self, point: PointLike, normal: VectorLike, name: str | None = None) -> None: super().__init__(name=name) self._point: Point | None = None self._normal: Vector | None = None @@ -123,7 +127,7 @@ def __getitem__(self, key: int) -> Point | Vector: return self.normal raise KeyError - def __setitem__(self, key: int, value: CoordinateType) -> None: + def __setitem__(self, key: int, value: PointLike) -> None: if key == 0: self.point = value return @@ -136,7 +140,7 @@ def __iter__(self) -> Iterator[Point | Vector]: return iter([self.point, self.normal]) def __eq__(self, other: object) -> bool: - if not isinstance(other, Coordinates): + if not isinstance(other, PointsProtocol): return False if len(other) != 2: return False @@ -172,7 +176,7 @@ def point(self) -> Point: return self._point @point.setter - def point(self, point: CoordinateType) -> None: + def point(self, point: PointLike) -> None: self._point = Point(point[0], point[1], point[2]) @property @@ -201,7 +205,7 @@ def normal(self) -> Vector: return self._normal @normal.setter - def normal(self, vector: CoordinateType) -> None: + def normal(self, vector: VectorLike) -> None: self._normal = Vector(vector[0], vector[1], vector[2]) self._normal.unitize() @@ -238,7 +242,7 @@ def abcd(self) -> tuple[float, float, float, float]: # ========================================================================== @classmethod - def from_three_points(cls, a: CoordinateType, b: CoordinateType, c: CoordinateType) -> Self: + def from_three_points(cls, a: PointLike, b: PointLike, c: PointLike) -> Self: """Construct a plane from three points in three-dimensional space. Parameters @@ -273,7 +277,7 @@ def from_three_points(cls, a: CoordinateType, b: CoordinateType, c: CoordinateTy return cls(a, normal) @classmethod - def from_point_and_two_vectors(cls, point: CoordinateType, u: CoordinateType, v: CoordinateType) -> Self: + def from_point_and_two_vectors(cls, point: PointLike, u: VectorLike, v: VectorLike) -> Self: """Construct a plane from a base point and two vectors. Parameters @@ -399,7 +403,7 @@ def from_frame(cls, frame: "Frame") -> Self: return cls(frame.point, frame.normal) @classmethod - def from_points(cls, points: CoordinatesType) -> Self: + def from_points(cls, points: PointsLike) -> Self: """Construct a plane from a collection of points. If the list contains more than three points, a plane is constructed that minimizes the distance to all points. @@ -438,7 +442,7 @@ def from_points(cls, points: CoordinatesType) -> Self: # Methods # ========================================================================== - def is_parallel(self, other: "Plane", tol: float | None = None) -> bool: + def is_parallel(self, other: "Plane", tol: float | None = None) -> bool | np.bool_: """Verify if this plane is parallel to another plane. Parameters @@ -469,7 +473,7 @@ def is_parallel(self, other: "Plane", tol: float | None = None) -> bool: """ return TOL.is_close(abs(self.normal.dot(other.normal)), 1, rtol=0, atol=tol) - def is_perpendicular(self, other: "Plane", tol: float | None = None) -> bool: + def is_perpendicular(self, other: "Plane", tol: float | None = None) -> bool | np.bool_: """Verify if this plane is perpendicular to another plane. Parameters @@ -496,7 +500,7 @@ def is_perpendicular(self, other: "Plane", tol: float | None = None) -> bool: """ return TOL.is_zero(self.normal.dot(other.normal), tol) - def contains_point(self, point: CoordinateType, tol: float | None = None) -> bool: + def contains_point(self, point: PointLike, tol: float | None = None) -> bool | np.bool_: """Verify if a given point lies in the plane. Parameters @@ -525,7 +529,7 @@ def contains_point(self, point: CoordinateType, tol: float | None = None) -> boo # move to Point.distance_to_plane? # point.distance_to_plane(plane) - def distance_to_point(self, point: CoordinateType) -> float: + def distance_to_point(self, point: PointLike) -> RealInput: """Compute the distance from a given point to the plane. Parameters @@ -551,7 +555,7 @@ def distance_to_point(self, point: CoordinateType) -> float: # move to Point.closest_on_plane? # point.closest_on_plane(plane) # remove entirely? - def closest_point(self, point: CoordinateType) -> Point: + def closest_point(self, point: PointLike) -> Point: """Compute the closest point on the plane to a given point. Parameters @@ -580,7 +584,7 @@ def closest_point(self, point: CoordinateType) -> Point: # move to Point.projected_on_plane? # point.projected_on_plane(plane) # point.project_on_plane(plane) - def projected_point(self, point: CoordinateType, direction: CoordinateType | None = None) -> Point | None: + def projected_point(self, point: PointLike, direction: VectorLike | None = None) -> Point | None: """Returns the projection of a given point onto the plane. Parameters @@ -619,7 +623,7 @@ def projected_point(self, point: CoordinateType, direction: CoordinateType | Non # move to Point.mirrored_by_plane? # point.mirrored_by_plane(plane) # point.mirror_by_plane(plane) - def mirrored_point(self, point: CoordinateType) -> Point: + def mirrored_point(self, point: PointLike) -> Point: """Returns the mirror image of a given point. Parameters diff --git a/src/compas/geometry/point.py b/src/compas/geometry/point.py index 5d3f2cec1005..9e69e1eaec69 100644 --- a/src/compas/geometry/point.py +++ b/src/compas/geometry/point.py @@ -2,9 +2,11 @@ from collections.abc import Sequence from typing import TYPE_CHECKING from typing import Self +from typing import SupportsFloat from typing import overload -from compas._typing import Coordinate +import numpy as np + from compas.geometry import centroid_points from compas.geometry import distance_point_line from compas.geometry import distance_point_plane @@ -19,8 +21,10 @@ from compas.geometry import normal_polygon from compas.geometry import transform_points from compas.tolerance import TOL +from compas.typing import CoordinateProtocol +from compas.typing import PointLike +from compas.typing import VectorLike -from ._typing import CoordinateType from ._typing import LineType from ._typing import PlaneType from ._typing import PolygonType @@ -36,7 +40,7 @@ from compas.geometry import Polyhedron -class Point(Geometry): +class Point(Geometry, CoordinateProtocol[float]): """A point is defined by XYZ coordinates. Parameters @@ -148,7 +152,7 @@ def __from_data__(cls, data: Sequence[float]) -> Self: # type: ignore[override] # Dunder methods # ========================================================================== - def __init__(self, x: float, y: float, z: float = 0.0, name: str | None = None) -> None: + def __init__(self, x: SupportsFloat, y: SupportsFloat, z: SupportsFloat = 0.0, name: str | None = None) -> None: super().__init__(name=name) self._x = 0.0 self._y = 0.0 @@ -193,7 +197,7 @@ def __getitem__(self, key: int | slice) -> float | list[float]: return self.z raise IndexError("Point index out of range.") - def __setitem__(self, key: int, value: float) -> None: + def __setitem__(self, key: int, value: SupportsFloat) -> None: if key == 0 or key == -3: self.x = value return @@ -209,11 +213,11 @@ def __iter__(self) -> Iterator[float]: return iter([self.x, self.y, self.z]) def __eq__(self, other: object) -> bool: - if not isinstance(other, Coordinate) or len(other) != 3: + if not isinstance(other, CoordinateProtocol) or len(other) != 3: return False return TOL.is_allclose(self, other) - def __add__(self, other: CoordinateType) -> "Point": + def __add__(self, other: VectorLike) -> "Point": """Return the coordinate-wise sum of this point and another coordinate. Examples @@ -224,7 +228,7 @@ def __add__(self, other: CoordinateType) -> "Point": """ return Point(self.x + other[0], self.y + other[1], self.z + other[2]) - def __sub__(self, other: CoordinateType) -> Vector: + def __sub__(self, other: PointLike) -> Vector: """Return the displacement vector from another coordinate to this point. Examples @@ -263,7 +267,7 @@ def __truediv__(self, n: float) -> "Point": def __pow__(self, n: float) -> "Point": return Point(self.x**n, self.y**n, self.z**n) - def __iadd__(self, other: CoordinateType) -> Self: + def __iadd__(self, other: VectorLike) -> Self: """Add another coordinate to this point in place and return this point. Examples @@ -279,7 +283,7 @@ def __iadd__(self, other: CoordinateType) -> Self: self.z += other[2] return self - def __isub__(self, other: CoordinateType) -> Self: + def __isub__(self, other: VectorLike) -> Self: """Subtract another coordinate from this point in place and return this point. Examples @@ -342,7 +346,7 @@ def x(self) -> float: return self._x @x.setter - def x(self, x: float) -> None: + def x(self, x: SupportsFloat) -> None: self._x = float(x) @property @@ -350,7 +354,7 @@ def y(self) -> float: return self._y @y.setter - def y(self, y: float) -> None: + def y(self, y: SupportsFloat) -> None: self._y = float(y) @property @@ -358,14 +362,14 @@ def z(self) -> float: return self._z @z.setter - def z(self, z: float) -> None: + def z(self, z: SupportsFloat) -> None: self._z = float(z) # ========================================================================== # Methods # ========================================================================== - def distance_to_point(self, point: CoordinateType) -> float: + def distance_to_point(self, point: PointLike) -> float: """Compute the distance to another point. Parameters @@ -518,7 +522,7 @@ def in_convex_polygon(self, polygon: PolygonType) -> bool: # 3D predicates # ========================================================================== - def on_line(self, line: LineType, tol: float | None = None) -> bool: + def on_line(self, line: LineType, tol: float | None = None) -> bool | np.bool_: """Determine if the point lies on the given line. Parameters @@ -599,7 +603,7 @@ def on_polyline(self, polyline: PolylineType) -> bool: """ return is_point_on_polyline(self, polyline) - def on_plane(self, plane: PlaneType, tol: float | None = None) -> bool: + def on_plane(self, plane: PlaneType, tol: float | None = None) -> bool | np.bool_: """Determine if the point lies on the given plane. Parameters @@ -628,7 +632,7 @@ def on_plane(self, plane: PlaneType, tol: float | None = None) -> bool: """ return TOL.is_zero(self.distance_to_plane(plane), tol) - def on_circle(self, circle: "Circle", tol: float | None = None) -> bool: + def on_circle(self, circle: "Circle", tol: float | None = None) -> bool | np.bool_: """Determine if the point lies on the given circle. Parameters @@ -650,7 +654,7 @@ def on_circle(self, circle: "Circle", tol: float | None = None) -> bool: return False return TOL.is_close(self.distance_to_point(circle.center), circle.radius, rtol=0, atol=tol) - def on_curve(self, curve: "Curve", tol: float | None = None) -> bool: + def on_curve(self, curve: "Curve", tol: float | None = None) -> bool | np.bool_: """Determine if the point lies on the given curve. Parameters @@ -694,7 +698,7 @@ def in_triangle(self, triangle: TriangleType) -> bool: """ return is_point_in_triangle(self, triangle) - def in_circle(self, circle: "Circle") -> bool: + def in_circle(self, circle: "Circle") -> bool | np.bool_: """Determine if the point lies inside the given circle. Parameters diff --git a/src/compas/geometry/pointcloud.py b/src/compas/geometry/pointcloud.py index b5d7b81a811a..da2159fa72d4 100644 --- a/src/compas/geometry/pointcloud.py +++ b/src/compas/geometry/pointcloud.py @@ -3,9 +3,6 @@ from typing import TYPE_CHECKING from typing import Self -from compas._typing import CoordinatesType -from compas._typing import CoordinateType -from compas._typing import FilePath from compas.geometry import Geometry from compas.geometry import KDTree from compas.geometry import Point @@ -14,6 +11,10 @@ from compas.geometry import closest_point_in_cloud from compas.geometry import transform_points from compas.tolerance import TOL +from compas.typing import FilePath +from compas.typing import PointLike +from compas.typing import PointsLike +from compas.typing import RealInput if TYPE_CHECKING: from compas.geometry import Box @@ -50,7 +51,7 @@ def __data__(self) -> dict[str, list[list[float]]]: # Dunder methods # ========================================================================== - def __init__(self, points: CoordinatesType, name: str | None = None) -> None: + def __init__(self, points: PointsLike, name: str | None = None) -> None: super().__init__(name=name) self._points = None self._tree = None @@ -70,7 +71,7 @@ def __getitem__(self, key: int) -> Point: raise KeyError return self.points[key] - def __setitem__(self, key: int, value: CoordinateType) -> None: + def __setitem__(self, key: int, value: PointLike) -> None: if key > len(self) - 1: raise KeyError self.points[key] = Point(value[0], value[1], value[2]) @@ -100,7 +101,7 @@ def points(self) -> list[Point]: return self._points @points.setter - def points(self, points: CoordinatesType) -> None: + def points(self, points: PointsLike) -> None: self._points = [Point(point[0], point[1], point[2]) for point in points] self._tree = None @@ -242,7 +243,7 @@ def transform(self, transformation: "Transformation") -> None: # Methods # ========================================================================== - def centroid(self) -> list[float]: + def centroid(self) -> list[RealInput]: """Compute the centroid of the point cloud. Returns @@ -280,7 +281,7 @@ def obb(self) -> "Box": return Box.from_bounding_box(oriented_bounding_box(self.points)) - def closest_point(self, point: CoordinateType) -> Point: + def closest_point(self, point: PointLike) -> Point: """Compute the closest point on the pointcloud to a given point. Parameters @@ -297,7 +298,7 @@ def closest_point(self, point: CoordinateType) -> Point: _, closest, _ = closest_point_in_cloud(point, self.points) return Point(closest[0], closest[1], closest[2]) - def closest_points(self, point: CoordinateType, k: int = 1) -> list[Point]: + def closest_points(self, point: PointLike, k: int = 1) -> list[Point]: """Compute the closest point on the pointcloud to a given point. Parameters diff --git a/src/compas/geometry/polygon.py b/src/compas/geometry/polygon.py index a2ffd6ae08bd..05812f84228b 100644 --- a/src/compas/geometry/polygon.py +++ b/src/compas/geometry/polygon.py @@ -5,7 +5,6 @@ from typing import Self from typing import overload -from compas._typing import CoordinatesType from compas.geometry import Frame from compas.geometry import Geometry from compas.geometry import Line @@ -20,9 +19,13 @@ from compas.geometry import transform_points from compas.itertools import pairwise from compas.tolerance import TOL +from compas.typing import PointLike +from compas.typing import PointsLike -from ._typing import CoordinateType from ._typing import TransformationType +from .triangulation import Triangulator +from .triangulation import _validate_triangulation +from .triangulation import triangulate_polygon if TYPE_CHECKING: from compas.datastructures import Mesh @@ -30,7 +33,6 @@ from compas.geometry import Brep from compas.geometry import PlanarRegion from compas.geometry import Tessellation - from compas.geometry import TriangulationStrategy from compas.geometry import Vector @@ -89,7 +91,7 @@ def __data__(self) -> dict: # Dunder methods # ========================================================================== - def __init__(self, points: CoordinatesType, name: str | None = None) -> None: + def __init__(self, points: PointsLike, name: str | None = None) -> None: super().__init__(name=name) self._points: list[Point] = [] self._lines: list[Line] | None = None @@ -113,7 +115,7 @@ def __getitem__(self, key: slice) -> list[Point]: ... def __getitem__(self, key: int | slice) -> Point | list[Point]: return self.points[key] - def __setitem__(self, key: int, value: CoordinateType) -> None: + def __setitem__(self, key: int, value: PointLike) -> None: self.points[key] = Point(value[0], value[1], value[2]) self._lines = None @@ -142,7 +144,7 @@ def points(self) -> list[Point]: return self._points @points.setter - def points(self, points: CoordinatesType) -> None: + def points(self, points: PointsLike) -> None: previous = Point(points[0][0], points[0][1], points[0][2]) self._points = [previous] for index, xyz in enumerate(points): @@ -215,7 +217,7 @@ def is_planar(self) -> bool: # ========================================================================== @classmethod - def from_rectangle(cls, point: CoordinateType, width: float, height: float) -> Self: + def from_rectangle(cls, point: PointLike, width: float, height: float) -> Self: """Construct a rectangle. Parameters @@ -303,13 +305,16 @@ def from_sides_and_radius_xy(cls, n: int, radius: float) -> Self: # Conversions # ============================================================================= - def triangulated(self, strategy: "TriangulationStrategy") -> "Tessellation": + def triangulated(self, triangulator: Triangulator | None = None) -> "Tessellation": """Triangulate the polygon without modifying it. Parameters ---------- - strategy - The face triangulation strategy. + triangulator + A callable that maps ordered polygon vertices to triangle indices. + It should return a complete, nondegenerate triangulation with the + winding of the source boundary. If `None`, use + `triangulate_polygon`. Returns ------- @@ -318,9 +323,12 @@ def triangulated(self, strategy: "TriangulationStrategy") -> "Tessellation": """ from compas.geometry import Tessellation - from compas.geometry.triangulation import triangulate_polygon - return Tessellation(self.vertices, triangulate_polygon(self.vertices, strategy)) + if triangulator is None: + triangulator = triangulate_polygon + vertices = self.vertices + triangles = _validate_triangulation(triangulator(vertices), vertices) + return Tessellation(vertices, triangles) def to_vertices_and_faces(self) -> tuple[list[Point], list[list[int]]]: """Return the polygon vertices and indexed faces. diff --git a/src/compas/geometry/polygonal_surface.py b/src/compas/geometry/polygonal_surface.py index 19f8265200aa..fd597f155c5b 100644 --- a/src/compas/geometry/polygonal_surface.py +++ b/src/compas/geometry/polygonal_surface.py @@ -1,10 +1,12 @@ from collections import defaultdict from collections import deque from numbers import Real +from operator import index as integer_index from typing import TYPE_CHECKING from typing import Self -from compas._typing import CoordinatesType +import numpy as np + from compas.geometry import Geometry from compas.geometry import Line from compas.geometry import Point @@ -13,11 +15,17 @@ from compas.geometry import transform_points from compas.itertools import pairwise from compas.tolerance import TOL +from compas.typing import IndexGroupsLike +from compas.typing import PointsLike +from compas.typing import RealInput + +from .triangulation import Triangulator +from .triangulation import _validate_triangulation +from .triangulation import triangulate_polygon if TYPE_CHECKING: from compas.datastructures import Mesh from compas.geometry import Box - from compas.geometry import FaceTriangulation from compas.geometry import Tessellation @@ -65,12 +73,12 @@ def __from_data__(cls, data: dict) -> Self: def __init__( self, - vertices: CoordinatesType, - faces: list[list[int]], + vertices: PointsLike, + faces: IndexGroupsLike, name: str | None = None, ) -> None: super().__init__(name=name) - self._vertices: list[list[float]] = [] + self._vertices: list[list[RealInput]] = [] self._faces: list[list[int]] = [] self.vertices = vertices self.faces = faces @@ -87,7 +95,7 @@ def __str__(self) -> str: # ============================================================================= @property - def vertices(self) -> list[list[float]]: + def vertices(self) -> list[list[RealInput]]: """The vertex coordinates of the surface. Notes @@ -99,7 +107,7 @@ def vertices(self) -> list[list[float]]: return self._vertices @vertices.setter - def vertices(self, vertices: CoordinatesType) -> None: + def vertices(self, vertices: PointsLike) -> None: copied = [] for vertex in vertices: if len(vertex) != 3: @@ -122,8 +130,18 @@ def faces(self) -> list[list[int]]: return self._faces @faces.setter - def faces(self, faces: list[list[int]]) -> None: - copied = [list(face) for face in faces] + def faces(self, faces: IndexGroupsLike) -> None: + copied = [] + for face in faces: + copied_face = [] + for value in face: + if isinstance(value, (bool, np.bool_)): + raise TypeError("Polygonal-surface face indices should be integers.") + try: + copied_face.append(integer_index(value)) + except TypeError: + raise TypeError("Polygonal-surface face indices should be integers.") from None + copied.append(copied_face) self._validate_faces(copied) self._faces = copied @@ -212,13 +230,16 @@ def from_mesh(cls, mesh: "Mesh") -> Self: # Conversions # ============================================================================= - def triangulated(self, strategy: "FaceTriangulation") -> "Tessellation": + def triangulated(self, triangulator: Triangulator | None = None) -> "Tessellation": """Triangulate the polygonal faces without modifying the surface. Parameters ---------- - strategy - The collection-level face triangulation strategy. + triangulator + A callable that maps each face's ordered vertices to triangle + indices local to that face. It should return a complete, + nondegenerate triangulation with the winding of the source + boundary. If `None`, use `triangulate_polygon`. Returns ------- @@ -226,20 +247,18 @@ def triangulated(self, strategy: "FaceTriangulation") -> "Tessellation": The indexed triangular representation for visualization. """ - from compas.geometry import FaceTriangulation from compas.geometry import Tessellation - from compas.geometry.triangulation import triangulate_polygon - - if not isinstance(strategy, FaceTriangulation): - raise TypeError("strategy should be a FaceTriangulation.") + if triangulator is None: + triangulator = triangulate_polygon triangles = [] for face in self.faces: - local = triangulate_polygon([self.vertices[index] for index in face], strategy) + face_vertices = [self.vertices[index] for index in face] + local = _validate_triangulation(triangulator(face_vertices), face_vertices) triangles.extend([[face[index] for index in triangle] for triangle in local]) return Tessellation(self.vertices, triangles) - def to_vertices_and_faces(self) -> tuple[list[list[float]], list[list[int]]]: + def to_vertices_and_faces(self) -> tuple[list[list[RealInput]], list[list[int]]]: """Return independent copies of the vertices and faces. Returns @@ -331,8 +350,6 @@ def _validate_faces(self, faces: list[list[int]], vertex_count: int | None = Non if len(set(face)) != len(face): raise ValueError("A polygonal-surface face cannot repeat vertex indices.") for index in face: - if not isinstance(index, int): - raise TypeError("Polygonal-surface face indices should be integers.") if index < 0 or index >= vertex_count: raise IndexError("A polygonal-surface face index is outside the vertex range.") diff --git a/src/compas/geometry/polyhedron.py b/src/compas/geometry/polyhedron.py index 17d72b4c61c0..d4cea38c1487 100644 --- a/src/compas/geometry/polyhedron.py +++ b/src/compas/geometry/polyhedron.py @@ -4,8 +4,6 @@ from typing import TYPE_CHECKING from typing import Self -from compas._typing import CoordinatesType -from compas._typing import CoordinateType from compas.geometry import Line from compas.geometry import Plane from compas.geometry import Point @@ -16,14 +14,20 @@ from compas.geometry import transform_points from compas.geometry import volume_polyhedron from compas.tolerance import TOL +from compas.typing import IndexGroupsLike +from compas.typing import PointLike +from compas.typing import PointsLike +from compas.typing import RealInput +from compas.typing import VerticesFaces from ._typing import PlaneType from .geometry import Geometry +from .triangulation import Triangulator +from .triangulation import triangulate_polygon if TYPE_CHECKING: from compas.datastructures import Mesh from compas.geometry import Box - from compas.geometry import FaceTriangulation from compas.geometry import Tessellation @@ -40,6 +44,15 @@ def tetrahedron(): return vertices, faces +def _materialize_triangle_mesh(mesh: VerticesFaces) -> tuple[list[list[float]], list[list[int]]]: + """Materialize structural buffers for the owned Polyhedron representation.""" + vertices, triangles = mesh + return ( + [[float(value) for value in vertex] for vertex in vertices], + [[int(index) for index in triangle] for triangle in triangles], + ) + + def hexahedron(): faces = [ [0, 3, 2, 1], @@ -198,7 +211,7 @@ def __from_data__(cls, data: dict) -> Self: # Dunder methods # ========================================================================== - def __init__(self, vertices: CoordinatesType, faces: list[list[int]], name: str | None = None) -> None: + def __init__(self, vertices: PointsLike, faces: IndexGroupsLike, name: str | None = None) -> None: super().__init__(name=name) self._boundary = PolygonalSurface(vertices, faces) self._volume = 0.0 @@ -221,7 +234,7 @@ def __str__(self) -> str: def __len__(self) -> int: return 2 - def __getitem__(self, key: int) -> list[list[float]] | list[list[int]]: + def __getitem__(self, key: int) -> list[list[RealInput]] | list[list[int]]: if key == 0: return self.vertices elif key == 1: @@ -229,26 +242,26 @@ def __getitem__(self, key: int) -> list[list[float]] | list[list[int]]: else: raise KeyError - def __iter__(self) -> Iterator[list[list[float]] | list[list[int]]]: + def __iter__(self) -> Iterator[list[list[RealInput]] | list[list[int]]]: return iter([self.vertices, self.faces]) def __add__(self, other: "Polyhedron") -> Self: from compas.geometry import boolean_union_mesh_mesh - result = boolean_union_mesh_mesh(self._tessellation(), other._tessellation()) - return type(self)(result.vertices, result.triangles) + vertices, triangles = _materialize_triangle_mesh(boolean_union_mesh_mesh(self._triangle_mesh(), other._triangle_mesh())) + return type(self)(vertices, triangles) def __sub__(self, other: "Polyhedron") -> Self: from compas.geometry import boolean_difference_mesh_mesh - result = boolean_difference_mesh_mesh(self._tessellation(), other._tessellation()) - return type(self)(result.vertices, result.triangles) + vertices, triangles = _materialize_triangle_mesh(boolean_difference_mesh_mesh(self._triangle_mesh(), other._triangle_mesh())) + return type(self)(vertices, triangles) def __and__(self, other: "Polyhedron") -> Self: from compas.geometry import boolean_intersection_mesh_mesh - result = boolean_intersection_mesh_mesh(self._tessellation(), other._tessellation()) - return type(self)(result.vertices, result.triangles) + vertices, triangles = _materialize_triangle_mesh(boolean_intersection_mesh_mesh(self._triangle_mesh(), other._triangle_mesh())) + return type(self)(vertices, triangles) def __or__(self, other: "Polyhedron") -> Self: return self.__add__(other) @@ -263,7 +276,7 @@ def boundary(self) -> PolygonalSurface: return self._boundary @property - def vertices(self) -> list[list[float]]: + def vertices(self) -> list[list[RealInput]]: """The boundary vertex coordinates.""" return self.boundary.vertices @@ -359,7 +372,7 @@ def from_platonicsolid(cls, f: int) -> Self: return cls(vertices, faces) @classmethod - def from_halfspaces(cls, halfspaces: Sequence[Sequence[float]], interior_point: CoordinateType) -> Self: + def from_halfspaces(cls, halfspaces: Sequence[Sequence[float]], interior_point: PointLike) -> Self: """Construct a polyhedron from its half-spaces and one interior point. Parameters @@ -392,9 +405,9 @@ def from_halfspaces(cls, halfspaces: Sequence[Sequence[float]], interior_point: """ from itertools import combinations - from numpy import asarray # type: ignore - from scipy.spatial import ConvexHull # type: ignore - from scipy.spatial import HalfspaceIntersection # type: ignore + from numpy import asarray + from scipy.spatial import ConvexHull + from scipy.spatial import HalfspaceIntersection from compas.datastructures import Mesh from compas.linalg.vectors import cross_vectors @@ -452,7 +465,7 @@ def from_planes(cls, planes: Sequence[PlaneType]) -> Self: return cls.from_halfspaces([plane.abcd for plane in planes], interior) @classmethod - def from_convex_hull(cls, points: CoordinatesType) -> Self: + def from_convex_hull(cls, points: PointsLike) -> Self: """Construct a polyhedron from the convex hull of a set of points. Parameters @@ -485,13 +498,16 @@ def from_convex_hull(cls, points: CoordinatesType) -> Self: # Conversions # ============================================================================= - def triangulated(self, strategy: "FaceTriangulation") -> "Tessellation": + def triangulated(self, triangulator: Triangulator | None = None) -> "Tessellation": """Triangulate the boundary faces without modifying the polyhedron. Parameters ---------- - strategy - The collection-level face triangulation strategy. + triangulator + A callable that maps each face's ordered vertices to triangle + indices local to that face. It should return a complete, + nondegenerate triangulation with the winding of the source + boundary. If `None`, use `triangulate_polygon`. Returns ------- @@ -499,9 +515,11 @@ def triangulated(self, strategy: "FaceTriangulation") -> "Tessellation": The indexed triangular representation for visualization. """ - return self.boundary.triangulated(strategy) + if triangulator is None: + triangulator = triangulate_polygon + return self.boundary.triangulated(triangulator) - def to_vertices_and_faces(self) -> tuple[list[list[float]], list[list[int]]]: + def to_vertices_and_faces(self) -> tuple[list[list[RealInput]], list[list[int]]]: """Return independent copies of the vertices and faces. Returns @@ -576,15 +594,14 @@ def area(self) -> float: """Compute the area of the polygonal boundary.""" return sum(polygon.area() for polygon in self.polygons) - def volume(self) -> float: + def volume(self) -> RealInput: """Compute the volume enclosed by the polyhedron.""" return self._volume def centroid(self) -> Point: """Compute the center of mass of the polyhedron.""" vertices, faces = self.to_vertices_and_faces() - centroid_vertices: list[Sequence[float]] = [vertex for vertex in vertices] - x, y, z = centroid_polyhedron((centroid_vertices, faces)) + x, y, z = centroid_polyhedron((vertices, faces)) return Point(x, y, z) def boolean_union(self, other: "Polyhedron") -> Self: @@ -602,17 +619,17 @@ def boolean_union(self, other: "Polyhedron") -> Self: Examples -------- - >>> from compas.geometry import Box, Native, Polyhedron, Sphere, UVGrid - >>> A = Polyhedron(*Box(2).tessellate(Native()).to_vertices_and_triangles()) + >>> from compas.geometry import Box, Polyhedron, Sphere + >>> A = Polyhedron(*Box(2).to_tessellation().to_vertices_and_triangles()) >>> sphere = Sphere.from_point_and_radius([1, 1, 1], 1.0) - >>> B = Polyhedron(*sphere.tessellate(UVGrid(16, 16)).to_vertices_and_triangles()) + >>> B = Polyhedron(*sphere.to_tessellation(nu=16, nv=16).to_vertices_and_triangles()) >>> C = A.boolean_union(B) # doctest: +SKIP """ from compas.geometry import boolean_union_mesh_mesh - result = boolean_union_mesh_mesh(self._tessellation(), other._tessellation()) - return type(self)(result.vertices, result.triangles) + vertices, triangles = _materialize_triangle_mesh(boolean_union_mesh_mesh(self._triangle_mesh(), other._triangle_mesh())) + return type(self)(vertices, triangles) def boolean_difference(self, other: "Polyhedron") -> Self: """Compute the boolean difference of this polyhedron and another. @@ -629,17 +646,17 @@ def boolean_difference(self, other: "Polyhedron") -> Self: Examples -------- - >>> from compas.geometry import Box, Native, Polyhedron, Sphere, UVGrid - >>> A = Polyhedron(*Box(2).tessellate(Native()).to_vertices_and_triangles()) + >>> from compas.geometry import Box, Polyhedron, Sphere + >>> A = Polyhedron(*Box(2).to_tessellation().to_vertices_and_triangles()) >>> sphere = Sphere.from_point_and_radius([1, 1, 1], 1.0) - >>> B = Polyhedron(*sphere.tessellate(UVGrid(16, 16)).to_vertices_and_triangles()) + >>> B = Polyhedron(*sphere.to_tessellation(nu=16, nv=16).to_vertices_and_triangles()) >>> C = A.boolean_difference(B) # doctest: +SKIP """ from compas.geometry import boolean_difference_mesh_mesh - result = boolean_difference_mesh_mesh(self._tessellation(), other._tessellation()) - return type(self)(result.vertices, result.triangles) + vertices, triangles = _materialize_triangle_mesh(boolean_difference_mesh_mesh(self._triangle_mesh(), other._triangle_mesh())) + return type(self)(vertices, triangles) def boolean_intersection(self, other: "Polyhedron") -> Self: """Compute the boolean intersection of this polyhedron and another. @@ -656,27 +673,24 @@ def boolean_intersection(self, other: "Polyhedron") -> Self: Examples -------- - >>> from compas.geometry import Box, Native, Polyhedron, Sphere, UVGrid - >>> A = Polyhedron(*Box(2).tessellate(Native()).to_vertices_and_triangles()) + >>> from compas.geometry import Box, Polyhedron, Sphere + >>> A = Polyhedron(*Box(2).to_tessellation().to_vertices_and_triangles()) >>> sphere = Sphere.from_point_and_radius([1, 1, 1], 1.0) - >>> B = Polyhedron(*sphere.tessellate(UVGrid(16, 16)).to_vertices_and_triangles()) + >>> B = Polyhedron(*sphere.to_tessellation(nu=16, nv=16).to_vertices_and_triangles()) >>> C = A.boolean_intersection(B) # doctest: +SKIP """ from compas.geometry import boolean_intersection_mesh_mesh - result = boolean_intersection_mesh_mesh(self._tessellation(), other._tessellation()) - return type(self)(result.vertices, result.triangles) + vertices, triangles = _materialize_triangle_mesh(boolean_intersection_mesh_mesh(self._triangle_mesh(), other._triangle_mesh())) + return type(self)(vertices, triangles) # ============================================================================= # Private helpers # ============================================================================= - def _tessellation(self) -> "Tessellation": - from compas.geometry import EarClipping - from compas.geometry import FaceTriangulation - - return self.triangulated(FaceTriangulation(quad=EarClipping())) + def _triangle_mesh(self) -> VerticesFaces: + return self.triangulated().to_vertices_and_triangles() def _validate_boundary(self) -> None: if len(self.vertices) < 4: diff --git a/src/compas/geometry/polyline.py b/src/compas/geometry/polyline.py index e07a96836c0e..7894edff0a17 100644 --- a/src/compas/geometry/polyline.py +++ b/src/compas/geometry/polyline.py @@ -3,11 +3,11 @@ from typing import Self from typing import overload -from compas._typing import Coordinates -from compas._typing import CoordinatesType -from compas._typing import CoordinateType from compas.itertools import pairwise from compas.tolerance import TOL +from compas.typing import PointLike +from compas.typing import PointsLike +from compas.typing import PointsProtocol from ._core.predicates_3 import is_point_on_line from ._core.predicates_3 import is_point_on_polyline @@ -69,7 +69,7 @@ def __data__(self) -> dict[str, list[list[float]]]: # Dunder methods # ========================================================================== - def __init__(self, points: CoordinatesType, name: str | None = None) -> None: + def __init__(self, points: PointsLike, name: str | None = None) -> None: super().__init__(name=name) self._points: list[Point] = [] self.points = points @@ -89,7 +89,7 @@ def __getitem__(self, key: slice) -> list[Point]: ... def __getitem__(self, key: int | slice) -> Point | list[Point]: return self.points[key] - def __setitem__(self, key: int, value: CoordinateType) -> None: + def __setitem__(self, key: int, value: PointLike) -> None: self.points[key] = Point(value[0], value[1], value[2]) def __iter__(self) -> Iterator[Point]: @@ -99,7 +99,7 @@ def __len__(self) -> int: return len(self.points) def __eq__(self, other: object) -> bool: - if not isinstance(other, Coordinates) or len(self) != len(other): + if not isinstance(other, PointsProtocol) or len(self) != len(other): return False return TOL.is_allclose(self, other) @@ -128,7 +128,7 @@ def points(self) -> list[Point]: return self._points @points.setter - def points(self, points: CoordinatesType) -> None: + def points(self, points: PointsLike) -> None: self._points = [Point(xyz[0], xyz[1], xyz[2]) for xyz in points] @property @@ -226,7 +226,7 @@ def transform(self, transformation: TransformationType) -> None: # Methods # ========================================================================== - def append(self, point: CoordinateType) -> None: + def append(self, point: PointLike) -> None: """Append a point to the end of the polyline. Parameters @@ -237,7 +237,7 @@ def append(self, point: CoordinateType) -> None: """ self.points.append(Point(point[0], point[1], point[2])) - def insert(self, i: int, point: CoordinateType) -> None: + def insert(self, i: int, point: PointLike) -> None: """Insert a point at the specified index. Parameters @@ -303,7 +303,7 @@ def point_at(self, t: float, snap: bool = False) -> Point | None: x += dx return points[-1] - def parameter_at(self, point: CoordinateType, tol: float | None = None) -> float: + def parameter_at(self, point: PointLike, tol: float | None = None) -> float: """Parameter of the polyline at a specific point. Parameters @@ -392,7 +392,7 @@ def tangent_at(self, t: float) -> Vector | None: x += dx return tangent - def tangent_at_point(self, point: CoordinateType) -> Vector: + def tangent_at_point(self, point: PointLike) -> Vector: """Calculate the tangent vector at a point on the polyline. Parameters diff --git a/src/compas/geometry/quadmesh_planarize.py b/src/compas/geometry/quadmesh_planarize.py index afbc2131ffa8..bf634a7379db 100644 --- a/src/compas/geometry/quadmesh_planarize.py +++ b/src/compas/geometry/quadmesh_planarize.py @@ -1,8 +1,11 @@ -from compas._typing import MeshType +from operator import index as integer_index + from compas.plugins import pluggable +from compas.typing import RealInput +from compas.typing import VerticesFaces -def _face_flatness(vertices: list[list[float]], face: list[int]) -> float: +def _face_flatness(vertices: list[list[RealInput]], face: list[int]) -> float: if len(face) == 3: return 0.0 @@ -16,7 +19,7 @@ def _face_flatness(vertices: list[list[float]], face: list[int]) -> float: @pluggable(category="quadmesh") -def quadmesh_planarize(M: MeshType, kmax: int = 500, maxdev: float = 0.005) -> list[list[float]]: +def quadmesh_planarize(M: VerticesFaces, kmax: int = 500, maxdev: float = 0.005) -> list[list[RealInput]]: """Planarize the faces of a quad mesh. Parameters @@ -46,19 +49,17 @@ def quadmesh_planarize(M: MeshType, kmax: int = 500, maxdev: float = 0.005) -> l from compas.geometry import project_points_plane coordinates, mesh_faces = M - vertices = [[float(axis) for axis in point] for point in coordinates] - faces = [list(face) for face in mesh_faces] + vertices: list[list[RealInput]] = [[float(axis) for axis in point] for point in coordinates] + faces = [[integer_index(value) for value in face] for face in mesh_faces] for _ in range(kmax): - positions: list[list[list[float]]] = [[] for _ in vertices] + positions: list[list[list[RealInput]]] = [[] for _ in vertices] for face in faces: points = [vertices[index] for index in face] midpoints = [midpoint_point_point(points[index - 1], points[index]) for index in range(len(points))] plane = bestfit_plane(points + midpoints) - # NumPy rows support the coordinate operations used by the raw-data - # projection helper, although NumPy's stubs are not structurally compatible. - projections = project_points_plane(points, plane) # pyright: ignore[reportArgumentType] + projections = project_points_plane(points, plane) for index, projection in zip(face, projections): positions[index].append(projection) diff --git a/src/compas/geometry/quaternion.py b/src/compas/geometry/quaternion.py index 19acc0e655fc..08a01cc769bf 100644 --- a/src/compas/geometry/quaternion.py +++ b/src/compas/geometry/quaternion.py @@ -3,9 +3,11 @@ from collections.abc import Sequence from sys import float_info from typing import TYPE_CHECKING -from typing import Protocol from typing import Self -from typing import runtime_checkable +from typing import SupportsFloat +from typing import overload + +import numpy as np from compas.geometry import Geometry from compas.geometry import Rotation @@ -17,19 +19,14 @@ from compas.linalg.quaternions import quaternion_unitize from compas.linalg.transformations import quaternion_from_matrix from compas.tolerance import TOL +from compas.typing import CoordinateProtocol +from compas.typing import RealInput if TYPE_CHECKING: from compas.geometry import Frame -@runtime_checkable -class _QuaternionComponents(Protocol): - def __len__(self) -> int: ... - - def __iter__(self) -> Iterator[float]: ... - - -class Quaternion(Geometry): +class Quaternion(Geometry, CoordinateProtocol[float]): r"""A quaternion represented by one scalar and three vector components. Parameters @@ -91,7 +88,7 @@ def __data__(self) -> dict[str, float]: # Dunder methods # ========================================================================== - def __init__(self, w: float, x: float, y: float, z: float, name: str | None = None) -> None: + def __init__(self, w: SupportsFloat, x: SupportsFloat, y: SupportsFloat, z: SupportsFloat, name: str | None = None) -> None: super().__init__(name=name) self._w = 0.0 self._x = 0.0 @@ -115,11 +112,19 @@ def __str__(self) -> str: ) def __eq__(self, other: object) -> bool: - if not isinstance(other, _QuaternionComponents) or len(self) != len(other): + if not isinstance(other, CoordinateProtocol) or len(self) != len(other): return False return TOL.is_allclose(self, other, rtol=0) - def __getitem__(self, key: int) -> float: + @overload + def __getitem__(self, key: int) -> float: ... + + @overload + def __getitem__(self, key: slice) -> list[float]: ... + + def __getitem__(self, key: int | slice) -> float | list[float]: + if isinstance(key, slice): + return self.wxyz[key] if key == 0: return self.w if key == 1: @@ -130,7 +135,7 @@ def __getitem__(self, key: int) -> float: return self.z raise KeyError(key) - def __setitem__(self, key: int, value: float) -> None: + def __setitem__(self, key: int, value: SupportsFloat) -> None: if key == 0: self.w = value return @@ -192,7 +197,7 @@ def w(self) -> float: return self._w @w.setter - def w(self, w: float) -> None: + def w(self, w: SupportsFloat) -> None: self._w = float(w) @property @@ -201,7 +206,7 @@ def x(self) -> float: return self._x @x.setter - def x(self, x: float) -> None: + def x(self, x: SupportsFloat) -> None: self._x = float(x) @property @@ -210,7 +215,7 @@ def y(self) -> float: return self._y @y.setter - def y(self, y: float) -> None: + def y(self, y: SupportsFloat) -> None: self._y = float(y) @property @@ -219,7 +224,7 @@ def z(self) -> float: return self._z @z.setter - def z(self, z: float) -> None: + def z(self, z: SupportsFloat) -> None: self._z = float(z) @property @@ -238,7 +243,7 @@ def norm(self) -> float: return quaternion_norm(self) @property - def is_unit(self) -> bool: + def is_unit(self) -> bool | np.bool_: """Whether the quaternion has unit length within the tolerance.""" return quaternion_is_unit(self) @@ -275,7 +280,7 @@ def from_frame(cls, frame: "Frame") -> Self: return cls(w, x, y, z) @classmethod - def from_matrix(cls, M: Sequence[Sequence[float]]) -> Self: + def from_matrix(cls, M: Sequence[Sequence[RealInput]]) -> Self: """Create a Quaternion from a transformation matrix. Parameters diff --git a/src/compas/geometry/shapes/box.py b/src/compas/geometry/shapes/box.py index 39571a9696b8..8719f8c39875 100644 --- a/src/compas/geometry/shapes/box.py +++ b/src/compas/geometry/shapes/box.py @@ -2,24 +2,25 @@ from typing import TYPE_CHECKING from typing import Self -from compas._typing import CoordinatesType -from compas._typing import CoordinateType from compas.geometry import Frame from compas.geometry import Line -from compas.geometry import Native from compas.geometry import Point from compas.geometry import Transformation from compas.geometry import Vector from compas.geometry import centroid_points from compas.geometry import transform_points +from compas.typing import PointLike +from compas.typing import PointsLike from .shape import Shape if TYPE_CHECKING: from compas.geometry import Brep + from compas.geometry import PolygonalSurface + from compas.geometry import Tessellation -class Box(Shape[Native]): +class Box(Shape): """A box is defined by a frame and its dimensions along the frame's x-, y- and z-axes. The center of the box is positioned at the origin of the @@ -73,8 +74,6 @@ class Box(Shape[Native]): """ - _tessellation_strategy = "native" - @property def __data__(self) -> dict: return { @@ -215,7 +214,7 @@ def volume(self) -> float: @property def points(self) -> list[Point]: """The eight defining corner points of the box.""" - return [self.frame.to_world_coordinates(vertex) for vertex in self._compute_local_vertices(Native())] + return [self.frame.to_world_coordinates(vertex) for vertex in self._compute_local_vertices(())] @property def bottom(self) -> list[int]: @@ -287,7 +286,7 @@ def from_width_height_depth(cls, width: float, height: float, depth: float) -> S return cls(width, depth, height) @classmethod - def from_bounding_box(cls, bbox: CoordinatesType) -> Self: + def from_bounding_box(cls, bbox: PointsLike) -> Self: """Construct a box from the result of a bounding box calculation. Parameters @@ -330,7 +329,7 @@ def from_bounding_box(cls, bbox: CoordinatesType) -> Self: return cls(xsize=xsize, ysize=ysize, zsize=zsize, frame=frame) @classmethod - def from_corner_corner_height(cls, corner1: CoordinateType, corner2: CoordinateType, height: float) -> Self: + def from_corner_corner_height(cls, corner1: PointLike, corner2: PointLike, height: float) -> Self: """Construct a box from the opposite corners of its base and its height. Parameters @@ -371,7 +370,7 @@ def from_corner_corner_height(cls, corner1: CoordinateType, corner2: CoordinateT return cls(xsize=width, ysize=depth, zsize=height, frame=frame) @classmethod - def from_diagonal(cls, diagonal: Line | Sequence[CoordinateType]) -> Self: + def from_diagonal(cls, diagonal: Line | Sequence[PointLike]) -> Self: """Construct a box from its main diagonal. Parameters @@ -410,7 +409,7 @@ def from_diagonal(cls, diagonal: Line | Sequence[CoordinateType]) -> Self: return cls(width, depth, height, frame) @classmethod - def from_points(cls, points: CoordinatesType) -> Self: + def from_points(cls, points: PointsLike) -> Self: """Construct a box from a set of points. Parameters @@ -433,7 +432,7 @@ def from_points(cls, points: CoordinatesType) -> Self: # Discretisation # ========================================================================== - def _compute_local_vertices(self, strategy: Native) -> list[CoordinateType]: + def _compute_local_vertices(self, resolution: tuple[int, ...]) -> list[PointLike]: dx = 0.5 * self.xsize dy = 0.5 * self.ysize dz = 0.5 * self.zsize @@ -448,7 +447,7 @@ def _compute_local_vertices(self, strategy: Native) -> list[CoordinateType]: [-dx, +dy, +dz], ] - def _compute_faces(self, strategy: Native) -> list[list[int]]: + def _compute_faces(self, resolution: tuple[int, ...]) -> list[list[int]]: """Compute the faces of the discrete representation of the box.""" return [self.bottom, self.front, self.right, self.back, self.left, self.top] @@ -456,6 +455,28 @@ def _compute_faces(self, strategy: Native) -> list[list[int]]: # Conversions # ========================================================================== + def to_polygonal_surface(self) -> "PolygonalSurface": + """Convert the box to its natural polygonal boundary. + + Returns + ------- + PolygonalSurface + An independent surface with six quadrilateral faces. + + """ + return self._to_polygonal_surface(()) + + def to_tessellation(self) -> "Tessellation": + """Triangulate the natural polygonal boundary of the box. + + Returns + ------- + Tessellation + The indexed triangle representation. + + """ + return self.to_polygonal_surface().triangulated() + def to_brep(self) -> "Brep": """Return a BRep representation of the box. @@ -572,7 +593,7 @@ def corner(self, index: int) -> Point: raise ValueError("Index should be between 0 and 7.") return self.points[index] - def contains_point(self, point: CoordinateType, tol: float = 1e-6) -> bool: + def contains_point(self, point: PointLike, tol: float = 1e-6) -> bool: """Verify if the box contains a given point. Parameters @@ -611,7 +632,7 @@ def contains_point(self, point: CoordinateType, tol: float = 1e-6) -> bool: return True - def contains_points(self, points: CoordinatesType, tol: float = 1e-6) -> list[bool]: + def contains_points(self, points: PointsLike, tol: float = 1e-6) -> list[bool]: """Verify if the box contains the given points. Parameters diff --git a/src/compas/geometry/shapes/capsule.py b/src/compas/geometry/shapes/capsule.py index 306b5ae09a23..12af28ada4f6 100644 --- a/src/compas/geometry/shapes/capsule.py +++ b/src/compas/geometry/shapes/capsule.py @@ -1,24 +1,28 @@ from math import cos from math import pi from math import sin +from typing import TYPE_CHECKING from typing import Self -from compas._typing import CoordinatesType -from compas._typing import CoordinateType from compas.geometry import Circle from compas.geometry import Frame from compas.geometry import Line from compas.geometry import Plane from compas.geometry import Point from compas.geometry import Transformation -from compas.geometry import UVGrid from compas.geometry import transform_points +from compas.typing import PointLike +from compas.typing import PointsLike from .box import Box from .shape import Shape +if TYPE_CHECKING: + from compas.geometry import PolygonalSurface + from compas.geometry import Tessellation -class Capsule(Shape[UVGrid]): + +class Capsule(Shape): """A capsule is defined by a frame, radius, and height. The capsule is oriented along the z-axis of the frame. @@ -232,7 +236,7 @@ def from_circle_and_height(cls, circle: Circle, height: float) -> Self: # Discretisation # ============================================================================= - def _compute_local_vertices(self, strategy: UVGrid) -> list[CoordinateType]: + def _compute_local_vertices(self, resolution: tuple[int, ...]) -> list[PointLike]: """Compute the vertices of the discrete representation of the capsule. Returns @@ -240,10 +244,7 @@ def _compute_local_vertices(self, strategy: UVGrid) -> list[CoordinateType]: list[list[float]] """ - u = strategy.u - v = strategy.v - if v % 2 == 1: - raise ValueError("Capsule V resolution should be even.") + u, v = resolution theta = pi / v phi = pi * 2 / u @@ -270,7 +271,7 @@ def _compute_local_vertices(self, strategy: UVGrid) -> list[CoordinateType]: return vertices - def _compute_faces(self, strategy: UVGrid) -> list[list[int]]: + def _compute_faces(self, resolution: tuple[int, ...]) -> list[list[int]]: """Compute the faces of the discrete representation of the capsule. Returns @@ -278,10 +279,7 @@ def _compute_faces(self, strategy: UVGrid) -> list[list[int]]: list[list[int]] """ - u = strategy.u - v = strategy.v - if v % 2 == 1: - raise ValueError("Capsule V resolution should be even.") + u, v = resolution faces = [] @@ -309,6 +307,50 @@ def _compute_faces(self, strategy: UVGrid) -> list[list[int]]: return faces + # ============================================================================= + # Conversions + # ============================================================================= + + def to_polygonal_surface(self, *, nu: int = 64, nv: int = 64) -> "PolygonalSurface": + """Convert the capsule to a polygonal boundary. + + Parameters + ---------- + nu + The circumferential cell count. + nv + The even longitudinal cell count. + + Returns + ------- + PolygonalSurface + The independent polygonal boundary. + + """ + nu = self._validate_resolution(nu, "nu", 3) + nv = self._validate_resolution(nv, "nv", 4) + if nv % 2: + raise ValueError("nv should be even.") + return self._to_polygonal_surface((nu, nv)) + + def to_tessellation(self, *, nu: int = 64, nv: int = 64) -> "Tessellation": + """Triangulate the capsule's polygonal boundary. + + Parameters + ---------- + nu + The circumferential cell count. + nv + The even longitudinal cell count. + + Returns + ------- + Tessellation + The indexed triangle representation. + + """ + return self.to_polygonal_surface(nu=nu, nv=nv).triangulated() + # ============================================================================= # Transformations # ============================================================================= @@ -335,7 +377,7 @@ def scale(self, x: float, y: float | None = None, z: float | None = None) -> Non # Methods # ============================================================================= - def contains_point(self, point: CoordinateType, tol: float = 1e-6) -> bool: + def contains_point(self, point: PointLike, tol: float = 1e-6) -> bool: """Verify if a point is inside the capsule. Parameters @@ -375,7 +417,7 @@ def contains_point(self, point: CoordinateType, tol: float = 1e-6) -> bool: return True - def contains_points(self, points: CoordinatesType, tol: float = 1e-6) -> list[bool]: + def contains_points(self, points: PointsLike, tol: float = 1e-6) -> list[bool]: """Verify if a list of points is inside the capsule. Parameters diff --git a/src/compas/geometry/shapes/cone.py b/src/compas/geometry/shapes/cone.py index 3f51258f725d..407bf6dc58f7 100644 --- a/src/compas/geometry/shapes/cone.py +++ b/src/compas/geometry/shapes/cone.py @@ -5,23 +5,24 @@ from typing import TYPE_CHECKING from typing import Self -from compas._typing import CoordinatesType -from compas._typing import CoordinateType from compas.geometry import Circle from compas.geometry import Frame from compas.geometry import Line from compas.geometry import Plane from compas.geometry import Point -from compas.geometry import UGrid +from compas.typing import PointLike +from compas.typing import PointsLike from .box import Box from .shape import Shape if TYPE_CHECKING: from compas.geometry import Brep + from compas.geometry import PolygonalSurface + from compas.geometry import Tessellation -class Cone(Shape[UGrid]): +class Cone(Shape): """A cone is defined by a frame, radius, and height. The cone is oriented along the z-axis of the frame. @@ -51,8 +52,6 @@ class Cone(Shape[UGrid]): """ - _tessellation_strategy = "u" - @property def __data__(self) -> dict: return { @@ -245,7 +244,7 @@ def from_circle_and_height(cls, circle: Circle, height: float) -> Self: # Discretisation # ============================================================================= - def _compute_local_vertices(self, strategy: UGrid) -> list[CoordinateType]: + def _compute_local_vertices(self, resolution: tuple[int, ...]) -> list[PointLike]: """Compute the vertices of the discrete representation of the cone. Returns @@ -253,8 +252,8 @@ def _compute_local_vertices(self, strategy: UGrid) -> list[CoordinateType]: list[list[float]] """ - u = strategy.u - vertices: list[CoordinateType] = [[0, 0, 0]] + (u,) = resolution + vertices: list[PointLike] = [[0, 0, 0]] a = 2 * pi / u radius = self.circle.radius for i in range(u): @@ -265,7 +264,7 @@ def _compute_local_vertices(self, strategy: UGrid) -> list[CoordinateType]: return vertices - def _compute_faces(self, strategy: UGrid) -> list[list[int]]: + def _compute_faces(self, resolution: tuple[int, ...]) -> list[list[int]]: """Compute the faces of the discrete representation of the cone. Returns @@ -273,7 +272,7 @@ def _compute_faces(self, strategy: UGrid) -> list[list[int]]: list[list[int]] """ - u = strategy.u + (u,) = resolution faces = [] first = 0 last = u + 1 @@ -290,6 +289,39 @@ def _compute_faces(self, strategy: UGrid) -> list[list[int]]: # Conversions # ========================================================================== + def to_polygonal_surface(self, *, nu: int = 64) -> "PolygonalSurface": + """Convert the cone to a polygonal boundary. + + Parameters + ---------- + nu + The number of cells around the cone axis. + + Returns + ------- + PolygonalSurface + The independent polygonal boundary. + + """ + nu = self._validate_resolution(nu, "nu", 3) + return self._to_polygonal_surface((nu,)) + + def to_tessellation(self, *, nu: int = 64) -> "Tessellation": + """Triangulate the cone's polygonal boundary. + + Parameters + ---------- + nu + The number of cells around the cone axis. + + Returns + ------- + Tessellation + The indexed triangle representation. + + """ + return self.to_polygonal_surface(nu=nu).triangulated() + def to_brep(self) -> "Brep": """Return a BRep representation of the cone. @@ -328,7 +360,7 @@ def scale(self, x: float, y: float | None = None, z: float | None = None) -> Non # Methods # ========================================================================== - def contains_point(self, point: CoordinateType, tol: float = 1e-6) -> bool: + def contains_point(self, point: PointLike, tol: float = 1e-6) -> bool: """Verify whether a point is inside the cone. Parameters @@ -349,7 +381,7 @@ def contains_point(self, point: CoordinateType, tol: float = 1e-6) -> bool: radius = self.radius * max(0.0, 1.0 - z / self.height) + tol return x**2 + y**2 <= radius**2 - def contains_points(self, points: CoordinatesType, tol: float = 1e-6) -> list[bool]: + def contains_points(self, points: PointsLike, tol: float = 1e-6) -> list[bool]: """Verify whether points are inside the cone. Parameters diff --git a/src/compas/geometry/shapes/cylinder.py b/src/compas/geometry/shapes/cylinder.py index 528a629a1324..3dc544004461 100644 --- a/src/compas/geometry/shapes/cylinder.py +++ b/src/compas/geometry/shapes/cylinder.py @@ -5,23 +5,24 @@ from typing import TYPE_CHECKING from typing import Self -from compas._typing import CoordinatesType -from compas._typing import CoordinateType from compas.geometry import Circle from compas.geometry import Frame from compas.geometry import Line from compas.geometry import Plane from compas.geometry import Point -from compas.geometry import UGrid +from compas.typing import PointLike +from compas.typing import PointsLike from .box import Box from .shape import Shape if TYPE_CHECKING: from compas.geometry import Brep + from compas.geometry import PolygonalSurface + from compas.geometry import Tessellation -class Cylinder(Shape[UGrid]): +class Cylinder(Shape): """A cylinder is defined by a frame, radius, and height. The cylinder is oriented along the z-axis of the frame. @@ -50,8 +51,6 @@ class Cylinder(Shape[UGrid]): """ - _tessellation_strategy = "u" - @property def __data__(self) -> dict: return { @@ -240,7 +239,7 @@ def from_circle_and_height(cls, circle: Circle, height: float) -> Self: # Discretisation # ============================================================================= - def _compute_local_vertices(self, strategy: UGrid) -> list[CoordinateType]: + def _compute_local_vertices(self, resolution: tuple[int, ...]) -> list[PointLike]: """Compute the vertices of the discrete representation of the cylinder. Returns @@ -248,8 +247,8 @@ def _compute_local_vertices(self, strategy: UGrid) -> list[CoordinateType]: list[list[float]] """ - u = strategy.u - vertices: list[CoordinateType] = [] + (u,) = resolution + vertices: list[PointLike] = [] a = 2 * pi / u z = self.height / 2 for i in range(u): @@ -263,7 +262,7 @@ def _compute_local_vertices(self, strategy: UGrid) -> list[CoordinateType]: return vertices - def _compute_faces(self, strategy: UGrid) -> list[list[int]]: + def _compute_faces(self, resolution: tuple[int, ...]) -> list[list[int]]: """Compute the faces of the discrete representation of the cylinder. Returns @@ -271,7 +270,7 @@ def _compute_faces(self, strategy: UGrid) -> list[list[int]]: list[list[int]] """ - u = strategy.u + (u,) = resolution faces = [] # side faces for i in range(0, u * 2, 2): @@ -289,6 +288,39 @@ def _compute_faces(self, strategy: UGrid) -> list[list[int]]: # Conversions # ============================================================================= + def to_polygonal_surface(self, *, nu: int = 64) -> "PolygonalSurface": + """Convert the cylinder to a polygonal boundary. + + Parameters + ---------- + nu + The number of cells around the cylinder axis. + + Returns + ------- + PolygonalSurface + The independent polygonal boundary. + + """ + nu = self._validate_resolution(nu, "nu", 3) + return self._to_polygonal_surface((nu,)) + + def to_tessellation(self, *, nu: int = 64) -> "Tessellation": + """Triangulate the cylinder's polygonal boundary. + + Parameters + ---------- + nu + The number of cells around the cylinder axis. + + Returns + ------- + Tessellation + The indexed triangle representation. + + """ + return self.to_polygonal_surface(nu=nu).triangulated() + def to_brep(self) -> "Brep": """Return a BRep representation of the cylinder. @@ -327,7 +359,7 @@ def scale(self, x: float, y: float | None = None, z: float | None = None) -> Non # Methods # ============================================================================= - def contains_point(self, point: CoordinateType, tol: float = 1e-6) -> bool: + def contains_point(self, point: PointLike, tol: float = 1e-6) -> bool: """Verify if a point is inside the cylinder. Parameters @@ -353,7 +385,7 @@ def contains_point(self, point: CoordinateType, tol: float = 1e-6) -> bool: return x**2 + y**2 <= (self.radius + tol) ** 2 - def contains_points(self, points: CoordinatesType, tol: float = 1e-6) -> list[bool]: + def contains_points(self, points: PointsLike, tol: float = 1e-6) -> list[bool]: """Verify whether points are inside the cylinder. Parameters diff --git a/src/compas/geometry/shapes/shape.py b/src/compas/geometry/shapes/shape.py index f4b928713e51..0915d62226b9 100644 --- a/src/compas/geometry/shapes/shape.py +++ b/src/compas/geometry/shapes/shape.py @@ -1,29 +1,21 @@ +from numbers import Integral from typing import TYPE_CHECKING -from typing import Generic from typing import Self -from typing import TypeVar -from typing import cast -from compas._typing import CoordinatesType -from compas._typing import CoordinateType from compas.geometry import Frame from compas.geometry import Geometry -from compas.geometry import Native -from compas.geometry import TessellationStrategy from compas.geometry import Transformation -from compas.geometry import UGrid -from compas.geometry import UVGrid from compas.geometry import transform_points +from compas.typing import PointLike +from compas.typing import PointsLike if TYPE_CHECKING: from compas.geometry import Brep from compas.geometry import PolygonalSurface from compas.geometry import Tessellation -StrategyT = TypeVar("StrategyT", bound=TessellationStrategy) - -class Shape(Geometry, Generic[StrategyT]): +class Shape(Geometry): """Base class for geometric shapes. Shapes are parametrically defined with respect to a local coordinate frame. @@ -32,12 +24,11 @@ class Shape(Geometry, Generic[StrategyT]): Shapes are finite, closed objects, with a boundary that separates the interior from the exterior. They have a well-defined surface area and volume. - Polygonization samples the parametric boundary into an independent + Conversion to a polygonal surface samples the parametric boundary into an independent `PolygonalSurface` while preserving its natural polygonal topology. Tessellation creates an ephemeral indexed triangular representation. - Polygonization requires an explicit strategy matching the dimensionality - of the shape. Tessellation uses a deterministic strategy when none is - provided. Supported backends can convert a shape to a `Brep`. + Each concrete producer exposes resolution parameters appropriate to its + own parameterization. Supported backends can convert a shape to a `Brep`. Breps support exact boolean operations. @@ -50,8 +41,6 @@ class Shape(Geometry, Generic[StrategyT]): """ - _tessellation_strategy = "uv" - def __init__(self, frame: Frame | None = None, name: str | None = None) -> None: super().__init__(name=name) self._frame: Frame | None = None @@ -91,16 +80,16 @@ def transformation(self) -> Transformation: # Discretisation # ============================================================================= - def _compute_vertices(self, strategy: StrategyT) -> tuple[CoordinateType, ...]: + def _compute_vertices(self, resolution: tuple[int, ...]) -> tuple[PointLike, ...]: """Compute fresh vertex coordinates in world coordinates.""" - local_vertices = self._compute_local_vertices(strategy) + local_vertices = self._compute_local_vertices(resolution) return tuple(tuple(vertex) for vertex in transform_points(local_vertices, self.transformation)) - def _compute_local_vertices(self, strategy: StrategyT) -> list[CoordinateType]: + def _compute_local_vertices(self, resolution: tuple[int, ...]) -> list[PointLike]: """Compute fresh vertex coordinates in the local shape frame.""" raise NotImplementedError - def _compute_faces(self, strategy: StrategyT) -> list[list[int]]: + def _compute_faces(self, resolution: tuple[int, ...]) -> list[list[int]]: """Compute fresh face cycles for the requested strategy.""" raise NotImplementedError @@ -108,90 +97,55 @@ def _compute_faces(self, strategy: StrategyT) -> list[list[int]]: # Conversions # ============================================================================= - def _validate_tessellation_strategy(self, strategy: TessellationStrategy) -> StrategyT: - if self._tessellation_strategy == "native": - if not isinstance(strategy, Native): - raise TypeError(f"{type(self).__name__} requires Native tessellation.") - return cast(StrategyT, strategy) - if self._tessellation_strategy == "u": - if not isinstance(strategy, UGrid): - raise TypeError(f"{type(self).__name__} requires UGrid tessellation.") - if strategy.u < 3: - raise ValueError("The U resolution should be at least 3.") - return cast(StrategyT, strategy) - if not isinstance(strategy, UVGrid): - raise TypeError(f"{type(self).__name__} requires UVGrid tessellation.") - if strategy.u < 3: - raise ValueError("The U resolution should be at least 3.") - if strategy.v < 3: - raise ValueError("The V resolution should be at least 3.") - return cast(StrategyT, strategy) - - def _default_tessellation_strategy(self) -> TessellationStrategy: - if self._tessellation_strategy == "native": - return Native() - if self._tessellation_strategy == "u": - return UGrid(64) - return UVGrid(64, 64) - - def polygonize(self, strategy: TessellationStrategy) -> "PolygonalSurface": - """Polygonize the shape with an explicit approximation strategy. + def to_polygonal_surface(self) -> "PolygonalSurface": + """Convert the shape to its default polygonal boundary. - Parameters - ---------- - strategy - The polygonization strategy supported by this shape. + Concrete shapes override this method with their producer-specific + resolution keywords while preserving the no-argument default call. Returns ------- PolygonalSurface - An independent polygonal boundary representation. + The independent polygonal boundary. """ - from compas.geometry import PolygonalSurface - - supported_strategy = self._validate_tessellation_strategy(strategy) - return PolygonalSurface( - list(self._compute_vertices(supported_strategy)), - [list(face) for face in self._compute_faces(supported_strategy)], - ) + raise NotImplementedError - def tessellate( - self, - strategy: TessellationStrategy | None = None, - ) -> "Tessellation[list[CoordinateType], tuple[tuple[int, ...], ...]]": - """Tessellate the shape. + def to_tessellation(self) -> "Tessellation": + """Convert the shape to its default indexed-triangle representation. - Parameters - ---------- - strategy - The tessellation strategy supported by this shape. If `None`, - `Native()` is used for native shapes, `UGrid(64)` for - one-parameter shapes, and `UVGrid(64, 64)` for two-parameter - shapes. + Concrete shapes override this method with their producer-specific + resolution keywords while preserving the no-argument default call. Returns ------- Tessellation - The ephemeral indexed triangular representation. + The indexed triangle representation. """ - from compas.geometry import Tessellation - - supported_strategy = self._validate_tessellation_strategy(strategy if strategy is not None else self._default_tessellation_strategy()) - vertices = list(self._compute_vertices(supported_strategy)) - triangles = [] - for raw_face in self._compute_faces(supported_strategy): - face = tuple(raw_face) - if len(face) == 3: - triangles.append(face) - elif len(face) == 4: - a, b, c, d = face - triangles.append((a, b, c)) - triangles.append((a, c, d)) - else: - raise ValueError("Shape tessellation requires triangular or quadrilateral faces.") - return Tessellation(vertices, tuple(triangles)) + raise NotImplementedError + + @staticmethod + def _validate_resolution(value: int, name: str, minimum: int) -> int: + if not isinstance(value, Integral) or isinstance(value, bool): + raise TypeError(f"{name} should be an integer.") + value = int(value) + if value < minimum: + raise ValueError(f"{name} should be at least {minimum}.") + return value + + def _to_polygonal_surface(self, resolution: tuple[int, ...]) -> "PolygonalSurface": + """Build an independent polygonal boundary at the given resolution.""" + from compas.geometry import PolygonalSurface + + return PolygonalSurface( + list(self._compute_vertices(resolution)), + [list(face) for face in self._compute_faces(resolution)], + ) + + def _to_tessellation(self, resolution: tuple[int, ...]) -> "Tessellation": + """Triangulate the polygonal surface built at the given resolution.""" + return self._to_polygonal_surface(resolution).triangulated() def to_brep(self) -> "Brep": """Convert the shape to a Brep. @@ -296,7 +250,7 @@ def volume(self) -> float: """Compute the volume enclosed by the shape.""" raise NotImplementedError - def contains_point(self, point: CoordinateType) -> bool: + def contains_point(self, point: PointLike) -> bool: """Verify if a point is inside the shape. Parameters @@ -313,7 +267,7 @@ def contains_point(self, point: CoordinateType) -> bool: """ raise NotImplementedError - def contains_points(self, points: CoordinatesType) -> list[bool]: + def contains_points(self, points: PointsLike) -> list[bool]: """Verify if a list of points are inside the shape. Parameters diff --git a/src/compas/geometry/shapes/sphere.py b/src/compas/geometry/shapes/sphere.py index aa683ba2af70..a099b3a1fb87 100644 --- a/src/compas/geometry/shapes/sphere.py +++ b/src/compas/geometry/shapes/sphere.py @@ -4,22 +4,23 @@ from typing import TYPE_CHECKING from typing import Self -from compas._typing import CoordinatesType -from compas._typing import CoordinateType from compas.geometry import Circle from compas.geometry import Frame from compas.geometry import Line from compas.geometry import Point -from compas.geometry import UVGrid +from compas.typing import PointLike +from compas.typing import PointsLike from .box import Box from .shape import Shape if TYPE_CHECKING: from compas.geometry import Brep + from compas.geometry import PolygonalSurface + from compas.geometry import Tessellation -class Sphere(Shape[UVGrid]): +class Sphere(Shape): """A sphere is defined by a point and a radius. Parameters @@ -134,7 +135,7 @@ def volume(self) -> float: # ========================================================================== @classmethod - def from_point_and_radius(cls, point: CoordinateType, radius: float) -> Self: + def from_point_and_radius(cls, point: PointLike, radius: float) -> Self: """Construct a sphere from a point and a radius. Parameters @@ -158,7 +159,7 @@ def from_point_and_radius(cls, point: CoordinateType, radius: float) -> Self: # Discretisation # ========================================================================== - def _compute_local_vertices(self, strategy: UVGrid) -> list[CoordinateType]: + def _compute_local_vertices(self, resolution: tuple[int, ...]) -> list[PointLike]: """Compute the vertices of the discrete representation of the sphere. Returns @@ -166,15 +167,14 @@ def _compute_local_vertices(self, strategy: UVGrid) -> list[CoordinateType]: list[list[float]] """ - u = strategy.u - v = strategy.v + u, v = resolution theta = pi / v phi = pi * 2 / u hpi = pi * 0.5 x, y, z = 0, 0, 0 - vertices: list[CoordinateType] = [] + vertices: list[PointLike] = [] for i in range(1, v): for j in range(u): tx = self.radius * cos(i * theta - hpi) * cos(j * phi) + x @@ -187,7 +187,7 @@ def _compute_local_vertices(self, strategy: UVGrid) -> list[CoordinateType]: return vertices - def _compute_faces(self, strategy: UVGrid) -> list[list[int]]: + def _compute_faces(self, resolution: tuple[int, ...]) -> list[list[int]]: """Compute the faces of the discrete representation of the sphere. Returns @@ -195,8 +195,7 @@ def _compute_faces(self, strategy: UVGrid) -> list[list[int]]: list[list[int]] """ - u = strategy.u - v = strategy.v + u, v = resolution faces = [] vertex_count = (v - 1) * u + 2 @@ -227,6 +226,44 @@ def _compute_faces(self, strategy: UVGrid) -> list[list[int]]: # Conversions # ========================================================================== + def to_polygonal_surface(self, *, nu: int = 64, nv: int = 64) -> "PolygonalSurface": + """Convert the sphere to a polygonal boundary. + + Parameters + ---------- + nu + The longitudinal cell count. + nv + The latitudinal cell count. + + Returns + ------- + PolygonalSurface + The independent polygonal boundary. + + """ + nu = self._validate_resolution(nu, "nu", 3) + nv = self._validate_resolution(nv, "nv", 3) + return self._to_polygonal_surface((nu, nv)) + + def to_tessellation(self, *, nu: int = 64, nv: int = 64) -> "Tessellation": + """Triangulate the sphere's polygonal boundary. + + Parameters + ---------- + nu + The longitudinal cell count. + nv + The latitudinal cell count. + + Returns + ------- + Tessellation + The indexed triangle representation. + + """ + return self.to_polygonal_surface(nu=nu, nv=nv).triangulated() + def to_brep(self) -> "Brep": """Return a BRep representation of the sphere. @@ -264,7 +301,7 @@ def scale(self, x: float, y: float | None = None, z: float | None = None) -> Non # Methods # ============================================================================= - def contains_point(self, point: CoordinateType, tol: float = 1e-6) -> bool: + def contains_point(self, point: PointLike, tol: float = 1e-6) -> bool: """Verify if a point is inside the sphere. Parameters @@ -283,7 +320,7 @@ def contains_point(self, point: CoordinateType, tol: float = 1e-6) -> bool: """ return self.frame.point.distance_to_point(point) <= self.radius + tol - def contains_points(self, points: CoordinatesType, tol: float = 1e-6) -> list[bool]: + def contains_points(self, points: PointsLike, tol: float = 1e-6) -> list[bool]: """Verify if a list of points are inside the sphere. Parameters diff --git a/src/compas/geometry/shapes/torus.py b/src/compas/geometry/shapes/torus.py index 3750bead27af..d95ee5b46fc7 100644 --- a/src/compas/geometry/shapes/torus.py +++ b/src/compas/geometry/shapes/torus.py @@ -5,21 +5,22 @@ from typing import TYPE_CHECKING from typing import Self -from compas._typing import CoordinatesType -from compas._typing import CoordinateType from compas.geometry import Frame from compas.geometry import Plane from compas.geometry import Point -from compas.geometry import UVGrid +from compas.typing import PointLike +from compas.typing import PointsLike from .box import Box from .shape import Shape if TYPE_CHECKING: from compas.geometry import Brep + from compas.geometry import PolygonalSurface + from compas.geometry import Tessellation -class Torus(Shape[UVGrid]): +class Torus(Shape): """A torus is defined by a plane and two radii. Parameters @@ -188,7 +189,7 @@ def from_plane_and_radii(cls, plane: Plane, radius_axis: float, radius_pipe: flo # Discretisation # ========================================================================== - def _compute_local_vertices(self, strategy: UVGrid) -> list[CoordinateType]: + def _compute_local_vertices(self, resolution: tuple[int, ...]) -> list[PointLike]: """Compute the vertices of the discrete representation of the torus. Returns @@ -196,12 +197,11 @@ def _compute_local_vertices(self, strategy: UVGrid) -> list[CoordinateType]: list[list[float]] """ - u = strategy.u - v = strategy.v + u, v = resolution theta = pi * 2 / u phi = pi * 2 / v - vertices: list[CoordinateType] = [] + vertices: list[PointLike] = [] for i in range(u): for j in range(v): x = cos(i * theta) * (self.radius_axis + self.radius_pipe * cos(j * phi)) @@ -211,7 +211,7 @@ def _compute_local_vertices(self, strategy: UVGrid) -> list[CoordinateType]: return vertices - def _compute_faces(self, strategy: UVGrid) -> list[list[int]]: + def _compute_faces(self, resolution: tuple[int, ...]) -> list[list[int]]: """Compute the faces of the discrete representation of the torus. Returns @@ -219,8 +219,7 @@ def _compute_faces(self, strategy: UVGrid) -> list[list[int]]: list[list[int]] """ - u = strategy.u - v = strategy.v + u, v = resolution faces = [] for i in range(u): ii = (i + 1) % u @@ -238,6 +237,44 @@ def _compute_faces(self, strategy: UVGrid) -> list[list[int]]: # Conversions # ========================================================================== + def to_polygonal_surface(self, *, nu: int = 64, nv: int = 64) -> "PolygonalSurface": + """Convert the torus to a polygonal boundary. + + Parameters + ---------- + nu + The cell count around the torus axis. + nv + The cell count around the pipe section. + + Returns + ------- + PolygonalSurface + The independent polygonal boundary. + + """ + nu = self._validate_resolution(nu, "nu", 3) + nv = self._validate_resolution(nv, "nv", 3) + return self._to_polygonal_surface((nu, nv)) + + def to_tessellation(self, *, nu: int = 64, nv: int = 64) -> "Tessellation": + """Triangulate the torus's polygonal boundary. + + Parameters + ---------- + nu + The cell count around the torus axis. + nv + The cell count around the pipe section. + + Returns + ------- + Tessellation + The indexed triangle representation. + + """ + return self.to_polygonal_surface(nu=nu, nv=nv).triangulated() + def to_brep(self) -> "Brep": """Return a BRep representation of the torus. @@ -280,7 +317,7 @@ def scale(self, x: float, y: float | None = None, z: float | None = None) -> Non # Methods # ========================================================================== - def contains_point(self, point: CoordinateType, tol: float = 1e-6) -> bool: + def contains_point(self, point: PointLike, tol: float = 1e-6) -> bool: """Verify whether a point is inside the torus. Parameters @@ -299,7 +336,7 @@ def contains_point(self, point: CoordinateType, tol: float = 1e-6) -> bool: radial = sqrt(x**2 + y**2) - self.radius_axis return radial**2 + z**2 <= (self.radius_pipe + tol) ** 2 - def contains_points(self, points: CoordinatesType, tol: float = 1e-6) -> list[bool]: + def contains_points(self, points: PointsLike, tol: float = 1e-6) -> list[bool]: """Verify whether points are inside the torus. Parameters diff --git a/src/compas/geometry/surfaces/conical.py b/src/compas/geometry/surfaces/conical.py index 8f0536ddbd82..6971a95ba811 100644 --- a/src/compas/geometry/surfaces/conical.py +++ b/src/compas/geometry/surfaces/conical.py @@ -6,13 +6,13 @@ from typing import Any from typing import Self -from compas._typing import CoordinateType from compas.geometry import Circle from compas.geometry import Frame from compas.geometry import Line from compas.geometry import Plane from compas.geometry import Point from compas.geometry import Vector +from compas.typing import PointLike from .surface import Surface @@ -137,7 +137,7 @@ def center(self) -> Point: return self.frame.point @center.setter - def center(self, point: CoordinateType) -> None: + def center(self, point: PointLike) -> None: self.frame.point = point @property diff --git a/src/compas/geometry/surfaces/cylindrical.py b/src/compas/geometry/surfaces/cylindrical.py index bcb1a0b74bf2..79703df5f28c 100644 --- a/src/compas/geometry/surfaces/cylindrical.py +++ b/src/compas/geometry/surfaces/cylindrical.py @@ -6,13 +6,13 @@ from typing import Any from typing import Self -from compas._typing import CoordinateType from compas.geometry import Circle from compas.geometry import Frame from compas.geometry import Line from compas.geometry import Plane from compas.geometry import Point from compas.geometry import Vector +from compas.typing import PointLike from .surface import Surface @@ -116,7 +116,7 @@ def center(self) -> Point: return self.frame.point @center.setter - def center(self, point: CoordinateType) -> None: + def center(self, point: PointLike) -> None: self.frame.point = point @property @@ -185,7 +185,7 @@ def from_plane_and_radius(cls, plane: Plane, radius: float) -> Self: return cls(radius, frame=Frame.from_plane(plane)) @classmethod - def from_three_points(cls, a: CoordinateType, b: CoordinateType, c: CoordinateType) -> Self: + def from_three_points(cls, a: PointLike, b: PointLike, c: PointLike) -> Self: """Construct a cylindrical from three points. Parameters diff --git a/src/compas/geometry/surfaces/nurbs.py b/src/compas/geometry/surfaces/nurbs.py index 7a69f595724f..40e97e27c7a6 100644 --- a/src/compas/geometry/surfaces/nurbs.py +++ b/src/compas/geometry/surfaces/nurbs.py @@ -5,11 +5,11 @@ from typing import Literal from typing import overload -from compas._typing import CoordinateType -from compas._typing import FilePath from compas.geometry import Point from compas.itertools import linspace from compas.itertools import meshgrid +from compas.typing import FilePath +from compas.typing import PointLike from .._typing import TransformationType from .nurbs_operations import from_cylinder @@ -61,7 +61,7 @@ from compas.geometry import Torus from compas.geometry import Vector -ControlPointGrid = Sequence[Sequence[CoordinateType]] +ControlPointGrid = Sequence[Sequence[PointLike]] WeightGrid = Sequence[Sequence[float]] @@ -681,18 +681,18 @@ def frame_at(self, u: float, v: float) -> "Frame": return surface_frame_at(self, u, v) @overload - def closest_point(self, point: CoordinateType, return_parameters: Literal[False] = False) -> Point | None: ... + def closest_point(self, point: PointLike, return_parameters: Literal[False] = False) -> Point | None: ... @overload def closest_point( self, - point: CoordinateType, + point: PointLike, return_parameters: Literal[True], ) -> tuple[Point, tuple[float, float]] | None: ... def closest_point( self, - point: CoordinateType, + point: PointLike, return_parameters: bool = False, ) -> Point | tuple[Point, tuple[float, float]] | None: """Compute the closest point on the surface.""" diff --git a/src/compas/geometry/surfaces/nurbs_operations.py b/src/compas/geometry/surfaces/nurbs_operations.py index c4c05ac2738a..4177f668aaf9 100644 --- a/src/compas/geometry/surfaces/nurbs_operations.py +++ b/src/compas/geometry/surfaces/nurbs_operations.py @@ -4,11 +4,11 @@ from typing import TYPE_CHECKING from typing import Literal -from compas._typing import CoordinateType -from compas._typing import FilePath from compas.geometry._typing import TransformationType from compas.plugins import PluginNotInstalledError from compas.plugins import pluggable +from compas.typing import FilePath +from compas.typing import PointLike if TYPE_CHECKING: from compas.geometry import Brep @@ -25,7 +25,7 @@ from .nurbs import NurbsSurface -type ControlPointGrid = Sequence[Sequence[CoordinateType]] +type ControlPointGrid = Sequence[Sequence[PointLike]] type WeightGrid = Sequence[Sequence[float]] @@ -197,7 +197,7 @@ def surface_frame_at(surface: "NurbsSurface", u: float, v: float) -> "Frame": @pluggable(category="nurbssurface-evaluation") -def surface_closest_point(surface: "NurbsSurface", point: CoordinateType, return_parameters: bool = False) -> "Point | tuple[Point, tuple[float, float]] | None": +def surface_closest_point(surface: "NurbsSurface", point: PointLike, return_parameters: bool = False) -> "Point | tuple[Point, tuple[float, float]] | None": raise PluginNotInstalledError diff --git a/src/compas/geometry/surfaces/spherical.py b/src/compas/geometry/surfaces/spherical.py index 188ac5e9b3f1..cc26c62b5415 100644 --- a/src/compas/geometry/surfaces/spherical.py +++ b/src/compas/geometry/surfaces/spherical.py @@ -5,14 +5,14 @@ from typing import Any from typing import Self -from compas._typing import CoordinatesType -from compas._typing import CoordinateType from compas.geometry import Arc from compas.geometry import Circle from compas.geometry import Frame from compas.geometry import Plane from compas.geometry import Point from compas.geometry import Vector +from compas.typing import PointLike +from compas.typing import PointsLike from .surface import Surface @@ -108,7 +108,7 @@ def center(self) -> Point: return self.frame.point @center.setter - def center(self, point: CoordinateType) -> None: + def center(self, point: PointLike) -> None: self.frame.point = point @property @@ -161,7 +161,7 @@ def from_plane_and_radius(cls, plane: Plane, radius: float) -> Self: return cls(radius, frame=Frame.from_plane(plane)) @classmethod - def from_three_points(cls, a: CoordinateType, b: CoordinateType, c: CoordinateType) -> Self: + def from_three_points(cls, a: PointLike, b: PointLike, c: PointLike) -> Self: """Construct a sphere from three points. Parameters @@ -183,7 +183,7 @@ def from_three_points(cls, a: CoordinateType, b: CoordinateType, c: CoordinateTy return cls(circle.radius, frame=circle.frame) @classmethod - def from_points(cls, points: CoordinatesType) -> Self: + def from_points(cls, points: PointsLike) -> Self: """Construct the sphere that best fits a set of points in the least squares sense. Parameters diff --git a/src/compas/geometry/surfaces/surface.py b/src/compas/geometry/surfaces/surface.py index a5790da065cc..6861ecd17f2f 100644 --- a/src/compas/geometry/surfaces/surface.py +++ b/src/compas/geometry/surfaces/surface.py @@ -1,6 +1,7 @@ from collections.abc import Iterator from itertools import product from math import isfinite +from numbers import Integral from typing import TYPE_CHECKING from typing import Literal from typing import Self @@ -8,18 +9,16 @@ from typing import overload import compas.geometry -from compas._typing import CoordinateType -from compas._typing import FilePath from compas.geometry import Frame from compas.geometry import Geometry from compas.geometry import Point -from compas.geometry import TessellationStrategy from compas.geometry import Transformation -from compas.geometry import UVGrid from compas.geometry import Vector from compas.itertools import linspace from compas.plugins import PluginNotInstalledError from compas.plugins import pluggable +from compas.typing import FilePath +from compas.typing import PointLike if TYPE_CHECKING: from compas.geometry import Brep @@ -38,12 +37,11 @@ def surface_from_native(cls: type[SurfaceType], *args: object, **kwargs: object) class Surface(Geometry): """Class representing a general surface object. - Polygonization samples a parameter domain into an independent + Conversion to a polygonal surface samples a parameter domain into an independent `PolygonalSurface` while preserving its polygonal grid topology. Tessellation creates an ephemeral indexed triangular representation with - aligned normals and texture coordinates. Polygonization requires an - explicit strategy. Tessellation uses `UVGrid(64, 64)` when none is - provided. + aligned normals and texture coordinates. Resolution is expressed as cell + counts in the two parameter directions. Parameters ---------- @@ -110,7 +108,7 @@ def point(self) -> Point: return self.frame.point @point.setter - def point(self, point: CoordinateType) -> None: + def point(self, point: PointLike) -> None: self.frame.point = point @property @@ -180,12 +178,15 @@ def from_native(cls, surface: object) -> Self: def _discretization_parameters( self, - strategy: TessellationStrategy, + nu: int, + nv: int, ) -> tuple[int, int, tuple[float, float], tuple[float, float]]: - if not isinstance(strategy, UVGrid): - raise TypeError(f"{type(self).__name__} discretization requires UVGrid.") - nu = strategy.u - nv = strategy.v + if not isinstance(nu, Integral) or isinstance(nu, bool): + raise TypeError("nu should be an integer.") + if not isinstance(nv, Integral) or isinstance(nv, bool): + raise TypeError("nv should be an integer.") + nu = int(nu) + nv = int(nv) if nu < 1 or nv < 1: raise ValueError("Surface discretization requires at least one face in each parameter direction.") domain_u = self.domain_u @@ -200,7 +201,7 @@ def _compute_vertices( nv: int, domain_u: tuple[float, float], domain_v: tuple[float, float], - ) -> list[CoordinateType]: + ) -> list[PointLike]: parameters = product( linspace(domain_u[0], domain_u[1], nu + 1), linspace(domain_v[0], domain_v[1], nv + 1), @@ -222,42 +223,38 @@ def _compute_faces(self, nu: int, nv: int) -> list[list[int]]: # Conversions # ============================================================================== - def polygonize(self, strategy: TessellationStrategy) -> "PolygonalSurface": - """Polygonize the surface with an explicit approximation strategy. + def to_polygonal_surface(self, *, nu: int = 64, nv: int = 64) -> "PolygonalSurface": + """Convert the surface to a structured polygonal grid. Parameters ---------- - strategy - The polygonization strategy. + nu + The number of cells in the U parameter direction. + nv + The number of cells in the V parameter direction. Returns ------- PolygonalSurface An independent polygonal surface with quadrilateral cells. - Raises - ------ - TypeError - If the strategy is not `UVGrid`. - """ from compas.geometry import PolygonalSurface - nu, nv, domain_u, domain_v = self._discretization_parameters(strategy) + nu, nv, domain_u, domain_v = self._discretization_parameters(nu, nv) vertices = self._compute_vertices(nu, nv, domain_u, domain_v) faces = self._compute_faces(nu, nv) return PolygonalSurface(vertices, faces) - def tessellate( - self, - strategy: TessellationStrategy | None = None, - ) -> "Tessellation[list[CoordinateType], list[list[int]]]": - """Tessellate the surface. + def to_tessellation(self, *, nu: int = 64, nv: int = 64) -> "Tessellation": + """Triangulate the surface's structured polygonal grid. Parameters ---------- - strategy - The tessellation strategy. If `None`, `UVGrid(64, 64)` is used. + nu + The number of cells in the U parameter direction. + nv + The number of cells in the V parameter direction. Returns ------- @@ -265,33 +262,25 @@ def tessellate( The ephemeral indexed triangular representation, including analytic normals and normalized UV coordinates. - Raises - ------ - TypeError - If the strategy is not `UVGrid`. - """ from compas.geometry import Tessellation - nu, nv, domain_u, domain_v = self._discretization_parameters(strategy if strategy is not None else UVGrid(64, 64)) + nu, nv, domain_u, domain_v = self._discretization_parameters(nu, nv) + triangulation = self.to_polygonal_surface(nu=nu, nv=nv).triangulated() parameters = list( product( linspace(domain_u[0], domain_u[1], nu + 1), linspace(domain_v[0], domain_v[1], nv + 1), ) ) - vertices = self._compute_vertices(nu, nv, domain_u, domain_v) normals = [self.normal_at(u, v) for u, v in parameters] texture_coordinates = [[i / nu, j / nv] for i, j in product(range(nu + 1), range(nv + 1))] - triangles = [] - for i, j in product(range(nu), range(nv)): - a = i * (nv + 1) + j - b = (i + 1) * (nv + 1) + j - c = (i + 1) * (nv + 1) + j + 1 - d = i * (nv + 1) + j + 1 - triangles.append([a, b, c]) - triangles.append([a, c, d]) - return Tessellation(vertices, triangles, vertex_normals=normals, texture_coordinates=texture_coordinates) + return Tessellation( + triangulation.vertices, + triangulation.triangles, + vertex_normals=normals, + texture_coordinates=texture_coordinates, + ) def to_step(self, filepath: FilePath, schema: str = "AP203") -> None: """Write the surface geometry to a STP file. @@ -490,18 +479,18 @@ def frame_at(self, u: float, v: float) -> Frame: raise NotImplementedError @overload - def closest_point(self, point: CoordinateType, return_parameters: Literal[False] = False) -> Point | None: ... + def closest_point(self, point: PointLike, return_parameters: Literal[False] = False) -> Point | None: ... @overload def closest_point( self, - point: CoordinateType, + point: PointLike, return_parameters: Literal[True], ) -> tuple[Point, tuple[float, float]] | None: ... def closest_point( self, - point: CoordinateType, + point: PointLike, return_parameters: bool = False, ) -> Point | tuple[Point, tuple[float, float]] | None: """Compute the closest point on the surface to a given point. diff --git a/src/compas/geometry/surfaces/toroidal.py b/src/compas/geometry/surfaces/toroidal.py index ae6d7403fa81..ae17bf2755d7 100644 --- a/src/compas/geometry/surfaces/toroidal.py +++ b/src/compas/geometry/surfaces/toroidal.py @@ -6,12 +6,12 @@ from typing import Any from typing import Self -from compas._typing import CoordinateType from compas.geometry import Circle from compas.geometry import Frame from compas.geometry import Plane from compas.geometry import Point from compas.geometry import Vector +from compas.typing import PointLike from .surface import Surface @@ -124,7 +124,7 @@ def center(self) -> Point: return self.frame.point @center.setter - def center(self, point: CoordinateType) -> None: + def center(self, point: PointLike) -> None: self.frame.point = point @property diff --git a/src/compas/geometry/tessellation.py b/src/compas/geometry/tessellation.py index 5b81a856f11d..a4892e862346 100644 --- a/src/compas/geometry/tessellation.py +++ b/src/compas/geometry/tessellation.py @@ -1,194 +1,24 @@ -from collections.abc import Sequence from dataclasses import dataclass from math import isfinite -from numbers import Integral -from numbers import Real +from operator import index as integer_index from typing import TYPE_CHECKING from typing import Generic from typing import TypeVar -from compas._typing import CoordinatesType -from compas.data import Data +import numpy as np + +from compas.typing import IndexGroupsLike +from compas.typing import PointGroupsLike +from compas.typing import PointsLike if TYPE_CHECKING: from compas.datastructures import Mesh from compas.geometry import PolygonalSurface -__all__ = [ - "Adaptive", - "Deflection", - "Native", - "Tessellation", - "TessellationStrategy", - "UGrid", - "UVGrid", -] - -VerticesBuffer = TypeVar("VerticesBuffer", bound=CoordinatesType) -TrianglesBuffer = TypeVar("TrianglesBuffer", bound=Sequence[Sequence[int]]) - - -def _positive_integer(value: int, name: str) -> int: - if not isinstance(value, Integral) or isinstance(value, bool): - raise TypeError(f"{name} should be an integer.") - if value <= 0: - raise ValueError(f"{name} should be positive.") - return int(value) - - -def _positive_finite(value: float, name: str) -> float: - if not isinstance(value, Real) or isinstance(value, bool): - raise TypeError(f"{name} should be a real number.") - value = float(value) - if not isfinite(value) or value <= 0.0: - raise ValueError(f"{name} should be positive and finite.") - return value - - -class TessellationStrategy(Data): - """Base class for serializable tessellation policy configurations. - - Strategies describe approximation policy. Geometry producers own the - algorithms that implement the supported strategies. - - """ - - def __init__(self) -> None: - super().__init__() - self._frozen = False - - def __setattr__(self, name: str, value: object) -> None: - if getattr(self, "_frozen", False) and name not in {"_guid", "_name"}: - raise AttributeError(f"{type(self).__name__} is immutable.") - super().__setattr__(name, value) - - def __eq__(self, other: object) -> bool: - return type(self) is type(other) and isinstance(other, TessellationStrategy) and self.__data__ == other.__data__ - - def __hash__(self) -> int: - return hash((type(self), tuple(self.__data__.items()))) - - def _freeze(self) -> None: - self._frozen = True - - -class Native(TessellationStrategy): - """Use a geometry's fixed natural topology.""" - - @property - def __data__(self) -> dict: - return {} - - def __init__(self) -> None: - super().__init__() - self._freeze() - - -class UGrid(TessellationStrategy): - """A one-dimensional parameter-grid strategy. - - Parameters - ---------- - u - The number of faces in the U direction. - - """ - - @property - def __data__(self) -> dict: - return {"u": self.u} - - def __init__(self, u: int) -> None: - super().__init__() - self.u = _positive_integer(u, "u") - self._freeze() +__all__ = ["Tessellation"] - -class UVGrid(TessellationStrategy): - """A two-dimensional parameter-grid strategy. - - Parameters - ---------- - u - The number of faces in the U direction. - v - The number of faces in the V direction. - - """ - - @property - def __data__(self) -> dict: - return {"u": self.u, "v": self.v} - - def __init__(self, u: int, v: int) -> None: - super().__init__() - self.u = _positive_integer(u, "u") - self.v = _positive_integer(v, "v") - self._freeze() - - -class Deflection(TessellationStrategy): - """A linear and angular deflection strategy. - - Parameters - ---------- - linear - The positive linear deflection in model units. - angular - The positive angular deflection in degrees, at most 180. - - """ - - @property - def __data__(self) -> dict: - return {"linear": self.linear, "angular": self.angular} - - def __init__(self, linear: float, angular: float) -> None: - super().__init__() - self.linear = _positive_finite(linear, "linear") - self.angular = _positive_finite(angular, "angular") - if self.angular > 180.0: - raise ValueError("angular should be at most 180 degrees.") - self._freeze() - - -class Adaptive(TessellationStrategy): - """An adaptive tessellation request. - - Parameters - ---------- - max_deviation - The optional maximum deviation in model units. - max_angle - The optional maximum normal angle in degrees. - max_edge_length - The optional maximum edge length in model units. - - """ - - @property - def __data__(self) -> dict: - return { - "max_deviation": self.max_deviation, - "max_angle": self.max_angle, - "max_edge_length": self.max_edge_length, - } - - def __init__( - self, - max_deviation: float | None = None, - max_angle: float | None = None, - max_edge_length: float | None = None, - ) -> None: - super().__init__() - if max_deviation is None and max_angle is None and max_edge_length is None: - raise ValueError("At least one adaptive constraint is required.") - self.max_deviation = None if max_deviation is None else _positive_finite(max_deviation, "max_deviation") - self.max_angle = None if max_angle is None else _positive_finite(max_angle, "max_angle") - if self.max_angle is not None and self.max_angle > 180.0: - raise ValueError("max_angle should be at most 180 degrees.") - self.max_edge_length = None if max_edge_length is None else _positive_finite(max_edge_length, "max_edge_length") - self._freeze() +VerticesBuffer = TypeVar("VerticesBuffer", bound=PointsLike) +TrianglesBuffer = TypeVar("TrianglesBuffer", bound=IndexGroupsLike) @dataclass(frozen=True, slots=True, eq=False) @@ -244,9 +74,9 @@ class Tessellation(Generic[VerticesBuffer, TrianglesBuffer]): vertices: VerticesBuffer triangles: TrianglesBuffer - vertex_normals: CoordinatesType | None = None - texture_coordinates: Sequence[Sequence[float]] | None = None - polylines: Sequence[CoordinatesType] = () + vertex_normals: PointsLike | None = None + texture_coordinates: PointsLike | None = None + polylines: PointGroupsLike = () def __post_init__(self) -> None: self._validate() @@ -274,8 +104,8 @@ def to_vertices_and_triangles(self) -> tuple[VerticesBuffer, TrianglesBuffer]: Returns ------- - tuple[CoordinatesType, Sequence[Sequence[int]]] - The vertex and triangle buffers. + tuple[VerticesBuffer, TrianglesBuffer] + The original vertex and triangle buffer types. """ return self.vertices, self.triangles @@ -291,7 +121,9 @@ def to_polygonal_surface(self) -> "PolygonalSurface": """ from compas.geometry import PolygonalSurface - return PolygonalSurface(self.vertices, [list(triangle) for triangle in self.triangles]) + vertices = [[float(value) for value in vertex] for vertex in self.vertices] + triangles = [[integer_index(index) for index in triangle] for triangle in self.triangles] + return PolygonalSurface(vertices, triangles) def to_mesh(self) -> "Mesh": """Convert the tessellation to an independent half-edge mesh. @@ -304,7 +136,9 @@ def to_mesh(self) -> "Mesh": """ from compas.datastructures import Mesh - return Mesh.from_vertices_and_faces(list(self.vertices), [list(triangle) for triangle in self.triangles]) + vertices = [[float(value) for value in vertex] for vertex in self.vertices] + triangles = [[integer_index(index) for index in triangle] for triangle in self.triangles] + return Mesh.from_vertices_and_faces(vertices, triangles) # ============================================================================= # Validation @@ -318,9 +152,13 @@ def _validate(self) -> None: if len(triangle) != 3: raise ValueError("A tessellation triangle requires exactly three indices.") for index in triangle: - if not isinstance(index, Integral) or isinstance(index, bool): + if isinstance(index, (bool, np.bool_)): + raise TypeError("Tessellation triangle indices should be integers.") + try: + checked_index = integer_index(index) + except TypeError: raise TypeError("Tessellation triangle indices should be integers.") - if index < 0 or index >= vertex_count: + if checked_index < 0 or checked_index >= vertex_count: raise IndexError("Tessellation triangle index is out of range.") if self.vertex_normals is not None: @@ -337,12 +175,16 @@ def _validate(self) -> None: self._validate_coordinates(polyline, "polyline point", 3) @staticmethod - def _validate_coordinates(coordinates: CoordinatesType | Sequence[Sequence[float]], name: str, dimension: int) -> None: + def _validate_coordinates(coordinates: PointsLike, name: str, dimension: int) -> None: for coordinate in coordinates: if len(coordinate) != dimension: raise ValueError(f"A tessellation {name} requires exactly {dimension} coordinates.") for value in coordinate: - if not isinstance(value, Real) or isinstance(value, bool): + if isinstance(value, (bool, np.bool_)): + raise TypeError(f"Tessellation {name} coordinates should be real numbers.") + try: + finite = isfinite(value) + except (TypeError, ValueError): raise TypeError(f"Tessellation {name} coordinates should be real numbers.") - if not isfinite(value): + if not finite: raise ValueError(f"Tessellation {name} coordinates should be finite.") diff --git a/src/compas/geometry/transformations/projection.py b/src/compas/geometry/transformations/projection.py index 2733c6793a9d..9554a2a8165b 100644 --- a/src/compas/geometry/transformations/projection.py +++ b/src/compas/geometry/transformations/projection.py @@ -1,8 +1,5 @@ -from collections.abc import Sequence from typing import Self -from compas._typing import CoordinatesType -from compas._typing import CoordinateType from compas.itertools import flatten from compas.linalg.transformations import decompose_matrix from compas.linalg.transformations import matrix_from_orthogonal_projection @@ -10,8 +7,12 @@ from compas.linalg.transformations import matrix_from_perspective_entries from compas.linalg.transformations import matrix_from_perspective_projection from compas.tolerance import TOL +from compas.typing import FloatSequenceLike +from compas.typing import PointLike +from compas.typing import PointsLike +from compas.typing import VectorLike -from .transformation import Matrix +from .transformation import MatrixInput from .transformation import Transformation @@ -38,7 +39,7 @@ class Projection(Transformation): # Dunder methods # ========================================================================== - def __init__(self, matrix: Matrix | None = None, check: bool = False, name: str | None = None) -> None: + def __init__(self, matrix: MatrixInput | None = None, check: bool = False, name: str | None = None) -> None: if matrix and check: _, _, _, _, perspective = decompose_matrix(matrix) if not TOL.is_allclose(flatten(matrix), flatten(matrix_from_perspective_entries(perspective))): @@ -50,7 +51,7 @@ def __init__(self, matrix: Matrix | None = None, check: bool = False, name: str # ========================================================================== @classmethod - def from_plane(cls, plane: CoordinatesType) -> Self: + def from_plane(cls, plane: PointsLike) -> Self: """Construct an orthogonal projection transformation to project onto a plane. Parameters @@ -76,7 +77,7 @@ def from_plane(cls, plane: CoordinatesType) -> Self: return cls(matrix) @classmethod - def from_plane_and_direction(cls, plane: CoordinatesType, direction: CoordinateType) -> Self: + def from_plane_and_direction(cls, plane: PointsLike, direction: VectorLike) -> Self: """Construct a parallel projection transformation to project onto a plane along a specific direction. Parameters @@ -105,7 +106,7 @@ def from_plane_and_direction(cls, plane: CoordinatesType, direction: CoordinateT return cls(matrix) @classmethod - def from_plane_and_point(cls, plane: CoordinatesType, center_of_projection: CoordinateType) -> Self: + def from_plane_and_point(cls, plane: PointsLike, center_of_projection: PointLike) -> Self: """Construct a perspective projection transformation to project onto a plane along lines that emanate from a single point, called the center of projection. Parameters @@ -134,7 +135,7 @@ def from_plane_and_point(cls, plane: CoordinatesType, center_of_projection: Coor return cls(matrix) @classmethod - def from_entries(cls, perspective_entries: Sequence[float]) -> Self: + def from_entries(cls, perspective_entries: FloatSequenceLike) -> Self: """Construct a perspective transformation from matrix entries. Parameters diff --git a/src/compas/geometry/transformations/reflection.py b/src/compas/geometry/transformations/reflection.py index 2ff7497693b8..487492132d55 100644 --- a/src/compas/geometry/transformations/reflection.py +++ b/src/compas/geometry/transformations/reflection.py @@ -1,6 +1,5 @@ from typing import Self -from compas._typing import CoordinatesType from compas.itertools import flatten from compas.linalg.transformations import decompose_matrix from compas.linalg.transformations import identity_matrix @@ -9,8 +8,9 @@ from compas.linalg.vectors import dot_vectors from compas.linalg.vectors import normalize_vector from compas.tolerance import TOL +from compas.typing import PointsLike -from .transformation import Matrix +from .transformation import MatrixInput from .transformation import Transformation @@ -48,7 +48,7 @@ class Reflection(Transformation): # Dunder methods # ========================================================================== - def __init__(self, matrix: Matrix | None = None, check: bool = False, name: str | None = None) -> None: + def __init__(self, matrix: MatrixInput | None = None, check: bool = False, name: str | None = None) -> None: if matrix and check: _, _, _, _, perspective = decompose_matrix(matrix) if not TOL.is_allclose(flatten(matrix), flatten(matrix_from_perspective_entries(perspective))): @@ -60,7 +60,7 @@ def __init__(self, matrix: Matrix | None = None, check: bool = False, name: str # ========================================================================== @classmethod - def from_plane(cls, plane: CoordinatesType) -> Self: + def from_plane(cls, plane: PointsLike) -> Self: """Construct a reflection transformation across a plane. Parameters @@ -91,7 +91,7 @@ def from_plane(cls, plane: CoordinatesType) -> Self: return cls(matrix) @classmethod - def from_frame(cls, frame: CoordinatesType) -> Self: + def from_frame(cls, frame: PointsLike) -> Self: """Construct a reflection transformation across a frame's XY plane. Parameters diff --git a/src/compas/geometry/transformations/rotation.py b/src/compas/geometry/transformations/rotation.py index e5ffe1a867b3..a58bc9e5ed55 100644 --- a/src/compas/geometry/transformations/rotation.py +++ b/src/compas/geometry/transformations/rotation.py @@ -1,9 +1,6 @@ from typing import TYPE_CHECKING from typing import Self -from compas._typing import CoordinatesType -from compas._typing import CoordinateType -from compas._typing import FloatSequenceType from compas.itertools import flatten from compas.linalg.transformations import axis_and_angle_from_matrix from compas.linalg.transformations import basis_vectors_from_matrix @@ -17,8 +14,12 @@ from compas.linalg.vectors import length_vector from compas.linalg.vectors import normalize_vector from compas.tolerance import TOL +from compas.typing import FloatSequenceLike +from compas.typing import PointLike +from compas.typing import PointsLike +from compas.typing import VectorLike -from .transformation import Matrix +from .transformation import MatrixInput from .transformation import Transformation if TYPE_CHECKING: @@ -67,7 +68,7 @@ class Rotation(Transformation): # Dunder methods # ========================================================================== - def __init__(self, matrix: Matrix | None = None, check: bool = False, name: str | None = None) -> None: + def __init__(self, matrix: MatrixInput | None = None, check: bool = False, name: str | None = None) -> None: if matrix and check: _, _, angles, _, _ = decompose_matrix(matrix) if not TOL.is_allclose(flatten(matrix), flatten(matrix_from_euler_angles(angles))): @@ -112,7 +113,7 @@ def basis_vectors(self) -> tuple["Vector", "Vector"]: # ========================================================================== @classmethod - def from_axis_and_angle(cls, axis: CoordinateType, angle: float, point: CoordinateType | None = None) -> Self: + def from_axis_and_angle(cls, axis: VectorLike, angle: float, point: PointLike | None = None) -> Self: """Construct a rotation transformation from a rotation axis and an angle and an optional point of rotation. The rotation is based on the right hand rule, i.e. anti-clockwise if the @@ -153,7 +154,7 @@ def from_axis_and_angle(cls, axis: CoordinateType, angle: float, point: Coordina return cls(matrix) @classmethod - def from_basis_vectors(cls, xaxis: CoordinateType, yaxis: CoordinateType) -> Self: + def from_basis_vectors(cls, xaxis: VectorLike, yaxis: VectorLike) -> Self: """Construct a rotation transformation from basis vectors (= orthonormal vectors). Parameters @@ -188,7 +189,7 @@ def from_basis_vectors(cls, xaxis: CoordinateType, yaxis: CoordinateType) -> Sel return cls(matrix) @classmethod - def from_frame(cls, frame: CoordinatesType) -> Self: + def from_frame(cls, frame: PointsLike) -> Self: """Construct a rotation transformation from world XY to a frame. Parameters @@ -223,7 +224,7 @@ def from_frame(cls, frame: CoordinatesType) -> Self: return cls(matrix) @classmethod - def from_quaternion(cls, quaternion: FloatSequenceType) -> Self: + def from_quaternion(cls, quaternion: FloatSequenceLike) -> Self: """Construct a rotation transformation from quaternion coefficients. Parameters @@ -250,7 +251,7 @@ def from_quaternion(cls, quaternion: FloatSequenceType) -> Self: return cls(matrix) @classmethod - def from_axis_angle_vector(cls, axis_angle_vector: CoordinateType, point: CoordinateType = (0.0, 0.0, 0.0)) -> Self: + def from_axis_angle_vector(cls, axis_angle_vector: VectorLike, point: PointLike = (0.0, 0.0, 0.0)) -> Self: """Construct a rotation transformation from an axis-angle vector. Parameters diff --git a/src/compas/geometry/transformations/scale.py b/src/compas/geometry/transformations/scale.py index 7fcac6d366cf..605f9f0a80c7 100644 --- a/src/compas/geometry/transformations/scale.py +++ b/src/compas/geometry/transformations/scale.py @@ -1,7 +1,5 @@ -from collections.abc import Sequence from typing import Self -from compas._typing import CoordinatesType from compas.itertools import flatten from compas.linalg.matrices import matrix_inverse from compas.linalg.matrices import multiply_matrices @@ -9,8 +7,10 @@ from compas.linalg.transformations import matrix_from_frame from compas.linalg.transformations import matrix_from_scale_factors from compas.tolerance import TOL +from compas.typing import FloatSequenceLike +from compas.typing import PointsLike -from .transformation import Matrix +from .transformation import MatrixInput from .transformation import Transformation @@ -56,7 +56,7 @@ class Scale(Transformation): # Dunder methods # ========================================================================== - def __init__(self, matrix: Matrix | None = None, check: bool = False, name: str | None = None) -> None: + def __init__(self, matrix: MatrixInput | None = None, check: bool = False, name: str | None = None) -> None: if matrix and check: scale, _, _, _, _ = decompose_matrix(matrix) if not TOL.is_allclose(flatten(matrix), flatten(matrix_from_scale_factors(scale))): @@ -68,7 +68,7 @@ def __init__(self, matrix: Matrix | None = None, check: bool = False, name: str # ========================================================================== @classmethod - def from_factors(cls, factors: Sequence[float], frame: CoordinatesType | None = None) -> Self: + def from_factors(cls, factors: FloatSequenceLike, frame: PointsLike | None = None) -> Self: """Construct a scale transformation from scale factors. Parameters diff --git a/src/compas/geometry/transformations/shear.py b/src/compas/geometry/transformations/shear.py index 6e52f3868134..418876442888 100644 --- a/src/compas/geometry/transformations/shear.py +++ b/src/compas/geometry/transformations/shear.py @@ -1,15 +1,15 @@ -from collections.abc import Sequence from typing import Self -from compas._typing import CoordinatesType -from compas._typing import CoordinateType from compas.itertools import flatten from compas.linalg.transformations import decompose_matrix from compas.linalg.transformations import matrix_from_shear from compas.linalg.transformations import matrix_from_shear_entries from compas.tolerance import TOL +from compas.typing import FloatSequenceLike +from compas.typing import PointsLike +from compas.typing import VectorLike -from .transformation import Matrix +from .transformation import MatrixInput from .transformation import Transformation @@ -41,7 +41,7 @@ class Shear(Transformation): # Dunder methods # ========================================================================== - def __init__(self, matrix: Matrix | None = None, check: bool = False, name: str | None = None) -> None: + def __init__(self, matrix: MatrixInput | None = None, check: bool = False, name: str | None = None) -> None: if matrix and check: _, shear, _, _, _ = decompose_matrix(matrix) if not TOL.is_allclose(flatten(matrix), flatten(matrix_from_shear_entries(shear))): @@ -53,7 +53,7 @@ def __init__(self, matrix: Matrix | None = None, check: bool = False, name: str # ========================================================================== @classmethod - def from_angle_direction_plane(cls, angle: float, direction: CoordinateType, plane: CoordinatesType) -> Self: + def from_angle_direction_plane(cls, angle: float, direction: VectorLike, plane: PointsLike) -> Self: """Construct a shear transformation from an angle, direction and plane. Parameters @@ -91,7 +91,7 @@ def from_angle_direction_plane(cls, angle: float, direction: CoordinateType, pla return cls(matrix) @classmethod - def from_entries(cls, shear_entries: Sequence[float]) -> Self: + def from_entries(cls, shear_entries: FloatSequenceLike) -> Self: """Construct a shear transformation from the 3 factors for x-y, x-z, and y-z axes. Parameters diff --git a/src/compas/geometry/transformations/transformation.py b/src/compas/geometry/transformations/transformation.py index 70246f7d68a5..79013edaa40a 100644 --- a/src/compas/geometry/transformations/transformation.py +++ b/src/compas/geometry/transformations/transformation.py @@ -2,8 +2,8 @@ from collections.abc import Sequence from typing import TYPE_CHECKING from typing import Self +from typing import SupportsFloat -from compas._typing import CoordinateType from compas.data import Data from compas.linalg.matrices import matrix_determinant from compas.linalg.matrices import matrix_inverse @@ -17,6 +17,9 @@ from compas.linalg.transformations import matrix_from_translation from compas.linalg.transformations import translation_from_matrix from compas.tolerance import TOL +from compas.typing import FloatSequenceLike +from compas.typing import PointLike +from compas.typing import RealInput if TYPE_CHECKING: from compas.geometry import Frame @@ -27,7 +30,10 @@ from compas.geometry import Translation from compas.geometry import Vector -Matrix = list[list[float]] +type Matrix = list[list[RealInput]] +# Retained mutable lists are invariant. Context-typed numeric literals fit these +# two owned representations; pre-bound narrower lists cannot soundly be widened. +type MatrixInput = Matrix | list[list[float]] class Transformation(Data): @@ -66,14 +72,14 @@ class Transformation(Data): # ========================================================================== @property - def __data__(self) -> dict[str, Matrix]: + def __data__(self) -> dict[str, MatrixInput]: return {"matrix": self.matrix} # ========================================================================== # Dunder methods # ========================================================================== - def __init__(self, matrix: Matrix | None = None, name: str | None = None) -> None: + def __init__(self, matrix: MatrixInput | None = None, name: str | None = None) -> None: super().__init__(name=name) if not matrix: matrix = identity_matrix(4) @@ -85,7 +91,7 @@ def __mul__(self, other: "Transformation") -> "Transformation": def __imul__(self, other: "Transformation") -> "Transformation": return self.concatenated(other) - def __getitem__(self, key: tuple[int, int]) -> float: + def __getitem__(self, key: tuple[int, int]) -> RealInput: i, j = key return self.matrix[i][j] @@ -93,7 +99,7 @@ def __setitem__(self, key: tuple[int, int], value: float) -> None: i, j = key self.matrix[i][j] = value - def __iter__(self) -> Iterator[list[float]]: + def __iter__(self) -> Iterator[Sequence[RealInput]]: return iter(self.matrix) def __eq__(self, other: object, tol: float | None = None) -> bool: @@ -173,12 +179,12 @@ def basis_vectors(self) -> tuple["Vector", "Vector"]: return Vector(x[0], x[1], x[2]), Vector(y[0], y[1], y[2]) @property - def list(self) -> list[float]: + def list(self) -> list[RealInput]: """The matrix entries flattened in row-major order.""" return [a for c in self.matrix for a in c] @property - def determinant(self) -> float: + def determinant(self) -> RealInput: """The determinant of the transformation matrix.""" return matrix_determinant(self.matrix) @@ -187,7 +193,7 @@ def determinant(self) -> float: # ========================================================================== @classmethod - def from_matrix(cls, matrix: Matrix) -> Self: + def from_matrix(cls, matrix: MatrixInput) -> Self: """Creates a transformation from a list[list[float]] object. Parameters @@ -204,7 +210,7 @@ def from_matrix(cls, matrix: Matrix) -> Self: return cls(matrix) @classmethod - def from_list(cls, numbers: Sequence[float]) -> Self: + def from_list(cls, numbers: Sequence[RealInput]) -> Self: """Creates a transformation from a list of 16 numbers. Parameters @@ -237,10 +243,10 @@ def from_list(cls, numbers: Sequence[float]) -> Self: @classmethod def from_euler_angles( cls, - euler_angles: Sequence[float], + euler_angles: FloatSequenceLike | Sequence[SupportsFloat], static: bool = True, axes: str = "xyz", - point: CoordinateType = (0.0, 0.0, 0.0), + point: PointLike = (0.0, 0.0, 0.0), ) -> Self: """Construct a transformation from a rotation represented by Euler angles. diff --git a/src/compas/geometry/transformations/translation.py b/src/compas/geometry/transformations/translation.py index 1793ef315478..d66b30800e1a 100644 --- a/src/compas/geometry/transformations/translation.py +++ b/src/compas/geometry/transformations/translation.py @@ -1,13 +1,13 @@ from typing import TYPE_CHECKING from typing import Self -from compas._typing import CoordinateType from compas.itertools import flatten from compas.linalg.transformations import matrix_from_translation from compas.linalg.transformations import translation_from_matrix from compas.tolerance import TOL +from compas.typing import VectorLike -from .transformation import Matrix +from .transformation import MatrixInput from .transformation import Transformation if TYPE_CHECKING: @@ -64,7 +64,7 @@ class Translation(Transformation): # Dunder methods # ========================================================================== - def __init__(self, matrix: Matrix | None = None, check: bool = False, name: str | None = None) -> None: + def __init__(self, matrix: MatrixInput | None = None, check: bool = False, name: str | None = None) -> None: if matrix and check: translation = translation_from_matrix(matrix) if not TOL.is_allclose(flatten(matrix), flatten(matrix_from_translation(translation))): @@ -90,7 +90,7 @@ def translation_vector(self) -> "Vector": # ========================================================================== @classmethod - def from_vector(cls, vector: CoordinateType) -> Self: + def from_vector(cls, vector: VectorLike) -> Self: """Construct a translation transformation from a vector. Parameters diff --git a/src/compas/geometry/triangulation.py b/src/compas/geometry/triangulation.py index eaac1d091c88..6d6ea1ff9924 100644 --- a/src/compas/geometry/triangulation.py +++ b/src/compas/geometry/triangulation.py @@ -1,24 +1,76 @@ +from collections.abc import Callable +from collections.abc import Iterable from math import isfinite from numbers import Real +from operator import index as integer_index from typing import Literal +from typing import SupportsIndex +from typing import TypeAlias -from compas._typing import CoordinatesType -from compas.data import Data from compas.tolerance import TOL +from compas.typing import PointsLike __all__ = [ - "ConformingDelaunay", - "ConstrainedDelaunay", - "Delaunay", - "DiagonalSplit", - "EarClipping", - "FaceTriangulation", - "MinimumAreaTriangulation", - "ProjectedEarClipping", - "TriangulationQuality", - "TriangulationStrategy", + "Triangulator", + "triangulate_polygon", + "triangulate_polygon_earclip", + "triangulate_polygon_minimum_area", + "triangulate_polygon_projected", + "triangulate_quadrilateral", ] +Triangulator: TypeAlias = Callable[[PointsLike], Iterable[Iterable[SupportsIndex]]] + + +def _validate_triangulation( + triangles: Iterable[Iterable[SupportsIndex]], + vertices: PointsLike, +) -> list[list[int]]: + """Validate and normalize a complete local triangulation.""" + vertex_count = len(vertices) + validated: list[list[int]] = [] + for triangle in triangles: + indices = list(triangle) + if len(indices) != 3: + raise ValueError("A triangulator should return triangles with exactly three indices.") + try: + normalized = [integer_index(index) for index in indices] + except TypeError: + raise TypeError("Triangulation indices should support integer indexing.") from None + if len(set(normalized)) != 3: + raise ValueError("A triangulator should return three distinct indices per triangle.") + if any(index < 0 or index >= vertex_count for index in normalized): + raise ValueError("Triangulation indices should refer to vertices of the input polygon.") + validated.append(normalized) + + if len(validated) != vertex_count - 2: + raise ValueError("A triangulator should return exactly n - 2 triangles for n polygon vertices.") + + from compas.geometry import area_triangle + + for triangle in validated: + if TOL.is_zero(area_triangle([vertices[index] for index in triangle])): + raise ValueError("A triangulator should not return degenerate triangles.") + + boundary_edges = {frozenset((index, (index + 1) % vertex_count)): (index, (index + 1) % vertex_count) for index in range(vertex_count)} + edge_directions: dict[frozenset[int], list[tuple[int, int]]] = {} + for a, b, c in validated: + for edge in ((a, b), (b, c), (c, a)): + edge_directions.setdefault(frozenset(edge), []).append(edge) + + for edge, directions in edge_directions.items(): + boundary = boundary_edges.get(edge) + if boundary is not None: + if directions != [boundary]: + raise ValueError("A triangulator should preserve every directed polygon boundary edge exactly once.") + continue + if len(directions) != 2 or directions[0] != directions[1][::-1]: + raise ValueError("A triangulator should use every interior edge exactly twice in opposite directions.") + + if any(edge not in edge_directions for edge in boundary_edges): + raise ValueError("A triangulator should preserve every directed polygon boundary edge exactly once.") + return validated + def _positive_finite(value: float, name: str) -> float: if not isinstance(value, Real) or isinstance(value, bool): @@ -29,279 +81,16 @@ def _positive_finite(value: float, name: str) -> float: return value -class _ImmutableData(Data): - def __init__(self) -> None: - super().__init__() - self._frozen = False - - def __setattr__(self, name: str, value: object) -> None: - if getattr(self, "_frozen", False) and name not in {"_guid", "_name"}: - raise AttributeError(f"{type(self).__name__} is immutable.") - super().__setattr__(name, value) - - def __eq__(self, other: object) -> bool: - return type(self) is type(other) and isinstance(other, _ImmutableData) and self.__data__ == other.__data__ - - def __hash__(self) -> int: - return hash((type(self), tuple(self.__data__.items()))) - - def _freeze(self) -> None: - self._frozen = True - - -class TriangulationStrategy(_ImmutableData): - """Base class for serializable polygon-face triangulation strategies.""" - - -class DiagonalSplit(TriangulationStrategy): - """Split triangular and quadrilateral faces without inserting vertices. - - Parameters - ---------- - diagonal - The quad diagonal selection: `"shortest"`, `"02"`, or `"13"`. - - """ - - @property - def __data__(self) -> dict: - return {"diagonal": self.diagonal} - - def __init__(self, diagonal: Literal["shortest", "02", "13"] = "shortest") -> None: - super().__init__() - if diagonal not in {"shortest", "02", "13"}: - raise ValueError('diagonal should be "shortest", "02", or "13".') - self.diagonal = diagonal - self._freeze() - - -class EarClipping(TriangulationStrategy): - """Triangulate planar polygonal faces without inserting vertices.""" - - @property - def __data__(self) -> dict: - return {} - - def __init__(self) -> None: - super().__init__() - self._freeze() - - -class ProjectedEarClipping(TriangulationStrategy): - """Triangulate a near-planar polygon through its best-fit plane. - - Parameters - ---------- - max_deviation - The maximum permitted distance from a vertex to the best-fit plane. - - Notes - ----- - The strategy projects the polygon to its best-fit plane, triangulates the - projected boundary, and returns indices into the original 3D vertices. It - rejects degenerate and self-intersecting projections and never inserts - vertices. - - """ - - @property - def __data__(self) -> dict: - return {"max_deviation": self.max_deviation} - - def __init__(self, max_deviation: float) -> None: - super().__init__() - if not isinstance(max_deviation, Real) or isinstance(max_deviation, bool): - raise TypeError("max_deviation should be a real number.") - max_deviation = float(max_deviation) - if not isfinite(max_deviation) or max_deviation < 0.0: - raise ValueError("max_deviation should be nonnegative and finite.") - self.max_deviation = max_deviation - self._freeze() - - -class MinimumAreaTriangulation(TriangulationStrategy): - """Triangulate an ordered 3D polygon with minimum total triangle area. - - Notes - ----- - This strategy uses the dynamic program of Barequet and Sharir for additive - per-triangle weights. It performs no projection, inserts no vertices, and - takes $O(n^3)$ time and $O(n^2)$ space. - - The result is a combinatorially manifold disk, but its triangles are not - guaranteed to form an intersection-free surface in 3D. - - References - ---------- - Barequet, G. and Sharir, M. *Filling gaps in the boundary of a polyhedron*. - Computer Aided Geometric Design 12(2), 1995. - https://doi.org/10.1016/0167-8396(94)00011-G - - """ - - @property - def __data__(self) -> dict: - return {} - - def __init__(self) -> None: - super().__init__() - self._freeze() - - -class FaceTriangulation(TriangulationStrategy): - """Triangulate mixed polygonal faces with arity-specific policies. - - Parameters - ---------- - quad - The strategy for quadrilateral faces. Supported policies are - :class:`DiagonalSplit`, :class:`EarClipping`, - :class:`ProjectedEarClipping`, and :class:`MinimumAreaTriangulation`. - Defaults to :class:`DiagonalSplit` with its shortest-diagonal policy. - ngon - The strategy for faces with five or more vertices. Supported policies - are :class:`EarClipping`, :class:`ProjectedEarClipping`, and - :class:`MinimumAreaTriangulation`. Defaults to :class:`EarClipping`. - - Notes - ----- - Triangular faces pass through unchanged. A :class:`DiagonalSplit` may be - used for non-planar quads because its selected diagonal explicitly defines - the piecewise-planar surface. :class:`EarClipping` requires planar input. - Projection and twisted-polygon triangulation are available only when their - corresponding explicit strategies are selected. - - """ - - @property - def __data__(self) -> dict: - return {"quad": self.quad, "ngon": self.ngon} - - def __init__( - self, - quad: DiagonalSplit | EarClipping | ProjectedEarClipping | MinimumAreaTriangulation | None = None, - ngon: EarClipping | ProjectedEarClipping | MinimumAreaTriangulation | None = None, - ) -> None: - super().__init__() - quad = DiagonalSplit() if quad is None else quad - ngon = EarClipping() if ngon is None else ngon - if not isinstance(quad, (DiagonalSplit, EarClipping, ProjectedEarClipping, MinimumAreaTriangulation)): - raise TypeError("quad should be a DiagonalSplit, EarClipping, ProjectedEarClipping, or MinimumAreaTriangulation strategy.") - if not isinstance(ngon, (EarClipping, ProjectedEarClipping, MinimumAreaTriangulation)): - raise TypeError("ngon should be an EarClipping, ProjectedEarClipping, or MinimumAreaTriangulation strategy.") - self.quad = quad - self.ngon = ngon - self._freeze() - - def __hash__(self) -> int: - return hash((type(self), self.quad, self.ngon)) - - -class _PlanarTriangulationStrategy(_ImmutableData): - pass - - -class Delaunay(_PlanarTriangulationStrategy): - """Construct an unconstrained planar Delaunay triangulation.""" - - @property - def __data__(self) -> dict: - return {} - - def __init__(self) -> None: - super().__init__() - self._freeze() - - -class ConstrainedDelaunay(_PlanarTriangulationStrategy): - """Construct a planar Delaunay triangulation with constraints.""" - - @property - def __data__(self) -> dict: - return {} - - def __init__(self) -> None: - super().__init__() - self._freeze() - - -class TriangulationQuality(_ImmutableData): - """Quality targets for conforming planar triangulation. - - Parameters - ---------- - min_angle - The optional minimum triangle angle in degrees, below 60. - max_area - The optional maximum triangle area in model units squared. - max_edge_length - The optional maximum triangle edge length in model units. - - """ - - @property - def __data__(self) -> dict: - return { - "min_angle": self.min_angle, - "max_area": self.max_area, - "max_edge_length": self.max_edge_length, - } - - def __init__( - self, - min_angle: float | None = None, - max_area: float | None = None, - max_edge_length: float | None = None, - ) -> None: - super().__init__() - if min_angle is None and max_area is None and max_edge_length is None: - raise ValueError("At least one triangulation-quality target is required.") - self.min_angle = None if min_angle is None else _positive_finite(min_angle, "min_angle") - if self.min_angle is not None and self.min_angle >= 60.0: - raise ValueError("min_angle should be less than 60 degrees.") - self.max_area = None if max_area is None else _positive_finite(max_area, "max_area") - self.max_edge_length = None if max_edge_length is None else _positive_finite(max_edge_length, "max_edge_length") - self._freeze() - - -class ConformingDelaunay(_PlanarTriangulationStrategy): - """Construct a conforming planar Delaunay triangulation. - - Parameters - ---------- - quality - Optional quality targets. - - """ - - @property - def __data__(self) -> dict: - return {"quality": None if self.quality is None else self.quality.__data__} - - @classmethod - def __from_data__(cls, data: dict) -> "ConformingDelaunay": - quality = data.get("quality") - return cls(None if quality is None else TriangulationQuality.__from_data__(quality)) - - def __init__(self, quality: TriangulationQuality | None = None) -> None: - super().__init__() - if quality is not None and not isinstance(quality, TriangulationQuality): - raise TypeError("quality should be a TriangulationQuality or None.") - self.quality = quality - self._freeze() - - def __hash__(self) -> int: - return hash((type(self), self.quality)) - - -def _validate_finite_polygon(vertices: CoordinatesType) -> None: +def _validate_finite_polygon(vertices: PointsLike) -> None: for vertex in vertices: if len(vertex) != 3 or any(not isfinite(vertex[axis]) for axis in range(3)): raise ValueError("Polygon vertices should have exactly three finite coordinates.") -def _validate_polygon_boundary(vertices: CoordinatesType) -> None: +def _validate_polygon_boundary(vertices: PointsLike) -> None: from compas.geometry import distance_point_point + from compas.geometry import intersection_segment_segment + from compas.geometry import is_point_on_segment for index, vertex in enumerate(vertices): if TOL.is_zero(distance_point_point(vertex, vertices[(index + 1) % len(vertices)])): @@ -309,11 +98,26 @@ def _validate_polygon_boundary(vertices: CoordinatesType) -> None: for index, vertex in enumerate(vertices): for other_index in range(index + 1, len(vertices)): other = vertices[other_index] - if TOL.is_allclose(vertex, other): + if TOL.is_zero(distance_point_point(vertex, other)): raise ValueError("A polygon should not contain repeated vertices.") - - -def _minimum_area_triangulation(vertices: CoordinatesType) -> list[list[int]]: + count = len(vertices) + for index in range(count): + segment = (vertices[index], vertices[(index + 1) % count]) + for other_index in range(index + 1, count): + if other_index == index + 1 or (index == 0 and other_index == count - 1): + continue + other = (vertices[other_index], vertices[(other_index + 1) % count]) + if ( + intersection_segment_segment(segment, other) + or is_point_on_segment(segment[0], other) + or is_point_on_segment(segment[1], other) + or is_point_on_segment(other[0], segment) + or is_point_on_segment(other[1], segment) + ): + raise ValueError("A polygon boundary should not self-intersect.") + + +def _minimum_area_triangulation(vertices: PointsLike) -> list[list[int]]: from compas.geometry import area_triangle _validate_polygon_boundary(vertices) @@ -363,7 +167,7 @@ def collect(start: int, end: int) -> None: return triangles -def _projected_ear_clipping(vertices: CoordinatesType, strategy: ProjectedEarClipping) -> list[list[int]]: +def _projected_ear_clipping(vertices: PointsLike, max_deviation: float) -> list[list[int]]: from compas.geometry import Frame from compas.geometry import Plane from compas.geometry import Polygon @@ -377,8 +181,8 @@ def _projected_ear_clipping(vertices: CoordinatesType, strategy: ProjectedEarCli origin, normal = bestfit_plane(vertices) plane = Plane(origin.tolist(), normal.tolist()) deviation = max(distance_point_plane(vertex, plane) for vertex in vertices) - if deviation > strategy.max_deviation and not TOL.is_close(deviation, strategy.max_deviation, rtol=0.0): - raise ValueError(f"The polygon deviates from its best-fit plane by {deviation}, exceeding max_deviation={strategy.max_deviation}.") + if deviation > max_deviation and not TOL.is_close(deviation, max_deviation, rtol=0.0): + raise ValueError(f"The polygon deviates from its best-fit plane by {deviation}, exceeding max_deviation={max_deviation}.") transformation = Transformation.from_frame_to_frame(Frame.from_plane(plane), Frame.worldXY()) projected = transform_points(vertices, transformation) for point in projected: @@ -386,15 +190,176 @@ def _projected_ear_clipping(vertices: CoordinatesType, strategy: ProjectedEarCli return earclip_polygon(Polygon(projected)) -def triangulate_polygon(vertices: CoordinatesType, strategy: TriangulationStrategy) -> list[list[int]]: +def triangulate_quadrilateral( + vertices: PointsLike, + *, + diagonal: Literal["shortest", "02", "13"] = "shortest", +) -> list[list[int]]: + """Triangulate a quadrilateral without inserting vertices. + + Parameters + ---------- + vertices + Four ordered XYZ coordinates. + diagonal + The diagonal selection. `"02"` connects vertices 0 and 2, `"13"` + connects vertices 1 and 3, and `"shortest"` selects the shortest valid + interior diagonal for a planar quadrilateral. + + Returns + ------- + list[list[int]] + Two triangles indexing the input vertices. + + Raises + ------ + ValueError + If the boundary is invalid, the selected diagonal produces a + degenerate triangle, or an explicitly selected diagonal lies outside + a planar concave quadrilateral. + + """ + source = [[float(value) for value in vertex] for vertex in vertices] + if len(source) != 4: + raise ValueError("A quadrilateral requires exactly four vertices.") + if diagonal not in {"shortest", "02", "13"}: + raise ValueError('diagonal should be "shortest", "02", or "13".') + _validate_finite_polygon(source) + _validate_polygon_boundary(source) + candidates: dict[Literal["02", "13"], list[list[int]]] = { + "02": [[0, 1, 2], [0, 2, 3]], + "13": [[0, 1, 3], [1, 2, 3]], + } + diagonal_vertices: dict[Literal["02", "13"], tuple[int, int]] = {"02": (0, 2), "13": (1, 3)} + from compas.geometry import area_polygon + from compas.geometry import area_triangle + from compas.geometry import is_coplanar + + polygon_area = area_polygon(source) + planar = is_coplanar(source) + + def is_valid(candidate: list[list[int]]) -> bool: + areas = [area_triangle([source[index] for index in triangle]) for triangle in candidate] + if any(TOL.is_zero(area) for area in areas): + return False + return not planar or bool(TOL.is_close(sum(areas), polygon_area)) + + if diagonal == "shortest": + valid_diagonals: list[Literal["02", "13"]] = [candidate for candidate, triangles in candidates.items() if is_valid(triangles)] + if not valid_diagonals: + raise ValueError("The quadrilateral has no valid nondegenerate triangulation.") + + def diagonal_length_squared(candidate: Literal["02", "13"]) -> float: + start, end = diagonal_vertices[candidate] + return sum((source[start][axis] - source[end][axis]) ** 2 for axis in range(3)) + + selected_diagonal = min( + valid_diagonals, + key=diagonal_length_squared, + ) + else: + selected_diagonal = diagonal + + triangles = candidates[selected_diagonal] + + if any(TOL.is_zero(area_triangle([source[index] for index in triangle])) for triangle in triangles): + raise ValueError("The quadrilateral triangulation contains a degenerate triangle.") + if planar and not is_valid(triangles): + raise ValueError("The selected diagonal lies outside the planar quadrilateral.") + return triangles + + +def triangulate_polygon_earclip(vertices: PointsLike) -> list[list[int]]: + """Triangulate an ordered planar polygon by ear clipping. + + Parameters + ---------- + vertices + Ordered planar XYZ coordinates. + + Returns + ------- + list[list[int]] + Triangle indices into `vertices`. + + """ + source = [[float(value) for value in vertex] for vertex in vertices] + if len(source) < 3: + raise ValueError("A polygon requires at least three vertices for triangulation.") + _validate_finite_polygon(source) + _validate_polygon_boundary(source) + from compas.geometry import Polygon + from compas.geometry import earclip_polygon + from compas.geometry import is_coplanar + + if not is_coplanar(source): + raise ValueError("Ear clipping requires a planar polygon.") + return earclip_polygon(Polygon(source)) + + +def triangulate_polygon_projected(vertices: PointsLike, *, max_deviation: float) -> list[list[int]]: + """Triangulate a near-planar polygon through its best-fit plane. + + Parameters + ---------- + vertices + Ordered XYZ coordinates. + max_deviation + Positive finite maximum distance from the best-fit plane. + + Returns + ------- + list[list[int]] + Triangle indices into `vertices`. + + """ + checked_deviation = _positive_finite(max_deviation, "max_deviation") + source = [[float(value) for value in vertex] for vertex in vertices] + if len(source) < 3: + raise ValueError("A polygon requires at least three vertices for triangulation.") + _validate_finite_polygon(source) + return _projected_ear_clipping(source, checked_deviation) + + +def triangulate_polygon_minimum_area(vertices: PointsLike) -> list[list[int]]: + """Triangulate an ordered 3D polygon with minimum total triangle area. + + Parameters + ---------- + vertices + Ordered XYZ coordinates. + + Returns + ------- + list[list[int]] + Triangle indices into `vertices`. + + Notes + ----- + The dynamic program takes $O(n^3)$ time and $O(n^2)$ space. It performs + no projection and inserts no vertices. + + References + ---------- + Barequet, G. and Sharir, M. *Filling gaps in the boundary of a polyhedron*. + Computer Aided Geometric Design 12(2), 1995. + + + """ + source = [[float(value) for value in vertex] for vertex in vertices] + if len(source) < 3: + raise ValueError("A polygon requires at least three vertices for triangulation.") + _validate_finite_polygon(source) + return _minimum_area_triangulation(source) + + +def triangulate_polygon(vertices: PointsLike) -> list[list[int]]: """Triangulate one polygon represented by ordered vertex coordinates. Parameters ---------- vertices The ordered polygon vertex coordinates. - strategy - The polygon triangulation strategy. Returns ------- @@ -405,37 +370,15 @@ def triangulate_polygon(vertices: CoordinatesType, strategy: TriangulationStrate count = len(vertices) if count < 3: raise ValueError("A polygon requires at least three vertices for triangulation.") - _validate_finite_polygon(vertices) - if isinstance(strategy, FaceTriangulation): - if count == 3: - return [[0, 1, 2]] - if count == 4: - return triangulate_polygon(vertices, strategy.quad) - return triangulate_polygon(vertices, strategy.ngon) - if isinstance(strategy, DiagonalSplit): - if count == 3: - return [[0, 1, 2]] - if count != 4: - raise ValueError("DiagonalSplit applies only to triangular and quadrilateral faces.") - diagonal = strategy.diagonal - if diagonal == "shortest": - length_02 = sum((vertices[0][axis] - vertices[2][axis]) ** 2 for axis in range(3)) - length_13 = sum((vertices[1][axis] - vertices[3][axis]) ** 2 for axis in range(3)) - diagonal = "02" if length_02 <= length_13 else "13" - if diagonal == "02": - return [[0, 1, 2], [0, 2, 3]] - return [[0, 1, 3], [1, 2, 3]] - if isinstance(strategy, EarClipping): - from compas.geometry import Polygon - from compas.geometry import earclip_polygon - from compas.geometry import is_coplanar - - _validate_polygon_boundary(vertices) - if not is_coplanar(vertices): - raise ValueError("EarClipping requires a planar polygon.") - return earclip_polygon(Polygon(vertices)) - if isinstance(strategy, ProjectedEarClipping): - return _projected_ear_clipping(vertices, strategy) - if isinstance(strategy, MinimumAreaTriangulation): - return _minimum_area_triangulation(vertices) - raise TypeError("Unsupported triangulation strategy.") + source = [[float(value) for value in vertex] for vertex in vertices] + _validate_finite_polygon(source) + if count == 3: + _validate_polygon_boundary(source) + from compas.geometry import area_triangle + + if TOL.is_zero(area_triangle(source)): + raise ValueError("The polygon triangle is degenerate.") + return [[0, 1, 2]] + if count == 4: + return triangulate_quadrilateral(source) + return triangulate_polygon_earclip(source) diff --git a/src/compas/geometry/triangulation_delaunay.py b/src/compas/geometry/triangulation_delaunay.py index 8bba08ca700b..02cc3180e4b7 100644 --- a/src/compas/geometry/triangulation_delaunay.py +++ b/src/compas/geometry/triangulation_delaunay.py @@ -1,22 +1,20 @@ -from collections.abc import Sequence from math import isfinite +from numbers import Integral +from numbers import Real from typing import TYPE_CHECKING -from compas._typing import CoordinatesType from compas.plugins import pluggable from compas.tolerance import TOL - -from .triangulation import ConformingDelaunay -from .triangulation import ConstrainedDelaunay -from .triangulation import Delaunay +from compas.typing import PointGroupsLike +from compas.typing import PointsLike +from compas.typing import VerticesFaces if TYPE_CHECKING: from compas.geometry import Frame - from compas.geometry import Tessellation def _planar_coordinates( - points: CoordinatesType, + points: PointsLike, frame: "Frame | None", name: str, minimum: int, @@ -52,128 +50,305 @@ def _planar_coordinates( return source, xy -def triangulate_planar_points( - points: CoordinatesType, +def _coalesce_points( + points: list[list[float]], + frame: "Frame | None", + registry: list[tuple[list[float], list[float]]], +) -> list[list[float]]: + from compas.geometry import distance_point_point + + _, points_xy = _planar_coordinates(points, frame, "Planar input", 0) + coalesced = [] + for point, xy in zip(points, points_xy, strict=True): + for known_xy, known_point in registry: + planar_distance = ((xy[0] - known_xy[0]) ** 2 + (xy[1] - known_xy[1]) ** 2) ** 0.5 + if TOL.is_zero(planar_distance): + if not TOL.is_zero(distance_point_point(point, known_point)): + raise ValueError("Points at the same planar location should have identical 3D coordinates.") + coalesced.append(known_point) + break + else: + registry.append((xy, point)) + coalesced.append(point) + return coalesced + + +def _validate_domain( + boundary: list[list[float]], + holes: list[list[list[float]]], + constraints: list[list[list[float]]], + points: list[list[float]], + frame: "Frame | None", +) -> None: + from shapely import LineString # pyright: ignore[reportMissingModuleSource] + from shapely import Point # pyright: ignore[reportMissingModuleSource] + from shapely import Polygon # pyright: ignore[reportMissingModuleSource] + + from compas.geometry.triangulation_earclip import _validate_boundaries_xy + + boundary_xy = _planar_coordinates(boundary, frame, "Boundary", 3)[1] + holes_xy = [_planar_coordinates(hole, frame, "Hole", 3)[1] for hole in holes] + constraints_xy = [_planar_coordinates(constraint, frame, "Constraint", 2)[1] for constraint in constraints] + points_xy = _planar_coordinates(points, frame, "Interior", 0)[1] + _validate_boundaries_xy([boundary_xy, *holes_xy]) + domain = Polygon(boundary_xy, holes_xy) + + lines = [LineString(constraint) for constraint in constraints_xy] + for index, line in enumerate(lines): + if not line.is_simple: + raise ValueError(f"Constraint {index + 1} should not self-intersect.") + if not domain.covers(line): + raise ValueError(f"Constraint {index + 1} should remain inside the triangulation domain.") + boundary_intersection = line.intersection(domain.boundary) + endpoints = [Point(constraints_xy[index][0]), Point(constraints_xy[index][-1])] + parts = getattr(boundary_intersection, "geoms", None) + intersections = list(parts) if parts is not None else [boundary_intersection] + if any(not intersection.is_empty and not any(intersection.equals(endpoint) for endpoint in endpoints) for intersection in intersections): + raise ValueError(f"Constraint {index + 1} may meet the domain boundary only at an endpoint.") + + for index, line in enumerate(lines): + endpoints = [Point(constraints_xy[index][0]), Point(constraints_xy[index][-1])] + for other_index, other in enumerate(lines[index + 1 :], start=index + 1): + other_endpoints = [Point(constraints_xy[other_index][0]), Point(constraints_xy[other_index][-1])] + intersection = line.intersection(other) + if intersection.is_empty: + continue + parts = getattr(intersection, "geoms", None) + intersections = list(parts) if parts is not None else [intersection] + shared_endpoints = [point for point in endpoints if any(point.equals(other_point) for other_point in other_endpoints)] + if not shared_endpoints or any(not any(part.equals(endpoint) for endpoint in shared_endpoints) for part in intersections): + raise ValueError("Constraints may meet only at shared endpoints.") + + for point in points_xy: + if not domain.contains(Point(point)): + raise ValueError("Interior points should lie strictly inside the triangulation domain.") + + +def _validated_result( + result: VerticesFaces, + frame: "Frame | None", + required_vertices: list[list[float]], *, - strategy: Delaunay, - frame: "Frame | None" = None, -) -> "Tessellation": + allow_steiner: bool, +) -> tuple[list[list[float]], list[list[int]]]: + vertices, triangles = result + vertex_source, vertex_xy = _planar_coordinates(vertices, frame, "Result", 3) + triangle_source: list[list[int]] = [] + for triangle in triangles: + if len(triangle) != 3: + raise ValueError("Delaunay results should contain only triangles.") + indices = [] + for index in triangle: + if not isinstance(index, Integral) or isinstance(index, bool): + raise TypeError("Triangle indices should be integers.") + indices.append(int(index)) + if len(set(indices)) != 3 or any(index < 0 or index >= len(vertex_source) for index in indices): + raise ValueError("Triangle indices should be distinct and in range.") + a, b, c = [vertex_xy[index] for index in indices] + signed_area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) + if TOL.is_zero(signed_area): + raise ValueError("Delaunay results should not contain degenerate triangles.") + if signed_area < 0: + indices[1], indices[2] = indices[2], indices[1] + triangle_source.append(indices) + for required in required_vertices: + if required not in vertex_source: + raise ValueError("A Delaunay result omitted or changed an accepted source coordinate.") + if not allow_steiner and (len(vertex_source) != len(required_vertices) or any(vertex not in required_vertices for vertex in vertex_source)): + raise ValueError("This Delaunay operation should not insert vertices.") + return vertex_source, triangle_source + + +def delaunay_triangulation(points: PointsLike, *, frame: "Frame | None" = None) -> VerticesFaces: """Construct a planar Delaunay triangulation of points. Parameters ---------- points - The points to triangulate. Input coordinates are retained exactly. - strategy - The Delaunay strategy. + Planar XYZ coordinates. frame - The triangulation frame. If `None`, use world XY. + The plane in which to interpret the coordinates. If `None`, use world + XY. Returns ------- - Tessellation + tuple[PointsLike, IndexGroupsLike] + Structural vertex and triangle buffers. Repeated source locations are + coalesced and no vertices are inserted. - Raises - ------ - TypeError - If the strategy is not a Delaunay strategy. - ValueError - If there are fewer than three points, coordinates are non-finite or - are not three-dimensional, or an input point lies outside the - triangulation plane within the standard COMPAS tolerance. + Notes + ----- + Input coordinates are retained in the structural vertex buffer. Triangles + are counterclockwise in the supplied frame, or world XY when no frame is + supplied. """ - if not isinstance(strategy, Delaunay): - raise TypeError("strategy should be a Delaunay strategy.") - vertices, _ = _planar_coordinates(points, frame, "Delaunay input", 3) - return _triangulate_planar_points(vertices, strategy=strategy, frame=frame) + source, _ = _planar_coordinates(points, frame, "Delaunay input", 3) + registry: list[tuple[list[float], list[float]]] = [] + source = _coalesce_points(source, frame, registry) + unique = [point for _, point in registry] + if len(unique) < 3: + raise ValueError("Delaunay input requires at least 3 distinct points.") + return _validated_result( + _delaunay_triangulation(unique, frame=frame), + frame, + unique, + allow_steiner=False, + ) @pluggable(category="triangulation") -def _triangulate_planar_points( - points: CoordinatesType, - *, - strategy: Delaunay, - frame: "Frame | None" = None, -) -> "Tessellation": +def _delaunay_triangulation(points: PointsLike, *, frame: "Frame | None" = None) -> VerticesFaces: from scipy.spatial import Delaunay as ScipyDelaunay # pyright: ignore[reportMissingImports] - from compas.geometry import Tessellation - vertices, points_xy = _planar_coordinates(points, frame, "Delaunay input", 3) triangles = ScipyDelaunay(points_xy).simplices.tolist() - return Tessellation(vertices, triangles) + return vertices, triangles -def triangulate_planar_domain( - boundary: CoordinatesType, +def constrained_delaunay_triangulation( + boundary: PointsLike, *, - holes: Sequence[CoordinatesType] = (), - constraints: Sequence[CoordinatesType] = (), - points: CoordinatesType = (), - strategy: ConstrainedDelaunay | ConformingDelaunay, + holes: PointGroupsLike = (), + constraints: PointGroupsLike = (), + points: PointsLike = (), frame: "Frame | None" = None, -) -> "Tessellation": - """Triangulate a planar domain with optional holes and constraints. +) -> VerticesFaces: + """Triangulate a constrained planar domain without inserting vertices. Parameters ---------- boundary - The exterior domain boundary. + The exterior planar boundary. holes - Closed interior hole boundaries. + Closed interior boundaries. constraints Open constrained polylines. points - Optional unconstrained interior points. - strategy - A constrained or conforming Delaunay strategy. + Additional interior points. frame - The triangulation frame. If `None`, use world XY. + The plane in which to interpret the coordinates. If `None`, use world + XY. Returns ------- - Tessellation - - Raises - ------ - TypeError - If the strategy is not a constrained or conforming Delaunay strategy. - ValueError - If coordinates are non-finite, are not three-dimensional, lie outside - the triangulation plane, or a boundary, hole, or constraint has too few - points. + tuple[PointsLike, IndexGroupsLike] + Structural vertex and triangle buffers with counterclockwise winding + in the triangulation frame. Notes ----- - This extension point has no core implementation and requires a plugin. + This operation requires a triangulation plugin. It preserves every + accepted source coordinate and introduces no Steiner vertices. """ - if not isinstance(strategy, (ConstrainedDelaunay, ConformingDelaunay)): - raise TypeError("strategy should be a ConstrainedDelaunay or ConformingDelaunay strategy.") + boundary_source, _ = _planar_coordinates(boundary, frame, "Boundary", 3) + hole_sources = [_planar_coordinates(hole, frame, "Hole", 3)[0] for hole in holes] + constraint_sources = [_planar_coordinates(constraint, frame, "Constraint", 2)[0] for constraint in constraints] + point_source, _ = _planar_coordinates(points, frame, "Interior", 0) + registry: list[tuple[list[float], list[float]]] = [] + boundary_source = _coalesce_points(boundary_source, frame, registry) + hole_sources = [_coalesce_points(hole, frame, registry) for hole in hole_sources] + constraint_sources = [_coalesce_points(constraint, frame, registry) for constraint in constraint_sources] + point_source = _coalesce_points(point_source, frame, registry) + _validate_domain(boundary_source, hole_sources, constraint_sources, point_source, frame) + result = _constrained_delaunay_triangulation( + boundary_source, + holes=hole_sources, + constraints=constraint_sources, + points=point_source, + frame=frame, + ) + return _validated_result(result, frame, [point for _, point in registry], allow_steiner=False) + + +@pluggable(category="triangulation") +def _constrained_delaunay_triangulation( + boundary: PointsLike, + *, + holes: PointGroupsLike = (), + constraints: PointGroupsLike = (), + points: PointsLike = (), + frame: "Frame | None" = None, +) -> VerticesFaces: + raise NotImplementedError + + +def conforming_delaunay_triangulation( + boundary: PointsLike, + *, + holes: PointGroupsLike = (), + constraints: PointGroupsLike = (), + points: PointsLike = (), + max_edge_length: float | None = None, + frame: "Frame | None" = None, +) -> VerticesFaces: + """Triangulate and optionally refine a constrained planar domain. + + Parameters + ---------- + boundary + The exterior planar boundary. + holes + Closed interior boundaries. + constraints + Open constrained polylines. + points + Additional interior points. + max_edge_length + A positive finite edge-length refinement target. If `None`, no edge + length target is requested. + frame + The plane in which to interpret the coordinates. If `None`, use world + XY. + Returns + ------- + tuple[PointsLike, IndexGroupsLike] + Structural vertex and triangle buffers with counterclockwise winding + in the triangulation frame. + + Notes + ----- + This operation requires a triangulation plugin. It preserves every + accepted source coordinate and may insert planar Steiner vertices. + + """ + if max_edge_length is not None: + if not isinstance(max_edge_length, Real) or isinstance(max_edge_length, bool): + raise TypeError("max_edge_length should be a real number.") + max_edge_length = float(max_edge_length) + if not isfinite(max_edge_length) or max_edge_length <= 0: + raise ValueError("max_edge_length should be positive and finite.") boundary_source, _ = _planar_coordinates(boundary, frame, "Boundary", 3) hole_sources = [_planar_coordinates(hole, frame, "Hole", 3)[0] for hole in holes] constraint_sources = [_planar_coordinates(constraint, frame, "Constraint", 2)[0] for constraint in constraints] point_source, _ = _planar_coordinates(points, frame, "Interior", 0) - return _triangulate_planar_domain( + registry: list[tuple[list[float], list[float]]] = [] + boundary_source = _coalesce_points(boundary_source, frame, registry) + hole_sources = [_coalesce_points(hole, frame, registry) for hole in hole_sources] + constraint_sources = [_coalesce_points(constraint, frame, registry) for constraint in constraint_sources] + point_source = _coalesce_points(point_source, frame, registry) + _validate_domain(boundary_source, hole_sources, constraint_sources, point_source, frame) + result = _conforming_delaunay_triangulation( boundary_source, holes=hole_sources, constraints=constraint_sources, points=point_source, - strategy=strategy, + max_edge_length=max_edge_length, frame=frame, ) + return _validated_result(result, frame, [point for _, point in registry], allow_steiner=True) @pluggable(category="triangulation") -def _triangulate_planar_domain( - boundary: CoordinatesType, +def _conforming_delaunay_triangulation( + boundary: PointsLike, *, - holes: Sequence[CoordinatesType] = (), - constraints: Sequence[CoordinatesType] = (), - points: CoordinatesType = (), - strategy: ConstrainedDelaunay | ConformingDelaunay, + holes: PointGroupsLike = (), + constraints: PointGroupsLike = (), + points: PointsLike = (), + max_edge_length: float | None = None, frame: "Frame | None" = None, -) -> "Tessellation": +) -> VerticesFaces: raise NotImplementedError diff --git a/src/compas/geometry/triangulation_earclip.py b/src/compas/geometry/triangulation_earclip.py index 82691d278bee..06b86fccc981 100644 --- a/src/compas/geometry/triangulation_earclip.py +++ b/src/compas/geometry/triangulation_earclip.py @@ -4,21 +4,22 @@ import mapbox_earcut # pyright: ignore[reportMissingImports] import numpy as np # pyright: ignore[reportMissingImports] -from compas._typing import CoordinateType from compas.linalg import cross_vectors from compas.linalg import length_vector from compas.linalg import subtract_vectors +from compas.typing import PointLike +from compas.typing import RealInput if TYPE_CHECKING: from compas.geometry import Frame from compas.geometry import Polygon -def _signed_area_xy(points: Sequence[CoordinateType]) -> float: +def _signed_area_xy(points: Sequence[PointLike]) -> RealInput: return 0.5 * sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) -def _validate_boundaries_xy(boundaries: Sequence[Sequence[CoordinateType]]) -> None: +def _validate_boundaries_xy(boundaries: Sequence[Sequence[PointLike]]) -> None: from shapely import LinearRing # pyright: ignore[reportMissingModuleSource] from shapely import Polygon as ShapelyPolygon # pyright: ignore[reportMissingModuleSource] from shapely.validation import explain_validity # pyright: ignore[reportMissingModuleSource] @@ -81,7 +82,7 @@ def _earclip_boundaries(outer: "Polygon", holes: Sequence["Polygon"] = ()) -> tu vertices = [[float(value) for value in point] for boundary in boundaries for point in boundary.points] transformation = Transformation.from_frame_to_frame(_projection_frame(outer), Frame.worldXY()) points = [point.transformed(transformation) for boundary in boundaries for point in boundary.points] - boundaries_xy: list[Sequence[CoordinateType]] = [] + boundaries_xy: list[Sequence[PointLike]] = [] start = 0 for boundary in boundaries: end = start + len(boundary) diff --git a/src/compas/geometry/trimesh/__init__.py b/src/compas/geometry/trimesh/__init__.py index 61d2a634c495..76cf7b3b55ac 100644 --- a/src/compas/geometry/trimesh/__init__.py +++ b/src/compas/geometry/trimesh/__init__.py @@ -1,6 +1,4 @@ from .curvature import trimesh_gaussian_curvature -from .curvature import trimesh_mean_curvature -from .curvature import trimesh_principal_curvature from .descent import trimesh_descent from .geodistance import trimesh_geodistance from .gradient import trimesh_gradient @@ -32,8 +30,6 @@ "trimesh_isolines", "trimesh_lscm", "trimesh_massmatrix", - "trimesh_mean_curvature", - "trimesh_principal_curvature", "trimesh_pull_points", "trimesh_remesh", "trimesh_remesh_along_isoline", diff --git a/src/compas/geometry/trimesh/curvature.py b/src/compas/geometry/trimesh/curvature.py index d455ac97aeaf..b8e8ce24c09e 100644 --- a/src/compas/geometry/trimesh/curvature.py +++ b/src/compas/geometry/trimesh/curvature.py @@ -1,86 +1,41 @@ from math import pi +from operator import index as integer_index from compas.geometry import angle_points +from compas.typing import VerticesFaces -from .._tessellation_plugin import tessellation_pluggable -from ..tessellation import Tessellation - -@tessellation_pluggable(category="trimesh", inputs=("mesh",)) -def trimesh_gaussian_curvature(mesh: "Tessellation") -> list[float]: - """Compute the discrete Gaussian curvature of a triangle mesh. +def trimesh_gaussian_curvature(mesh: VerticesFaces) -> list[float]: + r"""Compute the integrated Gaussian curvature of a triangle mesh. Parameters ---------- mesh - The triangle tessellation. + The vertex coordinates and triangle indices. Returns ------- list[float] - The discrete gaussian curvature per vertex. + The integrated Gaussian curvature per vertex, in radians. Notes ----- - Curvature is computed as the angular deficit: $2π$ minus the incident - face angles at interior vertices and $π$ minus the incident face angles - at boundary vertices. + Curvature is the dimensionless angular deficit. At an interior vertex it + is $2\pi - \sum_i \theta_i$. At a boundary vertex it is + $\pi - \sum_i \theta_i$. """ - vertices, faces = mesh.to_vertices_and_triangles() + vertices, faces = mesh angles = [0.0] * len(vertices) edge_faces = {} for face in faces: for index, vertex in enumerate(face): - previous = face[index - 1] - following = face[(index + 1) % len(face)] - angles[vertex] += angle_points(vertices[vertex], vertices[previous], vertices[following]) + vertex = integer_index(vertex) + previous = integer_index(face[index - 1]) + following = integer_index(face[(index + 1) % len(face)]) + angles[vertex] += angle_points(vertices[vertex], vertices[previous], vertices[following]) # pyright: ignore[reportArgumentType] edge = tuple(sorted((vertex, following))) edge_faces[edge] = edge_faces.get(edge, 0) + 1 boundary = {vertex for edge, count in edge_faces.items() if count == 1 for vertex in edge} return [(pi if vertex in boundary else 2 * pi) - angle for vertex, angle in enumerate(angles)] - - -@tessellation_pluggable(category="trimesh", inputs=("mesh",)) -def trimesh_principal_curvature(mesh: "Tessellation") -> list[tuple[list[float], list[float]]]: - """Compute the principal curvature directions of a triangle mesh. - - Parameters - ---------- - mesh - The triangle tessellation. - - Returns - ------- - list[tuple[list[float], list[float]]] - The two principal curvature directions at every vertex. - - Notes - ----- - This operation requires a registered plugin. - - """ - raise NotImplementedError - - -@tessellation_pluggable(category="trimesh", inputs=("mesh",)) -def trimesh_mean_curvature(mesh: "Tessellation") -> list[float]: - """Compute the discrete mean curvature of a triangle mesh. - - Parameters - ---------- - mesh - The triangle tessellation. - - Returns - ------- - list[float] - The discrete mean curvature per vertex. - - Notes - ----- - This operation requires a registered plugin. - - """ - raise NotImplementedError diff --git a/src/compas/geometry/trimesh/descent.py b/src/compas/geometry/trimesh/descent.py index 04805c2a25a2..cdbee9e0ea4b 100644 --- a/src/compas/geometry/trimesh/descent.py +++ b/src/compas/geometry/trimesh/descent.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING -from compas._typing import MeshType +from compas.typing import VerticesFaces if TYPE_CHECKING: from numpy.typing import NDArray @@ -8,7 +8,7 @@ from .gradient import trimesh_gradient -def trimesh_descent(mesh: MeshType) -> "NDArray": +def trimesh_descent(mesh: VerticesFaces) -> "NDArray": """Compute the descent directions of a triangular mesh as the gradient of the vertex heights. Parameters diff --git a/src/compas/geometry/trimesh/geodistance.py b/src/compas/geometry/trimesh/geodistance.py index ec3692b90455..0992343e20a8 100644 --- a/src/compas/geometry/trimesh/geodistance.py +++ b/src/compas/geometry/trimesh/geodistance.py @@ -1,17 +1,17 @@ from typing import Literal -from .._tessellation_plugin import tessellation_pluggable -from ..tessellation import Tessellation +from compas.plugins import PluginNotInstalledError +from compas.plugins import pluggable +from compas.typing import VerticesFaces -@tessellation_pluggable(category="trimesh", inputs=("mesh",)) -def trimesh_geodistance(mesh: "Tessellation", source: int, method: Literal["exact", "heat"] = "exact") -> list[float]: +def trimesh_geodistance(mesh: VerticesFaces, source: int, method: Literal["exact", "heat"] = "exact") -> list[float]: """Compute geodesic distances from one mesh vertex. Parameters ---------- mesh - The triangle tessellation. + The triangle mesh as vertices and triangles. source Index of the source vertex. method @@ -28,4 +28,9 @@ def trimesh_geodistance(mesh: "Tessellation", source: int, method: Literal["exac method. """ - raise NotImplementedError + return _trimesh_geodistance(mesh, source, method) + + +@pluggable(category="trimesh") +def _trimesh_geodistance(mesh: VerticesFaces, source: int, method: Literal["exact", "heat"] = "exact") -> list[float]: + raise PluginNotInstalledError diff --git a/src/compas/geometry/trimesh/gradient.py b/src/compas/geometry/trimesh/gradient.py index d0ecad79f637..051cd047fdd0 100644 --- a/src/compas/geometry/trimesh/gradient.py +++ b/src/compas/geometry/trimesh/gradient.py @@ -2,7 +2,7 @@ from typing import Literal from typing import overload -from compas._typing import MeshType +from compas.typing import VerticesFaces if TYPE_CHECKING: from numpy.typing import NDArray @@ -11,18 +11,18 @@ @overload -def trimesh_gradient(mesh: MeshType, rtype: Literal["array"] = "array") -> "NDArray": ... +def trimesh_gradient(mesh: VerticesFaces, rtype: Literal["array"] = "array") -> "NDArray": ... @overload -def trimesh_gradient(mesh: MeshType, rtype: Literal["list"]) -> list[list[float]]: ... +def trimesh_gradient(mesh: VerticesFaces, rtype: Literal["list"]) -> list[list[float]]: ... @overload -def trimesh_gradient(mesh: MeshType, rtype: Literal["csc", "csr", "coo"]) -> "spmatrix | sparray": ... +def trimesh_gradient(mesh: VerticesFaces, rtype: Literal["csc", "csr", "coo"]) -> "spmatrix | sparray": ... -def trimesh_gradient(mesh: MeshType, rtype: str = "array") -> "NDArray | spmatrix | sparray | list[list[float]]": +def trimesh_gradient(mesh: VerticesFaces, rtype: str = "array") -> "NDArray | spmatrix | sparray | list[list[float]]": """Construct the gradient operator of a triangular mesh. Parameters diff --git a/src/compas/geometry/trimesh/isolines.py b/src/compas/geometry/trimesh/isolines.py index 8a8abdb6e3ab..ebb8275d31f5 100644 --- a/src/compas/geometry/trimesh/isolines.py +++ b/src/compas/geometry/trimesh/isolines.py @@ -1,13 +1,12 @@ -from collections.abc import Sequence +from compas.plugins import PluginNotInstalledError +from compas.plugins import pluggable +from compas.typing import FloatSequenceLike +from compas.typing import VerticesFaces -from .._tessellation_plugin import tessellation_pluggable -from ..tessellation import Tessellation - -@tessellation_pluggable(category="trimesh", inputs=("mesh",)) def trimesh_isolines( - mesh: "Tessellation", - scalarfield: Sequence[float], + mesh: VerticesFaces, + scalarfield: FloatSequenceLike, number_of_isolines: int = 50, ) -> tuple[list[list[float]], list[list[int]]]: """Compute isolines of a scalar field on a triangle mesh. @@ -15,7 +14,7 @@ def trimesh_isolines( Parameters ---------- mesh - The triangle tessellation. + The triangle mesh as vertices and triangles. scalarfield Scalar value at every mesh vertex. number_of_isolines @@ -32,4 +31,13 @@ def trimesh_isolines( This operation requires a registered plugin. """ - raise NotImplementedError + return _trimesh_isolines(mesh, scalarfield, number_of_isolines) + + +@pluggable(category="trimesh") +def _trimesh_isolines( + mesh: VerticesFaces, + scalarfield: FloatSequenceLike, + number_of_isolines: int = 50, +) -> tuple[list[list[float]], list[list[int]]]: + raise PluginNotInstalledError diff --git a/src/compas/geometry/trimesh/matrices.py b/src/compas/geometry/trimesh/matrices.py index 86b3ecc0face..4a83cf93736c 100644 --- a/src/compas/geometry/trimesh/matrices.py +++ b/src/compas/geometry/trimesh/matrices.py @@ -6,9 +6,10 @@ from compas.linalg.vectors import cross_vectors from compas.linalg.vectors import dot_vectors from compas.linalg.vectors import length_vector - -from .._tessellation_plugin import tessellation_pluggable -from ..tessellation import Tessellation +from compas.plugins import PluginNotInstalledError +from compas.plugins import pluggable +from compas.typing import RealInput +from compas.typing import VerticesFaces if TYPE_CHECKING: from numpy.typing import NDArray @@ -24,14 +25,13 @@ from compas.datastructures.mesh.types import Edge -@tessellation_pluggable(category="trimesh", inputs=("mesh",)) -def trimesh_massmatrix(mesh: "Tessellation") -> list[float]: +def trimesh_massmatrix(mesh: VerticesFaces) -> list[float]: """Compute the per-vertex mass matrix of a triangle mesh. Parameters ---------- mesh - The triangle tessellation. + The triangle mesh as vertices and triangles. Returns ------- @@ -43,10 +43,15 @@ def trimesh_massmatrix(mesh: "Tessellation") -> list[float]: This operation requires a registered plugin. """ - raise NotImplementedError + return _trimesh_massmatrix(mesh) + + +@pluggable(category="trimesh") +def _trimesh_massmatrix(mesh: VerticesFaces) -> list[float]: + raise PluginNotInstalledError -def trimesh_edge_cotangent(mesh: "Mesh", edge: "Edge") -> float: +def trimesh_edge_cotangent(mesh: "Mesh", edge: "Edge") -> RealInput: """Compute the cotangent of the angle opposite a halfedge of the triangle mesh. Parameters @@ -76,7 +81,7 @@ def trimesh_edge_cotangent(mesh: "Mesh", edge: "Edge") -> float: return cotangent -def trimesh_edge_cotangents(mesh: "Mesh", edge: "Edge") -> tuple[float, float]: +def trimesh_edge_cotangents(mesh: "Mesh", edge: "Edge") -> tuple[RealInput, RealInput]: """Compute the cotangents of the angles opposite both sides of an edge of the triangle mesh. Parameters diff --git a/src/compas/geometry/trimesh/parametrisation.py b/src/compas/geometry/trimesh/parametrisation.py index b10f59eddf78..1fa605bc48bf 100644 --- a/src/compas/geometry/trimesh/parametrisation.py +++ b/src/compas/geometry/trimesh/parametrisation.py @@ -1,15 +1,15 @@ -from .._tessellation_plugin import tessellation_pluggable -from ..tessellation import Tessellation +from compas.plugins import PluginNotInstalledError +from compas.plugins import pluggable +from compas.typing import VerticesFaces -@tessellation_pluggable(category="trimesh", inputs=("mesh",)) -def trimesh_harmonic(mesh: "Tessellation") -> list[list[float]]: +def trimesh_harmonic(mesh: VerticesFaces) -> list[list[float]]: """Compute a harmonic parametrisation with a fixed circular boundary. Parameters ---------- mesh - The triangle tessellation. + The triangle mesh as vertices and triangles. Returns ------- @@ -21,17 +21,21 @@ def trimesh_harmonic(mesh: "Tessellation") -> list[list[float]]: This operation requires a registered plugin. """ - raise NotImplementedError + return _trimesh_harmonic(mesh) -@tessellation_pluggable(category="trimesh", inputs=("mesh",)) -def trimesh_lscm(mesh: "Tessellation") -> list[list[float]]: +@pluggable(category="trimesh") +def _trimesh_harmonic(mesh: VerticesFaces) -> list[list[float]]: + raise PluginNotInstalledError + + +def trimesh_lscm(mesh: VerticesFaces) -> list[list[float]]: """Compute a least-squares conformal parametrisation. Parameters ---------- mesh - The triangle tessellation. + The triangle mesh as vertices and triangles. Returns ------- @@ -43,4 +47,9 @@ def trimesh_lscm(mesh: "Tessellation") -> list[list[float]]: This operation requires a registered plugin. """ - raise NotImplementedError + return _trimesh_lscm(mesh) + + +@pluggable(category="trimesh") +def _trimesh_lscm(mesh: VerticesFaces) -> list[list[float]]: + raise PluginNotInstalledError diff --git a/src/compas/geometry/trimesh/pull_points.py b/src/compas/geometry/trimesh/pull_points.py index 89dc64058c38..16c2ccdd6bc3 100644 --- a/src/compas/geometry/trimesh/pull_points.py +++ b/src/compas/geometry/trimesh/pull_points.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING -from compas._typing import CoordinatesType -from compas._typing import MeshType +from compas.typing import PointsLike +from compas.typing import VerticesFaces if TYPE_CHECKING: from numpy.typing import NDArray @@ -69,7 +69,7 @@ def _closest_point_on_triangle(point: "NDArray", triangle: "NDArray") -> "NDArra return a + vb * denominator * ab + vc * denominator * ac -def trimesh_pull_points(mesh: MeshType, points: CoordinatesType) -> list[list[float]]: +def trimesh_pull_points(mesh: VerticesFaces, points: PointsLike) -> list[list[float]]: """Compute the closest point on a triangle mesh for each input point. Parameters diff --git a/src/compas/geometry/trimesh/remeshing.py b/src/compas/geometry/trimesh/remeshing.py index e4283a909012..2f064f645ddd 100644 --- a/src/compas/geometry/trimesh/remeshing.py +++ b/src/compas/geometry/trimesh/remeshing.py @@ -1,22 +1,22 @@ from collections.abc import Sequence -from .._tessellation_plugin import tessellation_pluggable -from ..tessellation import Tessellation +from compas.plugins import pluggable +from compas.typing import FloatSequenceLike +from compas.typing import VerticesFaces -@tessellation_pluggable(category="trimesh", inputs=("mesh",), result=True) def trimesh_remesh( - mesh: "Tessellation", + mesh: VerticesFaces, target_edge_length: float, number_of_iterations: int = 10, do_project: bool = True, -) -> "Tessellation": +) -> VerticesFaces: """Remesh a triangle mesh toward a uniform target edge length. Parameters ---------- mesh - The triangle tessellation. + The triangle mesh as vertices and triangles. target_edge_length Target length of mesh edges. number_of_iterations @@ -27,33 +27,43 @@ def trimesh_remesh( Returns ------- - Tessellation - The remeshed triangle geometry. + tuple[Sequence[Sequence[float]], Sequence[Sequence[int]]] + The vertices and triangles of the remeshed geometry. Notes ----- - This operation requires a registered plugin. Use + This operation requires a registered plugin. Core dispatches structural + buffers without converting or pre-validating them. Use [`trimesh_remesh_constrained`][compas.geometry.trimesh_remesh_constrained] when specific edges must be preserved. """ + return _trimesh_remesh(mesh, target_edge_length, number_of_iterations, do_project) + + +@pluggable(category="trimesh") +def _trimesh_remesh( + mesh: VerticesFaces, + target_edge_length: float, + number_of_iterations: int = 10, + do_project: bool = True, +) -> VerticesFaces: raise NotImplementedError -@tessellation_pluggable(category="trimesh", inputs=("mesh",), result=True) def trimesh_remesh_constrained( - mesh: "Tessellation", + mesh: VerticesFaces, target_edge_length: float, protected_edges: Sequence[Sequence[int]], number_of_iterations: int = 10, do_project: bool = True, -) -> "Tessellation": +) -> VerticesFaces: """Remesh a triangle mesh while preserving selected edges. Parameters ---------- mesh - The triangle tessellation. + The triangle mesh as vertices and triangles. target_edge_length Target length of mesh edges. protected_edges @@ -66,25 +76,40 @@ def trimesh_remesh_constrained( Returns ------- - Tessellation - The remeshed triangle geometry. + tuple[Sequence[Sequence[float]], Sequence[Sequence[int]]] + The vertices and triangles of the remeshed geometry. Notes ----- - This operation requires a registered plugin. + This operation requires a registered plugin. Core dispatches structural + buffers without converting or pre-validating them. """ + return _trimesh_remesh_constrained(mesh, target_edge_length, protected_edges, number_of_iterations, do_project) + + +@pluggable(category="trimesh") +def _trimesh_remesh_constrained( + mesh: VerticesFaces, + target_edge_length: float, + protected_edges: Sequence[Sequence[int]], + number_of_iterations: int = 10, + do_project: bool = True, +) -> VerticesFaces: raise NotImplementedError -@tessellation_pluggable(category="trimesh", inputs=("mesh",), result=True) -def trimesh_remesh_along_isoline(mesh: "Tessellation", scalarfield: Sequence[float], scalar: float) -> "Tessellation": +def trimesh_remesh_along_isoline( + mesh: VerticesFaces, + scalarfield: FloatSequenceLike, + scalar: float, +) -> VerticesFaces: """Remesh a triangle mesh so an isoline follows mesh edges. Parameters ---------- mesh - The triangle tessellation. + The triangle mesh as vertices and triangles. scalarfield Scalar value at every mesh vertex. scalar @@ -92,13 +117,23 @@ def trimesh_remesh_along_isoline(mesh: "Tessellation", scalarfield: Sequence[flo Returns ------- - Tessellation - The remeshed triangle geometry. + tuple[Sequence[Sequence[float]], Sequence[Sequence[int]]] + The vertices and triangles of the remeshed geometry. Notes ----- - This operation requires a registered plugin. Backend-internal face labels - are not part of the initial public result contract. + This operation requires a registered plugin. Core dispatches structural + buffers without converting or pre-validating them. Backend-internal face + labels are not part of the public result contract. """ + return _trimesh_remesh_along_isoline(mesh, scalarfield, scalar) + + +@pluggable(category="trimesh") +def _trimesh_remesh_along_isoline( + mesh: VerticesFaces, + scalarfield: FloatSequenceLike, + scalar: float, +) -> VerticesFaces: raise NotImplementedError diff --git a/src/compas/geometry/trimesh/sample_points.py b/src/compas/geometry/trimesh/sample_points.py index 0e2eece81add..a476394084f6 100644 --- a/src/compas/geometry/trimesh/sample_points.py +++ b/src/compas/geometry/trimesh/sample_points.py @@ -2,26 +2,26 @@ from typing import Literal from typing import overload -from compas._typing import MeshType +from compas.typing import VerticesFaces if TYPE_CHECKING: from numpy.typing import NDArray @overload -def trimesh_sample_points(mesh: MeshType, num_points: int = 1000, return_normals: Literal[False] = False) -> "NDArray": ... +def trimesh_sample_points(mesh: VerticesFaces, num_points: int = 1000, return_normals: Literal[False] = False) -> "NDArray": ... @overload -def trimesh_sample_points(mesh: MeshType, num_points: int, return_normals: Literal[True]) -> "tuple[NDArray, NDArray]": ... +def trimesh_sample_points(mesh: VerticesFaces, num_points: int, return_normals: Literal[True]) -> "tuple[NDArray, NDArray]": ... @overload -def trimesh_sample_points(mesh: MeshType, num_points: int, return_normals: bool) -> "NDArray | tuple[NDArray, NDArray]": ... +def trimesh_sample_points(mesh: VerticesFaces, num_points: int, return_normals: bool) -> "NDArray | tuple[NDArray, NDArray]": ... def trimesh_sample_points( - mesh: MeshType, + mesh: VerticesFaces, num_points: int = 1000, return_normals: bool = False, ) -> "NDArray | tuple[NDArray, NDArray]": diff --git a/src/compas/geometry/trimesh/slicing.py b/src/compas/geometry/trimesh/slicing.py index 411b12da92c4..9df41b7c29fc 100644 --- a/src/compas/geometry/trimesh/slicing.py +++ b/src/compas/geometry/trimesh/slicing.py @@ -1,30 +1,34 @@ from collections.abc import Sequence from compas.geometry._typing import PlaneType +from compas.plugins import pluggable +from compas.typing import PointGroupsLike +from compas.typing import VerticesFaces -from .._tessellation_plugin import tessellation_pluggable -from ..tessellation import Tessellation - -@tessellation_pluggable(category="trimesh", inputs=("mesh",)) -def trimesh_slice(mesh: "Tessellation", planes: Sequence[PlaneType]) -> list[list[list[float]]]: +def trimesh_slice(mesh: VerticesFaces, planes: Sequence[PlaneType]) -> PointGroupsLike: """Slice a triangle mesh with multiple planes. Parameters ---------- mesh - The triangle tessellation. + The triangle mesh as vertices and triangles. planes The slicing planes. Returns ------- - list[list[list[float]]] - Slice polylines represented by lists of point coordinates. + PointGroupsLike + Slice polyline coordinate buffers. Notes ----- This operation requires a registered plugin. """ + return _trimesh_slice(mesh, planes) + + +@pluggable(category="trimesh") +def _trimesh_slice(mesh: VerticesFaces, planes: Sequence[PlaneType]) -> PointGroupsLike: raise NotImplementedError diff --git a/src/compas/geometry/vector.py b/src/compas/geometry/vector.py index c00c6adfd1d7..0639b15aacc7 100644 --- a/src/compas/geometry/vector.py +++ b/src/compas/geometry/vector.py @@ -1,9 +1,12 @@ from collections.abc import Iterator from collections.abc import Sequence from typing import Self +from typing import SupportsFloat from typing import overload -from compas._typing import Coordinate +from numpy import floating +from numpy import integer + from compas.geometry import Geometry from compas.geometry import angle_vectors from compas.geometry import angle_vectors_signed @@ -14,12 +17,15 @@ from compas.linalg.vectors import length_vector from compas.linalg.vectors import subtract_vectors from compas.tolerance import TOL +from compas.typing import CoordinateProtocol +from compas.typing import PointLike +from compas.typing import RealInput +from compas.typing import VectorLike -from ._typing import CoordinateType from ._typing import TransformationType -class Vector(Geometry): +class Vector(Geometry, CoordinateProtocol[float]): """A vector is defined by XYZ components and a homogenisation factor. Parameters @@ -132,7 +138,7 @@ def __from_data__(cls, data: Sequence[float]) -> Self: # type: ignore[override] # Dunder methods # ========================================================================== - def __init__(self, x: float, y: float, z: float = 0.0, name: str | None = None) -> None: + def __init__(self, x: SupportsFloat, y: SupportsFloat, z: SupportsFloat = 0.0, name: str | None = None) -> None: super().__init__(name=name) self._x = 0.0 self._y = 0.0 @@ -179,7 +185,7 @@ def __getitem__(self, key: int | slice) -> float | list[float]: return self.z raise IndexError("Vector index out of range.") - def __setitem__(self, key: int, value: float) -> None: + def __setitem__(self, key: int, value: SupportsFloat) -> None: if key == 0 or key == -3: self.x = value return @@ -195,11 +201,11 @@ def __iter__(self) -> Iterator[float]: return iter([self.x, self.y, self.z]) def __eq__(self, other: object) -> bool: - if not isinstance(other, Coordinate) or len(other) != 3: + if not isinstance(other, CoordinateProtocol) or len(other) != 3: return False return TOL.is_allclose(self, other) - def __add__(self, other: CoordinateType) -> "Vector": + def __add__(self, other: VectorLike) -> "Vector": """Return the coordinate-wise sum of this vector and another coordinate. Examples @@ -210,7 +216,7 @@ def __add__(self, other: CoordinateType) -> "Vector": """ return Vector(self.x + other[0], self.y + other[1], self.z + other[2]) - def __sub__(self, other: CoordinateType) -> "Vector": + def __sub__(self, other: VectorLike) -> "Vector": """Return the coordinate-wise difference with another coordinate. Examples @@ -221,7 +227,7 @@ def __sub__(self, other: CoordinateType) -> "Vector": """ return Vector(self.x - other[0], self.y - other[1], self.z - other[2]) - def __mul__(self, other: float | CoordinateType) -> "Vector": + def __mul__(self, other: RealInput | VectorLike) -> "Vector": """Multiply by a scalar or multiply component-wise by another coordinate. Examples @@ -230,7 +236,7 @@ def __mul__(self, other: float | CoordinateType) -> "Vector": [2.0, 6.0, 12.0] """ - if isinstance(other, (int, float)): + if isinstance(other, (int, float, integer, floating)): return Vector(self.x * other, self.y * other, self.z * other) try: @@ -239,7 +245,7 @@ def __mul__(self, other: float | CoordinateType) -> "Vector": except TypeError: raise TypeError("Cannot cast {} {} to Vector".format(other, type(other))) - def __truediv__(self, other: float | CoordinateType) -> "Vector": + def __truediv__(self, other: RealInput | VectorLike) -> "Vector": """Divide by a scalar or divide component-wise by another coordinate. Examples @@ -248,7 +254,7 @@ def __truediv__(self, other: float | CoordinateType) -> "Vector": [1.0, 2.0, 3.0] """ - if isinstance(other, (int, float)): + if isinstance(other, (int, float, integer, floating)): return Vector(self.x / other, self.y / other, self.z / other) try: @@ -263,7 +269,7 @@ def __pow__(self, n: float) -> "Vector": def __neg__(self) -> Self: return self.scaled(-1.0) - def __iadd__(self, other: CoordinateType) -> Self: + def __iadd__(self, other: VectorLike) -> Self: """Add another coordinate to this vector in place and return this vector. Examples @@ -279,7 +285,7 @@ def __iadd__(self, other: CoordinateType) -> Self: self.z += other[2] return self - def __isub__(self, other: CoordinateType) -> Self: + def __isub__(self, other: VectorLike) -> Self: """Subtract another coordinate from this vector in place and return this vector. Examples @@ -295,7 +301,7 @@ def __isub__(self, other: CoordinateType) -> Self: self.z -= other[2] return self - def __imul__(self, n: float) -> Self: + def __imul__(self, n: RealInput) -> Self: """Multiply this vector by a scalar in place and return this vector. Examples @@ -311,7 +317,7 @@ def __imul__(self, n: float) -> Self: self.z *= n return self - def __itruediv__(self, n: float) -> Self: + def __itruediv__(self, n: RealInput) -> Self: """Divide this vector by a scalar in place and return this vector. Examples @@ -327,13 +333,13 @@ def __itruediv__(self, n: float) -> Self: self.z /= n return self - def __ipow__(self, n: float) -> Self: + def __ipow__(self, n: RealInput) -> Self: self.x **= n self.y **= n self.z **= n return self - def __rmul__(self, other: float | CoordinateType) -> "Vector": + def __rmul__(self, other: RealInput | VectorLike) -> "Vector": """Multiply by a scalar or component-wise coordinate on the left. Examples @@ -344,7 +350,7 @@ def __rmul__(self, other: float | CoordinateType) -> "Vector": """ return self.__mul__(other) - def __radd__(self, other: CoordinateType) -> "Vector": + def __radd__(self, other: VectorLike) -> "Vector": """Return the coordinate-wise sum with another coordinate on the left. Examples @@ -355,7 +361,7 @@ def __radd__(self, other: CoordinateType) -> "Vector": """ return self.__add__(other) - def __rsub__(self, other: CoordinateType) -> "Vector": + def __rsub__(self, other: VectorLike) -> "Vector": """Subtract this vector component-wise from a coordinate on the left. Examples @@ -370,7 +376,7 @@ def __rsub__(self, other: CoordinateType) -> "Vector": except TypeError: raise TypeError("Cannot cast {} {} to Vector".format(other, type(other))) - def __rtruediv__(self, other: CoordinateType) -> "Vector": + def __rtruediv__(self, other: VectorLike) -> "Vector": """Divide a coordinate on the left component-wise by this vector. Examples @@ -394,7 +400,7 @@ def x(self) -> float: return self._x @x.setter - def x(self, x: float) -> None: + def x(self, x: SupportsFloat) -> None: self._x = float(x) self._direction = None self._magnitude = None @@ -404,7 +410,7 @@ def y(self) -> float: return self._y @y.setter - def y(self, y: float) -> None: + def y(self, y: SupportsFloat) -> None: self._y = float(y) self._direction = None self._magnitude = None @@ -414,7 +420,7 @@ def z(self) -> float: return self._z @z.setter - def z(self, z: float) -> None: + def z(self, z: SupportsFloat) -> None: self._z = float(z) self._direction = None self._magnitude = None @@ -491,7 +497,7 @@ def Zaxis(cls) -> Self: return cls(0.0, 0.0, 1.0) @classmethod - def from_start_end(cls, start: CoordinateType, end: CoordinateType) -> Self: + def from_start_end(cls, start: PointLike, end: PointLike) -> Self: """Construct a vector from start and end points. Parameters @@ -522,7 +528,7 @@ def from_start_end(cls, start: CoordinateType, end: CoordinateType) -> Self: # ========================================================================== @staticmethod - def length_vectors(vectors: Sequence[CoordinateType]) -> list[float]: + def length_vectors(vectors: Sequence[VectorLike]) -> list[float]: """Compute the length of multiple vectors. Parameters @@ -545,7 +551,7 @@ def length_vectors(vectors: Sequence[CoordinateType]) -> list[float]: return [length_vector(vector) for vector in vectors] @staticmethod - def sum_vectors(vectors: Sequence[CoordinateType]) -> "Vector": + def sum_vectors(vectors: Sequence[VectorLike]) -> "Vector": """Compute the sum of multiple vectors. Parameters @@ -570,7 +576,7 @@ def sum_vectors(vectors: Sequence[CoordinateType]) -> "Vector": return Vector(data[0], data[1], z) @staticmethod - def dot_vectors(left: Sequence[CoordinateType], right: Sequence[CoordinateType]) -> list[float]: + def dot_vectors(left: Sequence[VectorLike], right: Sequence[VectorLike]) -> list[RealInput]: """Compute the dot product of two lists of vectors. Parameters @@ -595,7 +601,7 @@ def dot_vectors(left: Sequence[CoordinateType], right: Sequence[CoordinateType]) return [dot_vectors(u, v) for u, v in zip(left, right)] @staticmethod - def cross_vectors(left: Sequence[CoordinateType], right: Sequence[CoordinateType]) -> list["Vector"]: + def cross_vectors(left: Sequence[VectorLike], right: Sequence[VectorLike]) -> list["Vector"]: """Compute the cross product of two lists of vectors. Parameters @@ -625,7 +631,7 @@ def cross_vectors(left: Sequence[CoordinateType], right: Sequence[CoordinateType return vectors @staticmethod - def angles_vectors(left: Sequence[CoordinateType], right: Sequence[CoordinateType]) -> list[tuple[float, float]]: + def angles_vectors(left: Sequence[VectorLike], right: Sequence[VectorLike]) -> list[tuple[float, float]]: """Compute both angles between corresponding pairs of two lists of vectors. Parameters @@ -650,7 +656,7 @@ def angles_vectors(left: Sequence[CoordinateType], right: Sequence[CoordinateTyp return [angles_vectors(u, v) for u, v in zip(left, right)] @staticmethod - def angle_vectors(left: Sequence[CoordinateType], right: Sequence[CoordinateType]) -> list[float]: + def angle_vectors(left: Sequence[VectorLike], right: Sequence[VectorLike]) -> list[float]: """Compute the smallest angle between corresponding pairs of two lists of vectors. Parameters @@ -791,7 +797,13 @@ def inverted(self) -> Self: flipped = inverted - def dot(self, other: CoordinateType) -> float: + @overload + def dot(self, other: CoordinateProtocol[float]) -> float: ... + + @overload + def dot(self, other: VectorLike) -> RealInput: ... + + def dot(self, other: VectorLike) -> RealInput: """The dot product of this vector and another vector. Parameters @@ -814,7 +826,7 @@ def dot(self, other: CoordinateType) -> float: """ return dot_vectors(self, other) - def cross(self, other: CoordinateType) -> "Vector": + def cross(self, other: VectorLike) -> "Vector": """The cross product of this vector and another vector. Parameters @@ -839,7 +851,7 @@ def cross(self, other: CoordinateType) -> "Vector": coordinates = cross_vectors(self, other) return Vector(coordinates[0], coordinates[1], coordinates[2]) - def angle(self, other: CoordinateType, degrees: bool = False) -> float: + def angle(self, other: VectorLike, degrees: bool = False) -> float: """Compute the smallest angle between this vector and another vector. Parameters @@ -865,7 +877,7 @@ def angle(self, other: CoordinateType, degrees: bool = False) -> float: """ return angle_vectors(self, other, deg=degrees) - def angle_signed(self, other: CoordinateType, normal: CoordinateType) -> float: + def angle_signed(self, other: VectorLike, normal: VectorLike) -> float: """Compute the signed angle between this vector and another vector. Parameters @@ -893,7 +905,7 @@ def angle_signed(self, other: CoordinateType, normal: CoordinateType) -> float: """ return angle_vectors_signed(self, other, normal) - def angles(self, other: CoordinateType) -> tuple[float, float]: + def angles(self, other: VectorLike) -> tuple[float, float]: """Compute both angles between this vector and another vector. Parameters @@ -917,7 +929,7 @@ def angles(self, other: CoordinateType) -> tuple[float, float]: """ return angles_vectors(self, other) - def component(self, other: CoordinateType) -> "Vector": + def component(self, other: VectorLike) -> "Vector": """Compute the component of this vector in the direction of another vector. Parameters @@ -941,7 +953,7 @@ def component(self, other: CoordinateType) -> "Vector": # Transformations # ========================================================================== - def scale(self, x: float, y: float | None = None, z: float | None = None) -> None: + def scale(self, x: RealInput, y: RealInput | None = None, z: RealInput | None = None) -> None: """Scale this vector by one or more factors. Parameters @@ -971,7 +983,7 @@ def scale(self, x: float, y: float | None = None, z: float | None = None) -> Non self.y *= y self.z *= z - def scaled(self, x: float, y: float | None = None, z: float | None = None) -> Self: + def scaled(self, x: RealInput, y: RealInput | None = None, z: RealInput | None = None) -> Self: """Return a scaled copy of this vector. Parameters diff --git a/src/compas/linalg/matrices.py b/src/compas/linalg/matrices.py index 9e45d530e8d6..dd7c94350a12 100644 --- a/src/compas/linalg/matrices.py +++ b/src/compas/linalg/matrices.py @@ -1,8 +1,9 @@ from collections.abc import Iterable from collections.abc import Sequence -from compas._typing import FloatSequenceType from compas.tolerance import TOL +from compas.typing import FloatSequenceLike +from compas.typing import RealInput from .vectors import dot_vectors @@ -11,7 +12,7 @@ # ============================================================================= -def transpose_matrix(M: Iterable[Iterable[float]]) -> list[list[float]]: +def transpose_matrix(M: Iterable[Iterable[RealInput]]) -> list[list[RealInput]]: """Transpose a matrix. Parameters @@ -21,14 +22,14 @@ def transpose_matrix(M: Iterable[Iterable[float]]) -> list[list[float]]: Returns ------- - list[list[float]] + list[list[RealInput]] The result matrix. """ return list(map(list, zip(*list(M)))) -def multiply_matrices(A: Sequence[Sequence[float]], B: Sequence[Sequence[float]]) -> list[list[float]]: +def multiply_matrices(A: Sequence[Sequence[RealInput]], B: Sequence[Sequence[RealInput]]) -> list[list[RealInput]]: r"""Mutliply a matrix with a matrix. Parameters @@ -40,7 +41,7 @@ def multiply_matrices(A: Sequence[Sequence[float]], B: Sequence[Sequence[float]] Returns ------- - list[list[float]] + list[list[RealInput]] The result matrix. Raises @@ -79,7 +80,7 @@ def multiply_matrices(A: Sequence[Sequence[float]], B: Sequence[Sequence[float]] return [[dot_vectors(row, col) for col in B] for row in A] -def multiply_matrix_vector(A: Sequence[Sequence[float]], b: FloatSequenceType) -> list[float]: +def multiply_matrix_vector(A: Sequence[Sequence[RealInput]], b: FloatSequenceLike) -> list[RealInput]: r"""Multiply a matrix with a vector. Parameters @@ -124,7 +125,7 @@ def multiply_matrix_vector(A: Sequence[Sequence[float]], b: FloatSequenceType) - return [dot_vectors(row, b) for row in A] -def is_matrix_square(M: Sequence[Sequence[float]]) -> bool: +def is_matrix_square(M: Sequence[Sequence[RealInput]]) -> bool: """Verify that a matrix is square. Parameters @@ -153,7 +154,7 @@ def is_matrix_square(M: Sequence[Sequence[float]]) -> bool: return True -def matrix_minor(M: Sequence[Sequence[float]], i: int, j: int) -> list[list[float]]: +def matrix_minor(M: Sequence[Sequence[RealInput]], i: int, j: int) -> list[list[RealInput]]: """Construct the minor corresponding to an element of a matrix. Parameters @@ -167,7 +168,7 @@ def matrix_minor(M: Sequence[Sequence[float]], i: int, j: int) -> list[list[floa Returns ------- - list[list[float]] + list[list[RealInput]] The minor. See Also @@ -179,7 +180,7 @@ def matrix_minor(M: Sequence[Sequence[float]], i: int, j: int) -> list[list[floa return [list(row[:j]) + list(row[j + 1 :]) for index, row in enumerate(M) if index != i] -def matrix_determinant(M: Sequence[Sequence[float]], check: bool = True) -> float: +def matrix_determinant(M: Sequence[Sequence[RealInput]], check: bool = True) -> RealInput: """Calculate the determinant of a square matrix. Parameters @@ -233,7 +234,7 @@ def matrix_determinant(M: Sequence[Sequence[float]], check: bool = True) -> floa return D -def matrix_inverse(M: Sequence[Sequence[float]]) -> list[list[float]]: +def matrix_inverse(M: Sequence[Sequence[RealInput]]) -> list[list[RealInput]]: """Calculate the inverse of a square matrix. Parameters @@ -243,7 +244,7 @@ def matrix_inverse(M: Sequence[Sequence[float]]) -> list[list[float]]: Returns ------- - list[list[float]] + list[list[RealInput]] The inverse matrix. The inverse of the 0x0 matrix is the 0x0 matrix. Raises diff --git a/src/compas/linalg/quaternions.py b/src/compas/linalg/quaternions.py index c05433a34748..7ba01fde29b1 100644 --- a/src/compas/linalg/quaternions.py +++ b/src/compas/linalg/quaternions.py @@ -1,8 +1,12 @@ import math from collections.abc import Sequence -from compas._typing import FloatSequenceType +import numpy as np + from compas.tolerance import TOL +from compas.typing import FloatSequenceLike +from compas.typing import RealInput +from compas.typing import VectorLike from .transformations import axis_and_angle_from_matrix from .transformations import euler_angles_from_matrix @@ -12,7 +16,7 @@ from .transformations import quaternion_from_matrix -def quaternion_norm(q: FloatSequenceType) -> float: +def quaternion_norm(q: FloatSequenceLike) -> float: """Calculates the length (euclidean norm) of a quaternion. Parameters @@ -71,7 +75,7 @@ def quaternion_unitize(q: Sequence[float]) -> list[float]: return [x / n for x in q] -def quaternion_is_unit(q: FloatSequenceType, tol: float | None = None) -> bool: +def quaternion_is_unit(q: FloatSequenceLike, tol: float | None = None) -> bool | np.bool_: """Checks if a quaternion is unit-length. Parameters @@ -205,7 +209,7 @@ def quaternion_conjugate(q: Sequence[float]) -> list[float]: return [q[0], -q[1], -q[2], -q[3]] -def quaternion_from_euler_angles(e: Sequence[float], static: bool = True, axes: str = "xyz") -> list[float]: +def quaternion_from_euler_angles(e: Sequence[float], static: bool = True, axes: str = "xyz") -> list[RealInput]: """Returns a quaternion from Euler angles. Parameters @@ -253,7 +257,7 @@ def euler_angles_from_quaternion(q: Sequence[float], static: bool = True, axes: return e -def quaternion_from_axis_angle(axis: Sequence[float], angle: float) -> list[float]: +def quaternion_from_axis_angle(axis: VectorLike, angle: float) -> list[RealInput]: """Returns a quaternion describing a rotation around the given axis by the given angle. Parameters @@ -283,7 +287,7 @@ def quaternion_from_axis_angle(axis: Sequence[float], angle: float) -> list[floa return q -def axis_angle_from_quaternion(q: Sequence[float]) -> tuple[list[float], float]: +def axis_angle_from_quaternion(q: FloatSequenceLike) -> tuple[list[RealInput], float]: """Returns an axis and an angle of rotation from the given quaternion. Parameters diff --git a/src/compas/linalg/transformations.py b/src/compas/linalg/transformations.py index 89f4c1c409aa..b9df6b02acee 100644 --- a/src/compas/linalg/transformations.py +++ b/src/compas/linalg/transformations.py @@ -9,11 +9,14 @@ from math import sin from math import sqrt from math import tan +from typing import SupportsFloat -from compas._typing import CoordinatesType -from compas._typing import CoordinateType -from compas._typing import FloatSequenceType from compas.tolerance import TOL +from compas.typing import FloatSequenceLike +from compas.typing import PointLike +from compas.typing import PointsLike +from compas.typing import RealInput +from compas.typing import VectorLike from .matrices import matrix_determinant from .matrices import matrix_inverse @@ -63,8 +66,8 @@ def decompose_matrix( - M: Sequence[Sequence[float]], -) -> tuple[list[float], list[float], list[float], list[float], list[float]]: + M: Sequence[Sequence[RealInput]], +) -> tuple[list[RealInput], list[RealInput], list[float], list[RealInput], list[RealInput]]: """Calculates the components of rotation, translation, scale, shear, and perspective of a given transformation matrix `M`.[^decompose-matrix-slabaugh] @@ -127,7 +130,7 @@ def decompose_matrix( Mt[i][j] /= Mt[3][3] # copy Mt[:3, :3] into row - row = [ + row: list[list[RealInput]] = [ [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], @@ -140,8 +143,8 @@ def decompose_matrix( translation = [M[0][3], M[1][3], M[2][3]] # scale, shear, angles - scale = [0.0, 0.0, 0.0] - shear = [0.0, 0.0, 0.0] + scale: list[RealInput] = [0.0, 0.0, 0.0] + shear: list[RealInput] = [0.0, 0.0, 0.0] angles = [0.0, 0.0, 0.0] scale[0] = norm_vector(row[0]) @@ -200,6 +203,7 @@ def decompose_matrix( angles = [alpha, beta, gamma] # perspective + perspective: list[RealInput] if not TOL.is_zero(Mt[0][3]) and not TOL.is_zero(Mt[1][3]) and not TOL.is_zero(Mt[2][3]): P = deepcopy(Mt) P[0][3], P[1][3], P[2][3], P[3][3] = 0.0, 0.0, 0.0, 1.0 @@ -217,7 +221,7 @@ def compose_matrix( angles: Sequence[float] | None = None, translation: Sequence[float] | None = None, perspective: Sequence[float] | None = None, -) -> list[list[float]]: +) -> list[list[RealInput]]: """Calculates a matrix from the components of scale, shear, euler_angles, translation and perspective. Parameters @@ -235,7 +239,7 @@ def compose_matrix( Returns ------- - list[list[float]] + list[list[RealInput]] The 4x4 matrix that combines the provided transformation components. See Also @@ -257,7 +261,7 @@ def compose_matrix( True """ - M = [[1.0 if i == j else 0.0 for i in range(4)] for j in range(4)] + M: list[list[RealInput]] = [[1.0 if i == j else 0.0 for i in range(4)] for j in range(4)] if perspective is not None: P = matrix_from_perspective_entries(perspective) M = multiply_matrices(M, P) @@ -279,7 +283,7 @@ def compose_matrix( return M -def identity_matrix(dim: int) -> list[list[float]]: +def identity_matrix(dim: int) -> list[list[RealInput]]: """Construct an identity matrix. Parameters @@ -289,7 +293,7 @@ def identity_matrix(dim: int) -> list[list[float]]: Returns ------- - list[list[float]] + list[list[RealInput]] A list of `dim` lists, with each list containing `dim` elements. The items on the "diagonal" are one. All other items are zero. @@ -312,10 +316,11 @@ def identity_matrix(dim: int) -> list[list[float]]: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]] """ - return [[1.0 if i == j else 0.0 for i in range(dim)] for j in range(dim)] + matrix: list[list[RealInput]] = [[1.0 if i == j else 0.0 for i in range(dim)] for j in range(dim)] + return matrix -def matrix_from_frame(frame: CoordinatesType) -> list[list[float]]: +def matrix_from_frame(frame: PointsLike) -> list[list[RealInput]]: """Computes a change of basis transformation from world XY to the frame. Parameters @@ -325,7 +330,7 @@ def matrix_from_frame(frame: CoordinatesType) -> list[list[float]]: Returns ------- - list[list[float]] + list[list[RealInput]] A 4x4 transformation matrix representing the transformation from world coordinates to frame coordinates. @@ -347,7 +352,7 @@ def matrix_from_frame(frame: CoordinatesType) -> list[list[float]]: return M -def matrix_from_frame_to_frame(frame_from: Sequence[Sequence[float]], frame_to: Sequence[Sequence[float]]) -> list[list[float]]: +def matrix_from_frame_to_frame(frame_from: Sequence[Sequence[RealInput]], frame_to: Sequence[Sequence[RealInput]]) -> list[list[RealInput]]: """Computes a transformation between two frames. This transformation allows to transform geometry from one Cartesian @@ -363,7 +368,7 @@ def matrix_from_frame_to_frame(frame_from: Sequence[Sequence[float]], frame_to: Returns ------- - list[list[float]] + list[list[RealInput]] A 4x4 transformation matrix representing the transformation from one frame to another. @@ -380,7 +385,7 @@ def matrix_from_frame_to_frame(frame_from: Sequence[Sequence[float]], frame_to: return multiply_matrices(T2, matrix_inverse(T1)) -def matrix_from_change_of_basis(frame_from: Sequence[Sequence[float]], frame_to: Sequence[Sequence[float]]) -> list[list[float]]: +def matrix_from_change_of_basis(frame_from: Sequence[Sequence[RealInput]], frame_to: Sequence[Sequence[RealInput]]) -> list[list[RealInput]]: """Computes a change of basis transformation between two frames. A basis change is essentially a remapping of geometry from one @@ -395,7 +400,7 @@ def matrix_from_change_of_basis(frame_from: Sequence[Sequence[float]], frame_to: Returns ------- - list[list[float]] + list[list[RealInput]] A 4x4 transformation matrix representing a change of basis. Examples @@ -411,7 +416,7 @@ def matrix_from_change_of_basis(frame_from: Sequence[Sequence[float]], frame_to: return multiply_matrices(matrix_inverse(T2), T1) -def matrix_from_euler_angles(euler_angles: Sequence[float], static: bool = True, axes: str = "xyz") -> list[list[float]]: +def matrix_from_euler_angles(euler_angles: FloatSequenceLike | Sequence[SupportsFloat], static: bool = True, axes: str = "xyz") -> list[list[float]]: """Calculates a rotation matrix from Euler angles. In 3D space any orientation can be achieved by composing three elemental @@ -460,11 +465,11 @@ def matrix_from_euler_angles(euler_angles: Sequence[float], static: bool = True, if frame: ai, ak = ak, ai - if parity: - ai, aj, ak = -ai, -aj, -ak si, sj, sk = sin(ai), sin(aj), sin(ak) ci, cj, ck = cos(ai), cos(aj), cos(ak) + if parity: + si, sj, sk = -si, -sj, -sk cc, cs = ci * ck, ci * sk sc, ss = si * ck, si * sk @@ -494,7 +499,7 @@ def matrix_from_euler_angles(euler_angles: Sequence[float], static: bool = True, return M -def euler_angles_from_matrix(M: Sequence[Sequence[float]], static: bool = True, axes: str = "xyz") -> list[float]: +def euler_angles_from_matrix(M: Sequence[Sequence[RealInput]], static: bool = True, axes: str = "xyz") -> list[float]: """Returns Euler angles from the rotation matrix M according to specified axis sequence and type of rotation. @@ -563,7 +568,7 @@ def euler_angles_from_matrix(M: Sequence[Sequence[float]], static: bool = True, return [ax, ay, az] -def matrix_from_axis_and_angle(axis: CoordinateType, angle: float, point: CoordinateType | None = None) -> list[list[float]]: +def matrix_from_axis_and_angle(axis: VectorLike, angle: float, point: PointLike | None = None) -> list[list[RealInput]]: """Calculates a rotation matrix from an rotation axis, an angle and an optional point of rotation. @@ -578,7 +583,7 @@ def matrix_from_axis_and_angle(axis: CoordinateType, angle: float, point: Coordi Returns ------- - list[list[float]] + list[list[RealInput]] A 4x4 transformation matrix representing a rotation. Raises @@ -638,7 +643,7 @@ def matrix_from_axis_and_angle(axis: CoordinateType, angle: float, point: Coordi return M -def matrix_from_axis_angle_vector(axis_angle_vector: CoordinateType, point: CoordinateType = (0, 0, 0)) -> list[list[float]]: +def matrix_from_axis_angle_vector(axis_angle_vector: VectorLike, point: PointLike = (0, 0, 0)) -> list[list[RealInput]]: """Calculates a rotation matrix from an axis-angle vector. Parameters @@ -651,7 +656,7 @@ def matrix_from_axis_angle_vector(axis_angle_vector: CoordinateType, point: Coor Returns ------- - list[list[float]] + list[list[RealInput]] The 4x4 transformation matrix representing a rotation. Notes @@ -675,7 +680,7 @@ def matrix_from_axis_angle_vector(axis_angle_vector: CoordinateType, point: Coor return matrix_from_axis_and_angle(axis, angle, point) -def axis_and_angle_from_matrix(M: Sequence[Sequence[float]]) -> tuple[list[float], float]: +def axis_and_angle_from_matrix(M: Sequence[Sequence[RealInput]]) -> tuple[list[RealInput], float]: """Returns the axis and the angle of the rotation matrix M. Parameters @@ -740,7 +745,7 @@ def axis_and_angle_from_matrix(M: Sequence[Sequence[float]]) -> tuple[list[float return [x, y, z], angle -def axis_angle_vector_from_matrix(M: Sequence[Sequence[float]]) -> list[float]: +def axis_angle_vector_from_matrix(M: Sequence[Sequence[RealInput]]) -> list[RealInput]: """Returns the axis-angle vector of the rotation matrix M. Parameters @@ -758,7 +763,7 @@ def axis_angle_vector_from_matrix(M: Sequence[Sequence[float]]) -> list[float]: return scale_vector(axis, angle) -def matrix_from_quaternion(quaternion: FloatSequenceType) -> list[list[float]]: +def matrix_from_quaternion(quaternion: FloatSequenceLike) -> list[list[RealInput]]: """Calculates a rotation matrix from quaternion coefficients. Parameters @@ -768,7 +773,7 @@ def matrix_from_quaternion(quaternion: FloatSequenceType) -> list[list[float]]: Returns ------- - list[list[float]] + list[list[RealInput]] The 4x4 transformation matrix representing a rotation. Raises @@ -806,7 +811,7 @@ def matrix_from_quaternion(quaternion: FloatSequenceType) -> list[list[float]]: return rotation -def quaternion_from_matrix(M: Sequence[Sequence[float]]) -> list[float]: +def quaternion_from_matrix(M: Sequence[Sequence[RealInput]]) -> list[RealInput]: """Returns the 4 quaternion coefficients from a rotation matrix. Parameters @@ -861,7 +866,7 @@ def quaternion_from_matrix(M: Sequence[Sequence[float]]) -> list[float]: return [qw, qx, qy, qz] -def matrix_from_basis_vectors(xaxis: CoordinateType, yaxis: CoordinateType) -> list[list[float]]: +def matrix_from_basis_vectors(xaxis: VectorLike, yaxis: VectorLike) -> list[list[RealInput]]: """Creates a rotation matrix from basis vectors (= orthonormal vectors). Parameters @@ -873,7 +878,7 @@ def matrix_from_basis_vectors(xaxis: CoordinateType, yaxis: CoordinateType) -> l Returns ------- - list[list[float]] + list[list[RealInput]] A 4x4 transformation matrix representing a rotation. Notes @@ -904,7 +909,7 @@ def matrix_from_basis_vectors(xaxis: CoordinateType, yaxis: CoordinateType) -> l return R -def basis_vectors_from_matrix(R: Sequence[Sequence[float]]) -> tuple[list[float], list[float]]: +def basis_vectors_from_matrix(R: Sequence[Sequence[RealInput]]) -> tuple[list[RealInput], list[RealInput]]: """Returns the basis vectors from the rotation matrix R. Parameters @@ -940,7 +945,7 @@ def basis_vectors_from_matrix(R: Sequence[Sequence[float]]) -> tuple[list[float] return xaxis, yaxis -def matrix_from_translation(translation: CoordinateType) -> list[list[float]]: +def matrix_from_translation(translation: VectorLike) -> list[list[RealInput]]: """Returns a 4x4 translation matrix in row-major order. Parameters @@ -950,7 +955,7 @@ def matrix_from_translation(translation: CoordinateType) -> list[list[float]]: Returns ------- - list[list[float]] + list[list[RealInput]] The 4x4 transformation matrix representing a translation. Notes @@ -975,7 +980,7 @@ def matrix_from_translation(translation: CoordinateType) -> list[list[float]]: return M -def translation_from_matrix(M: Sequence[Sequence[float]]) -> list[float]: +def translation_from_matrix(M: Sequence[Sequence[RealInput]]) -> list[RealInput]: """Returns the 3 values of translation from the matrix M. Parameters @@ -993,8 +998,8 @@ def translation_from_matrix(M: Sequence[Sequence[float]]) -> list[float]: def matrix_from_orthogonal_projection( - plane: CoordinatesType, -) -> list[list[float]]: + plane: PointsLike, +) -> list[list[RealInput]]: """Returns an orthogonal projection matrix to project onto a plane. Parameters @@ -1004,7 +1009,7 @@ def matrix_from_orthogonal_projection( Returns ------- - list[list[float]] + list[list[RealInput]] The 4x4 transformation matrix representing an orthogonal projection. Raises @@ -1034,7 +1039,7 @@ def matrix_from_orthogonal_projection( return T -def matrix_from_parallel_projection(plane: CoordinatesType, direction: CoordinateType) -> list[list[float]]: +def matrix_from_parallel_projection(plane: PointsLike, direction: VectorLike) -> list[list[RealInput]]: """Returns an parallel projection matrix to project onto a plane. Parameters @@ -1046,7 +1051,7 @@ def matrix_from_parallel_projection(plane: CoordinatesType, direction: Coordinat Returns ------- - list[list[float]] + list[list[RealInput]] A 4-by-4 transformation matrix. Raises @@ -1081,7 +1086,7 @@ def matrix_from_parallel_projection(plane: CoordinatesType, direction: Coordinat return T -def matrix_from_perspective_projection(plane: CoordinatesType, center_of_projection: CoordinateType) -> list[list[float]]: +def matrix_from_perspective_projection(plane: PointsLike, center_of_projection: PointLike) -> list[list[RealInput]]: """Returns a perspective projection matrix to project onto a plane along lines that emanate from a single point, called the center of projection. Parameters @@ -1093,7 +1098,7 @@ def matrix_from_perspective_projection(plane: CoordinatesType, center_of_project Returns ------- - list[list[float]] + list[list[RealInput]] A 4-by-4 transformation matrix. Examples @@ -1125,7 +1130,7 @@ def matrix_from_perspective_projection(plane: CoordinatesType, center_of_project return T -def matrix_from_perspective_entries(perspective: Sequence[float]) -> list[list[float]]: +def matrix_from_perspective_entries(perspective: FloatSequenceLike) -> list[list[RealInput]]: """Returns a matrix from perspective entries. Parameters @@ -1135,7 +1140,7 @@ def matrix_from_perspective_entries(perspective: Sequence[float]) -> list[list[f Returns ------- - list[list[float]] + list[list[RealInput]] A 4-by-4 transformation matrix. Notes @@ -1155,7 +1160,7 @@ def matrix_from_perspective_entries(perspective: Sequence[float]) -> list[list[f return M -def matrix_from_shear_entries(shear_entries: Sequence[float]) -> list[list[float]]: +def matrix_from_shear_entries(shear_entries: FloatSequenceLike) -> list[list[RealInput]]: """Returns a shear matrix from the 3 factors for x-y, x-z, and y-z axes. Parameters @@ -1165,7 +1170,7 @@ def matrix_from_shear_entries(shear_entries: Sequence[float]) -> list[list[float Returns ------- - list[list[float]] + list[list[RealInput]] A 4-by-4 transformation matrix. Notes @@ -1189,7 +1194,7 @@ def matrix_from_shear_entries(shear_entries: Sequence[float]) -> list[list[float return M -def matrix_from_shear(angle: float, direction: CoordinateType, point: CoordinateType, normal: CoordinateType) -> list[list[float]]: +def matrix_from_shear(angle: float, direction: VectorLike, point: PointLike, normal: VectorLike) -> list[list[RealInput]]: """Constructs a shear matrix by an angle along the direction vector on the shear plane (defined by point and normal). @@ -1207,7 +1212,7 @@ def matrix_from_shear(angle: float, direction: CoordinateType, point: Coordinate Returns ------- - list[list[float]] + list[list[RealInput]] A 4-by-4 transformation matrix. Raises @@ -1249,7 +1254,7 @@ def matrix_from_shear(angle: float, direction: CoordinateType, point: Coordinate return M -def matrix_from_scale_factors(scale_factors: Sequence[float]) -> list[list[float]]: +def matrix_from_scale_factors(scale_factors: FloatSequenceLike) -> list[list[RealInput]]: """Returns a 4x4 scaling transformation. Parameters @@ -1259,7 +1264,7 @@ def matrix_from_scale_factors(scale_factors: Sequence[float]) -> list[list[float Returns ------- - list[list[float]] + list[list[RealInput]] A 4-by-4 transformation matrix. Notes diff --git a/src/compas/linalg/vectors.py b/src/compas/linalg/vectors.py index 190a246272d8..65cdcaf569be 100644 --- a/src/compas/linalg/vectors.py +++ b/src/compas/linalg/vectors.py @@ -1,10 +1,10 @@ -from collections.abc import Iterable from collections.abc import Sequence from math import sqrt from typing import overload -from compas._typing import CoordinateType -from compas._typing import FloatSequenceType +from compas.typing import FloatSequenceLike +from compas.typing import RealInput +from compas.typing import VectorLike _SPEC2TUPLE = { "sxyz": (0, 0, 0, 0), @@ -155,7 +155,7 @@ def argmin(values: Sequence[float]) -> int: # ============================================================================== -def sum_vectors(vectors: Sequence[Sequence[float]], axis: int = 0) -> list[float]: +def sum_vectors(vectors: Sequence[VectorLike], axis: int = 0) -> list[RealInput]: """Calculate the sum of a series of vectors along the specified axis. Parameters @@ -192,7 +192,7 @@ def sum_vectors(vectors: Sequence[Sequence[float]], axis: int = 0) -> list[float raise ValueError("The axis must be 0 or 1.") -def norm_vector(vector: Sequence[float]) -> float: +def norm_vector(vector: VectorLike) -> float: """Calculate the length of a vector. Parameters @@ -217,7 +217,7 @@ def norm_vector(vector: Sequence[float]) -> float: return sqrt(sum(axis**2 for axis in vector)) -def norm_vectors(vectors: Sequence[Sequence[float]]) -> list[float]: +def norm_vectors(vectors: Sequence[VectorLike]) -> list[float]: """ Calculate the norm of each vector in a list of vectors. @@ -240,7 +240,7 @@ def norm_vectors(vectors: Sequence[Sequence[float]]) -> list[float]: return [norm_vector(vector) for vector in vectors] -def length_vector(vector: CoordinateType) -> float: +def length_vector(vector: VectorLike) -> float: """Calculate the length of the vector. Parameters @@ -265,7 +265,7 @@ def length_vector(vector: CoordinateType) -> float: return sqrt(length_vector_sqrd(vector)) -def length_vector_xy(vector: CoordinateType) -> float: +def length_vector_xy(vector: VectorLike) -> float: """Compute the length of a vector, assuming it lies in the XY plane. Parameters @@ -293,7 +293,7 @@ def length_vector_xy(vector: CoordinateType) -> float: return sqrt(length_vector_sqrd_xy(vector)) -def length_vector_sqrd(vector: CoordinateType) -> float: +def length_vector_sqrd(vector: VectorLike) -> RealInput: """Compute the squared length of a vector. Parameters @@ -315,7 +315,7 @@ def length_vector_sqrd(vector: CoordinateType) -> float: return vector[0] ** 2 + vector[1] ** 2 + vector[2] ** 2 -def length_vector_sqrd_xy(vector: CoordinateType) -> float: +def length_vector_sqrd_xy(vector: VectorLike) -> RealInput: """Compute the squared length of a vector, assuming it lies in the XY plane. Parameters @@ -351,7 +351,7 @@ def length_vector_sqrd_xy(vector: CoordinateType) -> float: # ============================================================================== -def scale_vector(vector: CoordinateType, factor: float) -> list[float]: +def scale_vector(vector: VectorLike, factor: RealInput) -> list[RealInput]: """Scale a vector by a given factor. Parameters @@ -379,7 +379,7 @@ def scale_vector(vector: CoordinateType, factor: float) -> list[float]: return [axis * factor for axis in vector] -def scale_vector_xy(vector: Sequence[float], factor: float) -> list[float]: +def scale_vector_xy(vector: VectorLike, factor: RealInput) -> list[RealInput]: """Scale a vector by a given factor, assuming it lies in the XY plane. Parameters @@ -403,7 +403,7 @@ def scale_vector_xy(vector: Sequence[float], factor: float) -> list[float]: return [vector[0] * factor, vector[1] * factor, 0.0] -def scale_vectors(vectors: Sequence[Sequence[float]], factor: float) -> list[list[float]]: +def scale_vectors(vectors: Sequence[VectorLike], factor: RealInput) -> list[list[RealInput]]: """Scale multiple vectors by a given factor. Parameters @@ -422,7 +422,7 @@ def scale_vectors(vectors: Sequence[Sequence[float]], factor: float) -> list[lis return [scale_vector(vector, factor) for vector in vectors] -def scale_vectors_xy(vectors: Sequence[Sequence[float]], factor: float) -> list[list[float]]: +def scale_vectors_xy(vectors: Sequence[VectorLike], factor: RealInput) -> list[list[RealInput]]: """Scale multiple vectors by a given factor, assuming they lie in the XY plane. Parameters @@ -446,10 +446,14 @@ def normalize_vector(vector: list[float]) -> list[float]: ... @overload -def normalize_vector(vector: FloatSequenceType) -> FloatSequenceType: ... +def normalize_vector(vector: list[RealInput]) -> list[RealInput]: ... -def normalize_vector(vector: FloatSequenceType) -> FloatSequenceType: +@overload +def normalize_vector(vector: FloatSequenceLike) -> FloatSequenceLike: ... + + +def normalize_vector(vector: FloatSequenceLike) -> FloatSequenceLike: """Normalise a given vector. Parameters @@ -474,10 +478,14 @@ def normalize_vector_xy(vector: list[float]) -> list[float]: ... @overload -def normalize_vector_xy(vector: FloatSequenceType) -> FloatSequenceType: ... +def normalize_vector_xy(vector: list[RealInput]) -> list[RealInput]: ... + + +@overload +def normalize_vector_xy(vector: FloatSequenceLike) -> FloatSequenceLike: ... -def normalize_vector_xy(vector: FloatSequenceType) -> FloatSequenceType: +def normalize_vector_xy(vector: FloatSequenceLike) -> FloatSequenceLike: """Normalize a vector, assuming it lies in the XY-plane. Parameters @@ -497,7 +505,7 @@ def normalize_vector_xy(vector: FloatSequenceType) -> FloatSequenceType: return [vector[0] / length, vector[1] / length, 0.0] -def normalize_vectors(vectors: Sequence[FloatSequenceType]) -> list[FloatSequenceType]: +def normalize_vectors(vectors: Sequence[FloatSequenceLike]) -> list[FloatSequenceLike]: """Normalise multiple vectors. Parameters @@ -514,7 +522,7 @@ def normalize_vectors(vectors: Sequence[FloatSequenceType]) -> list[FloatSequenc return [normalize_vector(vector) for vector in vectors] -def normalize_vectors_xy(vectors: Sequence[FloatSequenceType]) -> list[FloatSequenceType]: +def normalize_vectors_xy(vectors: Sequence[FloatSequenceLike]) -> list[FloatSequenceLike]: """Normalise multiple vectors, assuming they lie in the XY plane. Parameters @@ -610,7 +618,7 @@ def square_vectors(vectors: Sequence[Sequence[float]]) -> list[list[float]]: # ============================================================================== -def add_vectors(u: Iterable[float], v: Iterable[float]) -> list[float]: +def add_vectors(u: VectorLike, v: VectorLike) -> list[RealInput]: """Add two vectors. Parameters @@ -629,7 +637,7 @@ def add_vectors(u: Iterable[float], v: Iterable[float]) -> list[float]: return [a + b for (a, b) in zip(u, v)] -def add_vectors_xy(u: Sequence[float], v: Sequence[float]) -> list[float]: +def add_vectors_xy(u: VectorLike, v: VectorLike) -> list[RealInput]: """Add two vectors, assuming they lie in the XY-plane. Parameters @@ -648,7 +656,7 @@ def add_vectors_xy(u: Sequence[float], v: Sequence[float]) -> list[float]: return [u[0] + v[0], u[1] + v[1], 0.0] -def subtract_vectors(u: CoordinateType, v: CoordinateType) -> list[float]: +def subtract_vectors(u: VectorLike, v: VectorLike) -> list[RealInput]: """Subtract one vector from another. Parameters @@ -667,7 +675,7 @@ def subtract_vectors(u: CoordinateType, v: CoordinateType) -> list[float]: return [a - b for (a, b) in zip(u, v)] -def subtract_vectors_xy(u: CoordinateType, v: CoordinateType) -> list[float]: +def subtract_vectors_xy(u: VectorLike, v: VectorLike) -> list[RealInput]: """Subtract one vector from another, assuming they lie in the XY plane. Parameters @@ -767,7 +775,7 @@ def divide_vectors_xy(u: Sequence[float], v: Sequence[float]) -> list[float]: # ============================================================================== -def cross_vectors(u: CoordinateType, v: CoordinateType) -> list[float]: +def cross_vectors(u: VectorLike, v: VectorLike) -> list[RealInput]: r"""Compute the cross product of two vectors. Parameters @@ -822,7 +830,7 @@ def cross_vectors(u: CoordinateType, v: CoordinateType) -> list[float]: ] -def cross_vectors_xy(u: Sequence[float], v: Sequence[float]) -> list[float]: +def cross_vectors_xy(u: VectorLike, v: VectorLike) -> list[RealInput]: """Compute the cross product of two vectors, assuming they lie in the XY-plane. Parameters @@ -853,7 +861,7 @@ def cross_vectors_xy(u: Sequence[float], v: Sequence[float]) -> list[float]: return [0.0, 0.0, u[0] * v[1] - u[1] * v[0]] -def dot_vectors(u: CoordinateType, v: CoordinateType) -> float: +def dot_vectors(u: VectorLike, v: VectorLike) -> RealInput: """Compute the dot product of two vectors. Parameters @@ -877,7 +885,7 @@ def dot_vectors(u: CoordinateType, v: CoordinateType) -> float: return sum(a * b for a, b in zip(u, v)) -def dot_vectors_xy(u: Sequence[float], v: Sequence[float]) -> float: +def dot_vectors_xy(u: VectorLike, v: VectorLike) -> RealInput: """Compute the dot product of two vectors, assuming they lie in the XY-plane. Parameters @@ -907,7 +915,7 @@ def dot_vectors_xy(u: Sequence[float], v: Sequence[float]) -> float: return u[0] * v[0] + u[1] * v[1] -def vector_component(u: Sequence[float], v: Sequence[float]) -> list[float]: +def vector_component(u: VectorLike, v: VectorLike) -> list[RealInput]: """Compute the component of u in the direction of v. Parameters @@ -940,7 +948,7 @@ def vector_component(u: Sequence[float], v: Sequence[float]) -> list[float]: return scale_vector(v, x) -def vector_component_xy(u: Sequence[float], v: Sequence[float]) -> list[float]: +def vector_component_xy(u: VectorLike, v: VectorLike) -> list[RealInput]: """Compute the component of u in the direction of v, assuming they lie in the XY-plane. Parameters diff --git a/src/compas/py.typed b/src/compas/py.typed new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/src/compas/py.typed @@ -0,0 +1 @@ + diff --git a/src/compas/scene/graphobject.py b/src/compas/scene/graphobject.py index 58ebd7495a11..4937a2b95a82 100644 --- a/src/compas/scene/graphobject.py +++ b/src/compas/scene/graphobject.py @@ -7,6 +7,7 @@ from compas.colors.color import ColorInput from compas.datastructures import Graph from compas.geometry import transform_points +from compas.typing import RealInput from .descriptors.colordict import ColorDictAttribute from .sceneobject import SceneObject @@ -40,7 +41,7 @@ def __init__( **kwargs: Any, ) -> None: super().__init__(**kwargs) - self._node_xyz: dict[Hashable, list[float]] | None = None + self._node_xyz: dict[Hashable, list[RealInput]] | None = None self._node_xyz_revision: tuple[int, ...] | None = None self.show_nodes = show_nodes self.show_edges = show_edges @@ -87,7 +88,7 @@ def graph(self, graph: Graph) -> None: self._node_xyz_revision = None @property - def node_xyz(self) -> dict[Hashable, list[float]]: + def node_xyz(self) -> dict[Hashable, list[RealInput]]: """The node coordinates in the scene object's world coordinate system. Notes @@ -106,7 +107,7 @@ def node_xyz(self) -> dict[Hashable, list[float]]: return self._node_xyz @node_xyz.setter - def node_xyz(self, node_xyz: dict[Hashable, list[float]]) -> None: + def node_xyz(self, node_xyz: dict[Hashable, list[RealInput]]) -> None: self._node_xyz = node_xyz self._node_xyz_revision = None diff --git a/src/compas/scene/volmeshobject.py b/src/compas/scene/volmeshobject.py index d138114b1e15..ce54bae96f20 100644 --- a/src/compas/scene/volmeshobject.py +++ b/src/compas/scene/volmeshobject.py @@ -6,6 +6,7 @@ from compas.colors.color import ColorInput from compas.datastructures import VolMesh from compas.geometry import transform_points +from compas.typing import RealInput from .descriptors.colordict import ColorDictAttribute from .sceneobject import SceneObject @@ -45,7 +46,7 @@ def __init__( **kwargs: Any, ) -> None: super().__init__(**kwargs) - self._vertex_xyz: dict[int, list[float]] | None = None + self._vertex_xyz: dict[int, list[RealInput]] | None = None self._vertex_xyz_revision: tuple[int, ...] | None = None self.show_vertices = show_vertices self.show_edges = show_edges @@ -98,7 +99,7 @@ def volmesh(self, volmesh: VolMesh) -> None: self._vertex_xyz_revision = None @property - def vertex_xyz(self) -> dict[int, list[float]]: + def vertex_xyz(self) -> dict[int, list[RealInput]]: """The vertex coordinates in the scene object's world coordinate system. Notes @@ -117,7 +118,7 @@ def vertex_xyz(self) -> dict[int, list[float]]: return self._vertex_xyz @vertex_xyz.setter - def vertex_xyz(self, vertex_xyz: dict[int, list[float]]) -> None: + def vertex_xyz(self, vertex_xyz: dict[int, list[RealInput]]) -> None: self._vertex_xyz = vertex_xyz self._vertex_xyz_revision = None diff --git a/src/compas/tolerance.py b/src/compas/tolerance.py index 9f8f8a305fd9..812639cddb21 100644 --- a/src/compas/tolerance.py +++ b/src/compas/tolerance.py @@ -41,11 +41,14 @@ from typing import TypeGuard from typing import cast -from compas._typing import CoordinateType +import numpy as np + from compas.data import Data +from compas.typing import PointLike +from compas.typing import RealInput type ToleranceValue = str | float | int -type NestedNumbers = Iterable[float | NestedNumbers] +type NestedNumbers = Iterable[RealInput | NestedNumbers] def _is_nested_numbers(value: object) -> TypeGuard[NestedNumbers]: @@ -521,7 +524,7 @@ def temporary( self._lineardeflection = saved["lineardeflection"] self._angulardeflection = saved["angulardeflection"] - def tolerance(self, truevalue: float, rtol: float, atol: float) -> float: + def tolerance(self, truevalue: RealInput, rtol: float, atol: float) -> RealInput: """Compute the tolerance for a comparison. Parameters @@ -551,7 +554,7 @@ def tolerance(self, truevalue: float, rtol: float, atol: float) -> float: """ return rtol * abs(truevalue) + atol - def compare(self, a: float, b: float, rtol: float, atol: float) -> bool: + def compare(self, a: RealInput, b: RealInput, rtol: float, atol: float) -> bool | np.bool_: """Compare two values. Parameters @@ -587,7 +590,7 @@ def compare(self, a: float, b: float, rtol: float, atol: float) -> bool: """ return abs(a - b) <= self.tolerance(b, rtol, atol) - def is_zero(self, a: float, tol: float | None = None) -> bool: + def is_zero(self, a: RealInput, tol: float | None = None) -> bool | np.bool_: """Check if a value is close enough to zero to be considered zero. Parameters @@ -616,7 +619,7 @@ def is_zero(self, a: float, tol: float | None = None) -> bool: tol = self.absolute if tol is None else tol return abs(a) <= tol - def is_positive(self, a: float, tol: float | None = None) -> bool: + def is_positive(self, a: RealInput, tol: float | None = None) -> bool | np.bool_: """Check if a value can be considered a strictly positive number. Parameters @@ -645,7 +648,7 @@ def is_positive(self, a: float, tol: float | None = None) -> bool: tol = self.absolute if tol is None else tol return a > tol - def is_negative(self, a: float, tol: float | None = None) -> bool: + def is_negative(self, a: RealInput, tol: float | None = None) -> bool | np.bool_: """Check if a value can be considered a strictly negative number. Parameters @@ -674,7 +677,7 @@ def is_negative(self, a: float, tol: float | None = None) -> bool: tol = self.absolute if tol is None else tol return a < -tol - def is_between(self, value: float, minval: float, maxval: float, atol: float | None = None) -> bool: + def is_between(self, value: RealInput, minval: RealInput, maxval: RealInput, atol: float | None = None) -> bool | np.bool_: """Check if a value is between two other values. Parameters @@ -707,7 +710,7 @@ def is_between(self, value: float, minval: float, maxval: float, atol: float | N atol = self.absolute if atol is None else atol return minval - atol <= value <= maxval + atol - def is_close(self, a: float, b: float, rtol: float | None = None, atol: float | None = None) -> bool: + def is_close(self, a: RealInput, b: RealInput, rtol: float | None = None, atol: float | None = None) -> bool | np.bool_: """Check if two values are close enough to be considered equal. Parameters @@ -873,7 +876,7 @@ def is_angles_close(self, a: float, b: float, tol: float | None = None) -> bool: tol = self.angular if tol is None else tol return abs(a - b) <= tol - def geometric_key(self, xyz: CoordinateType, precision: int | None = None, sanitize: bool = True) -> str: + def geometric_key(self, xyz: PointLike, precision: int | None = None, sanitize: bool = True) -> str: """Compute the geometric key of a point. Parameters @@ -949,7 +952,7 @@ def geometric_key(self, xyz: CoordinateType, precision: int | None = None, sanit return "{0:.{3}f},{1:.{3}f},{2:.{3}f}".format(x, y, z, precision) - def geometric_key_xy(self, xy: CoordinateType, precision: int | None = None, sanitize: bool = True) -> str: + def geometric_key_xy(self, xy: PointLike, precision: int | None = None, sanitize: bool = True) -> str: """Compute the geometric key of a point in the XY plane. Parameters @@ -1024,7 +1027,7 @@ def geometric_key_xy(self, xy: CoordinateType, precision: int | None = None, san return "{0:.{2}f},{1:.{2}f}".format(x, y, precision) - def format_number(self, number: float, precision: int | None = None) -> str: + def format_number(self, number: RealInput, precision: int | None = None) -> str: """Format a number as a string. Parameters diff --git a/src/compas/topology/orientation.py b/src/compas/topology/orientation.py index fff9ca3f3bcd..e26c7de521f2 100644 --- a/src/compas/topology/orientation.py +++ b/src/compas/topology/orientation.py @@ -1,13 +1,13 @@ from collections.abc import Sequence -from compas._typing import CoordinateType from compas.geometry import centroid_points from compas.itertools import pairwise from compas.topology.traversal import breadth_first_traverse +from compas.typing import PointLike def _closest_faces( - vertices: Sequence[CoordinateType], + vertices: Sequence[PointLike], faces: Sequence[list[int]], nmax: int | None, max_distance: float | None, @@ -48,7 +48,7 @@ def _closest_faces( def _face_adjacency( - vertices: Sequence[CoordinateType], + vertices: Sequence[PointLike], faces: Sequence[list[int]], nmax: int | None = None, max_distance: float | None = None, @@ -89,7 +89,7 @@ def _face_adjacency( def face_adjacency( - points: Sequence[CoordinateType], + points: Sequence[PointLike], faces: Sequence[list[int]], nmax: int | None = None, max_distance: float | None = None, @@ -154,7 +154,7 @@ def face_adjacency( def unify_cycles( - vertices: Sequence[CoordinateType], + vertices: Sequence[PointLike], faces: list[list[int]], root: int | None = None, nmax: int | None = None, diff --git a/src/compas/typing.py b/src/compas/typing.py new file mode 100644 index 000000000000..25bd6e95c0cb --- /dev/null +++ b/src/compas/typing.py @@ -0,0 +1,118 @@ +from collections.abc import Iterator +from collections.abc import Sequence +from os import PathLike +from typing import Any +from typing import Protocol +from typing import SupportsIndex +from typing import TypeVar +from typing import overload +from typing import runtime_checkable + +import numpy as np +from numpy.typing import NDArray + +__all__ = [ + "CoordinateProtocol", + "FilePath", + "Float64Array", + "FloatingArray", + "FloatSequenceLike", + "IndexGroupsLike", + "IndexGroupsProtocol", + "IndicesLike", + "IndicesProtocol", + "Int32Array", + "IntegerArray", + "IntegralInput", + "NumericArray", + "PointGroupsLike", + "PointGroupsProtocol", + "PointLike", + "PointsLike", + "PointsProtocol", + "RayData", + "RayMeshHit", + "RealInput", + "SphereData", + "VectorLike", + "VerticesFaces", +] + + +type FilePath = str | PathLike[str] + +type RealInput = int | float | np.integer[Any] | np.floating[Any] +type IntegralInput = int | np.integer[Any] + +type NumericArray = NDArray[np.integer[Any] | np.floating[Any]] +type FloatingArray = NDArray[np.floating[Any]] +type IntegerArray = NDArray[np.integer[Any]] +type Float64Array = NDArray[np.float64] +type Int32Array = NDArray[np.int32] + + +T_co = TypeVar("T_co", bound=RealInput, covariant=True) + + +@runtime_checkable +class CoordinateProtocol(Protocol[T_co]): + def __len__(self) -> int: ... + + def __iter__(self) -> Iterator[T_co]: ... + + @overload + def __getitem__(self, key: int) -> T_co: ... + + @overload + def __getitem__(self, key: slice) -> Sequence[T_co]: ... + + +type FloatSequenceLike = CoordinateProtocol[RealInput] | Sequence[RealInput] | NumericArray +type PointLike = FloatSequenceLike +type VectorLike = FloatSequenceLike + + +@runtime_checkable +class PointsProtocol(Protocol): + def __len__(self) -> int: ... + + def __iter__(self) -> Iterator[PointLike]: ... + + def __getitem__(self, key: int) -> PointLike: ... + + +class IndicesProtocol(Protocol): + def __len__(self) -> int: ... + + def __iter__(self) -> Iterator[SupportsIndex]: ... + + def __getitem__(self, key: int) -> SupportsIndex: ... + + +type PointsLike = PointsProtocol | Sequence[PointLike] | NumericArray +type IndicesLike = IndicesProtocol | Sequence[SupportsIndex] | IntegerArray + + +class IndexGroupsProtocol(Protocol): + def __len__(self) -> int: ... + + def __iter__(self) -> Iterator[IndicesLike]: ... + + def __getitem__(self, key: int) -> IndicesLike: ... + + +class PointGroupsProtocol(Protocol): + def __len__(self) -> int: ... + + def __iter__(self) -> Iterator[PointsLike]: ... + + def __getitem__(self, key: int) -> PointsLike: ... + + +type IndexGroupsLike = IndexGroupsProtocol | Sequence[IndicesLike] | IntegerArray +type PointGroupsLike = PointGroupsProtocol | Sequence[PointsLike] | NumericArray + +type VerticesFaces = tuple[PointsLike, IndexGroupsLike] +type SphereData = tuple[PointLike, RealInput] +type RayData = tuple[PointLike, VectorLike] +type RayMeshHit = tuple[int, float, float, float] diff --git a/tasks.py b/tasks.py index d9440d3f1398..a5861a0bdb4b 100644 --- a/tasks.py +++ b/tasks.py @@ -4,23 +4,41 @@ from compas_invocations2 import build from compas_invocations2 import docs from compas_invocations2 import mkdocs -from compas_invocations2 import style from compas_invocations2 import tests -from invoke import task from invoke.collection import Collection +from invoke.tasks import task @task def typecheck(ctx): """Run static type checking.""" - ctx.run(f'pyright --pythonpath "{sys.executable}" src/compas') + ctx.run(f'"{sys.executable}" -m pyright --pythonpath "{sys.executable}"') + + +@task +def lint(ctx): + """Run the non-mutating Ruff lint check.""" + ctx.run(f'"{sys.executable}" -m ruff check .') + + +@task +def format(ctx): + """Format the configured repository files.""" + ctx.run(f'"{sys.executable}" -m ruff format .') + + +@task +def check(ctx): + """Run the non-mutating Ruff lint and format checks.""" + ctx.run(f'"{sys.executable}" -m ruff check .') + ctx.run(f'"{sys.executable}" -m ruff format --check .') ns = Collection( docs.help, - style.check, - style.lint, - style.format, + check, + lint, + format, typecheck, mkdocs.docs, tests.test, diff --git a/tests/compas/datastructures/test_graph.py b/tests/compas/datastructures/test_graph.py index ff3399501f6b..9a24d5baeb74 100644 --- a/tests/compas/datastructures/test_graph.py +++ b/tests/compas/datastructures/test_graph.py @@ -86,6 +86,20 @@ def test_graph_from_pointcloud(): assert graph.degree(node) <= 3 +def test_graph_from_pointcloud_accepts_generator(): + points = ((float(index), 0.0, 0.0) for index in range(4)) + + graph = Graph.from_pointcloud(cloud=points, degree=2) + + assert graph.number_of_nodes() == 4 + assert [graph.node_coordinates(node) for node in graph.nodes()] == [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [3.0, 0.0, 0.0], + ] + + # ============================================================================== # Data # ============================================================================== @@ -325,8 +339,8 @@ def test_graph_to_networkx(): nxg = g.to_networkx() - assert nxg.graph["name"] == "DiGraph", "Graph attributes must be preserved" # type: ignore - # assert nxg.graph["val"] == (0, 0, 0), "Graph attributes must be preserved" # type: ignore + assert nxg.graph["name"] == "DiGraph", "Graph attributes must be preserved" + # assert nxg.graph["val"] == (0, 0, 0), "Graph attributes must be preserved" assert set(nxg.nodes()) == set(g.nodes()), "Node sets must match" assert nxg.nodes[1]["weight"] == 1.2, "Node attributes must be preserved" assert nxg.nodes[1]["height"] == "test", "Node attributes must be preserved" diff --git a/tests/compas/datastructures/test_mesh.py b/tests/compas/datastructures/test_mesh.py index 55fb2d77cac2..08da03a006e3 100644 --- a/tests/compas/datastructures/test_mesh.py +++ b/tests/compas/datastructures/test_mesh.py @@ -1,20 +1,23 @@ import tempfile +from copy import deepcopy +from functools import partial + import pytest import json import random +from operator import index import compas from compas.datastructures import Mesh from compas.geometry import Sphere +from compas.geometry import Tessellation from compas.geometry import Box -from compas.geometry import DiagonalSplit -from compas.geometry import EarClipping -from compas.geometry import FaceTriangulation -from compas.geometry import Native from compas.geometry import Polygon from compas.geometry import Polyhedron from compas.geometry import Translation -from compas.geometry import UVGrid +from compas.geometry import triangulate_polygon +from compas.geometry import triangulate_polygon_earclip +from compas.geometry import triangulate_quadrilateral from compas.tolerance import TOL @@ -49,13 +52,13 @@ def edge_key(): @pytest.fixture def sphere(): sphere = Sphere(radius=1.0) - mesh = sphere.polygonize(UVGrid(16, 16)).to_mesh() + mesh = sphere.to_polygonal_surface(nu=16, nv=16).to_mesh() return mesh def _box(): box = Box.from_width_height_depth(2, 2, 2) - return box.polygonize(Native()).to_mesh() + return box.to_polygonal_surface().to_mesh() @pytest.fixture @@ -304,28 +307,90 @@ def test_to_vertices_and_faces(): def test_mesh_triangulated(): # tri mesh = Polyhedron.from_platonicsolid(4).to_mesh() - result = mesh.triangulated(FaceTriangulation()) - assert len(result.vertices) == 4 - assert len(result.triangles) == 4 + result = mesh.triangulated() + assert result.number_of_vertices() == 4 + assert result.number_of_faces() == 4 + assert result.is_trimesh() # quad mesh = Polyhedron.from_platonicsolid(6).to_mesh() - result = mesh.triangulated(FaceTriangulation(quad=DiagonalSplit("02"))) - assert len(result.vertices) == 8 - assert len(result.triangles) == 12 + result = mesh.triangulated(partial(triangulate_quadrilateral, diagonal="02")) + assert result.number_of_vertices() == 8 + assert result.number_of_faces() == 12 + assert result.is_trimesh() # ngon mesh = Polyhedron.from_platonicsolid(12).to_mesh() - result = mesh.triangulated(FaceTriangulation()) - assert len(result.vertices) == 20 - assert len(result.triangles) == 36 - - -def test_mesh_triangulated_requires_collection_strategy(): + result = mesh.triangulated() + assert result.number_of_vertices() == 20 + assert result.number_of_faces() == 36 + assert result.is_trimesh() + + +def test_mesh_triangulated_preserves_representation_and_data(): + class CustomMesh(Mesh): + pass + + mesh = CustomMesh( + default_vertex_attributes={"weight": 0.0}, + default_edge_attributes={"kind": "default"}, + default_face_attributes={"material": "default"}, + name="source", + project="example", + ) + coordinates = { + 10: [0.0, 0.0, 0.0], + 11: [1.0, 0.0, 0.0], + 12: [0.0, 1.0, 0.0], + 20: [2.0, 0.0, 0.0], + 21: [3.0, 0.0, 0.0], + 22: [3.0, 1.0, 0.0], + 23: [2.0, 1.0, 0.0], + } + for key, xyz in coordinates.items(): + mesh.add_vertex(key=key, x=xyz[0], y=xyz[1], z=xyz[2], label=f"v{key}") + mesh.add_face([10, 11, 12], fkey=5, attr_dict={"source": "triangle"}) + mesh.add_face([20, 21, 22, 23], fkey=9, attr_dict={"source": "quad"}) + mesh.edge_attribute((20, 21), "kind", "boundary") + + source_guid = mesh.guid + source_data = deepcopy(mesh.__data__) + source_edges = {frozenset(edge) for edge in mesh.edges()} + + result = mesh.triangulated(lambda vertices: triangulate_quadrilateral(vertices, diagonal="02")) + + assert type(result) is CustomMesh + assert result.guid != source_guid + assert result.name == mesh.name + assert result.attributes == mesh.attributes + assert result.default_vertex_attributes == mesh.default_vertex_attributes + assert result.default_edge_attributes == mesh.default_edge_attributes + assert result.default_face_attributes == mesh.default_face_attributes + assert result.vertex == mesh.vertex + assert result.face_vertices(5) == [10, 11, 12] + assert result.face_attribute(5, "source") == "triangle" + assert sum(result.face_attribute(face, "source") == "quad" for face in result.faces()) == 2 + assert result.edge_attribute((20, 21), "kind") == "boundary" + new_edges = [edge for edge in result.edges() if frozenset(edge) not in source_edges] + assert len(new_edges) == 1 + assert result.edge_attribute(new_edges[0], "kind") == "default" + assert result.is_trimesh() + assert mesh.guid == source_guid + assert mesh.__data__ == source_data + + result.vertex_attribute(10, "label", "changed") + assert mesh.vertex_attribute(10, "label") == "v10" + + vertices, triangles = result.to_vertices_and_faces() + tessellation = Tessellation(vertices, triangles) + assert tessellation.number_of_triangles == result.number_of_faces() + + +def test_mesh_triangulated_requires_callable(): mesh = Polyhedron.from_platonicsolid(6).to_mesh() with pytest.raises(TypeError): - mesh.triangulated(DiagonalSplit()) # type: ignore[arg-type] + mesh.triangulated(object()) # type: ignore[arg-type] def test_to_lines(): @@ -454,6 +519,60 @@ def test_delete_face(): assert mesh.number_of_faces() == f - 1 +def test_from_vertices_and_faces_materializes_structural_closed_face_once(): + class CountingIndex: + def __init__(self, value): + self.value = value + self.conversions = 0 + + def __index__(self): + self.conversions += 1 + return index(self.value) + + class StructuralFace: + def __init__(self, values): + self.values = values + + def __len__(self): + return len(self.values) + + def __getitem__(self, key: int): + if not isinstance(key, int): + raise TypeError("StructuralFace does not support slicing.") + return self.values[key] + + def __iter__(self): + return iter(self.values) + + indices = [CountingIndex(0), CountingIndex(1), CountingIndex(2), CountingIndex(0)] + face = StructuralFace(indices) + + mesh = Mesh.from_vertices_and_faces( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + {7: face}, + ) + + assert mesh.face_vertices(7) == [0, 1, 2] + assert face.values == indices + assert all(value.conversions == 1 for value in indices) + + +@pytest.mark.parametrize( + ("faces", "fkey"), + [ + ([["0", 1.0, "2", "0"]], 0), + ({7: ["0", 1.0, "2", "0"]}, 7), + ], +) +def test_from_vertices_and_faces_preserves_legacy_face_element_normalization(faces, fkey): + mesh = Mesh.from_vertices_and_faces( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + faces, + ) + + assert mesh.face_vertices(fkey) == [0, 1, 2] + + def test_cull_vertices(): mesh = Mesh.from_stl(compas.get("cube_binary.stl")) mesh.add_vertex() @@ -470,7 +589,7 @@ def test_triangulate_preserves_face_attributes_and_vertex_keys(): mesh.face_attribute(0, "name", "quad") vertex_keys = list(mesh.vertices()) - result = mesh.triangulate(FaceTriangulation(quad=DiagonalSplit("13"))) + result = mesh.triangulate(lambda vertices: triangulate_quadrilateral(vertices, diagonal="13")) assert result is None assert mesh.number_of_faces() == 2 @@ -496,8 +615,13 @@ def test_triangulate_validates_before_mutating(): ) before = mesh.__data__ + def fail_on_ngon(vertices): + if len(vertices) > 4: + raise ValueError("unsupported") + return triangulate_polygon(vertices) + with pytest.raises(ValueError): - mesh.triangulate(FaceTriangulation()) + mesh.triangulate(fail_on_ngon) assert mesh.__data__ == before @@ -521,12 +645,18 @@ def test_triangulate_mixed_face_topology(): [[0, 1, 2], [3, 4, 5, 6], [7, 8, 9, 10, 11]], ) - tessellation = mesh.triangulated(FaceTriangulation(quad=DiagonalSplit("13"), ngon=EarClipping())) + def triangulator(vertices): + if len(vertices) == 4: + return triangulate_quadrilateral(vertices, diagonal="13") + return triangulate_polygon_earclip(vertices) + + triangle_mesh = mesh.triangulated(triangulator) + _, triangles = triangle_mesh.to_vertices_and_faces() - assert len(tessellation.vertices) == 12 - assert len(tessellation.triangles) == 6 - assert [3, 4, 6] in tessellation.triangles - assert [4, 5, 6] in tessellation.triangles + assert triangle_mesh.number_of_vertices() == 12 + assert len(triangles) == 6 + assert [3, 4, 6] in triangles + assert [4, 5, 6] in triangles def test_triangulated_warped_quad_policy_is_explicit(): @@ -535,11 +665,11 @@ def test_triangulated_warped_quad_policy_is_explicit(): [[0, 1, 2, 3]], ) - tessellation = mesh.triangulated(FaceTriangulation(quad=DiagonalSplit("02"))) - assert tessellation.triangles == [[0, 1, 2], [0, 2, 3]] + triangle_mesh = mesh.triangulated(lambda vertices: triangulate_quadrilateral(vertices, diagonal="02")) + assert triangle_mesh.to_vertices_and_faces()[1] == [[0, 1, 2], [0, 2, 3]] with pytest.raises(ValueError): - mesh.triangulated(FaceTriangulation(quad=EarClipping())) + mesh.triangulated(triangulate_polygon_earclip) # -------------------------------------------------------------------------- @@ -1128,14 +1258,14 @@ def test_volume(): # Test with a box (3x4x5) box = Box.from_width_height_depth(3, 4, 5) - mesh = box.polygonize(Native()).to_mesh() + mesh = box.to_polygonal_surface().to_mesh() volume = mesh.volume() expected_volume = 3 * 4 * 5 # 60 assert TOL.is_close(volume, expected_volume) # Test with a smaller box (2x2x2) box2 = Box.from_width_height_depth(2, 2, 2) - mesh2 = box2.polygonize(Native()).to_mesh() + mesh2 = box2.to_polygonal_surface().to_mesh() volume2 = mesh2.volume() expected_volume2 = 2 * 2 * 2 # 8 assert TOL.is_close(volume2, expected_volume2) @@ -1149,7 +1279,7 @@ def test_volume(): assert TOL.is_close(volume, expected_tet_volume) # Test with a sphere approximation - sphere_mesh = Sphere(radius=1.0).polygonize(UVGrid(32, 32)).to_mesh() + sphere_mesh = Sphere(radius=1.0).to_polygonal_surface(nu=32, nv=32).to_mesh() volume = sphere_mesh.volume() assert volume is not None expected_sphere_volume = (4.0 / 3.0) * math.pi @@ -1163,7 +1293,7 @@ def test_volume(): # Test optional parameters box3 = Box.from_width_height_depth(2, 3, 4) - mesh3 = box3.polygonize(Native()).to_mesh() + mesh3 = box3.to_polygonal_surface().to_mesh() # Test with copy=False and unify_cycles=False (should still work for well-oriented mesh) volume3 = mesh3.volume(copy=False, unify_cycles=False) diff --git a/tests/compas/datastructures/test_mesh_slice.py b/tests/compas/datastructures/test_mesh_slice.py index a497feb5bb0e..b8102e7a615e 100644 --- a/tests/compas/datastructures/test_mesh_slice.py +++ b/tests/compas/datastructures/test_mesh_slice.py @@ -1,12 +1,11 @@ from compas.datastructures import Mesh from compas.datastructures.mesh.slice import mesh_slice_plane from compas.geometry import Box -from compas.geometry import Native from compas.geometry import Plane def test_slice_closed_mesh_constructs_two_closed_valid_meshes(): - mesh = Box.from_width_height_depth(2, 2, 2).polygonize(Native()).to_mesh() + mesh = Box.from_width_height_depth(2, 2, 2).to_polygonal_surface().to_mesh() result = mesh_slice_plane(mesh, Plane((0, 0, 0), (1, 0, 0))) @@ -21,7 +20,7 @@ def test_slice_closed_mesh_constructs_two_closed_valid_meshes(): def test_slice_returns_none_without_polygonal_intersection(): - mesh = Box.from_width_height_depth(2, 2, 2).polygonize(Native()).to_mesh() + mesh = Box.from_width_height_depth(2, 2, 2).to_polygonal_surface().to_mesh() assert mesh_slice_plane(mesh, Plane((5, 0, 0), (1, 0, 0))) is None @@ -30,7 +29,7 @@ def test_slice_preserves_mesh_type(): class CustomMesh(Mesh): pass - surface = Box.from_width_height_depth(2, 2, 2).polygonize(Native()) + surface = Box.from_width_height_depth(2, 2, 2).to_polygonal_surface() mesh = CustomMesh.from_vertices_and_faces(surface.vertices, surface.faces) result = mesh_slice_plane(mesh, Plane((0, 0, 0), (1, 0, 0))) diff --git a/tests/compas/datastructures/test_mesh_subd.py b/tests/compas/datastructures/test_mesh_subd.py index 056e28bf508d..312e3d72fb71 100644 --- a/tests/compas/datastructures/test_mesh_subd.py +++ b/tests/compas/datastructures/test_mesh_subd.py @@ -4,14 +4,12 @@ from compas.datastructures import Mesh from compas.datastructures.mesh.subdivision import mesh_subdivide -from compas.geometry import DiagonalSplit -from compas.geometry import FaceTriangulation @pytest.fixture def mesh_tris(): mesh = Mesh.from_polyhedron(6) - mesh.triangulate(FaceTriangulation(quad=DiagonalSplit())) + mesh.triangulate() return mesh diff --git a/tests/compas/geometry/test_box.py b/tests/compas/geometry/test_box.py index 467ca8638107..5e790e377b0c 100644 --- a/tests/compas/geometry/test_box.py +++ b/tests/compas/geometry/test_box.py @@ -1,6 +1,5 @@ import pytest from compas.geometry import Box -from compas.geometry import Native from compas.geometry import Point @@ -37,25 +36,24 @@ def test_box_corners(default_box): def test_box_native_materialization_has_no_synthetic_resolution(monkeypatch, unit_box): - strategies = [] + calls = [] compute_local_vertices = unit_box._compute_local_vertices compute_faces = unit_box._compute_faces - def record_vertices(strategy): - strategies.append(strategy) - return compute_local_vertices(strategy) + def record_vertices(resolution): + calls.append("vertices") + return compute_local_vertices(resolution) - def record_faces(strategy): - strategies.append(strategy) - return compute_faces(strategy) + def record_faces(resolution): + calls.append("faces") + return compute_faces(resolution) monkeypatch.setattr(unit_box, "_compute_local_vertices", record_vertices) monkeypatch.setattr(unit_box, "_compute_faces", record_faces) - unit_box.polygonize(Native()) + unit_box.to_polygonal_surface() - assert len(strategies) == 2 - assert all(isinstance(strategy, Native) for strategy in strategies) + assert calls == ["vertices", "faces"] def test_box_corner(default_box): diff --git a/tests/compas/geometry/test_brep_query_api.py b/tests/compas/geometry/test_brep_query_api.py new file mode 100644 index 000000000000..49d7c16a085b --- /dev/null +++ b/tests/compas/geometry/test_brep_query_api.py @@ -0,0 +1,77 @@ +from types import SimpleNamespace + +import pytest # pyright: ignore[reportMissingImports] + +import compas.plugins +from compas.geometry import Brep + + +QUERY_PROPERTIES = { + "orientation": "brep_orientation", + "type": "brep_type", + "is_valid": "brep_is_valid", + "is_orientable": "brep_is_orientable", + "is_closed": "brep_is_closed", + "is_infinite": "brep_is_infinite", + "is_convex": "brep_is_convex", + "is_manifold": "brep_is_manifold", + "is_surface": "brep_is_surface", + "is_compound": "brep_is_compound", + "is_compoundsolid": "brep_is_compoundsolid", + "is_shell": "brep_is_shell", + "is_solid": "brep_is_solid", + "vertices": "brep_vertices", + "edges": "brep_edges", + "trims": "brep_trims", + "loops": "brep_loops", + "faces": "brep_faces", + "shells": "brep_shells", + "solids": "brep_solids", +} + + +@pytest.mark.parametrize(("property_name", "plugin_name"), QUERY_PROPERTIES.items()) +def test_brep_query_properties_dispatch_independently(monkeypatch, property_name, plugin_name): + calls = [] + expected = object() + + def query(brep): + calls.append(brep) + return expected + + def select(extension_point): + if extension_point.name == plugin_name: + return SimpleNamespace(id="tests.fake_brep_query", method=query) + return None + + monkeypatch.setattr(compas.plugins.plugin_manager, "select", select) + brep = Brep() + + assert getattr(brep, property_name) is expected + assert calls == [brep] + + +def test_unsupported_brep_query_does_not_disable_supported_queries(monkeypatch): + def vertices(brep): + return [brep] + + def trims(brep): + raise NotImplementedError("The backend does not provide Brep trims.") + + implementations = { + "brep_vertices": vertices, + "brep_trims": trims, + } + + def select(extension_point): + method = implementations.get(extension_point.name) + if method is None: + return None + return SimpleNamespace(id="tests.fake_brep_query", method=method) + + monkeypatch.setattr(compas.plugins.plugin_manager, "select", select) + brep = Brep() + + assert brep.vertices == [brep] + with pytest.raises(NotImplementedError, match="does not provide Brep trims"): + brep.trims diff --git a/tests/compas/geometry/test_brep_tessellation_api.py b/tests/compas/geometry/test_brep_tessellation_api.py index 48caf9f55a13..0b568592ee4d 100644 --- a/tests/compas/geometry/test_brep_tessellation_api.py +++ b/tests/compas/geometry/test_brep_tessellation_api.py @@ -1,3 +1,4 @@ +from math import degrees from types import SimpleNamespace import pytest # pyright: ignore[reportMissingImports] @@ -6,29 +7,22 @@ from compas.geometry import Brep from compas.geometry import BrepFace from compas.geometry import Box -from compas.geometry import Deflection -from compas.geometry import Native -from compas.geometry import PolygonalSurface from compas.geometry import Tessellation from compas.geometry import Translation +from compas.geometry.brep.operations import brep_to_tessellation +from compas.tolerance import TOL def _install_fake_brep_plugin(monkeypatch): calls = [] tessellation = Tessellation([[0.0, 0.0, 0.0]], []) - polygonal = PolygonalSurface([[0.0, 0.0, 0.0]], []) - def tessellate(brep, strategy): - calls.append(("tessellate", brep, strategy)) + def tessellate(brep, *, linear_deflection, angular_deflection): + calls.append((brep, linear_deflection, angular_deflection)) return tessellation - def polygonize(brep, strategy): - calls.append(("polygonize", brep, strategy)) - return polygonal - implementations = { - "brep_tessellate": tessellate, - "brep_polygonize": polygonize, + "brep_to_tessellation": tessellate, } def select(extension_point): @@ -38,36 +32,77 @@ def select(extension_point): return SimpleNamespace(id="tests.fake_brep", method=method) monkeypatch.setattr(compas.plugins.plugin_manager, "select", select) - return calls, tessellation, polygonal + return calls, tessellation -def test_brep_tessellation_operations_dispatch_exact_strategy(monkeypatch): - calls, tessellation, polygonal = _install_fake_brep_plugin(monkeypatch) +def test_brep_tessellation_dispatches_concrete_values(monkeypatch): + calls, tessellation = _install_fake_brep_plugin(monkeypatch) brep = Brep() - strategy = Deflection(0.1, 15.0) - assert brep.tessellate(strategy) is tessellation - assert brep.polygonize(strategy) is polygonal - assert calls == [ - ("tessellate", brep, strategy), - ("polygonize", brep, strategy), - ] + assert brep.to_tessellation(linear_deflection=0.1, angular_deflection=15.0) is tessellation + assert calls == [(brep, 0.1, 15.0)] -def test_brep_rejects_non_deflection_strategies_before_dispatch(monkeypatch): - calls, _, _ = _install_fake_brep_plugin(monkeypatch) +def test_brep_resolves_defaults_and_validates_before_dispatch(monkeypatch): + calls, _ = _install_fake_brep_plugin(monkeypatch) brep = Brep() - with pytest.raises(TypeError, match="Deflection"): - brep.tessellate(Native()) # pyright: ignore[reportArgumentType] - with pytest.raises(TypeError, match="Deflection"): - brep.polygonize(Native()) # pyright: ignore[reportArgumentType] + brep.to_tessellation() + assert calls[-1] == (brep, TOL.lineardeflection, degrees(TOL.angulardeflection)) + + for value in (True, "0.1"): + with pytest.raises(TypeError): + brep.to_tessellation(linear_deflection=value) # pyright: ignore[reportArgumentType] + for value in (0.0, float("inf")): + with pytest.raises(ValueError): + brep.to_tessellation(linear_deflection=value) + with pytest.raises(ValueError): + brep.to_tessellation(angular_deflection=181.0) + + +def test_brep_resolves_independently_omitted_controls(monkeypatch): + calls, _ = _install_fake_brep_plugin(monkeypatch) + brep = Brep() + + brep.to_tessellation(linear_deflection=0.1) + assert calls[-1] == (brep, 0.1, degrees(TOL.angulardeflection)) + + brep.to_tessellation(angular_deflection=15.0) + assert calls[-1] == (brep, TOL.lineardeflection, 15.0) + + +def test_brep_resolves_scoped_tolerance_defaults(monkeypatch): + calls, _ = _install_fake_brep_plugin(monkeypatch) + brep = Brep() + + with TOL.temporary(lineardeflection=0.025, angulardeflection=0.125): + brep.to_tessellation() + + assert calls == [(brep, 0.025, degrees(0.125))] + + +def test_direct_brep_tessellation_hook_rejects_none(monkeypatch): + calls, _ = _install_fake_brep_plugin(monkeypatch) + brep = Brep() + + with pytest.raises(TypeError): + brep_to_tessellation( + brep, + linear_deflection=None, # pyright: ignore[reportArgumentType] + angular_deflection=15.0, + ) + with pytest.raises(TypeError): + brep_to_tessellation( + brep, + linear_deflection=0.1, + angular_deflection=None, # pyright: ignore[reportArgumentType] + ) assert calls == [] def test_brep_face_reuses_brep_plugin_boundary(monkeypatch): - calls, tessellation, polygonal = _install_fake_brep_plugin(monkeypatch) + calls, tessellation = _install_fake_brep_plugin(monkeypatch) brep = Brep() class TestFace(BrepFace): @@ -75,18 +110,17 @@ def as_brep(self): return brep face = TestFace() - strategy = Deflection(0.1, 15.0) - - assert face.tessellate(strategy) is tessellation - assert face.polygonize(strategy) is polygonal - assert [call[1] for call in calls] == [brep, brep] + assert face.to_tessellation(linear_deflection=0.1, angular_deflection=15.0) is tessellation + assert calls == [(brep, 0.1, 15.0)] def test_legacy_brep_tessellation_entry_points_are_removed(): brep = Brep() + assert not hasattr(brep, "tessellate") + assert not hasattr(brep, "polygonize") + assert not hasattr(brep, "to_polygonal_surface") assert not hasattr(brep, "to_meshes") - assert not hasattr(brep, "to_tessellation") assert not hasattr(brep, "to_tesselation") assert not hasattr(brep, "to_viewmesh") diff --git a/tests/compas/geometry/test_capsule.py b/tests/compas/geometry/test_capsule.py index 30e880bc6b2c..71bec9327518 100644 --- a/tests/compas/geometry/test_capsule.py +++ b/tests/compas/geometry/test_capsule.py @@ -3,7 +3,6 @@ from compas.geometry import Capsule from compas.geometry import Frame from compas.geometry import Line -from compas.geometry import UVGrid @pytest.fixture @@ -14,7 +13,7 @@ def capsule(): def test_capsule_discretization(capsule): # just checking these don't break. Could not quickly find a formula that worked to test the actual values # as function of the resolution - surface = capsule.polygonize(UVGrid(6, 4)) + surface = capsule.to_polygonal_surface(nu=6, nv=4) assert surface.vertices assert surface.faces @@ -83,7 +82,7 @@ def fail(): raise AssertionError("Topology computation should not regenerate positions.") monkeypatch.setattr(capsule, "_compute_local_vertices", fail) - assert capsule._compute_faces(UVGrid(6, 4)) + assert capsule._compute_faces((6, 4)) def test_capsule_bounds_are_invalidated(): diff --git a/tests/compas/geometry/test_cone.py b/tests/compas/geometry/test_cone.py index 6b1a6b0a7272..918d15c7f3c7 100644 --- a/tests/compas/geometry/test_cone.py +++ b/tests/compas/geometry/test_cone.py @@ -3,7 +3,6 @@ from compas.geometry import Cone from compas.geometry import Frame from compas.geometry import Line -from compas.geometry import UGrid @pytest.fixture @@ -14,7 +13,7 @@ def cone(): def test_cone_discretization(cone): # just checking these don't break. Could not quickly find a formula that worked to test the actual values # as function of the resolution - surface = cone.polygonize(UGrid(6)) + surface = cone.to_polygonal_surface(nu=6) assert surface.vertices assert surface.faces @@ -86,7 +85,7 @@ def fail(): raise AssertionError("Topology computation should not regenerate positions.") monkeypatch.setattr(cone, "_compute_local_vertices", fail) - assert cone._compute_faces(UGrid(6)) + assert cone._compute_faces((6,)) def test_cone_bounds_are_invalidated(): diff --git a/tests/compas/geometry/test_continuous_tessellation_api.py b/tests/compas/geometry/test_continuous_tessellation_api.py index dcc1ea7d3553..598af534ce84 100644 --- a/tests/compas/geometry/test_continuous_tessellation_api.py +++ b/tests/compas/geometry/test_continuous_tessellation_api.py @@ -1,183 +1,88 @@ +import inspect + import pytest +import compas.geometry as geometry from compas.geometry import Box -from compas.geometry import Brep -from compas.geometry import BrepFace from compas.geometry import Capsule from compas.geometry import Cone from compas.geometry import Cylinder -from compas.geometry import Native from compas.geometry import PlanarSurface from compas.geometry import Sphere from compas.geometry import Torus -from compas.geometry import UGrid -from compas.geometry import UVGrid - - -def test_parametric_surface_requires_uvgrid(): - surface = PlanarSurface() - - polygonal = surface.polygonize(UVGrid(2, 3)) - tessellation = surface.tessellate(UVGrid(2, 3)) - - assert len(polygonal.vertices) == 12 - assert len(polygonal.faces) == 6 - assert all(len(face) == 4 for face in polygonal.faces) - assert tessellation.number_of_vertices == 12 - assert tessellation.number_of_triangles == 12 - assert tessellation.vertex_normals is not None - assert tessellation.texture_coordinates is not None - assert not hasattr(surface, "resolution_u") - assert not hasattr(surface, "resolution_v") - - with pytest.raises(TypeError): - surface.tessellate(UGrid(3)) - - -def test_surface_tessellation_default_is_uvgrid_64(): - surface = PlanarSurface() - data = surface.__data__ - - omitted = surface.tessellate() - none = surface.tessellate(None) - explicit = surface.tessellate(UVGrid(64, 64)) - - assert omitted.number_of_vertices == 4225 - assert omitted.number_of_triangles == 8192 - assert omitted.vertices == explicit.vertices - assert omitted.triangles == explicit.triangles - assert none.vertices == explicit.vertices - assert none.triangles == explicit.triangles - assert omitted is not none - assert omitted.vertices is not none.vertices - assert surface.__data__ == data @pytest.mark.parametrize( - ("shape", "strategy", "vertex_count", "triangle_count"), + ("producer", "kwargs", "vertex_count", "face_count", "triangle_count"), [ - (Box(1.0), Native(), 8, 12), - (Cylinder(1.0, 2.0), UGrid(64), 130, 256), - (Cone(1.0, 2.0), UGrid(64), 66, 128), - (Capsule(1.0, 2.0), UVGrid(64, 64), 4098, 8192), - (Sphere(1.0), UVGrid(64, 64), 4034, 8064), - (Torus(2.0, 0.5), UVGrid(64, 64), 4096, 8192), + (Box(1.0), {}, 8, 6, 12), + (Cylinder(1.0, 2.0), {"nu": 6}, 14, 18, 24), + (Cone(1.0, 2.0), {"nu": 6}, 8, 12, 12), + (Capsule(1.0, 2.0), {"nu": 6, "nv": 4}, 26, 30, 48), + (Sphere(1.0), {"nu": 6, "nv": 4}, 20, 24, 36), + (Torus(2.0, 0.5), {"nu": 6, "nv": 4}, 24, 24, 48), + (PlanarSurface(), {"nu": 2, "nv": 3}, 12, 6, 12), ], ) -def test_shape_tessellation_defaults(shape, strategy, vertex_count, triangle_count): - data = shape.__data__ - - omitted = shape.tessellate() - none = shape.tessellate(None) - explicit = shape.tessellate(strategy) - - assert omitted.number_of_vertices == vertex_count - assert omitted.number_of_triangles == triangle_count - assert omitted.vertices == explicit.vertices - assert omitted.triangles == explicit.triangles - assert none.vertices == explicit.vertices - assert none.triangles == explicit.triangles - assert omitted is not none - assert omitted.vertices is not none.vertices - assert shape.__data__ == data - - -def test_explicit_tessellation_strategy_overrides_default(): - sphere = Sphere(1.0) - - default = sphere.tessellate() - explicit = sphere.tessellate(UVGrid(6, 4)) - - assert default.number_of_vertices == 4034 - assert explicit.number_of_vertices == 20 - - -@pytest.mark.parametrize("geometry", [Box(1.0), PlanarSurface(), Brep(), BrepFace()]) -def test_persistent_and_deflection_materialization_remain_explicit(geometry): - with pytest.raises(TypeError): - geometry.polygonize() - - if isinstance(geometry, (Brep, BrepFace)): - with pytest.raises(TypeError): - geometry.tessellate() - - -@pytest.mark.parametrize( - ("shape", "strategy", "vertex_count", "face_count"), - [ - (Box(1.0), Native(), 8, 6), - (Cylinder(1.0, 2.0), UGrid(6), 14, 18), - (Cone(1.0, 2.0), UGrid(6), 8, 12), - (Capsule(1.0, 2.0), UVGrid(6, 4), 26, 30), - (Sphere(1.0), UVGrid(6, 4), 20, 24), - (Torus(2.0, 0.5), UVGrid(6, 4), 24, 24), - ], -) -def test_shapes_use_explicit_strategy_dimensions(shape, strategy, vertex_count, face_count): - polygonal = shape.polygonize(strategy) - tessellation = shape.tessellate(strategy) +def test_structured_conversions_are_exactly_composed(producer, kwargs, vertex_count, face_count, triangle_count): + data = producer.__data__ + polygonal = producer.to_polygonal_surface(**kwargs) + direct = producer.to_tessellation(**kwargs) + composed = polygonal.triangulated() assert len(polygonal.vertices) == vertex_count assert len(polygonal.faces) == face_count - assert tessellation.number_of_vertices == vertex_count - assert not hasattr(shape, "resolution_u") - assert not hasattr(shape, "resolution_v") + assert direct.number_of_triangles == triangle_count + assert direct.vertices == composed.vertices + assert direct.triangles == composed.triangles + assert producer.__data__ == data -def test_continuous_materialization_is_fresh(): - shape = Sphere(1.0) - strategy = UVGrid(6, 4) +def test_surface_preserves_aligned_normals_and_uvs(): + result = PlanarSurface().to_tessellation(nu=2, nv=3) + assert len(result.vertex_normals) == result.number_of_vertices + assert len(result.texture_coordinates) == result.number_of_vertices - first = shape.polygonize(strategy) - second = shape.polygonize(strategy) - assert first is not second - assert first.vertices is not second.vertices - first.vertices[0][0] = 99 - assert second.vertices[0][0] != 99 +def test_default_resolutions_and_exact_signatures(): + assert str(inspect.signature(Box.to_polygonal_surface)) == "(self) -> 'PolygonalSurface'" + assert str(inspect.signature(Cone.to_polygonal_surface)) == "(self, *, nu: int = 64) -> 'PolygonalSurface'" + assert str(inspect.signature(Sphere.to_tessellation)) == "(self, *, nu: int = 64, nv: int = 64) -> 'Tessellation'" + assert str(inspect.signature(PlanarSurface.to_tessellation)) == "(self, *, nu: int = 64, nv: int = 64) -> 'Tessellation'" + assert Sphere(1.0).to_tessellation().number_of_vertices == 4034 + assert PlanarSurface().to_tessellation().number_of_vertices == 4225 -@pytest.mark.parametrize( - ("shape", "invalid_strategy"), - [ - (Box(1.0), UVGrid(3, 4)), - (Cylinder(1.0, 2.0), UVGrid(3, 4)), - (Cone(1.0, 2.0), Native()), - (Capsule(1.0, 2.0), UGrid(4)), - (Sphere(1.0), UGrid(4)), - (Torus(2.0, 0.5), Native()), - ], -) -def test_shapes_reject_unsupported_strategies(shape, invalid_strategy): +@pytest.mark.parametrize("invalid", [True, 3.0, "3"]) +def test_resolution_rejects_bool_and_non_integral_values(invalid): with pytest.raises(TypeError): - shape.polygonize(invalid_strategy) - with pytest.raises(TypeError): - shape.tessellate(invalid_strategy) - + Sphere(1.0).to_polygonal_surface(nu=invalid) -def test_capsule_rejects_odd_v_resolution(): - capsule = Capsule(1.0, 2.0) +def test_resolution_minimums_and_capsule_even_nv(): with pytest.raises(ValueError): - capsule.polygonize(UVGrid(6, 5)) + Cone(1.0, 2.0).to_polygonal_surface(nu=2) with pytest.raises(ValueError): - capsule.tessellate(UVGrid(6, 5)) - + PlanarSurface().to_polygonal_surface(nu=0) + with pytest.raises(ValueError): + Capsule(1.0, 2.0).to_polygonal_surface(nv=3) + with pytest.raises(ValueError, match="even"): + Capsule(1.0, 2.0).to_polygonal_surface(nv=5) -def test_polygonize_returns_independent_serializable_geometry(): - shape = Box(1.0) - surface = shape.polygonize(Native()) - surface.vertices[0][0] = 100.0 - assert shape.polygonize(Native()).vertices[0][0] != 100.0 - assert surface.__data__ +def test_structured_results_are_fresh(): + shape = Sphere(1.0) + first = shape.to_polygonal_surface(nu=6, nv=4) + second = shape.to_polygonal_surface(nu=6, nv=4) + first.vertices[0][0] = 99 + assert second.vertices[0][0] != 99 -@pytest.mark.parametrize("geometry", [PlanarSurface(), Box(1.0)]) -def test_legacy_continuous_tessellation_entry_points_are_removed(geometry): - assert not hasattr(geometry, "tessellation") - assert not hasattr(geometry, "compute_tessellation") - assert not hasattr(geometry, "to_tessellation") - assert not hasattr(geometry, "to_polygonal_surface") - assert not hasattr(geometry, "mesh") - assert not hasattr(geometry, "compute_mesh") +def test_removed_continuous_entry_points_and_strategies_are_absent(): + for producer in (Box(1.0), PlanarSurface()): + assert not hasattr(producer, "polygonize") + assert not hasattr(producer, "tessellate") + for producer in (Box(1.0), Cone(1.0, 2.0), Cylinder(1.0, 2.0)): + assert not hasattr(producer, "_tessellation_strategy") + for name in ("TessellationStrategy", "Native", "UGrid", "UVGrid", "Deflection", "Adaptive"): + assert not hasattr(geometry, name) diff --git a/tests/compas/geometry/test_core.py b/tests/compas/geometry/test_core.py index 32048062c775..d9e74849a3a9 100644 --- a/tests/compas/geometry/test_core.py +++ b/tests/compas/geometry/test_core.py @@ -1,5 +1,6 @@ from math import pi, radians, sqrt +import numpy as np import pytest from compas.geometry import Polyhedron @@ -344,6 +345,20 @@ def test_volume_polyhedron(polyhedron, volume): assert TOL.is_close(V, volume) +def test_polyhedron_measurements_accept_numpy_buffers_without_mutating_inputs(): + polyhedron = Polyhedron.from_platonicsolid(6) + vertices = [vertex[:] for vertex in polyhedron.vertices] + faces = [face[:] for face in polyhedron.faces] + vertex_count = len(vertices) + expected_centroid = centroid_polyhedron((vertices, faces)) + expected_volume = volume_polyhedron((vertices, faces)) + + assert len(vertices) == vertex_count + assert TOL.is_allclose(centroid_polyhedron((np.asarray(vertices), np.asarray(faces))), expected_centroid) + assert TOL.is_close(volume_polyhedron((np.asarray(vertices), np.asarray(faces))), expected_volume) + assert len(vertices) == vertex_count + + def test_area_square(square, R): assert TOL.is_close(area_polygon(square.points), 1) assert TOL.is_close(area_polygon_xy(square.points), 1) diff --git a/tests/compas/geometry/test_core_predicates_2.py b/tests/compas/geometry/test_core_predicates_2.py index f6c5212c2402..a2ca4e6085ec 100644 --- a/tests/compas/geometry/test_core_predicates_2.py +++ b/tests/compas/geometry/test_core_predicates_2.py @@ -1,10 +1,13 @@ # from compas.geometry import Circle # from compas.geometry import Plane # from compas.geometry import Point +import numpy as np + from compas.geometry import Polygon # from compas.geometry import Vector from compas.geometry import is_point_in_circle_xy, is_polygon_in_polygon_xy +from compas.geometry._core.predicates_2 import is_intersection_segment_segment_xy def test_is_point_in_circle_xy(): @@ -17,6 +20,20 @@ def test_is_point_in_circle_xy(): assert not is_point_in_circle_xy(pt_outside, circle) +def test_is_intersection_segment_segment_xy_preserves_scalar_return_type(): + python_result = is_intersection_segment_segment_xy( + [[0, 0, 0], [1, 1, 0]], + [[0, 1, 0], [1, 0, 0]], + ) + numpy_result = is_intersection_segment_segment_xy( + np.asarray([[0, 0, 0], [1, 1, 0]], dtype=np.float32), + np.asarray([[0, 1, 0], [1, 0, 0]], dtype=np.float64), + ) + + assert type(python_result) is bool + assert type(numpy_result) is np.bool_ + + # def test_is_point_in_circle_xy_class_input(): # pt_inside = Point(1, 2, 0) # plane = Plane(Point(2, 2, 10), Vector(0, 0, 1)) diff --git a/tests/compas/geometry/test_core_tangent.py b/tests/compas/geometry/test_core_tangent.py index e874cd61ea2c..9150d0dca6ec 100644 --- a/tests/compas/geometry/test_core_tangent.py +++ b/tests/compas/geometry/test_core_tangent.py @@ -28,7 +28,7 @@ def test_tangent_point_on_circle_returns_same_point_twice(): def test_tangent_points_rejects_point_inside_circle(): circle = ((0.0, 0.0, 0.0), (0.0, 0.0, 1.0)), 1.0 - with pytest.raises(ValueError, match="math domain error"): + with pytest.raises(ValueError): tangent_points_to_circle_xy(circle, (0.5, 0.0)) diff --git a/tests/compas/geometry/test_cylinder.py b/tests/compas/geometry/test_cylinder.py index 55f4a9bf3be1..0a16627418b6 100644 --- a/tests/compas/geometry/test_cylinder.py +++ b/tests/compas/geometry/test_cylinder.py @@ -4,7 +4,6 @@ from compas.geometry import Frame from compas.geometry import Line from compas.geometry import Point -from compas.geometry import UGrid @pytest.fixture @@ -15,7 +14,7 @@ def cylinder(): def test_cylinder_discretization(cylinder): # just checking these don't break. Could not quickly find a formula that worked to test the actual values # as function of the resolution - surface = cylinder.polygonize(UGrid(6)) + surface = cylinder.to_polygonal_surface(nu=6) assert surface.vertices assert surface.faces @@ -96,7 +95,7 @@ def fail(): raise AssertionError("Topology computation should not regenerate positions.") monkeypatch.setattr(cylinder, "_compute_local_vertices", fail) - assert cylinder._compute_faces(UGrid(6)) + assert cylinder._compute_faces((6,)) def test_cylinder_bounds_are_invalidated(): diff --git a/tests/compas/geometry/test_incomplete_geometry_apis.py b/tests/compas/geometry/test_incomplete_geometry_apis.py index 9be3345603d6..7a633f45ac0f 100644 --- a/tests/compas/geometry/test_incomplete_geometry_apis.py +++ b/tests/compas/geometry/test_incomplete_geometry_apis.py @@ -10,8 +10,6 @@ from compas.geometry import BrepType from compas.geometry import BrepVertex from compas.geometry import Circle -from compas.geometry import ConformingDelaunay -from compas.geometry import ConstrainedDelaunay from compas.geometry import Cone from compas.geometry import Cylinder from compas.geometry import Ellipse @@ -23,7 +21,8 @@ from compas.geometry import Polyline from compas.geometry import Sphere from compas.geometry import Torus -from compas.geometry import triangulate_planar_domain +from compas.geometry import conforming_delaunay_triangulation +from compas.geometry import constrained_delaunay_triangulation from compas.plugins import PluginNotInstalledError from compas.plugins import plugin_manager @@ -331,11 +330,11 @@ def test_constrained_delaunay_requires_plugin(): boundary = [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)] with pytest.raises(PluginNotInstalledError): - triangulate_planar_domain(boundary, strategy=ConstrainedDelaunay()) + constrained_delaunay_triangulation(boundary) def test_conforming_delaunay_requires_plugin(): boundary = [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)] with pytest.raises(PluginNotInstalledError): - triangulate_planar_domain(boundary, strategy=ConformingDelaunay()) + conforming_delaunay_triangulation(boundary) diff --git a/tests/compas/geometry/test_planar_region.py b/tests/compas/geometry/test_planar_region.py index faf9acacc4c2..27f16198c2cb 100644 --- a/tests/compas/geometry/test_planar_region.py +++ b/tests/compas/geometry/test_planar_region.py @@ -2,7 +2,6 @@ import pytest -from compas.geometry import EarClipping from compas.geometry import PlanarRegion from compas.geometry import Translation @@ -16,7 +15,7 @@ def region(): def test_planar_region_triangulates_holes(region): - tessellation = region.triangulated(EarClipping()) + tessellation = region.triangulated() vertices, triangles = tessellation.to_vertices_and_triangles() assert len(vertices) == 8 @@ -76,7 +75,7 @@ def test_planar_region_defers_topology_validation_until_tessellation(): assert len(region.outer) == 4 with pytest.raises(ValueError, match="self-intersecting"): - region.triangulated(EarClipping()) + region.triangulated() @pytest.mark.parametrize( @@ -94,7 +93,7 @@ def test_planar_region_rejects_holes_not_strictly_inside_outer_boundary(hole): ) with pytest.raises(ValueError, match="strictly inside"): - region.triangulated(EarClipping()) + region.triangulated() @pytest.mark.parametrize( @@ -115,11 +114,11 @@ def test_planar_region_rejects_overlapping_touching_and_nested_holes(second_hole ) with pytest.raises(ValueError, match="must not overlap, touch, or contain"): - region.triangulated(EarClipping()) + region.triangulated() def test_planar_region_rejects_repeated_nonconsecutive_vertices(): region = PlanarRegion([[0, 0, 0], [3, 0, 0], [3, 3, 0], [0, 3, 0], [3, 0, 0]]) with pytest.raises(ValueError, match="duplicate vertices"): - region.triangulated(EarClipping()) + region.triangulated() diff --git a/tests/compas/geometry/test_polygonal_surface.py b/tests/compas/geometry/test_polygonal_surface.py index 24c48a002314..56d1fe7f2084 100644 --- a/tests/compas/geometry/test_polygonal_surface.py +++ b/tests/compas/geometry/test_polygonal_surface.py @@ -3,14 +3,11 @@ import pytest from compas.geometry import Box -from compas.geometry import DiagonalSplit -from compas.geometry import EarClipping -from compas.geometry import FaceTriangulation -from compas.geometry import Native from compas.geometry import PlanarSurface from compas.geometry import PolygonalSurface from compas.geometry import Translation -from compas.geometry import UVGrid +from compas.geometry import triangulate_polygon_earclip +from compas.geometry import triangulate_quadrilateral @pytest.fixture @@ -112,7 +109,7 @@ def test_polygonal_surface_conversions(quad): def test_shape_to_polygonal_surface(): - surface = Box(1.0).polygonize(Native()) + surface = Box(1.0).to_polygonal_surface() assert isinstance(surface, PolygonalSurface) assert surface.is_closed @@ -120,7 +117,7 @@ def test_shape_to_polygonal_surface(): def test_parametric_surface_to_polygonal_surface(): - surface = PlanarSurface().polygonize(UVGrid(2, 3)) + surface = PlanarSurface().to_polygonal_surface(nu=2, nv=3) assert isinstance(surface, PolygonalSurface) assert not surface.is_closed @@ -133,7 +130,7 @@ def test_polygonal_surface_triangulates_concave_faces(): [[0, 0, 0], [2, 0, 0], [2, 1, 0], [1, 1, 0], [1, 2, 0], [0, 2, 0]], [[0, 1, 2, 3, 4, 5]], ) - tessellation = surface.triangulated(FaceTriangulation()) + tessellation = surface.triangulated() triangulated = PolygonalSurface(tessellation.vertices, tessellation.triangles) assert len(tessellation.triangles) == 4 @@ -142,16 +139,16 @@ def test_polygonal_surface_triangulates_concave_faces(): def test_polygonal_surface_diagonal_split(quad): - split_02 = quad.triangulated(FaceTriangulation(quad=DiagonalSplit("02"))) - split_13 = quad.triangulated(FaceTriangulation(quad=DiagonalSplit("13"))) + split_02 = quad.triangulated(lambda vertices: triangulate_quadrilateral(vertices, diagonal="02")) + split_13 = quad.triangulated(lambda vertices: triangulate_quadrilateral(vertices, diagonal="13")) assert split_02.triangles == [[0, 1, 2], [0, 2, 3]] assert split_13.triangles == [[0, 1, 3], [1, 2, 3]] def test_polygonal_surface_requires_collection_strategy(quad): - with pytest.raises(TypeError, match="FaceTriangulation"): - quad.triangulated(DiagonalSplit()) + with pytest.raises(TypeError): + quad.triangulated(object()) # type: ignore[arg-type] def test_polygonal_surface_triangulates_mixed_faces(): @@ -173,7 +170,15 @@ def test_polygonal_surface_triangulates_mixed_faces(): ], [[0, 1, 2], [3, 4, 5, 6], [7, 8, 9, 10, 11, 12]], ) - tessellation = surface.triangulated(FaceTriangulation(quad=DiagonalSplit("02"), ngon=EarClipping())) + + def triangulator(vertices): + if len(vertices) == 4: + return triangulate_quadrilateral(vertices, diagonal="02") + if len(vertices) > 4: + return triangulate_polygon_earclip(vertices) + return [[0, 1, 2]] + + tessellation = surface.triangulated(triangulator) assert tessellation.triangles[:3] == [[0, 1, 2], [3, 4, 5], [3, 5, 6]] assert len(tessellation.triangles) == 7 @@ -185,8 +190,8 @@ def test_polygonal_surface_warped_quad_policy(): [[0, 1, 2, 3]], ) - tessellation = surface.triangulated(FaceTriangulation(quad=DiagonalSplit("02"))) + tessellation = surface.triangulated(lambda vertices: triangulate_quadrilateral(vertices, diagonal="02")) assert tessellation.triangles == [[0, 1, 2], [0, 2, 3]] with pytest.raises(ValueError, match="planar"): - surface.triangulated(FaceTriangulation(quad=EarClipping())) + surface.triangulated(triangulate_polygon_earclip) diff --git a/tests/compas/geometry/test_polyhedron.py b/tests/compas/geometry/test_polyhedron.py index 9f044cbd74a4..d2c79c148e1c 100644 --- a/tests/compas/geometry/test_polyhedron.py +++ b/tests/compas/geometry/test_polyhedron.py @@ -2,12 +2,10 @@ import pytest -from compas.geometry import DiagonalSplit -from compas.geometry import FaceTriangulation +from compas.geometry import triangulate_quadrilateral from compas.geometry import Point from compas.geometry import PolygonalSurface from compas.geometry import Polyhedron -from compas.geometry import Tessellation from compas.geometry import Scale @@ -90,7 +88,7 @@ def test_polyhedron_rejects_collapsing_transform_without_mutation(): def test_polyhedron_triangulates_polygonal_boundary(): polyhedron = Polyhedron.from_platonicsolid(6) - tessellation = polyhedron.triangulated(FaceTriangulation(quad=DiagonalSplit("02"))) + tessellation = polyhedron.triangulated(lambda vertices: triangulate_quadrilateral(vertices, diagonal="02")) vertices, faces = tessellation.to_vertices_and_triangles() assert len(vertices) == 8 @@ -110,5 +108,5 @@ def boolean_union(A, B): result = polyhedron.boolean_union(polyhedron) assert isinstance(result, Polyhedron) - assert all(isinstance(item, Tessellation) for item in captured) - assert all(len(face) == 3 for item in captured for face in item.triangles) + assert all(isinstance(item, tuple) for item in captured) + assert all(len(face) == 3 for _, faces in captured for face in faces) diff --git a/tests/compas/geometry/test_quadmesh_planarize.py b/tests/compas/geometry/test_quadmesh_planarize.py index 157576b21023..c1637364c37f 100644 --- a/tests/compas/geometry/test_quadmesh_planarize.py +++ b/tests/compas/geometry/test_quadmesh_planarize.py @@ -2,11 +2,14 @@ from compas.geometry import quadmesh_planarize +default_quadmesh_planarize = quadmesh_planarize.__wrapped__ + + def test_quadmesh_planarize_has_default_implementation(): vertices = [[0, 0, 0], [1, 0, 0], [1, 1, 0.2], [0, 1, 0]] faces = [[0, 1, 2, 3]] - planarized = quadmesh_planarize((vertices, faces), kmax=10, maxdev=1e-6) + planarized = default_quadmesh_planarize((vertices, faces), kmax=10, maxdev=1e-6) diagonal_distance = distance_line_line( (planarized[0], planarized[2]), @@ -19,6 +22,6 @@ def test_quadmesh_planarize_has_default_implementation(): def test_quadmesh_planarize_preserves_unused_vertices(): vertices = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [2, 2, 2]] - planarized = quadmesh_planarize((vertices, [[0, 1, 2, 3]]), kmax=1) + planarized = default_quadmesh_planarize((vertices, [[0, 1, 2, 3]]), kmax=1) assert planarized[4] == [2, 2, 2] diff --git a/tests/compas/geometry/test_shpere.py b/tests/compas/geometry/test_shpere.py index b2833da28dce..ecddbb4b3285 100644 --- a/tests/compas/geometry/test_shpere.py +++ b/tests/compas/geometry/test_shpere.py @@ -3,7 +3,6 @@ from compas.geometry import Frame from compas.geometry import Point from compas.geometry import Sphere -from compas.geometry import UVGrid @pytest.fixture @@ -16,7 +15,7 @@ def test_sphere_discretization(sphere): v = 16 expected_face_count = v * u expected_vertex_count = (v - 1) * u + 2 - surface = sphere.polygonize(UVGrid(u, v)) + surface = sphere.to_polygonal_surface(nu=u, nv=v) assert len(surface.faces) == expected_face_count assert len(surface.vertices) == expected_vertex_count @@ -77,7 +76,7 @@ def fail(): raise AssertionError("Topology computation should not regenerate positions.") monkeypatch.setattr(sphere, "_compute_local_vertices", fail) - assert sphere._compute_faces(UVGrid(6, 4)) + assert sphere._compute_faces((6, 4)) def test_sphere_bounds_are_invalidated(): diff --git a/tests/compas/geometry/test_surfaces.py b/tests/compas/geometry/test_surfaces.py index d5b9a64eeebd..7c2598442925 100644 --- a/tests/compas/geometry/test_surfaces.py +++ b/tests/compas/geometry/test_surfaces.py @@ -7,7 +7,6 @@ from compas.geometry import ConicalSurface from compas.geometry import ToroidalSurface from compas.geometry import PlanarSurface -from compas.geometry import UVGrid @pytest.mark.parametrize( @@ -88,10 +87,10 @@ def test_surface_transformation_reflects_direct_frame_mutation(): def test_surface_placement_preserves_discretization_topology(): surface = PlanarSurface() - before = surface.polygonize(UVGrid(2, 3)) + before = surface.to_polygonal_surface(nu=2, nv=3) surface.frame.point.x = 10.0 - after = surface.polygonize(UVGrid(2, 3)) + after = surface.to_polygonal_surface(nu=2, nv=3) assert after.vertices != before.vertices assert after.faces == before.faces @@ -119,14 +118,14 @@ def test_analytic_surface_periodicity(surface, periodic_u, periodic_v): def test_surface_discretization_counts(): surface = PlanarSurface(xsize=2.0, ysize=3.0) - polygonal = surface.polygonize(UVGrid(2, 3)) + polygonal = surface.to_polygonal_surface(nu=2, nv=3) assert len(polygonal.vertices) == 12 assert len(polygonal.faces) == 6 - assert surface.tessellate(UVGrid(2, 3)).number_of_triangles == 12 + assert surface.to_tessellation(nu=2, nv=3).number_of_triangles == 12 -@pytest.mark.parametrize("operation", ["polygonize", "tessellate"]) +@pytest.mark.parametrize("operation", ["to_polygonal_surface", "to_tessellation"]) def test_surface_discretization_rejects_nonfinite_domains(operation): class UnboundedPlanarSurface(PlanarSurface): @property @@ -136,7 +135,7 @@ def domain_u(self): surface = UnboundedPlanarSurface() with pytest.raises(ValueError, match="finite parameter domains"): - getattr(surface, operation)(UVGrid(2, 3)) + getattr(surface, operation)(nu=2, nv=3) def test_surface_face_computation_is_pure_topology(): diff --git a/tests/compas/geometry/test_surfaces_plane.py b/tests/compas/geometry/test_surfaces_plane.py index 58557072522f..41d8e551ea09 100644 --- a/tests/compas/geometry/test_surfaces_plane.py +++ b/tests/compas/geometry/test_surfaces_plane.py @@ -7,7 +7,6 @@ from compas.geometry import Frame from compas.geometry import Plane from compas.geometry import PlanarSurface -from compas.geometry import UVGrid from compas.tolerance import TOL from compas.itertools import linspace @@ -178,12 +177,12 @@ def test_plane_conversion_to_mesh(xsize, ysize): area = plane.xsize * plane.ysize - mesh = plane.polygonize(UVGrid(1, 1)).to_mesh() + mesh = plane.to_polygonal_surface(nu=1, nv=1).to_mesh() assert mesh.number_of_vertices() == 4 assert mesh.number_of_faces() == 1 assert TOL.is_close(mesh.area(), area) - mesh = plane.polygonize(UVGrid(10, 10)).to_mesh() + mesh = plane.to_polygonal_surface(nu=10, nv=10).to_mesh() assert mesh.number_of_vertices() == 121 assert mesh.number_of_faces() == 100 assert TOL.is_close(mesh.area(), area) diff --git a/tests/compas/geometry/test_tessellation.py b/tests/compas/geometry/test_tessellation.py index d8859338d501..75e397457e53 100644 --- a/tests/compas/geometry/test_tessellation.py +++ b/tests/compas/geometry/test_tessellation.py @@ -1,16 +1,11 @@ import math +from operator import index import numpy as np # pyright: ignore[reportMissingImports] import pytest import compas -from compas.geometry import Adaptive from compas.geometry import Box -from compas.geometry import Deflection -from compas.geometry import DiagonalSplit -from compas.geometry import EarClipping -from compas.geometry import FaceTriangulation -from compas.geometry import Native from compas.geometry import PlanarRegion from compas.geometry import PlanarSurface from compas.geometry import Polygon @@ -18,67 +13,42 @@ from compas.geometry import Polyhedron from compas.geometry import Sphere from compas.geometry import Tessellation -from compas.geometry import TessellationStrategy -from compas.geometry import UGrid -from compas.geometry import UVGrid from compas.geometry import normal_triangle from compas.tolerance import TOL -@pytest.mark.parametrize( - "strategy", - [ - Native(), - UGrid(8), - UVGrid(8, 4), - Deflection(0.01, 15.0), - Adaptive(max_deviation=0.01), - Adaptive(max_angle=5.0, max_edge_length=0.1), - ], -) -def test_tessellation_strategies_are_data(strategy): - assert isinstance(strategy, TessellationStrategy) - other = compas.json_loads(compas.json_dumps(strategy)) - assert other == strategy - assert hash(other) == hash(strategy) +class FloatLike: + def __init__(self, value): + self.value = value + self.conversions = 0 + def __float__(self): + self.conversions += 1 + return float(self.value) -def test_tessellation_strategies_are_immutable(): - strategy = UVGrid(8, 4) - with pytest.raises(AttributeError): - strategy.u = 16 +class IndexLike: + def __init__(self, value): + self.value = value + self.conversions = 0 -@pytest.mark.parametrize("value", [0, -1, 1.5, True]) -def test_grid_strategies_require_positive_integers(value): - with pytest.raises((TypeError, ValueError)): - UGrid(value) - with pytest.raises((TypeError, ValueError)): - UVGrid(1, value) + def __index__(self): + self.conversions += 1 + return index(self.value) -@pytest.mark.parametrize("value", [0.0, -1.0, math.inf, math.nan]) -def test_deflection_requires_positive_finite_values(value): - with pytest.raises(ValueError): - Deflection(value, 1.0) - with pytest.raises(ValueError): - Deflection(1.0, value) +class DuckBuffer: + def __init__(self, values): + self.values = values + def __len__(self): + return len(self.values) -def test_deflection_uses_public_degrees(): - strategy = Deflection(0.01, 180.0) - assert strategy.angular == 180.0 - with pytest.raises(ValueError): - Deflection(0.01, 180.1) - + def __getitem__(self, key): + return self.values[key] -def test_adaptive_requires_at_least_one_constraint(): - with pytest.raises(ValueError): - Adaptive() - with pytest.raises(ValueError): - Adaptive(max_deviation=0.0) - with pytest.raises(ValueError): - Adaptive(max_angle=180.1) + def __iter__(self): + return iter(self.values) def test_tessellation_retains_input_buffers(): @@ -100,6 +70,84 @@ def test_tessellation_retains_input_buffers(): assert tessellation.number_of_triangles == 1 +def test_tessellation_retains_noncontiguous_readonly_buffers_without_mutation(): + vertex_source = np.arange(18.0).reshape(3, 6) + vertices = vertex_source[:, ::2] + vertices.setflags(write=False) + triangles = np.asfortranarray([[0, 1, 2]], dtype=np.int32) + normals = np.asfortranarray([[0.0, 0.0, 1.0]] * 3) + texture_coordinates = np.arange(12.0).reshape(3, 4)[:, ::2] + texture_coordinates.setflags(write=False) + polylines = np.asfortranarray([[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]]) + before = vertex_source.copy(), triangles.copy(), normals.copy(), texture_coordinates.copy(), polylines.copy() + + tessellation = Tessellation(vertices, triangles, normals, texture_coordinates, polylines) + + assert tessellation.vertices is vertices + assert tessellation.triangles is triangles + assert tessellation.vertex_normals is normals + assert tessellation.texture_coordinates is texture_coordinates + assert tessellation.polylines is polylines + for actual, expected in zip((vertex_source, triangles, normals, texture_coordinates, polylines), before): + np.testing.assert_array_equal(actual, expected) + + +def test_tessellation_validates_duck_typed_numeric_elements_by_capability(): + vertices = DuckBuffer([DuckBuffer([FloatLike(0.0), FloatLike(0.0), FloatLike(0.0)])]) + triangles = DuckBuffer([DuckBuffer([IndexLike(0), IndexLike(0), IndexLike(0)])]) + + tessellation = Tessellation(vertices, triangles) + + assert tessellation.vertices is vertices + assert tessellation.triangles is triangles + + +@pytest.mark.parametrize("value", [True, np.bool_(True)]) +def test_tessellation_rejects_boolean_coordinate_elements(value): + with pytest.raises(TypeError, match="coordinates should be real numbers"): + Tessellation([[value, 0.0, 0.0]], []) + + +@pytest.mark.parametrize("value", [True, np.bool_(True)]) +def test_tessellation_rejects_boolean_index_elements(value): + with pytest.raises(TypeError, match="indices should be integers"): + Tessellation([[0.0, 0.0, 0.0]], [[value, 0, 0]]) + + +@pytest.mark.parametrize("conversion", ["surface", "mesh"]) +def test_tessellation_normalizes_once_only_at_materialization_boundaries(conversion): + coordinate_values = [ + FloatLike(0.0), + FloatLike(0.0), + FloatLike(0.0), + FloatLike(1.0), + FloatLike(0.0), + FloatLike(0.0), + FloatLike(0.0), + FloatLike(1.0), + FloatLike(0.0), + ] + index_values = [IndexLike(0), IndexLike(1), IndexLike(2)] + vertices = DuckBuffer([DuckBuffer(coordinate_values[offset : offset + 3]) for offset in range(0, 9, 3)]) + triangles = DuckBuffer([DuckBuffer(index_values)]) + tessellation = Tessellation(vertices, triangles) + for value in coordinate_values + index_values: + value.conversions = 0 + + if conversion == "surface": + result = tessellation.to_polygonal_surface() + assert result.vertices == [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + assert result.faces == [[0, 1, 2]] + else: + result = tessellation.to_mesh() + assert result.vertex_coordinates(0) == [0.0, 0.0, 0.0] + assert result.face_vertices(0) == [0, 1, 2] + + assert tessellation.vertices is vertices + assert tessellation.triangles is triangles + assert all(value.conversions == 1 for value in coordinate_values + index_values) + + def test_tessellation_converts_to_independent_compas_objects(): vertices = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] triangles = [[0, 1, 2]] @@ -128,6 +176,19 @@ def test_tessellation_validates_structural_buffers(vertices, triangles, error): Tessellation(vertices, triangles) +def test_tessellation_does_not_mutate_buffers_when_validation_fails(): + vertices = np.asarray([[0.0, 0.0, 0.0]]) + triangles = np.asarray([[0, 0, 1]], dtype=np.int64) + vertices_before = vertices.copy() + triangles_before = triangles.copy() + + with pytest.raises(IndexError, match="triangle index is out of range"): + Tessellation(vertices, triangles) + + np.testing.assert_array_equal(vertices, vertices_before) + np.testing.assert_array_equal(triangles, triangles_before) + + def test_tessellation_validates_aligned_attributes(): vertices = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] triangles = [[0, 1, 2]] @@ -185,16 +246,15 @@ def test_polygonal_geometry_produces_tessellations(): surface = PolygonalSurface([[0, 0, 0], [1, 0, 0], [0, 1, 0]], [[0, 1, 2]]) polyhedron = Polyhedron.from_platonicsolid(4) - face_strategy = FaceTriangulation(quad=EarClipping(), ngon=EarClipping()) - assert polygon.triangulated(face_strategy).number_of_triangles == 1 - assert region.triangulated(EarClipping()).number_of_triangles == 1 - assert surface.triangulated(face_strategy).vertices is surface.vertices - assert polyhedron.triangulated(face_strategy).number_of_triangles == 4 + assert polygon.triangulated().number_of_triangles == 1 + assert region.triangulated().number_of_triangles == 1 + assert surface.triangulated().vertices is surface.vertices + assert polyhedron.triangulated().number_of_triangles == 4 def test_parametric_geometry_produces_tessellations(): - shape_tessellation = Box(1.0).tessellate(Native()) - surface_tessellation = PlanarSurface().tessellate(UVGrid(2, 3)) + shape_tessellation = Box(1.0).to_tessellation() + surface_tessellation = PlanarSurface().to_tessellation(nu=2, nv=3) assert shape_tessellation.number_of_triangles == 12 assert surface_tessellation.number_of_vertices == 12 @@ -202,7 +262,7 @@ def test_parametric_geometry_produces_tessellations(): def test_surface_tessellation_aligns_positions_normals_uvs_and_winding(): - tessellation = PlanarSurface().tessellate(UVGrid(2, 3)) + tessellation = PlanarSurface().to_tessellation(nu=2, nv=3) assert tessellation.vertex_normals is not None assert tessellation.texture_coordinates is not None @@ -219,7 +279,7 @@ def test_surface_tessellation_aligns_positions_normals_uvs_and_winding(): def test_surface_tessellation_always_includes_aligned_attributes(): - tessellation = PlanarSurface().tessellate(UVGrid(2, 3)) + tessellation = PlanarSurface().to_tessellation(nu=2, nv=3) assert tessellation.vertex_normals is not None assert tessellation.texture_coordinates is not None @@ -230,7 +290,7 @@ def test_surface_tessellation_always_includes_aligned_attributes(): def test_shape_tessellation_has_no_stored_resolution(): shape = Sphere(1.0) - tessellation = shape.tessellate(UVGrid(5, 7)) + tessellation = shape.to_tessellation(nu=5, nv=7) assert tessellation.number_of_vertices == 32 assert not hasattr(shape, "resolution_u") @@ -245,33 +305,31 @@ def test_direct_conversions_preserve_polygonal_topology(): [[0, 1, 2, 3]], ) - assert shape.polygonize(Native()).to_mesh().number_of_faces() == 6 - assert shape.tessellate(Native()).to_mesh().number_of_faces() == 12 - assert surface.polygonize(UVGrid(2, 3)).to_mesh().number_of_faces() == 6 - assert surface.tessellate(UVGrid(2, 3)).to_mesh().number_of_faces() == 12 + assert shape.to_polygonal_surface().to_mesh().number_of_faces() == 6 + assert shape.to_tessellation().to_mesh().number_of_faces() == 12 + assert surface.to_polygonal_surface(nu=2, nv=3).to_mesh().number_of_faces() == 6 + assert surface.to_tessellation(nu=2, nv=3).to_mesh().number_of_faces() == 12 assert polygonal_surface.to_mesh().number_of_faces() == 1 - assert polygonal_surface.triangulated(FaceTriangulation()).to_mesh().number_of_faces() == 2 + assert polygonal_surface.triangulated().to_mesh().number_of_faces() == 2 def test_shape_topology_results_are_independent(): shape = Box(1.0) - first = shape.polygonize(Native()) - second = shape.polygonize(Native()) - triangles = shape.tessellate(Native()).triangles + first = shape.to_polygonal_surface() + second = shape.to_polygonal_surface() + triangles = shape.to_tessellation().triangles first.faces[0][0] = 99 assert second.faces[0][0] != 99 - assert isinstance(triangles, tuple) - assert all(isinstance(triangle, tuple) for triangle in triangles) + assert isinstance(triangles, list) + assert all(isinstance(triangle, list) for triangle in triangles) def test_surface_direct_and_two_step_tessellation_are_geometrically_consistent(): surface = PlanarSurface() - strategy = UVGrid(2, 3) - - direct = surface.tessellate(strategy) - two_step = surface.polygonize(strategy).triangulated(FaceTriangulation(quad=DiagonalSplit("02"))) + direct = surface.to_tessellation(nu=2, nv=3) + two_step = surface.to_polygonal_surface(nu=2, nv=3).triangulated() assert TOL.is_allclose(direct.vertices, two_step.vertices) assert direct.number_of_triangles == two_step.number_of_triangles @@ -279,12 +337,11 @@ def test_surface_direct_and_two_step_tessellation_are_geometrically_consistent() def test_surface_tessellation_and_polygonization_follow_placement(): surface = PlanarSurface() - strategy = UVGrid(2, 3) - original = surface.tessellate(strategy) + original = surface.to_tessellation(nu=2, nv=3) surface.frame.point.x += 2.0 - moved = surface.tessellate(strategy) - polygonal = surface.polygonize(strategy) + moved = surface.to_tessellation(nu=2, nv=3) + polygonal = surface.to_polygonal_surface(nu=2, nv=3) for before, after in zip(original.vertices, moved.vertices, strict=True): assert TOL.is_close(after[0] - before[0], 2.0) diff --git a/tests/compas/geometry/test_tessellation_plugins.py b/tests/compas/geometry/test_tessellation_plugins.py index e4ce5a6f6758..fc196f527e6c 100644 --- a/tests/compas/geometry/test_tessellation_plugins.py +++ b/tests/compas/geometry/test_tessellation_plugins.py @@ -3,32 +3,31 @@ from types import SimpleNamespace from typing import get_type_hints +import numpy as np import pytest import compas.plugins -from compas.geometry import Tessellation +from compas.typing import VerticesFaces +from compas.typing import PointGroupsLike from compas.geometry import boolean_difference_mesh_mesh from compas.geometry import boolean_intersection_mesh_mesh from compas.geometry import boolean_union_mesh_mesh from compas.geometry import intersection_mesh_mesh from compas.geometry import intersection_ray_mesh -from compas.geometry import trimesh_gaussian_curvature from compas.geometry import trimesh_geodistance from compas.geometry import trimesh_harmonic from compas.geometry import trimesh_isolines from compas.geometry import trimesh_lscm from compas.geometry import trimesh_massmatrix -from compas.geometry import trimesh_mean_curvature -from compas.geometry import trimesh_principal_curvature from compas.geometry import trimesh_remesh from compas.geometry import trimesh_remesh_along_isoline from compas.geometry import trimesh_remesh_constrained from compas.geometry import trimesh_slice -TRIANGLE = Tessellation( - [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], - [[0, 1, 2]], +TETRAHEDRON = ( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + [[0, 2, 1], [0, 1, 3], [1, 2, 3], [0, 3, 2]], ) @@ -47,73 +46,131 @@ def test_geometry_package_imports_in_fresh_interpreter(): assert result.returncode == 0, result.stderr +def test_geodistance_dispatches_raw_buffers(monkeypatch): + calls = [] + selected = [] + + def implementation(mesh, source, method="exact"): + calls.append((mesh, source, method)) + return [0.0, 1.0, 1.0] + + def select(extension_point): + selected.append((extension_point.category, extension_point.name)) + return SimpleNamespace(id="tests.geodistance", method=implementation) + + monkeypatch.setattr(compas.plugins.plugin_manager, "select", select) + + assert trimesh_geodistance(TETRAHEDRON, 0) == [0.0, 1.0, 1.0] + assert calls == [(TETRAHEDRON, 0, "exact")] + assert selected == [("trimesh", "_trimesh_geodistance")] + + @pytest.mark.parametrize( - ("operation", "parameter"), + "operation", [ - (boolean_union_mesh_mesh, "A"), - (boolean_difference_mesh_mesh, "A"), - (boolean_intersection_mesh_mesh, "A"), - (intersection_mesh_mesh, "A"), - (intersection_ray_mesh, "mesh"), - (trimesh_gaussian_curvature, "mesh"), - (trimesh_principal_curvature, "mesh"), - (trimesh_mean_curvature, "mesh"), - (trimesh_geodistance, "mesh"), - (trimesh_isolines, "mesh"), - (trimesh_massmatrix, "mesh"), - (trimesh_harmonic, "mesh"), - (trimesh_lscm, "mesh"), - (trimesh_remesh, "mesh"), - (trimesh_remesh_constrained, "mesh"), - (trimesh_remesh_along_isoline, "mesh"), - (trimesh_slice, "mesh"), + intersection_ray_mesh, + trimesh_geodistance, + trimesh_isolines, + trimesh_massmatrix, + trimesh_harmonic, + trimesh_lscm, ], ) -def test_geometry_processing_contracts_accept_tessellation(operation, parameter): - assert get_type_hints(operation)[parameter] is Tessellation +def test_libigl_backed_contracts_use_structural_triangle_meshes(operation): + hints = get_type_hints(operation) + assert hints["mesh"] == VerticesFaces @pytest.mark.parametrize( - "operation", + ("operation", "args", "hook", "result"), [ - boolean_union_mesh_mesh, - boolean_difference_mesh_mesh, - boolean_intersection_mesh_mesh, - trimesh_remesh, - trimesh_remesh_constrained, - trimesh_remesh_along_isoline, + (trimesh_isolines, ([0.0, 1.0, 1.0, 0.0], 4), "_trimesh_isolines", ([[0.0, 0.0, 0.0]], [[0, 0]])), + (trimesh_massmatrix, (), "_trimesh_massmatrix", [1.0, 1.0, 1.0, 1.0]), + (trimesh_harmonic, (), "_trimesh_harmonic", [[0.0, 0.0]] * 4), + (trimesh_lscm, (), "_trimesh_lscm", [[0.0, 0.0]] * 4), ], ) -def test_geometry_processing_geometry_results_are_tessellations(operation): - assert get_type_hints(operation)["return"] is Tessellation +def test_libigl_backed_operations_dispatch_raw_buffers(monkeypatch, operation, args, hook, result): + selected = [] + def implementation(mesh, *parameters): + assert mesh is TETRAHEDRON + assert parameters == args + return result -def test_natural_result_plugin_receives_tessellation(monkeypatch): - calls = [] + def select(extension_point): + selected.append((extension_point.category, extension_point.name)) + return SimpleNamespace(id="tests.libigl", method=implementation) + + monkeypatch.setattr(compas.plugins.plugin_manager, "select", select) + + assert operation(TETRAHEDRON, *args) == result + assert selected == [("trimesh", hook)] + + +def test_ray_mesh_intersection_dispatches_raw_buffers(monkeypatch): selected = [] + ray = ([0.0, 0.0, -1.0], [0.0, 0.0, 1.0]) + result = [(0, 0.25, 0.25, 1.0)] - def implementation(mesh, source, method="exact"): - calls.append((mesh, source, method)) - return [0.0, 1.0, 1.0] + def implementation(input_ray, mesh): + assert input_ray is ray + assert mesh is TETRAHEDRON + return result def select(extension_point): selected.append((extension_point.category, extension_point.name)) - return SimpleNamespace(id="tests.geodistance", method=implementation) + return SimpleNamespace(id="tests.libigl", method=implementation) monkeypatch.setattr(compas.plugins.plugin_manager, "select", select) - assert trimesh_geodistance(TRIANGLE, 0) == [0.0, 1.0, 1.0] - assert calls == [(TRIANGLE, 0, "exact")] - assert selected == [("trimesh", "trimesh_geodistance")] + assert intersection_ray_mesh(ray, TETRAHEDRON) == result + assert selected == [("intersections", "_intersection_ray_mesh")] -def test_geometry_result_plugin_returns_tessellation(monkeypatch): +@pytest.mark.parametrize( + "operation", + [boolean_union_mesh_mesh, boolean_difference_mesh_mesh, boolean_intersection_mesh_mesh], +) +def test_boolean_contracts_use_structural_triangle_meshes(operation): + hints = get_type_hints(operation) + assert hints["A"] == VerticesFaces + assert hints["B"] == VerticesFaces + assert hints["return"] == VerticesFaces + + +def test_boolean_plugin_receives_and_returns_raw_mesh(monkeypatch): calls = [] - result = Tessellation(TRIANGLE.vertices, TRIANGLE.triangles) + selected = [] def implementation(A, B): calls.append((A, B)) - return result + return A + + def select(extension_point): + selected.append((extension_point.category, extension_point.name)) + return SimpleNamespace(id="tests.boolean", method=implementation) + + monkeypatch.setattr(compas.plugins.plugin_manager, "select", select) + + assert boolean_union_mesh_mesh(TETRAHEDRON, TETRAHEDRON) is TETRAHEDRON + assert calls == [(TETRAHEDRON, TETRAHEDRON)] + assert selected == [("booleans", "_boolean_union_mesh_mesh")] + + +def test_boolean_plugin_accepts_numpy_buffers_without_conversion(monkeypatch): + vertices = np.asarray(TETRAHEDRON[0], dtype=np.float32, order="F") + triangles = np.asarray(TETRAHEDRON[1], dtype=np.int64, order="F") + vertices.flags.writeable = False + triangles.flags.writeable = False + mesh = (vertices, triangles) + + def implementation(A, B): + assert A[0] is vertices + assert A[1] is triangles + assert B[0] is vertices + assert B[1] is triangles + return A monkeypatch.setattr( compas.plugins.plugin_manager, @@ -121,33 +178,122 @@ def implementation(A, B): lambda extension_point: SimpleNamespace(id="tests.boolean", method=implementation), ) - assert boolean_union_mesh_mesh(TRIANGLE, TRIANGLE) is result - assert calls == [(TRIANGLE, TRIANGLE)] + result = boolean_union_mesh_mesh(mesh, mesh) + assert result[0] is vertices + assert result[1] is triangles -def test_raw_mesh_tuple_is_rejected_before_plugin_dispatch(monkeypatch): - calls = [] +def test_boolean_plugin_accepts_empty_result(monkeypatch): + monkeypatch.setattr( + compas.plugins.plugin_manager, + "select", + lambda extension_point: SimpleNamespace(id="tests.boolean", method=lambda A, B: ([], [])), + ) + + assert boolean_intersection_mesh_mesh(TETRAHEDRON, TETRAHEDRON) == ([], []) + + +@pytest.mark.parametrize( + ("operation", "parameters"), + [ + (intersection_mesh_mesh, ("A", "B")), + (trimesh_slice, ("mesh",)), + ], +) +def test_natural_result_contracts_use_structural_triangle_meshes(operation, parameters): + hints = get_type_hints(operation) + for parameter in parameters: + assert hints[parameter] == VerticesFaces + assert hints["return"] == PointGroupsLike + + +def test_mesh_intersection_dispatches_raw_buffers(monkeypatch): + selected = [] + + def implementation(A, B): + assert A is TETRAHEDRON + assert B is TETRAHEDRON + return [[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]] def select(extension_point): - calls.append(extension_point) - return None + selected.append((extension_point.category, extension_point.name)) + return SimpleNamespace(id="tests.intersection", method=implementation) monkeypatch.setattr(compas.plugins.plugin_manager, "select", select) - with pytest.raises(TypeError, match="Tessellation"): - trimesh_geodistance((TRIANGLE.vertices, TRIANGLE.triangles), 0) # pyright: ignore[reportArgumentType] - assert calls == [] + assert intersection_mesh_mesh(TETRAHEDRON, TETRAHEDRON) == [[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]] + assert selected == [("intersections", "_intersection_mesh_mesh")] -def test_geometry_result_plugin_rejects_raw_tuple(monkeypatch): +def test_mesh_slice_dispatches_raw_buffers(monkeypatch): + selected = [] + planes = [([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])] + + def implementation(mesh, slicing_planes): + assert mesh is TETRAHEDRON + assert slicing_planes is planes + return [[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]] + + def select(extension_point): + selected.append((extension_point.category, extension_point.name)) + return SimpleNamespace(id="tests.slice", method=implementation) + + monkeypatch.setattr(compas.plugins.plugin_manager, "select", select) + + assert trimesh_slice(TETRAHEDRON, planes) == [[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]] + assert selected == [("trimesh", "_trimesh_slice")] + + +@pytest.mark.parametrize( + "operation", + [trimesh_remesh, trimesh_remesh_constrained, trimesh_remesh_along_isoline], +) +def test_remesh_contracts_use_structural_triangle_meshes(operation): + hints = get_type_hints(operation) + assert hints["mesh"] == VerticesFaces + assert hints["return"] == VerticesFaces + + +def test_remesh_dispatches_raw_buffers(monkeypatch): + selected = [] + def implementation(mesh, target_edge_length, number_of_iterations=10, do_project=True): - return mesh.to_vertices_and_triangles() + assert mesh is TETRAHEDRON + assert target_edge_length == 0.5 + assert number_of_iterations == 7 + assert do_project is False + return mesh - monkeypatch.setattr( - compas.plugins.plugin_manager, - "select", - lambda extension_point: SimpleNamespace(id="tests.remesh", method=implementation), - ) + def select(extension_point): + selected.append((extension_point.category, extension_point.name)) + return SimpleNamespace(id="tests.remesh", method=implementation) + + monkeypatch.setattr(compas.plugins.plugin_manager, "select", select) + + assert trimesh_remesh(TETRAHEDRON, 0.5, 7, False) is TETRAHEDRON + assert selected == [("trimesh", "_trimesh_remesh")] + + +@pytest.mark.parametrize( + ("operation", "args", "hook"), + [ + (trimesh_remesh_constrained, (0.5, [[0, 1]], 7, False), "_trimesh_remesh_constrained"), + (trimesh_remesh_along_isoline, ([0.0, 1.0, 1.0, 0.0], 0.5), "_trimesh_remesh_along_isoline"), + ], +) +def test_specialized_remesh_dispatches_raw_buffers(monkeypatch, operation, args, hook): + selected = [] + + def implementation(mesh, *parameters): + assert mesh is TETRAHEDRON + assert parameters == args + return mesh + + def select(extension_point): + selected.append((extension_point.category, extension_point.name)) + return SimpleNamespace(id="tests.remesh", method=implementation) + + monkeypatch.setattr(compas.plugins.plugin_manager, "select", select) - with pytest.raises(TypeError, match="return a Tessellation"): - trimesh_remesh(TRIANGLE, 1.0) + assert operation(TETRAHEDRON, *args) is TETRAHEDRON + assert selected == [("trimesh", hook)] diff --git a/tests/compas/geometry/test_torus.py b/tests/compas/geometry/test_torus.py index 079abf43e66b..3c9d484a140c 100644 --- a/tests/compas/geometry/test_torus.py +++ b/tests/compas/geometry/test_torus.py @@ -2,7 +2,6 @@ from compas.geometry import Torus from compas.geometry import Frame -from compas.geometry import UVGrid @pytest.fixture @@ -13,7 +12,7 @@ def torus(): def test_torus_discretization(torus): # just checking these don't break. Could not quickly find a formula that worked to test the actual values # as function of the resolution - surface = torus.polygonize(UVGrid(6, 4)) + surface = torus.to_polygonal_surface(nu=6, nv=4) assert surface.vertices assert surface.faces @@ -23,7 +22,7 @@ def fail(strategy): raise AssertionError("face generation should not compute positions") monkeypatch.setattr(torus, "_compute_local_vertices", fail) - assert torus._compute_faces(UVGrid(6, 4)) + assert torus._compute_faces((6, 4)) def test_torus_scaled(): diff --git a/tests/compas/geometry/test_transformations/test_transformation.py b/tests/compas/geometry/test_transformations/test_transformation.py index 4e24f993caaa..53b45a167a2e 100644 --- a/tests/compas/geometry/test_transformations/test_transformation.py +++ b/tests/compas/geometry/test_transformations/test_transformation.py @@ -1,3 +1,5 @@ +import numpy as np + from compas.geometry import Frame from compas.geometry import Projection from compas.geometry import Reflection @@ -97,6 +99,23 @@ def test_from_matrix(): assert Transformation.from_matrix(matrix).matrix == matrix +def test_matrix_constructor_retains_mutable_float_and_numpy_scalar_lists(): + float_matrix = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]] + numpy_matrix = [[np.float64(value) for value in row] for row in float_matrix] + + float_transformation = Transformation(float_matrix) + numpy_transformation = Transformation(numpy_matrix) + + assert float_transformation.matrix is float_matrix + assert numpy_transformation.matrix is numpy_matrix + + float_transformation[0, 3] = 2.0 + numpy_transformation[0, 3] = 3.0 + + assert float_matrix[0][3] == 2.0 + assert numpy_matrix[0][3] == 3.0 + + def test_from_list(): numbers = [1, 0, 0, 3, 0, 1, 0, 4, 0, 0, 1, 5, 0, 0, 0, 1] T = Transformation.from_list(numbers) diff --git a/tests/compas/geometry/test_triangulation_delaunay.py b/tests/compas/geometry/test_triangulation_delaunay.py index bf8ff9b01ba9..967bcb802b04 100644 --- a/tests/compas/geometry/test_triangulation_delaunay.py +++ b/tests/compas/geometry/test_triangulation_delaunay.py @@ -1,117 +1,137 @@ -import compas -import compas.geometry as geometry +import numpy as np import pytest -from compas.geometry import ConformingDelaunay -from compas.geometry import ConstrainedDelaunay -from compas.geometry import Delaunay +import compas.geometry as geometry from compas.geometry import Frame -from compas.geometry import Tessellation -from compas.geometry import TriangulationQuality -from compas.geometry import triangulate_planar_domain -from compas.geometry import triangulate_planar_points +from compas.geometry import conforming_delaunay_triangulation +from compas.geometry import constrained_delaunay_triangulation +from compas.geometry import delaunay_triangulation -def test_unqualified_planar_operation_names_are_not_exported(): - assert not hasattr(geometry, "triangulate_points") - assert not hasattr(geometry, "triangulate_domain") +def test_removed_planar_strategy_paths_are_not_exported(): + for name in ( + "Delaunay", + "ConstrainedDelaunay", + "ConformingDelaunay", + "TriangulationQuality", + "triangulate_planar_points", + "triangulate_planar_domain", + ): + assert not hasattr(geometry, name) -def test_planar_strategies_roundtrip_as_data(): - strategies = [ - Delaunay(), - ConstrainedDelaunay(), - ConformingDelaunay(), - ConformingDelaunay(TriangulationQuality(min_angle=20.0, max_area=1.0, max_edge_length=2.0)), - ] - for strategy in strategies: - other = compas.json_loads(compas.json_dumps(strategy)) - assert other == strategy - assert hash(other) == hash(strategy) +def test_delaunay_returns_structural_buffers_and_preserves_coordinates(): + points = [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [2.0, 1.0, 0.0], [0.0, 1.0, 0.0]] + vertices, triangles = delaunay_triangulation(points) + assert vertices == points + assert len(triangles) == 2 + assert all(len(triangle) == 3 for triangle in triangles) + + +def test_delaunay_coalesces_repeated_source_locations(): + points = [[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 0]] + vertices, triangles = delaunay_triangulation(points) + assert vertices == points[:3] + assert triangles == [[1, 2, 0]] + + +@pytest.mark.parametrize("dtype", [np.float32, np.float64, np.int32, np.int64]) +@pytest.mark.parametrize("layout", ["c", "f", "sliced", "readonly"]) +def test_delaunay_accepts_numpy_without_mutation(dtype, layout): + values = np.asarray([[0, 0, 0], [2, 0, 0], [2, 1, 0], [0, 1, 0]], dtype=dtype) + if layout == "f": + points = np.asfortranarray(values) + elif layout == "sliced": + points = np.pad(values, ((0, 0), (0, 1)))[:, :3] + else: + points = values.copy() + if layout == "readonly": + points.flags.writeable = False + before = points.copy() + _, triangles = delaunay_triangulation(points) + assert len(triangles) == 2 + np.testing.assert_array_equal(points, before) + + +def test_delaunay_supports_explicit_frame_and_orients_results(): + frame = Frame.worldYZ() + points = [[0, 0, 0], [0, 2, 0], [0, 2, 1], [0, 0, 1]] + vertices, triangles = delaunay_triangulation(points, frame=frame) + assert vertices == points + for triangle in triangles: + local = [frame.to_local_coordinates(vertices[index]) for index in triangle] + signed_area = sum(local[index].x * local[(index + 1) % 3].y - local[(index + 1) % 3].x * local[index].y for index in range(3)) + assert signed_area > 0 -def test_triangulation_quality_validation(): - with pytest.raises(ValueError): - TriangulationQuality() - with pytest.raises(ValueError): - TriangulationQuality(min_angle=60.0) - with pytest.raises(ValueError): - TriangulationQuality(max_area=0.0) +def test_domain_functions_normalize_numpy_and_dispatch_named_hooks(monkeypatch): + boundary = np.asarray([[0, 0, 0], [3, 0, 0], [3, 3, 0], [0, 3, 0]], dtype=np.float32) + boundary.flags.writeable = False + before = boundary.copy() + captured = {} + def constrained(boundary, *, holes, constraints, points, frame): + captured["constrained"] = (boundary, holes, constraints, points, frame) + return boundary, [[0, 1, 2], [0, 2, 3]] -def test_triangulate_planar_points_returns_tessellation_and_preserves_coordinates(): - points = [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [2.0, 1.0, 0.0], [0.0, 1.0, 0.0]] + def conforming(boundary, *, holes, constraints, points, max_edge_length, frame): + captured["conforming"] = max_edge_length + return boundary, [[0, 1, 2], [0, 2, 3]] - result = triangulate_planar_points(points, strategy=Delaunay()) + monkeypatch.setattr("compas.geometry.triangulation_delaunay._constrained_delaunay_triangulation", constrained) + monkeypatch.setattr("compas.geometry.triangulation_delaunay._conforming_delaunay_triangulation", conforming) + assert len(constrained_delaunay_triangulation(boundary)[1]) == 2 + assert len(conforming_delaunay_triangulation(boundary, max_edge_length=1.0)[1]) == 2 + assert captured["constrained"][0] == boundary.tolist() + assert captured["conforming"] == 1.0 + np.testing.assert_array_equal(boundary, before) - assert isinstance(result, Tessellation) - assert result.vertices == points - assert len(result.triangles) == 2 - assert {int(index) for triangle in result.triangles for index in triangle} == {0, 1, 2, 3} +def test_domain_result_must_preserve_every_source_coordinate(monkeypatch): + boundary = [[0, 0, 0], [2, 0, 0], [0, 2, 0]] + points = [[0.5, 0.5, 0]] -def test_triangulate_planar_points_requires_world_xy_without_frame(): - with pytest.raises(ValueError): - triangulate_planar_points([[0, 0, 1], [1, 0, 1], [0, 1, 1]], strategy=Delaunay()) + def omit_point(boundary, *, holes, constraints, points, frame): + return boundary, [[0, 1, 2]] + monkeypatch.setattr("compas.geometry.triangulation_delaunay._constrained_delaunay_triangulation", omit_point) + with pytest.raises(ValueError, match="omitted or changed"): + constrained_delaunay_triangulation(boundary, points=points) -def test_triangulate_planar_points_accepts_explicit_frame_and_preserves_coordinates(): - frame = Frame.worldYZ() - points = [[0.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 2.0, 1.0], [0.0, 0.0, 1.0]] - result = triangulate_planar_points(points, strategy=Delaunay(), frame=frame) +def test_domain_validation_rejects_invalid_rings_and_crossing_constraints(monkeypatch): + boundary = [[0, 0, 0], [4, 0, 0], [4, 4, 0], [0, 4, 0]] - assert result.vertices == points - assert len(result.triangles) == 2 + def should_not_run(*args, **kwargs): + raise AssertionError("The plugin should not run for invalid input.") - -def test_triangulate_planar_points_rejects_off_frame_input(): - frame = Frame.worldYZ() - with pytest.raises(ValueError): - triangulate_planar_points([[1, 0, 0], [0, 1, 0], [0, 0, 1]], strategy=Delaunay(), frame=frame) + monkeypatch.setattr("compas.geometry.triangulation_delaunay._constrained_delaunay_triangulation", should_not_run) + with pytest.raises(ValueError, match="strictly inside"): + constrained_delaunay_triangulation( + boundary, + holes=[[[5, 5, 0], [6, 5, 0], [5, 6, 0]]], + ) + with pytest.raises(ValueError, match="shared endpoints"): + constrained_delaunay_triangulation( + boundary, + constraints=[[[1, 1, 0], [3, 3, 0]], [[1, 3, 0], [3, 1, 0]]], + ) -def test_triangulate_planar_points_requires_delaunay_strategy(): +@pytest.mark.parametrize("value", [True, "1"]) +def test_conforming_max_edge_length_rejects_non_real(value): + boundary = [[0, 0, 0], [1, 0, 0], [0, 1, 0]] with pytest.raises(TypeError): - triangulate_planar_points( - [[0, 0, 0], [1, 0, 0], [0, 1, 0]], - strategy=ConstrainedDelaunay(), # type: ignore[arg-type] - ) + conforming_delaunay_triangulation(boundary, max_edge_length=value) # pyright: ignore[reportArgumentType] + + +@pytest.mark.parametrize("value", [0.0, -1.0, float("inf")]) +def test_conforming_max_edge_length_requires_positive_finite(value): + boundary = [[0, 0, 0], [1, 0, 0], [0, 1, 0]] + with pytest.raises(ValueError): + conforming_delaunay_triangulation(boundary, max_edge_length=value) -@pytest.mark.parametrize( - ("points", "message"), - [ - ([[0, 0, 0], [1, 0, 0]], "requires at least 3 points"), - ([[0, 0], [1, 0, 0], [0, 1, 0]], "exactly three coordinates"), - ([[0, 0, 0], [1, 0, 0], [0, float("inf"), 0]], "finite coordinates"), - ], -) -def test_triangulate_planar_points_validates_coordinates(points, message): - with pytest.raises(ValueError, match=message): - triangulate_planar_points(points, strategy=Delaunay()) - - -@pytest.mark.parametrize( - ("kwargs", "message"), - [ - ({"boundary": [[0, 0, 0], [1, 0, 0]]}, "Boundary requires at least 3 points"), - ( - { - "boundary": [[0, 0, 0], [1, 0, 0], [0, 1, 0]], - "holes": [[[0.1, 0.1, 0], [0.2, 0.1, 0]]], - }, - "Hole requires at least 3 points", - ), - ( - { - "boundary": [[0, 0, 0], [1, 0, 0], [0, 1, 0]], - "constraints": [[[0.1, 0.1, 0]]], - }, - "Constraint requires at least 2 points", - ), - ], -) -def test_triangulate_planar_domain_validates_groups_before_dispatch(kwargs, message): - with pytest.raises(ValueError, match=message): - triangulate_planar_domain(strategy=ConstrainedDelaunay(), **kwargs) +def test_planarity_is_validated_before_dispatch(): + with pytest.raises(ValueError, match="world XY"): + delaunay_triangulation([[0, 0, 1], [1, 0, 1], [0, 1, 1]]) diff --git a/tests/compas/geometry/test_triangulation_strategies.py b/tests/compas/geometry/test_triangulation_strategies.py index 9c01dd5798a8..fc6987c26ec1 100644 --- a/tests/compas/geometry/test_triangulation_strategies.py +++ b/tests/compas/geometry/test_triangulation_strategies.py @@ -1,202 +1,322 @@ -import compas +from functools import partial + +import numpy as np import pytest -from compas.geometry import DiagonalSplit -from compas.geometry import EarClipping -from compas.geometry import FaceTriangulation -from compas.geometry import MinimumAreaTriangulation +import compas.geometry as geometry +from compas.datastructures import Mesh from compas.geometry import PolygonalSurface -from compas.geometry import ProjectedEarClipping -from compas.geometry import TriangulationStrategy -from compas.geometry.triangulation import triangulate_polygon +from compas.geometry import Polygon +from compas.geometry import Polyhedron +from compas.geometry import triangulate_polygon +from compas.geometry import triangulate_polygon_earclip +from compas.geometry import triangulate_polygon_minimum_area +from compas.geometry import triangulate_polygon_projected +from compas.geometry import triangulate_quadrilateral + + +PENTAGON = [[0, 0, 0], [2, 0, 0], [3, 1, 0], [1.5, 2, 0], [0, 1, 0]] +DEGENERATE_PENTAGON = [[0, 0, 0], [1, 0, 0], [2, 0, 0], [2, 1, 0], [0, 1, 0]] + + +def make_single_face_geometry(kind, vertices): + face = list(range(len(vertices))) + if kind == "polygon": + return Polygon(vertices) + if kind == "surface": + return PolygonalSurface(vertices, [face]) + if kind == "polyhedron": + apex = [sum(vertex[0] for vertex in vertices) / len(vertices), 1.0, 3.0] + faces = [face] + apex_index = len(vertices) + faces.extend([[(index + 1) % len(vertices), index, apex_index] for index in range(len(vertices))]) + return Polyhedron([*vertices, apex], faces) + return Mesh.from_vertices_and_faces(vertices, [face]) + + +def invalid_triangulation(failure, vertex_count): + valid = [[0, index, index + 1] for index in range(1, vertex_count - 1)] + if failure == "incomplete": + return valid[:-1] + if failure == "duplicate": + return [valid[0], valid[0], *valid[2:]] + if failure == "reversed": + return [[a, c, b] for a, b, c in valid] + if failure == "invalid-incidence": + if vertex_count == 4: + return [[0, 1, 3], [0, 2, 3]] + return [[0, 1, 3], [1, 2, 4], [2, 3, 4]] + if failure == "degenerate" and vertex_count == 5: + return [[2, 3, 4], [0, 1, 2], [0, 2, 4]] + return valid + + +def triangulation_faces(result): + if isinstance(result, Mesh): + return result.to_vertices_and_faces()[1] + return result.triangles + + +def test_removed_strategy_types_are_not_public(): + for name in ( + "TriangulationStrategy", + "FaceTriangulation", + "DiagonalSplit", + "EarClipping", + "ProjectedEarClipping", + "MinimumAreaTriangulation", + ): + assert not hasattr(geometry, name) + + +def test_named_quadrilateral_triangulation(): + vertices = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]] + assert triangulate_quadrilateral(vertices, diagonal="02") == [[0, 1, 2], [0, 2, 3]] + assert triangulate_quadrilateral(vertices, diagonal="13") == [[0, 1, 3], [1, 2, 3]] + assert triangulate_polygon(vertices) == [[0, 1, 2], [0, 2, 3]] -@pytest.mark.parametrize( - "strategy", - [ - DiagonalSplit(), - DiagonalSplit("02"), - DiagonalSplit("13"), - EarClipping(), - FaceTriangulation(), - FaceTriangulation(quad=EarClipping()), - MinimumAreaTriangulation(), - ProjectedEarClipping(0.01), - FaceTriangulation(quad=MinimumAreaTriangulation(), ngon=ProjectedEarClipping(0.01)), - ], -) -def test_face_triangulation_strategies_roundtrip_as_data(strategy): - assert isinstance(strategy, TriangulationStrategy) - other = compas.json_loads(compas.json_dumps(strategy)) - assert other == strategy - assert hash(other) == hash(strategy) +def test_quadrilateral_shortest_uses_only_valid_interior_diagonals(): + concave = [[0, 100, 0], [-1, 0, 0], [0, 0.1, 0], [1, 0, 0]] + triangles = triangulate_quadrilateral(concave) + assert triangles == [[0, 1, 2], [0, 2, 3]] + assert triangulate_polygon(concave) == [[0, 1, 2], [0, 2, 3]] + assert triangulate_quadrilateral(concave, diagonal="02") == triangles -def test_face_triangulation_strategies_are_immutable(): - strategy = DiagonalSplit() - with pytest.raises(AttributeError): - strategy.diagonal = "13" + reversed_concave = list(reversed(concave)) + reversed_triangles = triangulate_quadrilateral(reversed_concave) + assert reversed_triangles == [[0, 1, 3], [1, 2, 3]] + def signed_area(points): + return sum(point[0] * next_point[1] - next_point[0] * point[1] for point, next_point in zip(points, points[1:] + points[:1])) -def test_diagonal_split_validates_diagonal(): - with pytest.raises(ValueError): - DiagonalSplit("longest") # type: ignore[arg-type] - + for boundary, result in ((concave, triangles), (reversed_concave, reversed_triangles)): + boundary_area = signed_area(boundary) + assert all(signed_area([boundary[index] for index in triangle]) * boundary_area > 0 for triangle in result) -def test_face_triangulation_validates_leaf_strategies(): - with pytest.raises(TypeError, match="quad"): - FaceTriangulation(quad=object()) # type: ignore[arg-type] + with pytest.raises(ValueError, match="outside"): + triangulate_quadrilateral(concave, diagonal="13") - with pytest.raises(TypeError, match="ngon"): - FaceTriangulation(ngon=DiagonalSplit()) # type: ignore[arg-type] +@pytest.mark.parametrize("triangulator", [triangulate_quadrilateral, triangulate_polygon]) +def test_polygon_triangulation_rejects_self_intersecting_quadrilateral_boundary(triangulator): + bow_tie = [[0, 0, 0], [1, 1, 0], [0, 1, 0], [1, 0, 0]] + with pytest.raises(ValueError, match="self-intersect"): + triangulator(bow_tie) -def test_projected_ear_clipping_validates_max_deviation(): - with pytest.raises(TypeError, match="real number"): - ProjectedEarClipping(True) # type: ignore[arg-type] - with pytest.raises(ValueError, match="nonnegative and finite"): - ProjectedEarClipping(-1.0) - with pytest.raises(ValueError, match="nonnegative and finite"): - ProjectedEarClipping(float("inf")) +def test_quadrilateral_triangulation_accepts_warped_simple_boundary(): + warped = [[0, 0, 0], [1, 0, 0], [1, 1, 1], [0, 1, 0]] + assert triangulate_quadrilateral(warped, diagonal="02") == [[0, 1, 2], [0, 2, 3]] -def test_face_triangulation_dispatches_by_face_arity(): - strategy = FaceTriangulation(quad=DiagonalSplit("13"), ngon=EarClipping()) - assert triangulate_polygon([[0, 0, 0], [1, 0, 0], [0, 1, 0]], strategy) == [[0, 1, 2]] - assert triangulate_polygon([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], strategy) == [ - [0, 1, 3], - [1, 2, 3], +def test_triangulation_coincidence_checks_are_independent_of_coordinate_scale(): + offset = 1_000_000.0 + vertices = [ + [offset, offset, offset], + [offset + 1, offset, offset], + [offset + 1, offset + 1, offset], + [offset, offset + 1, offset], ] + assert triangulate_polygon(vertices) == [[0, 1, 2], [0, 2, 3]] + + +@pytest.mark.parametrize("dtype", [np.float32, np.float64, np.int32, np.int64]) +@pytest.mark.parametrize("layout", ["c", "f", "sliced", "readonly"]) +def test_triangulate_polygon_accepts_numpy_inputs_without_mutation(dtype, layout): + values = np.asarray([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], dtype=dtype) + if layout == "f": + vertices = np.asfortranarray(values) + elif layout == "sliced": + vertices = np.pad(values, ((0, 0), (0, 1)))[:, :3] + else: + vertices = values.copy() + if layout == "readonly": + vertices.flags.writeable = False + before = vertices.copy() + + assert triangulate_quadrilateral(vertices, diagonal="02") == [[0, 1, 2], [0, 2, 3]] + np.testing.assert_array_equal(vertices, before) + + +def test_named_polygon_algorithms(): + polygon = [[0, 0, 0], [2, 0, 0], [2, 2, 0], [1, 1, 0], [0, 2, 0]] + assert len(triangulate_polygon_earclip(polygon)) == 3 + assert len(triangulate_polygon_projected(polygon, max_deviation=0.1)) == 3 + assert len(triangulate_polygon_minimum_area(polygon)) == 3 + + +def test_projected_triangulation_requires_positive_finite_bound(): + polygon = [[0, 0, 0], [1, 0, 0], [0, 1, 0]] + with pytest.raises(TypeError): + triangulate_polygon_projected(polygon, max_deviation=True) + with pytest.raises(ValueError): + triangulate_polygon_projected(polygon, max_deviation=0.0) - ngon = [[0, 0, 0], [2, 0, 0], [2, 2, 0], [1, 1, 0], [0, 2, 0]] - triangles = triangulate_polygon(ngon, strategy) - assert len(triangles) == 3 - assert {index for triangle in triangles for index in triangle} == set(range(5)) - - -def test_face_triangulation_nonplanar_contract(): - warped_quad = [[0, 0, 0], [1, 0, 0], [1, 1, 1], [0, 1, 0]] - assert triangulate_polygon(warped_quad, FaceTriangulation(quad=DiagonalSplit("02"))) == [ - [0, 1, 2], - [0, 2, 3], - ] - - with pytest.raises(ValueError, match="planar"): - triangulate_polygon(warped_quad, FaceTriangulation(quad=EarClipping())) - - warped_ngon = [[0, 0, 0], [2, 0, 0], [2, 2, 0], [1, 1, 1], [0, 2, 0]] - with pytest.raises(ValueError, match="planar"): - triangulate_polygon(warped_ngon, FaceTriangulation()) - - -def test_face_triangulation_rejects_degenerate_face_arity(): - with pytest.raises(ValueError, match="at least three"): - triangulate_polygon([[0, 0, 0], [1, 0, 0]], FaceTriangulation()) +def test_aggregate_triangulation_accepts_callable_and_maps_indices(): + surface = PolygonalSurface( + [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], + [[0, 1, 2, 3]], + ) + result = surface.triangulated(partial(triangulate_quadrilateral, diagonal="13")) + assert result.triangles == [[0, 1, 3], [1, 2, 3]] -def test_diagonal_split_shortest_uses_02_for_equal_diagonals(): - vertices = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]] - surface = PolygonalSurface(vertices, [[0, 1, 2, 3]]) - assert surface.triangulated(FaceTriangulation(quad=DiagonalSplit())).triangles == [[0, 1, 2], [0, 2, 3]] +@pytest.mark.parametrize( + ("triangle", "exception"), + [ + ([0, 1], ValueError), + ([-1, 1, 2], ValueError), + ([0, 1, 4], ValueError), + ([0, 0, 1], ValueError), + ([0, 1, 2.0], TypeError), + ], +) +@pytest.mark.parametrize( + "geometry", + [ + Polygon([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]), + PolygonalSurface([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], [[0, 1, 2, 3]]), + Polyhedron.from_platonicsolid(6), + Mesh.from_polyhedron(6), + ], +) +def test_aggregate_triangulation_validates_callable_results(geometry, triangle, exception): + with pytest.raises(exception): + geometry.triangulated(lambda vertices: [triangle]) -def test_ear_clipping_rejects_nonplanar_faces(): - vertices = [[0, 0, 0], [1, 0, 0], [1, 1, 1], [0, 1, 0]] - surface = PolygonalSurface(vertices, [[0, 1, 2, 3]]) +def test_aggregate_triangulation_accepts_supports_index_results(): + polygon = Polygon([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]) + tessellation = polygon.triangulated(lambda vertices: np.asarray([[0, 1, 2], [0, 2, 3]], dtype=np.int64)) + assert tessellation.triangles == [[0, 1, 2], [0, 2, 3]] - with pytest.raises(ValueError, match="planar"): - surface.triangulated(FaceTriangulation(quad=EarClipping())) +@pytest.mark.parametrize("kind", ["polygon", "surface", "polyhedron", "mesh"]) +@pytest.mark.parametrize( + ("failure", "message"), + [ + ("incomplete", "exactly n - 2"), + ("duplicate", "boundary edge"), + ("reversed", "boundary edge"), + ("invalid-incidence", "interior edge"), + ("degenerate", "degenerate"), + ], +) +def test_aggregate_triangulation_requires_complete_source_wound_results(kind, failure, message): + vertices = DEGENERATE_PENTAGON if failure == "degenerate" else PENTAGON + geometry = make_single_face_geometry(kind, vertices) -def test_ear_clipping_preserves_original_indices(): - vertices = [[0, 0, 0], [2, 0, 0], [2, 2, 0], [1, 1, 0], [0, 2, 0]] - triangles = triangulate_polygon(vertices, EarClipping()) + def triangulator(points): + return invalid_triangulation(failure, len(points)) - assert len(triangles) == len(vertices) - 2 - assert {index for triangle in triangles for index in triangle} == set(range(len(vertices))) + with pytest.raises(ValueError, match=message): + geometry.triangulated(triangulator) -@pytest.mark.parametrize("strategy", [EarClipping(), ProjectedEarClipping(0.1)]) +@pytest.mark.parametrize("kind", ["polygon", "surface", "mesh"]) @pytest.mark.parametrize( - "vertices", + ("vertices", "triangulator"), [ - [[0, 0, 0], [2, 0, 0], [2, 0, 0], [2, 2, 0], [0, 2, 0]], - [[0, 0, 0], [2, 0, 0], [2, 2, 0], [0, 2, 0], [0, 0, 0]], + ( + [[0, 0, 0], [2, 0, 0], [2, 2, 0], [1, 1, 0], [0, 2, 0]], + triangulate_polygon_earclip, + ), + ( + [[0, 0, 0], [2, 0, 1], [3, 1, -1], [2, 3, 2], [0, 2, -1]], + triangulate_polygon_minimum_area, + ), ], + ids=["concave-planar", "twisted-minimum-area"], ) -def test_ear_clipping_strategies_reject_zero_length_boundary_edges_before_polygon_normalization(strategy, vertices): - with pytest.raises(ValueError, match="zero-length boundary edges"): - triangulate_polygon(vertices, strategy) +def test_aggregate_triangulation_accepts_valid_source_wound_results(kind, vertices, triangulator): + geometry = make_single_face_geometry(kind, vertices) + assert len(triangulation_faces(geometry.triangulated(triangulator))) == len(vertices) - 2 -@pytest.mark.parametrize("strategy", [EarClipping(), ProjectedEarClipping(0.1)]) -def test_ear_clipping_strategies_reject_repeated_vertices_before_polygon_normalization(strategy): - vertices = [[0, 0, 0], [2, 0, 0], [2, 2, 0], [2, 0, 0], [0, 2, 0]] - with pytest.raises(ValueError, match="repeated vertices"): - triangulate_polygon(vertices, strategy) +def test_polyhedron_triangulation_accepts_valid_concave_source_wound_results(): + vertices = [[0, 0, 0], [2, 0, 0], [2, 2, 0], [1, 1, 0], [0, 2, 0]] + polyhedron = make_single_face_geometry("polyhedron", vertices) + assert len(polyhedron.triangulated(triangulate_polygon_earclip).triangles) == 2 * len(vertices) - 2 -@pytest.mark.parametrize("strategy", [EarClipping(), ProjectedEarClipping(0.1)]) @pytest.mark.parametrize( - "vertices", + "geometry", [ - [[0, 0], [1, 0], [0, 1]], - [[0, 0, 0, 1], [1, 0, 0, 1], [0, 1, 0, 1]], + Polygon([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]), + PolygonalSurface([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], [[0, 1, 2, 3]]), + Polyhedron.from_platonicsolid(6), + Mesh.from_polyhedron(6), ], ) -def test_ear_clipping_strategies_require_exact_finite_xyz_coordinates(strategy, vertices): - with pytest.raises(ValueError, match="exactly three finite coordinates"): - triangulate_polygon(vertices, strategy) - +def test_nonmutating_aggregate_none_matches_omitted_default(geometry): + assert triangulation_faces(geometry.triangulated(None)) == triangulation_faces(geometry.triangulated()) -def test_projected_ear_clipping_accepts_only_bounded_deviation(): - vertices = [[0, 0, 0], [2, 0, 0.01], [2, 2, 0], [1, 1, 0.02], [0, 2, 0]] - triangles = triangulate_polygon(vertices, ProjectedEarClipping(0.1)) - assert len(triangles) == 3 - assert {index for triangle in triangles for index in triangle} == set(range(5)) +def test_mutating_mesh_none_matches_omitted_default(): + omitted = Mesh.from_polyhedron(6) + explicit_none = omitted.copy() + omitted.triangulate() + explicit_none.triangulate(None) + assert omitted.to_vertices_and_faces() == explicit_none.to_vertices_and_faces() - with pytest.raises(ValueError, match="max_deviation"): - triangulate_polygon(vertices, ProjectedEarClipping(1e-6)) +def test_mesh_triangulate_is_atomic_and_preserves_face_attributes(): + mesh = Mesh.from_vertices_and_faces( + [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [2, 0, 0], [2, 1, 0]], + [[0, 1, 2, 3], [1, 4, 5, 2]], + ) + mesh.face_attribute(0, "source", "first") -def test_projected_ear_clipping_rejects_self_intersecting_projection(): - vertices = [[0, 0, 0], [2, 2, 0.01], [0, 2, 0], [2, 0, -0.01]] - with pytest.raises(ValueError, match="self-intersecting"): - triangulate_polygon(vertices, ProjectedEarClipping(0.1)) + def fail_on_second(vertices): + if vertices[0][0] == 1: + raise ValueError("failed") + return triangulate_polygon(vertices) + before = mesh.__data__ + with pytest.raises(ValueError, match="failed"): + mesh.triangulate(fail_on_second) + assert mesh.__data__ == before -def test_minimum_area_triangulation_is_deterministic_for_equal_costs(): - vertices = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]] - expected = [[0, 1, 3], [1, 2, 3]] - assert triangulate_polygon(vertices, MinimumAreaTriangulation()) == expected - assert triangulate_polygon(vertices, MinimumAreaTriangulation()) == expected + mesh.triangulate() + assert mesh.number_of_faces() == 4 + assert sum(mesh.face_attribute(face, "source") == "first" for face in mesh.faces()) == 2 -def test_minimum_area_triangulation_handles_twisted_polygon_without_projection(): - vertices = [[0, 0, 0], [2, 0, 1], [3, 1, -1], [2, 3, 2], [0, 2, -1]] - triangles = triangulate_polygon(vertices, MinimumAreaTriangulation()) +def test_mesh_triangulate_validates_all_callable_results_before_mutating(): + mesh = Mesh.from_vertices_and_faces( + [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [2, 0, 0], [2, 1, 0]], + [[0, 1, 2, 3], [1, 4, 5, 2]], + ) + before = mesh.__data__ + calls = 0 - assert len(triangles) == 3 - assert {index for triangle in triangles for index in triangle} == set(range(5)) + def invalid_on_second_face(vertices): + nonlocal calls + calls += 1 + if calls == 2: + return [[0, 1, len(vertices)]] + return triangulate_polygon(vertices) - -def test_minimum_area_triangulation_selects_the_lower_area_diagonal(): - vertices = [[0, 0, 0], [2, 0, 1], [2, 2, 0], [0, 2, 0]] - assert triangulate_polygon(vertices, MinimumAreaTriangulation()) == [[0, 2, 3], [0, 1, 2]] + with pytest.raises(ValueError, match="refer to vertices"): + mesh.triangulate(invalid_on_second_face) + assert mesh.__data__ == before @pytest.mark.parametrize( - "vertices, message", - [ - ([[0, 0, 0], [1, 0, 0], [0, 0, 0]], "zero-length boundary edges"), - ([[0, 0, 0], [2, 0, 0], [2, 2, 0], [2, 0, 0], [0, 2, 0]], "repeated vertices"), - ([[0, 0, 0], [1, 0, 0], [2, 0, 0]], "no nondegenerate"), - ([[0, 0, 0], [1, 0, 0], [0, float("nan"), 0]], "three finite coordinates"), - ], + "failure", + ["incomplete", "duplicate", "reversed", "invalid-incidence", "degenerate"], ) -def test_minimum_area_triangulation_rejects_invalid_polygon(vertices, message): - with pytest.raises(ValueError, match=message): - triangulate_polygon(vertices, MinimumAreaTriangulation()) +def test_mesh_triangulate_rejects_invalid_results_atomically(failure): + vertices = DEGENERATE_PENTAGON if failure == "degenerate" else PENTAGON + mesh = Mesh.from_vertices_and_faces(vertices, [list(range(len(vertices)))]) + before = mesh.__data__ + + def triangulator(points): + return invalid_triangulation(failure, len(points)) + + with pytest.raises(ValueError): + mesh.triangulate(triangulator) + assert mesh.__data__ == before diff --git a/tests/compas/geometry/test_trimesh.py b/tests/compas/geometry/test_trimesh.py index d99b5ff1a365..093928860293 100644 --- a/tests/compas/geometry/test_trimesh.py +++ b/tests/compas/geometry/test_trimesh.py @@ -1,11 +1,14 @@ from math import pi +from typing import get_type_hints + import numpy as np import pytest from scipy.sparse import isspmatrix_coo from scipy.sparse import isspmatrix_csc from scipy.sparse import isspmatrix_csr +from compas.typing import VerticesFaces from compas.datastructures import Mesh from compas.geometry import trimesh_cotangent_laplacian_matrix from compas.geometry import trimesh_gaussian_curvature @@ -14,7 +17,6 @@ from compas.geometry import trimesh_sample_points from compas.geometry import trimesh_smooth_laplacian_cotangent from compas.geometry import trimesh_vertexarea_matrix -from compas.geometry import Tessellation from compas.geometry.trimesh import trimesh_gaussian_curvature as packaged_trimesh_gaussian_curvature @@ -22,17 +24,16 @@ [[0, 0, 0], [1, 0, 0], [0, 1, 0]], [[0, 1, 2]], ) -TRIANGLE = Tessellation(*TRIANGLE_BUFFERS) def test_trimesh_gaussian_curvature_counts_each_corner_once(): - curvature = trimesh_gaussian_curvature(TRIANGLE) + curvature = trimesh_gaussian_curvature(TRIANGLE_BUFFERS) assert curvature == [pi / 2, 3 * pi / 4, 3 * pi / 4] def test_trimesh_gaussian_curvature_uses_full_angular_deficit_at_interior_vertices(): - fan = Tessellation( + fan = ( [[0, 0, 0], [1, 0, 0], [0, 1, 0], [-1, 0, 0], [0, -1, 0]], [[0, 1, 2], [0, 2, 3], [0, 3, 4], [0, 4, 1]], ) @@ -42,6 +43,41 @@ def test_trimesh_gaussian_curvature_uses_full_angular_deficit_at_interior_vertic assert curvature[0] == pytest.approx(0.0) +def test_trimesh_gaussian_curvature_accepts_numpy_buffers(): + vertices = np.asarray(TRIANGLE_BUFFERS[0], dtype=np.float64) + triangles = np.asarray(TRIANGLE_BUFFERS[1], dtype=np.int64) + + curvature = trimesh_gaussian_curvature((vertices, triangles)) + + assert curvature == pytest.approx([pi / 2, 3 * pi / 4, 3 * pi / 4]) + + +def test_trimesh_gaussian_curvature_does_not_mutate_numpy_buffers(): + vertices = np.asarray(TRIANGLE_BUFFERS[0], dtype=np.float64) + triangles = np.asarray(TRIANGLE_BUFFERS[1], dtype=np.int64) + original_vertices = vertices.copy() + original_triangles = triangles.copy() + + trimesh_gaussian_curvature((vertices, triangles)) + + assert np.array_equal(vertices, original_vertices) + assert np.array_equal(triangles, original_triangles) + + +def test_trimesh_gaussian_curvature_uses_structural_mesh_contract(): + assert get_type_hints(trimesh_gaussian_curvature)["mesh"] == VerticesFaces + + +def test_unsupported_curvature_operations_are_not_public(): + import compas.geometry + import compas.geometry.trimesh + + assert not hasattr(compas.geometry, "trimesh_mean_curvature") + assert not hasattr(compas.geometry, "trimesh_principal_curvature") + assert not hasattr(compas.geometry.trimesh, "trimesh_mean_curvature") + assert not hasattr(compas.geometry.trimesh, "trimesh_principal_curvature") + + def test_trimesh_gradient_accepts_raw_lists_and_all_result_formats(): array = trimesh_gradient(TRIANGLE_BUFFERS) diff --git a/tests/compas/test_distribution_artifacts.py b/tests/compas/test_distribution_artifacts.py new file mode 100644 index 000000000000..382e088c7f31 --- /dev/null +++ b/tests/compas/test_distribution_artifacts.py @@ -0,0 +1,55 @@ +from importlib.util import module_from_spec +from importlib.util import spec_from_file_location +from pathlib import Path +from zipfile import ZipFile + +import pytest + + +SCRIPT = Path(__file__).parents[2] / ".github" / "verify_python_wheel.py" +BUILD_WORKFLOW = Path(__file__).parents[2] / ".github" / "workflows" / "build.yml" +SPEC = spec_from_file_location("verify_python_wheel", SCRIPT) +assert SPEC and SPEC.loader +verify_python_wheel = module_from_spec(SPEC) +SPEC.loader.exec_module(verify_python_wheel) + + +def make_wheel(tmp_path: Path, *, include_marker: bool = True) -> Path: + wheel = tmp_path / "compas-3.0.0-py3-none-any.whl" + with ZipFile(wheel, "w") as archive: + archive.writestr("compas/__init__.py", "") + if include_marker: + archive.writestr("compas/py.typed", "") + return wheel + + +def test_verify_wheel_accepts_pep561_marker(tmp_path: Path) -> None: + verify_python_wheel.verify_wheel(make_wheel(tmp_path)) + + +def test_verify_wheel_rejects_missing_pep561_marker(tmp_path: Path) -> None: + with pytest.raises(RuntimeError, match="compas/py.typed"): + verify_python_wheel.verify_wheel(make_wheel(tmp_path, include_marker=False)) + + +def test_find_wheel_requires_exactly_one_artifact(tmp_path: Path) -> None: + with pytest.raises(RuntimeError, match="Expected exactly one wheel"): + verify_python_wheel.find_wheel(tmp_path) + + +def test_build_workflow_uploads_the_verified_candidate_wheel() -> None: + workflow = BUILD_WORKFLOW.read_text() + build_condition = "matrix.os == 'ubuntu-latest' && matrix.python == '3.12'" + build_step = workflow.index(" - name: Build typed wheel") + verify_step = workflow.index(" - name: Verify typed wheel") + upload_step = workflow.index(" - name: Upload verified candidate wheel") + + assert build_step < verify_step < upload_step + assert workflow.count("name: candidate-linux-py312") == 1 + assert workflow.count(f"if: {build_condition}") == 3 + + upload = workflow[upload_step : workflow.index("\n\n", upload_step)] + assert "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" in upload + assert "path: ${{ runner.temp }}/compas-wheelhouse/*.whl" in upload + assert "if-no-files-found: error" in upload + assert "retention-days: 14" in upload diff --git a/tests/compas/test_typing.py b/tests/compas/test_typing.py new file mode 100644 index 000000000000..6b7f9ae240b4 --- /dev/null +++ b/tests/compas/test_typing.py @@ -0,0 +1,278 @@ +import math +from decimal import Decimal + +import numpy as np +import pytest + +from compas.typing import CoordinateProtocol +from compas.typing import PointsProtocol +from compas.datastructures import Mesh +from compas.geometry import Point +from compas.geometry import Frame +from compas.geometry import Line +from compas.geometry import Plane +from compas.geometry import Pointcloud +from compas.geometry import Polygon +from compas.geometry import PolygonalSurface +from compas.geometry import Polyhedron +from compas.geometry import Polyline +from compas.geometry import Quaternion +from compas.geometry import Transformation +from compas.geometry import intersection_plane_circle +from compas.geometry import is_ccw_xy +from compas.geometry import transform_points +from compas.geometry import Vector +from compas.linalg import length_vector +from compas.tolerance import TOL + + +class DuckCoordinate: + def __init__(self, x, y, z): + self.values = (x, y, z) + + def __len__(self): + return len(self.values) + + def __getitem__(self, key): + return self.values[key] + + def __iter__(self): + return iter(self.values) + + +class IndexCoordinate: + def __init__(self, x, y, z): + self.values = (x, y, z) + + def __getitem__(self, key): + return self.values[key] + + +class IterableOnlyCoordinate: + def __init__(self, *values): + self.values = values + + def __iter__(self): + return iter(self.values) + + +def test_runtime_coordinate_protocol_behavior_is_preserved(): + coordinate = DuckCoordinate(np.float32(1), np.float64(2), 3) + + assert isinstance(coordinate, CoordinateProtocol) + assert isinstance([coordinate], PointsProtocol) + assert Point(1, 2, 3) == coordinate + + +def test_indexing_only_coordinate_remains_usable_by_duck_typing(): + coordinate = IndexCoordinate(1, 2, 2) + + assert not isinstance(coordinate, CoordinateProtocol) + assert math.isclose(length_vector(coordinate), 3.0) + + +def test_quaternion_conforms_to_coordinate_protocol_and_supports_slicing(): + quaternion = Quaternion(np.float32(1), np.int64(2), 3, 4) + + assert isinstance(quaternion, CoordinateProtocol) + assert quaternion[:2] == [1.0, 2.0] + assert quaternion == np.asarray([1, 2, 3, 4], dtype=np.float32) + assert quaternion != IterableOnlyCoordinate(1, 2, 3, 4) + + +def test_builtin_bool_remains_usable_where_an_operation_allows_it(): + assert math.isclose(length_vector((True, False, False)), 1.0) + + +def test_numpy_comparison_results_are_not_normalized_for_typing(): + scalar = np.float32(1.0) + + assert isinstance(TOL.compare(scalar, scalar, TOL.relative, TOL.absolute), np.bool_) + assert isinstance(TOL.is_zero(np.float32(0.0)), np.bool_) + assert isinstance(TOL.is_positive(scalar), np.bool_) + assert isinstance(TOL.is_negative(-scalar), np.bool_) + assert isinstance(TOL.is_between(scalar, np.float32(0.0), np.float32(2.0)), np.bool_) + assert isinstance( + is_ccw_xy( + np.asarray([0, 0, 0], dtype=np.float32), + np.asarray([1, 0, 0], dtype=np.float32), + np.asarray([0, 1, 0], dtype=np.float32), + ), + np.bool_, + ) + + +def test_datastructure_coordinate_annotations_do_not_add_conversion(): + vertices = np.asarray([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float32) + mesh = Mesh.from_vertices_and_faces(vertices, [[0, 1, 2]]) + + assert mesh.number_of_vertices() == 3 + assert mesh.vertex_coordinates(1) == [1.0, 0.0, 0.0] + + +def test_copying_geometry_constructors_accept_numpy_coordinates(): + point = np.asarray([1, 2, 3], dtype=np.float32) + direction = np.asarray([0, 0, 1], dtype=np.int64) + + plane = Plane(point, direction) + line = Line(point, np.asarray([4, 5, 6], dtype=np.float64)) + frame = Frame(point, np.asarray([1, 0, 0], dtype=np.float32), np.asarray([0, 1, 0], dtype=np.int32)) + + assert plane.point == Point(1, 2, 3) + assert plane.normal == Vector(0, 0, 1) + assert line.start == Point(1, 2, 3) + assert line.end == Point(4, 5, 6) + assert frame.point == Point(1, 2, 3) + + +def test_numpy_line_plane_and_matrix_composites_follow_runtime_contracts(): + line = np.asarray([[0, 0, 0], [1, 0, 0]], dtype=np.float32) + plane = np.asarray([[0, 0, 0], [0, 0, 1]], dtype=np.int64) + matrix = np.eye(4, dtype=np.float64) + + point = Point(0, 1, 1) + assert math.isclose(point.distance_to_line(line), math.sqrt(2.0)) + assert math.isclose(point.distance_to_plane(plane), 1.0) + assert transform_points(np.asarray([[1, 2, 3]], dtype=np.float32), matrix) == [[1.0, 2.0, 3.0]] + point.transform(matrix) + assert point == [0, 1, 1] + + +def test_numpy_scalars_are_accepted_by_circle_and_euler_angle_contracts(): + result = intersection_plane_circle( + np.asarray([[0, 0, 0], [0, 0, 1]], dtype=np.float32), + (np.asarray([[0, 0, 0], [1, 0, 0]], dtype=np.float64), np.float32(1.0)), + ) + transformation = Transformation.from_euler_angles(np.asarray([0, 0, 0], dtype=np.float32)) + decimal_transformation = Transformation.from_euler_angles([Decimal("0"), Decimal("0"), Decimal("0")]) + + assert len(result) == 2 + assert transformation == Transformation() + assert decimal_transformation == Transformation() + + +def _coordinate_layouts(): + contiguous = np.asarray([1, 2, 3], dtype=np.float32) + fortran = np.asfortranarray(np.asarray([[1, 2, 3]], dtype=np.float64))[0] + sliced = np.asarray([1, 0, 2, 0, 3, 0], dtype=np.int64)[::2] + readonly = contiguous.copy() + readonly.flags.writeable = False + return [contiguous, fortran, sliced, readonly] + + +@pytest.mark.parametrize("coordinates", _coordinate_layouts(), ids=["contiguous", "fortran", "sliced", "readonly"]) +def test_point_and_vector_accept_numpy_scalar_layouts_in_constructors_and_setters(coordinates): + point = Point(*coordinates) + vector = Vector(*coordinates) + + point.x, point.y, point.z = coordinates + vector.x, vector.y, vector.z = coordinates + + assert point == [1, 2, 3] + assert vector == [1, 2, 3] + assert point[:2] == [1.0, 2.0] + assert vector[:2] == [1.0, 2.0] + + +def _point_buffer_layouts(): + contiguous = np.asarray([[0, 0, 0], [1, 0, 0], [1, 1, 0]], dtype=np.float32) + fortran = np.asfortranarray(contiguous.astype(np.float64)) + expanded = np.zeros((3, 6), dtype=np.int64) + expanded[:, ::2] = contiguous + sliced = expanded[:, ::2] + readonly = contiguous.copy() + readonly.flags.writeable = False + return [contiguous, fortran, sliced, readonly] + + +@pytest.mark.parametrize("coordinates", _point_buffer_layouts(), ids=["contiguous", "fortran", "sliced", "readonly"]) +def test_point_collection_constructors_copy_numpy_buffer_layouts(coordinates): + polyline = Polyline(coordinates) + polygon = Polygon(coordinates) + pointcloud = Pointcloud(coordinates) + + polyline.points = coordinates + polygon.points = coordinates + pointcloud.points = coordinates + + expected = coordinates.tolist() + assert [list(point) for point in polyline] == expected + assert [list(point) for point in polygon] == expected + assert [list(point) for point in pointcloud] == expected + + +def test_empty_numpy_point_collections_preserve_existing_outcomes(): + points = np.empty((0, 3), dtype=np.float32) + faces = np.empty((0, 3), dtype=np.int64) + + assert len(Polyline(points)) == 0 + assert len(Pointcloud(points)) == 0 + with pytest.raises(IndexError): + Polygon(points) + + surface = PolygonalSurface(points, faces) + assert surface.vertices == [] + assert surface.faces == [] + + +@pytest.mark.parametrize( + ("geometry", "equivalent"), + [ + (Point(1, 2, 3), np.asarray([1, 2, 3], dtype=np.float32)), + (Vector(1, 2, 3), np.asarray([1, 2, 3], dtype=np.int64)), + (Plane([0, 0, 0], [0, 0, 1]), np.asarray([[0, 0, 0], [0, 0, 1]], dtype=np.float64)), + (Line([0, 0, 0], [1, 0, 0]), np.asarray([[0, 0, 0], [1, 0, 0]], dtype=np.float32)), + (Frame.worldXY(), np.asarray([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.int64)), + (Polyline([[0, 0, 0], [1, 0, 0]]), np.asarray([[0, 0, 0], [1, 0, 0]], dtype=np.float64)), + ], +) +def test_structural_geometry_equality_accepts_numpy_buffers_and_rejects_iterable_only_inputs(geometry, equivalent): + equivalent.flags.writeable = False + + assert geometry == equivalent + assert geometry != IterableOnlyCoordinate(*equivalent) + + +def test_polygonal_geometry_copies_numpy_indices_as_python_ints(): + vertices = np.asarray([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=np.float32) + faces = np.asarray([[0, 2, 1], [0, 1, 3], [1, 2, 3], [2, 0, 3]], dtype=np.int64) + + surface = PolygonalSurface(vertices, faces) + polyhedron = Polyhedron(vertices, faces) + + assert surface.faces == faces.tolist() + assert polyhedron.faces == faces.tolist() + assert all(type(index) is int for index in surface.faces[0]) + assert all(type(index) is int for index in polyhedron.faces[0]) + assert faces.tolist() == [[0, 2, 1], [0, 1, 3], [1, 2, 3], [2, 0, 3]] + + +def test_polygonal_surface_numpy_setters_copy_without_mutating_sources(): + vertices = np.asarray([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float32) + faces = np.asarray([[0, 1, 2]], dtype=np.int64) + expected_vertices = vertices.copy() + expected_faces = faces.copy() + surface = PolygonalSurface(np.empty((0, 3), dtype=np.float32), np.empty((0, 3), dtype=np.int64)) + + surface.vertices = vertices + surface.faces = faces + + np.testing.assert_array_equal(vertices, expected_vertices) + np.testing.assert_array_equal(faces, expected_faces) + assert surface.vertices == expected_vertices.tolist() + assert surface.faces == expected_faces.tolist() + + vertices[0, 0] = 10 + faces[0, 0] = 2 + assert surface.vertices[0] == [0.0, 0.0, 0.0] + assert surface.faces[0] == [0, 1, 2] + + +def test_polygonal_geometry_rejects_boolean_indices_during_copy(): + vertices = [[0, 0, 0], [1, 0, 0], [0, 1, 0]] + + for boolean in (True, np.bool_(True)): + with np.testing.assert_raises(TypeError): + PolygonalSurface(vertices, [[0, 1, boolean]]) + with np.testing.assert_raises(TypeError): + Polyhedron(vertices, [[0, 1, boolean]]) diff --git a/tests/compas/test_typing_fixtures.py b/tests/compas/test_typing_fixtures.py new file mode 100644 index 000000000000..95af23b04de2 --- /dev/null +++ b/tests/compas/test_typing_fixtures.py @@ -0,0 +1,75 @@ +import json +import re +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).parents[2] +FIXTURES = ROOT / "tests" / "typing" +EXPECTED_ERROR = re.compile(r"# expected-error: (?P[A-Za-z][A-Za-z0-9]+)\s*$") +BROAD_TYPE_IGNORE = re.compile(r"#\s*type:\s*ignore\s*$") + + +def run_pyright(fixture): + result = subprocess.run( + [ + sys.executable, + "-m", + "pyright", + "--outputjson", + "--pythonpath", + sys.executable, + str(fixture), + ], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + assert result.returncode in {0, 1}, result.stderr + return json.loads(result.stdout)["generalDiagnostics"] + + +def expected_diagnostics(fixture): + expected = [] + for line_number, line in enumerate(fixture.read_text().splitlines(), start=1): + match = EXPECTED_ERROR.search(line) + if match: + expected.append((str(fixture.resolve()), line_number, match.group("rule"))) + return sorted(expected) + + +def actual_diagnostics(diagnostics): + return sorted( + ( + str(Path(diagnostic["file"]).resolve()), + diagnostic["range"]["start"]["line"] + 1, + diagnostic.get("rule"), + ) + for diagnostic in diagnostics + ) + + +def test_accepted_typing_fixture_has_no_diagnostics(): + fixture = FIXTURES / "accepted.py" + + assert run_pyright(fixture) == [] + + +def test_expected_typing_errors_match_inline_markers(): + fixture = FIXTURES / "expected_errors.py" + + assert actual_diagnostics(run_pyright(fixture)) == expected_diagnostics(fixture) + + +def test_executable_examples_have_no_broad_type_ignores(): + examples = ROOT / "docs" / "assets" / "examples" + broad_ignores = [] + + for example in examples.rglob("*.py"): + for line_number, line in enumerate(example.read_text().splitlines(), start=1): + if BROAD_TYPE_IGNORE.search(line): + broad_ignores.append(f"{example.relative_to(ROOT)}:{line_number}") + + assert broad_ignores == [] diff --git a/tests/compas/topology/test_traversal.py b/tests/compas/topology/test_traversal.py index e36d36fb1e53..4d00a5b94673 100644 --- a/tests/compas/topology/test_traversal.py +++ b/tests/compas/topology/test_traversal.py @@ -40,9 +40,8 @@ def test_astar_shortest_path_disconnected(): def test_astar_shortest_path_mesh(): - from compas.geometry import Native - mesh = Box(frame=Frame.worldXY(), xsize=1, ysize=1, zsize=1).polygonize(Native()).to_mesh() + mesh = Box(frame=Frame.worldXY(), xsize=1, ysize=1, zsize=1).to_polygonal_surface().to_mesh() a, b = mesh.vertex_sample(size=2) path = astar_shortest_path(mesh, a, b) assert path is not None diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 000000000000..6685b1fd7788 --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,38 @@ +from importlib.util import module_from_spec +from importlib.util import spec_from_file_location +from pathlib import Path +import sys + + +spec = spec_from_file_location("compas_tasks", Path(__file__).parents[1] / "tasks.py") +assert spec +assert spec.loader +tasks = module_from_spec(spec) +spec.loader.exec_module(tasks) + + +class RecordingContext: + def __init__(self): + self.commands: list[str] = [] + + def run(self, command: str) -> None: + self.commands.append(command) + + +def test_lint_task_is_non_mutating_and_repository_wide(): + context = RecordingContext() + + tasks.ns["lint"].body(context) + + assert context.commands == [f'"{sys.executable}" -m ruff check .'] + + +def test_check_task_matches_the_canonical_ruff_commands(): + context = RecordingContext() + + tasks.ns["check"].body(context) + + assert context.commands == [ + f'"{sys.executable}" -m ruff check .', + f'"{sys.executable}" -m ruff format --check .', + ] diff --git a/tests/typing/accepted.py b/tests/typing/accepted.py new file mode 100644 index 000000000000..bfedd8ae6fa6 --- /dev/null +++ b/tests/typing/accepted.py @@ -0,0 +1,300 @@ +from collections.abc import Iterator +from decimal import Decimal +from typing import SupportsFloat +from typing import assert_type +from typing import overload + +import numpy as np + +from compas.typing import PointLike +from compas.typing import PointGroupsLike +from compas.typing import PointsLike +from compas.typing import Float64Array +from compas.typing import FloatSequenceLike +from compas.typing import FloatingArray +from compas.typing import IndicesLike +from compas.typing import Int32Array +from compas.typing import IntegerArray +from compas.typing import IntegralInput +from compas.typing import NumericArray +from compas.typing import RealInput +from compas.typing import VectorLike +from compas.typing import VerticesFaces +from compas.geometry import Point +from compas.geometry import Pointcloud +from compas.geometry import Polygon +from compas.geometry import Frame +from compas.geometry import Line +from compas.geometry import Plane +from compas.geometry import Quaternion +from compas.geometry import Transformation +from compas.geometry import Circle +from compas.geometry import Tessellation +from compas.geometry import Triangulator +from compas.geometry import Vector +from compas.geometry import boolean_union_mesh_mesh +from compas.geometry import closest_point_on_polygon_xy +from compas.geometry import closest_point_on_polyline_xy +from compas.geometry import intersection_mesh_mesh +from compas.geometry import intersection_line_plane +from compas.geometry import intersection_plane_circle +from compas.geometry import intersection_ray_mesh +from compas.geometry import is_polygon_in_polygon_xy +from compas.geometry import orthonormalize_axes +from compas.geometry import constrained_delaunay_triangulation +from compas.geometry import delaunay_triangulation +from compas.geometry import trimesh_remesh +from compas.geometry import trimesh_remesh_along_isoline +from compas.geometry import trimesh_remesh_constrained +from compas.geometry import trimesh_slice +from compas.geometry import trimesh_geodistance +from compas.geometry import trimesh_harmonic +from compas.geometry import trimesh_isolines +from compas.geometry import trimesh_lscm +from compas.geometry import trimesh_massmatrix +from compas.geometry import transform_points +from compas.geometry._core.predicates_2 import is_intersection_segment_segment_xy +from compas.geometry.triangulation import triangulate_polygon +from compas.datastructures import Graph +from compas.datastructures import Mesh + + +class CustomCoordinate: + def __init__(self, values: tuple[float, float, float]): + self.values = values + + def __len__(self) -> int: + return len(self.values) + + @overload + def __getitem__(self, key: int) -> float: ... + + @overload + def __getitem__(self, key: slice) -> tuple[float, ...]: ... + + def __getitem__(self, key: int | slice) -> float | tuple[float, ...]: + return self.values[key] + + def __iter__(self) -> Iterator[float]: + return iter(self.values) + + +class CustomCoordinates: + def __init__(self, values: list[CustomCoordinate]): + self.values = values + + def __len__(self) -> int: + return len(self.values) + + def __getitem__(self, key: int) -> CustomCoordinate: + return self.values[key] + + def __iter__(self) -> Iterator[CustomCoordinate]: + return iter(self.values) + + +class CustomIndices: + def __init__(self, values: tuple[int, int, int]): + self.values = values + + def __len__(self) -> int: + return len(self.values) + + def __getitem__(self, key: int) -> int: + return self.values[key] + + def __iter__(self) -> Iterator[int]: + return iter(self.values) + + +class CustomTriangles: + def __init__(self, values: list[CustomIndices]): + self.values = values + + def __len__(self) -> int: + return len(self.values) + + def __getitem__(self, key: int) -> CustomIndices: + return self.values[key] + + def __iter__(self) -> Iterator[CustomIndices]: + return iter(self.values) + + +class CustomPolylines: + def __init__(self, values: list[CustomCoordinates]): + self.values = values + + def __len__(self) -> int: + return len(self.values) + + def __getitem__(self, key: int) -> CustomCoordinates: + return self.values[key] + + def __iter__(self) -> Iterator[CustomCoordinates]: + return iter(self.values) + + +real_inputs: list[RealInput] = [True, 1, 1.0, np.int32(1), np.int64(1), np.float32(1), np.float64(1)] +integral_inputs: list[IntegralInput] = [True, 1, np.int32(1), np.int64(1)] + +float32_array: FloatingArray = np.asarray([1, 2, 3], dtype=np.float32) +float64_array: Float64Array = np.asarray([1, 2, 3], dtype=np.float64) +int32_array: Int32Array = np.asarray([1, 2, 3], dtype=np.int32) +int64_array: IntegerArray = np.asarray([1, 2, 3], dtype=np.int64) +numeric_arrays: list[NumericArray] = [float32_array, float64_array, int32_array, int64_array] +memoryview_input: PointLike = memoryview(np.asarray([1.0, 2.0, 3.0], dtype=np.float64)) + + +def accepts_supports_float(value: SupportsFloat) -> None: + pass + + +for real_input_value in real_inputs: + accepts_supports_float(real_input_value) + +coordinate_inputs: list[PointLike] = [ + [1, 2, 3], + (1.0, 2.0, 3.0), + Point(1, 2, 3), + Vector(1, 2, 3), + CustomCoordinate((1, 2, 3)), + float32_array, + int64_array, +] +vector_inputs: list[VectorLike] = coordinate_inputs +real_vector: list[RealInput] = [np.float32(1), np.float64(2), 3] + +Point(0, 0, 0).translate(real_vector) +assert_type(Point(0, 0, 0).translated(float32_array), Point) + + +def coordinate_arithmetic(coordinate: PointLike) -> RealInput: + subsection: FloatSequenceLike = coordinate[:2] + return subsection[0] + coordinate[2] + + +def assign_structural_components(point: PointLike, vector: VectorLike) -> None: + target_point = Point(0, 0, 0) + target_vector = Vector(0, 0, 0) + target_point.x = point[0] + target_vector.x = vector[0] + + +Plane(float32_array, int64_array) +Line(float32_array, float64_array) +Frame(float32_array, float64_array, int64_array) +Circle(np.float32(1.0)) +Transformation.from_euler_angles(float32_array) +Transformation.from_euler_angles([Decimal("0"), Decimal("0"), Decimal("0")]) +quaternion = Quaternion(1, 0, 0, 0) +quaternion_input: FloatSequenceLike = quaternion +assert_type(quaternion[:2], list[float]) +intersection_line_plane(np.asarray([[0, 0, -1], [0, 0, 1]], dtype=np.float32), np.asarray([[0, 0, 0], [0, 0, 1]], dtype=np.int64)) +intersection_plane_circle( + np.asarray([[0, 0, 0], [0, 0, 1]], dtype=np.float32), + (np.asarray([[0, 0, 0], [0, 0, 1]], dtype=np.float64), np.float32(1.0)), +) +assert_type( + closest_point_on_polyline_xy(float32_array, np.asarray([[0, 0, 0], [1, 0, 0]], dtype=np.float32)), + list[RealInput], +) +assert_type( + closest_point_on_polygon_xy(float32_array, np.asarray([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float32)), + list[RealInput], +) +assert_type(orthonormalize_axes(float32_array, int64_array), tuple[list[RealInput], list[RealInput]]) +transform_points(np.asarray([[1, 2, 3]], dtype=np.float32), np.eye(4, dtype=np.float64)) +Point(1, 2, 3).transform(np.eye(4, dtype=np.float64)) +Transformation([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) +Transformation( + [ + [np.float64(1), np.float64(0), np.float64(0), np.float64(0)], + [np.float64(0), np.float64(1), np.float64(0), np.float64(0)], + [np.float64(0), np.float64(0), np.float64(1), np.float64(0)], + [np.float64(0), np.float64(0), np.float64(0), np.float64(1)], + ] +) +coordinates_inputs: list[PointsLike] = [coordinate_inputs, np.asarray([[0, 0, 0], [1, 1, 1]], dtype=np.float64)] +Graph.from_pointcloud(Pointcloud(coordinates_inputs[1])) +Graph.from_pointcloud((point for point in coordinates_inputs[1])) +outer_polygon = Polygon([[0, 0, 0], [2, 0, 0], [2, 2, 0], [0, 2, 0]]) +inner_polygon = Polygon([[0.5, 0.5, 0], [1.5, 0.5, 0], [1, 1.5, 0]]) +is_polygon_in_polygon_xy(outer_polygon, inner_polygon) +assert_type( + is_intersection_segment_segment_xy([[0, 0, 0], [1, 1, 0]], [[0, 1, 0], [1, 0, 0]]), + bool | np.bool_, +) +assert_type( + is_intersection_segment_segment_xy( + np.asarray([[0, 0, 0], [1, 1, 0]], dtype=np.float32), + np.asarray([[0, 1, 0], [1, 0, 0]], dtype=np.float64), + ), + bool | np.bool_, +) +coordinates_collection_inputs: list[PointGroupsLike] = [ + [coordinates_inputs[1]], + np.asarray([[[0, 0, 0], [1, 0, 0], [0, 1, 0]]], dtype=np.float32), +] +indices_inputs: list[IndicesLike] = [[0, 1, 2], (np.int32(0), np.int64(1)), int32_array, int64_array] + +vertices32 = np.asarray([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float32) +triangles32 = np.asarray([[0, 1, 2]], dtype=np.int32) +vertices64 = np.asarray([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float64) +triangles64 = np.asarray([[0, 1, 2]], dtype=np.int64) +triangle_mesh_inputs: list[VerticesFaces] = [ + ([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], [[0, 1, 2]]), + (((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)), ((0, 1, 2),)), + (vertices32, triangles32), + (vertices64, triangles64), +] +boolean_union_mesh_mesh(triangle_mesh_inputs[0], triangle_mesh_inputs[1]) +boolean_union_mesh_mesh((vertices32, triangles32), (vertices64, triangles64)) +trimesh_remesh((vertices32, triangles32), 0.5) +trimesh_remesh_constrained((vertices32, triangles32), 0.5, [[0, 1]]) +trimesh_remesh_along_isoline((vertices32, triangles32), np.asarray([0.0, 1.0, 0.0], dtype=np.float32), 0.5) +intersection_mesh_mesh((vertices32, triangles32), (vertices64, triangles64)) +trimesh_slice((vertices32, triangles32), [([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])]) +intersection_ray_mesh(([0.0, 0.0, -1.0], [0.0, 0.0, 1.0]), (vertices32, triangles32)) +trimesh_geodistance((vertices32, triangles32), 0) +trimesh_isolines((vertices32, triangles32), np.asarray([0, 1, 0], dtype=np.int64)) +trimesh_massmatrix((vertices32, triangles32)) +trimesh_harmonic((vertices32, triangles32)) +trimesh_lscm((vertices32, triangles32)) +normals64 = np.asarray([[0, 0, 1]] * 3, dtype=np.float64) +uvs32 = np.asarray([[0, 0], [1, 0], [0, 1]], dtype=np.float32) +polylines64 = np.asarray([[[0, 0, 0], [1, 0, 0]]], dtype=np.float64) +tessellation = Tessellation(vertices32, triangles32, normals64, uvs32, polylines64) +assert_type(tessellation.vertices, np.ndarray[tuple[int, int], np.dtype[np.float32]]) +assert_type(tessellation.triangles, np.ndarray[tuple[int, int], np.dtype[np.int32]]) +assert_type( + tessellation.to_vertices_and_triangles(), + tuple[ + np.ndarray[tuple[int, int], np.dtype[np.float32]], + np.ndarray[tuple[int, int], np.dtype[np.int32]], + ], +) +custom_vertices = CustomCoordinates([CustomCoordinate((0.0, 0.0, 0.0))]) +custom_triangles = CustomTriangles([CustomIndices((0, 0, 0))]) +custom_polylines = CustomPolylines([custom_vertices]) +custom_tessellation = Tessellation(custom_vertices, custom_triangles, polylines=custom_polylines) +assert_type(custom_tessellation.vertices, CustomCoordinates) +assert_type(custom_tessellation.triangles, CustomTriangles) +Mesh.from_vertices_and_faces(vertices32, triangles32) + +delaunay_triangulation(vertices32) +triangulate_polygon(vertices32) + + +def numpy_triangulator(vertices: PointsLike) -> np.ndarray[tuple[int, int], np.dtype[np.int64]]: + return np.asarray([[0, 1, 2], [0, 2, 3]], dtype=np.int64) + + +triangulator: Triangulator = numpy_triangulator +Polygon([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]).triangulated(triangulator) +constrained_delaunay_triangulation( + vertices32, + holes=np.asarray([vertices32], dtype=np.float32), + constraints=np.asarray([vertices32[:2]], dtype=np.float64), + points=np.asarray([[0.25, 0.25, 0]], dtype=np.int64), +) diff --git a/tests/typing/expected_errors.py b/tests/typing/expected_errors.py new file mode 100644 index 000000000000..1cea26861815 --- /dev/null +++ b/tests/typing/expected_errors.py @@ -0,0 +1,48 @@ +import numpy as np +from numpy.typing import NDArray +from collections.abc import Iterator + +from compas.typing import PointLike +from compas.typing import Float64Array +from compas.typing import IndicesLike +from compas.typing import IntegralInput +from compas.typing import NumericArray +from compas.typing import PointsLike +from compas.typing import RealInput +from compas.typing import VerticesFaces +from compas.geometry import Tessellation +from compas.geometry import Transformation +from compas.geometry import delaunay_triangulation +from compas.geometry import is_polygon_in_polygon_xy + + +class IterableOnlyCoordinate: + def __iter__(self) -> Iterator[float]: + return iter((1.0, 2.0, 3.0)) + + +class IterableOnlyPoints: + def __iter__(self) -> Iterator[list[float]]: + return iter(([0.0, 0.0, 0.0], [1.0, 0.0, 0.0])) + + +real_input: RealInput = "1" # expected-error: reportAssignmentType +integral_input: IntegralInput = 1.5 # expected-error: reportAssignmentType +numpy_bool_input: RealInput = np.bool_(True) # expected-error: reportAssignmentType +coordinate_input: PointLike = ["0", "1", "2"] # expected-error: reportAssignmentType +iterable_only_coordinate: PointLike = IterableOnlyCoordinate() # expected-error: reportAssignmentType +iterable_only_points: PointsLike = IterableOnlyPoints() # expected-error: reportAssignmentType +object_array_value: NDArray[np.object_] = np.asarray([1, 2, 3], dtype=np.object_) +object_array: NumericArray = object_array_value # expected-error: reportAssignmentType +indices_input: IndicesLike = np.asarray([0, 1, 2], dtype=np.float64) # expected-error: reportAssignmentType +float64_array: Float64Array = np.asarray([1, 2, 3], dtype=np.float32) # expected-error: reportAssignmentType +Tessellation([["0", "0", "0"]], [[0, 0, 0]]) # expected-error: reportArgumentType +Tessellation([[0.0, 0.0, 0.0]], np.asarray([[0.0, 0.0, 0.0]])) # expected-error: reportArgumentType +delaunay_triangulation(np.asarray([[0, 0, 0]], dtype=np.complex128)) # expected-error: reportArgumentType +is_polygon_in_polygon_xy(np.asarray([[0, 0, 0]], dtype=np.complex128), [[0, 0, 0]]) # expected-error: reportArgumentType +triangle_mesh_complex_vertices: VerticesFaces = (np.asarray([[0, 0, 0]], dtype=np.complex128), [[0, 0, 0]]) # expected-error: reportAssignmentType +triangle_mesh_float_indices: VerticesFaces = ([[0.0, 0.0, 0.0]], np.asarray([[0.0, 0.0, 0.0]])) # expected-error: reportAssignmentType +narrow_int_matrix: list[list[int]] = [[1, 0], [0, 1]] +narrow_numpy_matrix: list[list[np.float64]] = [[np.float64(1), np.float64(0)], [np.float64(0), np.float64(1)]] +Transformation(narrow_int_matrix) # expected-error: reportArgumentType +Transformation(narrow_numpy_matrix) # expected-error: reportArgumentType diff --git a/tests/typing/installed_consumer.py b/tests/typing/installed_consumer.py new file mode 100644 index 000000000000..48ac1443f150 --- /dev/null +++ b/tests/typing/installed_consumer.py @@ -0,0 +1,77 @@ +"""Static consumer coverage for an installed COMPAS wheel.""" + +from typing import assert_type + +import numpy as np + +from compas.datastructures import Mesh +from compas.geometry import Box +from compas.geometry import Brep +from compas.geometry import PlanarRegion +from compas.geometry import Point +from compas.geometry import Plane +from compas.geometry import Polygon +from compas.geometry import PolygonalSurface +from compas.geometry import Polyhedron +from compas.geometry import Tessellation +from compas.geometry import closest_point_on_polygon_xy +from compas.geometry import closest_point_on_polyline_xy +from compas.geometry import orthonormalize_axes +from compas.geometry import triangulate_polygon +from compas.geometry import triangulate_polygon_earclip +from compas.geometry import triangulate_polygon_minimum_area +from compas.geometry import triangulate_polygon_projected +from compas.geometry import triangulate_quadrilateral +from compas.geometry import trimesh_gaussian_curvature +from compas.typing import PointLike +from compas.typing import RealInput +from compas.typing import VerticesFaces + + +vertices = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] +triangles = [[0, 1, 2]] +tessellation = Tessellation(vertices, triangles) +numpy_point: PointLike = np.asarray([0.0, 0.0, 0.0], dtype=np.float32) +numpy_mesh: VerticesFaces = ( + np.asarray(vertices, dtype=np.float32), + np.asarray(triangles, dtype=np.int32), +) +Plane(numpy_point, np.asarray([0, 0, 1], dtype=np.int64)) +polygon = Polygon(vertices) +polygonal_surface = PolygonalSurface(vertices, [triangles[0]]) +polyhedron = Polyhedron( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + [[0, 2, 1], [0, 1, 3], [1, 2, 3], [2, 0, 3]], +) +planar_region = PlanarRegion(vertices) +mesh = Mesh.from_vertices_and_faces(vertices, triangles) + + +class CustomMesh(Mesh): + pass + + +custom_mesh = CustomMesh.from_vertices_and_faces(vertices, triangles) + +assert_type(Point(0.0, 0.0, 0.0).x, float) +assert_type(tessellation.number_of_triangles, int) +assert_type(tessellation.to_vertices_and_triangles(), tuple[list[list[float]], list[list[int]]]) +assert_type(Box(1.0).to_polygonal_surface(), PolygonalSurface) +assert_type(Box(1.0).to_tessellation(), Tessellation) +assert_type(Brep().to_tessellation(), Tessellation) +assert_type(polygon.triangulated(), Tessellation) +assert_type(polygonal_surface.triangulated(), Tessellation) +assert_type(polyhedron.triangulated(), Tessellation) +assert_type(planar_region.triangulated(), Tessellation) +assert_type(mesh.triangulated(), Mesh) +assert_type(custom_mesh.triangulated(), CustomMesh) +assert_type(triangulate_polygon(vertices), list[list[int]]) +assert_type(triangulate_polygon_earclip(vertices), list[list[int]]) +assert_type(triangulate_polygon_minimum_area(vertices), list[list[int]]) +assert_type(triangulate_polygon_projected(vertices, max_deviation=0.01), list[list[int]]) +assert_type(triangulate_quadrilateral([*vertices, [1.0, 1.0, 0.0]]), list[list[int]]) +assert_type(trimesh_gaussian_curvature((vertices, triangles)), list[float]) +assert_type(trimesh_gaussian_curvature(numpy_mesh), list[float]) +assert_type(closest_point_on_polyline_xy(numpy_point, vertices[:2]), list[RealInput]) +assert_type(closest_point_on_polygon_xy(numpy_point, vertices), list[RealInput]) +assert_type(orthonormalize_axes([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), tuple[list[RealInput], list[RealInput]]) diff --git a/uv.lock b/uv.lock index b8da2d7e94cf..584163632ea3 100644 --- a/uv.lock +++ b/uv.lock @@ -561,9 +561,9 @@ requires-dist = [ { name = "mkdocs-section-index", marker = "extra == 'docs'", specifier = ">=0.3" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'" }, { name = "networkx", specifier = ">=3.0" }, - { name = "numpy", specifier = ">=1.15.4" }, + { name = "numpy", specifier = ">=1.26" }, { name = "pydantic", marker = "extra == 'docs'", specifier = ">=2.10" }, - { name = "pyright", marker = "extra == 'dev'" }, + { name = "pyright", marker = "extra == 'dev'", specifier = "==1.1.414" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "pytest-dependency", marker = "extra == 'dev'" },