diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 6a650d152560..000000000000 --- a/.editorconfig +++ /dev/null @@ -1,30 +0,0 @@ -# EditorConfig is awesome: http://EditorConfig.org -root = true - -[*] -end_of_line = lf -trim_trailing_whitespace = true -insert_final_newline = true -indent_style = space -indent_size = 4 -charset = utf-8 -max_line_length = 179 - -[*.{bat,cmd,ps1}] -end_of_line = crlf - -[*.md] -trim_trailing_whitespace = false - -[*.yml] -indent_size = 2 - -[*.rst] -indent_size = 4 - -[Makefile] -indent_style = tab -indent_size = 4 - -[LICENSE] -insert_final_newline = false diff --git a/.github/workflows/ironpython.yml b/.github/workflows/ironpython.yml deleted file mode 100644 index 6076db0850c3..000000000000 --- a/.github/workflows/ironpython.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: ironpython - -on: - push: - branches: - - main - - '*.LTS' - pull_request: - branches: - - main - - '*.LTS' - -jobs: - build: - name: windows-ironpython - runs-on: windows-latest - steps: - - uses: actions/checkout@v2 - - name: "[RPC tests] Set up CPython 3.9" - uses: actions/setup-python@v2 - with: - python-version: 3.9 - - name: "[RPC tests] Install CPython dependencies" - run: | - python -m pip install --upgrade pip - - name: "[RPC tests] Install COMPAS on CPython" - run: | - pip install --no-cache-dir . - - name: Install dependencies - run: | - choco install ironpython --version=2.7.8.1 - curl -o ironpython-pytest.tar.gz -LJO https://pypi.debian.net/ironpython-pytest/latest - ipy -X:Frames -m ensurepip - ipy -X:Frames -m pip install --no-deps ironpython-pytest.tar.gz - - uses: NuGet/setup-nuget@v1.0.5 - - name: Install dependencies - run: | - choco install ironpython --version=2.7.8.1 - - uses: compas-dev/compas-actions.ghpython_components@v2 - with: - source: src/compas_ghpython/components - target: src/compas_ghpython/components - - name: Test import - run: | - ipy -m compas - env: - IRONPYTHONPATH: ./src - - name: Run tests - run: | - ipy tests/ipy_test_runner.py - env: - IRONPYTHONPATH: ./src diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000000..f092cca138a0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,122 @@ +# COMPAS Agent Guide + +## Goal + +Modernize the codebase by: + +- removing or upgrading parts related to compatibility with IronPython or older versions of CPython +- adding type hints +- migrating docstrings from Sphinx conventions to mkdocs +- increasing test coverage + +## Scope + +These instructions apply to the entire repository. Preserve unrelated working-tree changes and keep modernization patches focused. + +Breaking changes are allowed in this modernization when they materially improve the architecture, API consistency, typing, maintainability, or removal of obsolete compatibility code. Do not preserve legacy parameters, import paths, inheritance, aliases, or behavior solely for backward compatibility. Breaking changes must remain intentional and scoped: update internal consumers, tests, type contracts, documentation, serialization assumptions, and migration notes together, and verify that the replacement design is coherent. + +## Compatibility and Typing + +- Support Python 3.9 as declared in `pyproject.toml`. +- Replace Python 2 compatibility forms such as `super(Class, self)` with zero-argument `super()` when touching a class. Preserve explicit arguments to `super(...)` only where they are semantically required, such as selecting a different point in the MRO. +- Use `typing.Union[...]` for type unions in annotations and type comments. Do not use PEP 604 `X | Y` unions for now. The `|` notation may remain in docstrings and prose. +- Prefer annotations that describe what runtime code already accepts. Do not add conversions solely to satisfy a narrow annotation. +- Numeric coordinate and component parameters should be annotated as `float`, not as `Union[float, str]`. Integer arguments are valid for parameters annotated as `float` under Python's numeric typing rules; accepting integers does not require adding strings to the type or adding a runtime conversion. +- Direct `Point` and `Vector` construction may accept two components and default `z` to zero. Coordinate inputs consumed by other geometry objects and their methods must provide all three components; do not add `len(...)` checks that silently promote 2D input to 3D. +- Model fixed-size coordinate data as having three components outside the direct `Point` and `Vector` construction APIs whenever the type system can express that constraint. Do not broaden a fixed-size coordinate alias to an arbitrary-length collection merely to accommodate a lower-level function. +- Geometry objects such as `Point` and `Vector` are iterable and support indexing/unpacking. Pass them directly when an API consumes coordinate iterables; avoid unnecessary `list(...)` allocation. +- Functions in `compas.geometry._core` should be typed against raw numerical data structures and primitives. They should remain unaware of geometry object types and should not import geometry classes. +- Put broadly reusable structural and raw-data typing helpers in `compas._typing`. Keep unions that mention geometry classes, such as `LineType` and `PlaneType`, in `compas.geometry._typing`; define an alias locally only when it is genuinely private to one module. +- At public geometry API boundaries, use the appropriate object-aware union such as `LineType` when callers may pass either a geometry object or raw data. Coordinate-like objects that already satisfy the shared structural `CoordinateType` do not need separate `PointType` or `VectorType` unions. Do not solve boundary typing by progressively broadening every low-level helper. +- When a public method has getter/setter modes or return types controlled by arguments, use overloads to describe the distinct call shapes rather than falling back to `Any`. +- For overloaded functions, document one entry in the `Returns` section for each overload return type. Describe the argument condition that selects each return type; do not collapse the entries into a union. +- Preserve subclass behavior. Constructors and algorithms that accept or infer `cls` should continue returning the expected subclass. +- Prefer standard library types over typing imports: `list[tuple[...]]` instead of `List[Tuple[...]]`. +- Prefer correcting the source annotation, protocol, or overload over using `cast`. Use `cast` only when the type system cannot express a valid runtime invariant cleanly. +- Avoid broad `Any` unless unavoidable. +- Avoid importing heavy optional dependencies only for typing; use `TYPE_CHECKING` when needed. +- Use `Self` for the return type of (class)methods that return an instance of the current type or a subtype. +- Replace string-quoted return annotations with `Self` whenever the result is the current instance type and subclass preservation is intended. Keep a quoted concrete class only when the method deliberately returns that exact class rather than the receiver's type. + +## Runtime-Behavior Preservation + +- Modernization should not silently change valid-input behavior, return types, ordering, orientation, side effects, or error behavior. +- Before changing an implementation, compare old and new control flow and identify any equivalence that depends on data-structure invariants. +- Prefer fixing types at API boundaries over changing runtime values inside algorithms. +- When modernization or typing requires a small, non-obvious change to a function body, add a concise comment explaining why the change is necessary and which input contract or invariant it preserves. +- Keep diffs narrow. Do not fold speculative architecture changes into typing, documentation, or compatibility patches. +- Remove obsolete compatibility parameters when their removal is explicitly part of the task instead of preserving hidden aliases or branching logic. Update the implementation, documentation, and tests together. +- Use names that reflect cardinality: a collection of returned points should have a plural name such as `points`, not `point`. +- Keep a short expression on one line when it remains within the formatter's line-length limit and splitting it does not improve readability. + +## Tests and Validation + +- Use the `compas3` Conda environment for tests and checks. +- Run the smallest relevant test selection first, then broaden validation when practical. +- Use the repository commands documented in `CONTRIBUTING.md`: `invoke test`, `invoke lint`, and `invoke format`. Direct `pytest` and `ruff` invocations are appropriate for focused checks. +- Always run `git diff --check` for changed patches. +- For annotation work, supplement tests with AST/static checks where useful so type comments and less obvious annotation sites are not missed. +- Add direct tests for modernized public classes, including construction, sequence behavior, operators, reflected and in-place operators, property setters, transformations, and subclass-preserving constructors where applicable. +- Prefer behavioral tests over tests that merely assert the presence or shape of implementation metadata. + +## Documentation and Public API + +- Keep docstrings consistent with the repository's current conventions and Ruff configuration. +- Docstrings are being migrated from Sphinx/reStructuredText markup to MkDocs/mkdocstrings-compatible Markdown. Apply the new convention whenever touching a docstring, even where the surrounding documentation still contains legacy Sphinx files. +- Keep the existing NumPy-style section structure (`Parameters`, `Returns`, `Raises`, `Notes`, `Examples`, `References`, and `See Also`), but write the content of those sections as Markdown. +- Once a function or method parameter is annotated in the signature, omit its type from the docstring's `Parameters` entry. Document the parameter name and description only; do not duplicate signature types in the parameter list. +- Keep explicit types in the `Returns` section even when the return annotation is present in the signature. Mkdocstrings/Griffe otherwise does not parse the return documentation correctly. +- Write return types using the same canonical Python-style syntax as annotations, for example `list[float]`, `list[list[float]]`, `tuple[float, float]`, and `Sequence[float]`. Do not use prose or shorthand forms such as `list of list`, `[float, float, float]`, or `list[[float, float, float]]`. +- When a function returns a tuple, document it as one tuple return entry matching the return annotation, not as separate return entries for each tuple element. Describe the elements and their order in the entry's description. +- Omit the `Returns` section entirely when the function returns `None`. Remove empty `Returns` sections and entries that merely document `None`. +- Do not introduce Sphinx roles or directives such as `:class:`, `:meth:`, `:func:`, `:attr:`, `:mod:`, `.. note::`, or `.. code-block::` in docstrings. Replace existing occurrences in touched docstrings with plain or backticked identifiers, Markdown links, admonitions, and fenced code blocks as appropriate. +- Use single backticks for inline code, literals, parameter names, and identifiers. Use Markdown link syntax (`[label](URL)`) rather than reStructuredText inline links. +- Refer to Python and COMPAS API objects by an unambiguous qualified name when useful; let mkdocstrings resolve supported cross-references rather than embedding Sphinx-specific roles. +- Format `See Also` as a NumPy-style section so Griffe recognizes its `see-also` admonition kind, and use mkdocstrings cross-references for API objects: + + ```text + See Also + -------- + [`Mesh.from_obj`][compas.datastructures.Mesh.from_obj] for the inverse operation. + ``` + +- Use Markdown footnotes for cited references because the MkDocs `footnotes` extension is enabled. Use descriptive, globally unique footnote labels rather than numeric labels because multiple docstrings can be rendered on one page: + + ```text + Notes + ----- + This follows the method described by Nurnberg.[^volume-polyhedron-nurnberg] + + References + ---------- + [^volume-polyhedron-nurnberg]: [Calculating the Area and Centroid of a Polygon in 2D](https://example.com/paper.pdf) + ``` + +- For uncited further reading, use a Markdown list in `References`. Do not use reStructuredText citations such as `[1]_` or `.. [1]`. +- Use `$...$` for inline mathematics and `$$...$$` for display mathematics. Math rendering is provided by `pymdownx.arithmatex` and MathJax; do not use `:math:` roles or `.. math::` directives. +- Keep examples valid as doctests where they are intended to execute. A Markdown migration must not change the example's runtime meaning. +- Document public dunder behavior in the class docstring when it forms part of the user-facing API. Cover ordinary, reflected, and in-place arithmetic variants as applicable, and include a short executable example for each behavior. +- Add short examples for public classmethods that serve as alternative constructors. Do not add `from_data` or implementation-level deserialization hooks to this constructor overview. +- Do not manually add `Attributes` lists to class docstrings when mkdocstrings can generate them from the class members. +- Document every public property on the property's getter. Include setter input, copying, normalization, cache invalidation, and coupled side effects in `Notes` where applicable; properties without setters still require a concise description. Add short executable examples for computed, cached, or otherwise non-obvious behavior. +- Document non-obvious setter side effects. In particular, note when setting one frame axis normalizes it or recomputes another axis to preserve orthonormality. +- Do not expose implementation-only payload, encoder, or helper types without a clear public use case. +- Remove `DATASCHEMA` declarations from modernized objects and remove tests that exist only to validate those declarations. Do not introduce replacement schema metadata unless a current public use case requires it. +- Preserve convenience APIs only when they remain coherent with the modernized design. Breaking changes do not require a deprecation layer in this modernization, but their scope and replacement behavior must be explicit and fully updated across the repository. +- When compatibility requires accepting both documents and native COMPAS objects, make the behavior explicit with overloads or documented wrappers rather than untyped `Any`. +- Remove empty `Examples` sections. +- Except for single-line docstrings, leave a blank line at the end of the docstring. + +## Known Modernization Follow-ups + +- `Line` and `Polyline` are lightweight root-level `Geometry` primitives and must not inherit from `Curve`. Their former `compas.geometry.curves.line` and `compas.geometry.curves.polyline` module paths have been intentionally removed. Future explicit Curve-derived wrappers such as `LineCurve` and `PolylineCurve` may provide domains, parametrization, and the complete curve API, but are not currently a priority. Decide bounded versus unbounded line-curve semantics explicitly before adding them. Do not reshape `Curve` contracts to accommodate the primitive classes. +- Coordinate NURBS backend alignment through `compas_framework` after this COMPAS upgrade. The canonical `NurbsSurface` contract uses V rows containing U values (`points[v][u]` and `weights[v][u]`) and full mathematical knot vectors. Rhino must transpose grids at its boundary and remove or restore the two openNURBS superfluous endpoint knots; OCC uses canonical knots directly and converts its U-first arrays to the canonical grid layout. Add asymmetric-grid cross-package contract tests for both backends. The current `compas_occ` audit also found that `OCCNurbsSurface.__eq__` compares its own U and V data instead of comparing with `other`, `__from_data__` loses subclasses, `copy()` does not match the shared signature, `from_plane()` wraps `Geom_Plane` without converting it to a B-spline, and the STEP factory plugin appears to recurse through the shared factory. +- Before changing the corresponding functions in `compas.linalg.vectors` or `compas.linalg.matrices`, add regression tests and resolve the intended behavior of the following existing edge cases: + - `vector_variance` currently computes the square root of the variance, and `vector_standard_deviation` takes another square root. + - `orthonormalize_vectors` tests residual components with `axis > 1e-10` rather than `abs(axis) > 1e-10`, which can discard residuals containing only negative components. + - `matrix_determinant` and `matrix_inverse` do not correctly support 0x0 or 1x1 matrices. + - Singular-matrix checks use exact determinant comparisons rather than the repository tolerance policy. + - `decompose_matrix` is implemented specifically for 4x4 matrices despite broader wording in its docstring. + - `sum_vectors` treats every axis other than `0` as row-wise summation. + - Normalization, projection, and rotation helpers need explicit boundary tests for zero-length vectors, zero normals or axes, and projection directions parallel to the target plane. +- Revisit the long-standing `close` and `allclose` deprecations before removing them; preserve the public API unless the work includes an explicit deprecation or breaking-change plan. diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 000000000000..d0431ab7b9f5 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,699 @@ +# Migration Changes + +## Colors + +## Data + +## Files + +### General + +- Updated file-format documentation to the current MkDocs-compatible + NumPy-style conventions by removing Sphinx roles, directives, duplicated + parameter types, and unnecessary fully qualified return-type paths. +- Removed the remaining Python 2, old CPython, and IronPython compatibility + paths from the file-format infrastructure. +- Added shared `_iotools.read_bytes()` and `_iotools.write_bytes()` helpers for + consistent path, URL, text-stream, and binary-stream handling across formats. + +### OBJ + +- Replaced the stateful legacy `OBJ` facade with an explicit document-based + pipeline consisting of `OBJReader`, `OBJParser`, `OBJDocument`, and + `OBJWriter`. +- Added typed OBJ document elements for vertices and their texture and normal + references, points, lines, faces, ordered elements, objects, groups, + materials, and smoothing groups. +- Added `read_obj()` and `write_obj()` convenience functions supporting paths, + URLs, text streams, and binary streams. Writing supports documents, individual + meshes, multiple named meshes, metadata headers, and explicitly unwelded face + output. +- Added `OBJData` and `obj_data()` as an identity-preserving projection from + structured OBJ elements to plain vertex indices. Reading no longer welds + coincident vertices implicitly. +- Added the explicitly named `weld_obj_data()` conversion for workflows that + require coordinate-based vertex merging, with a configurable geometric-key + precision. +- Added `read_obj_meshes()` to extract all named polygon meshes from a source, + preserve object names and independent vertex identities, collect unassigned + faces, and optionally request explicit welding. +- Updated OBJ integration in Mesh, Graph, VolMesh, and CellNetwork. Indexed mesh + faces preserve their OBJ vertex identities, line-based graph and mesh + construction performs topology merging in `from_lines()`, and cell-based + datastructures request welding explicitly when shared cell vertices are + required. +- Added parsing for positive and negative vertex references, texture and normal + references, objects, groups, material libraries, active materials, smoothing + groups, continuations, comments, and degree-one `curv` statements. +- Preserved standalone and inline comments in `OBJDocument`, moved all OBJ + serialization helpers out of the writer class, and made generated metadata a + document concern rather than writer configuration. +- Aligned OBJ and PLY pipeline responsibilities: readers now only normalize + paths, URLs, and streams to bytes, while parsers own decoding, lexical + processing, format syntax, and document construction. +- Added semantic document, parser, writer, stream, multiple-mesh, welded and + non-welded projection, and roundtrip tests. Roundtrip coverage verifies that + identity-preserving reads retain vertex counts while explicit welding produces + and retains the reduced topology. + +### PLY + +- Replaced the stateful `PLY` facade with a schema-driven pipeline consisting + of `PLYReader`, `PLYParser`, `PLYDocument`, and `PLYWriter`. +- Added structured PLY properties and elements that preserve ordered schemas, + custom scalar and list properties, comments, object information, format, and + version metadata. +- Added `read_ply()`, `write_ply()`, and the mesh-oriented `ply_data()` + projection, with support for paths, URLs, text streams, and binary streams. +- Added generic ASCII, binary little-endian, and binary big-endian parsing and + writing, including variable-length list properties and standard scalar type + aliases. +- Centralized PLY scalar definitions, parsing, validation, packing, and + unpacking in a shared codec module. +- Consolidated shared PLY domain declarations such as formats, data types, + byte order, scalar values, and records in `ply_types.py`, while reusing the + generic `_iotools` source and target aliases for I/O. +- Strengthened PLY validation for formats, property schemas, scalar kinds and + ranges, list-count types and capacities, trailing and truncated data, and + mesh vertex references. +- Applied numeric precision during ASCII serialization rather than rounding the + semantic document data, and ensured metadata options are applied to copied + documents without mutating caller-owned data. +- Updated Mesh and Pointcloud PLY integration without changing their public + constructors and conversion methods. +- Added tests for custom schemas and properties, ASCII document roundtrips, + binary fixtures, variable-size binary faces, streams, and mesh roundtrips. + +### OFF + +- Replaced the stateful `OFF` facade with the aligned `OFFReader`, `OFFParser`, + `OFFDocument`, and `OFFWriter` pipeline. +- Added `read_off()` and `write_off()` convenience functions using explicit + source and target parameters and shared byte I/O. +- Preserved standalone and inline comments as document metadata and applied + author, email, and date metadata without mutating caller-owned documents. +- Added strict parsing and validation for headers, counts, coordinate + dimensions, face degrees, vertex references, incomplete input, and trailing + data, with support for continuations and counts on the header line. +- Applied numeric precision only during serialization and updated Mesh OFF + integration without changing `Mesh.from_off()` or `Mesh.to_off()`. +- Added separated reader, parser, document, writer, convenience, malformed-input, + metadata, and welded and non-welded mesh roundtrip tests. + +### STL + +- Replaced the stateful legacy `STL` facade with the aligned `STLReader`, + `STLParser`, `STLDocument`, and `STLWriter` pipeline. +- Added `read_stl()` and `write_stl()` convenience functions for ASCII and + binary STL using shared path and stream I/O, while preserving solid names, + binary headers, facet normals, and binary attribute values. +- Added strict document validation and parsing for facet structure, coordinate + dimensions, incomplete ASCII input, binary facet counts, and exact binary + payload sizes. Binary files whose header starts with `solid` are detected by + their declared facet count rather than by the header text. +- Added identity-preserving `stl_data()` and explicit `weld_stl_data()` mesh + projections. `Mesh.from_stl()` requests welding explicitly because STL stores + independent facet vertices, while parsing itself no longer merges geometry. +- Applied numeric precision only during ASCII serialization, retained document + formats by default, and stopped translating negative coordinates during + writing. +- Added separated reader, parser, document, writer, malformed-input, stream, + welding, and ASCII and binary mesh roundtrip tests. + +### glTF + +- Removed postponed-annotation and other legacy Python compatibility syntax + from the glTF package, using explicit forward references where required. +- Removed JSON-oriented `to_data()` and `from_data()` methods from glTF + semantic objects. `GLTFParser` now owns JSON decoding and `GLTFEncoder` owns + JSON construction and index remapping, leaving document objects focused on + semantic content, validation, editing, and COMPAS conversions. +- Converted passive glTF semantic and extension values to Python dataclasses. + Document, scene, node, and mesh entities retain explicit constructors because + they allocate keys and establish validated document relationships. +- Added complete scene conversion with `Scene.from_gltf()` and + `Scene.to_gltf()`. The adapter preserves node hierarchy and local + transformations, converts meshes, point clouds, lines, and polylines, expands + all glTF line and triangle topology modes, and warns when unsupported COMPAS + scene items are omitted from glTF geometry. +- Added explicit, symmetric primitive conversion functions for glTF points, + lines, line strips and loops, triangles, triangle strips and fans, and their + closest COMPAS mesh, point-cloud, line, and polyline equivalents. +- Introduced an explicit source-to-document pipeline: `GLTFReader` now only + acquires primary bytes and configures relative resource loading, while + `GLTFParser` owns JSON and GLB decoding, resource resolution, accessor + decoding, and semantic document construction. +- Added typed `GLTFSource`, filesystem and URL resource loaders, strict JSON and + GLB container parsing, and dedicated buffer, buffer-view, data-URI, sparse, + interleaved, and normalized accessor decoding. +- Added `GLTFDocument` as the semantic document and scene-editing model. +- Added non-mutating `GLTFEncoder`, structured `GLTFPayload`, and `GLTFWriter` + layers, plus `read_gltf()` and `write_gltf()` convenience functions. JSON + glTF resources are written adjacent to path targets, and GLB output is + supported for paths and binary streams. +- Removed the stateful `GLTF` facade in favor of `read_gltf()` and + `write_gltf()`. +- Removed remaining IronPython buffer-packing compatibility and fixed matrix + accessor packing to select padding from the accessor type. +- Added separated reader, container, parser, encoder, writer, resource, + non-mutation, external-buffer, stream, JSON glTF, and GLB roundtrip tests. +- Modernized the semantic document core (`GLTFDocument`, `GLTFScene`, + `GLTFNode`, `GLTFMesh`, and `GLTFChildren`) with Python 3.9-compatible type + annotations and MkDocs-oriented docstrings, and removed the obsolete + executable example embedded in `gltf_content.py`. +- Added whole-document validation for scene, node, mesh, camera, and skin + references, multiple node parents, and hierarchy cycles. Parsing and encoding + now validate semantic content explicitly. +- Added `without_orphans()` for non-destructive cleanup. Encoding operates on + the cleaned copy rather than modifying the caller's scene graph. +- Fixed `GLTFChildren.pop(0)`, `index()`, and `count()` return behavior, corrected + node skin lookup to use the document's skin collection, and made animation + and skin cleanup safe while filtering collections. +- Added semantic document tests for child-sequence behavior, skin references, + cyclic and multiply-parented hierarchies, and non-mutating orphan cleanup. +- Modernized material, texture, image, camera, animation, skin, primitive, and + supported-extension data classes with typed construction and serialization, + modern `super()` usage, and explicit recursive semantic traversal. +- Removed the IronPython-only semantic marker attributes and replaced + reflection through `dir()` with `iter_data()` and `extension_keys()`. +- Fixed material serialization keys for occlusion textures and alpha cutoff, + preserved explicit zero glossiness, remapped skin skeleton keys during + serialization, and omitted absent primitive index accessors. +- Preserved asset metadata, required versus used extensions, and unknown + top-level properties across semantic roundtrips. +- Extended validation to materials, textures, images, samplers, primitive + modes and indices, animation targets and interpolation, skins, cameras, asset + versions, and required-extension declarations. +- Merged JSON and binary construction directly into `GLTFEncoder` and removed + the redundant encoding-state and legacy `GLTFExporter` classes. +- Added automatic 32-bit index accessors for large meshes, four-byte buffer-view + alignment, padded matrix accessor decoding, and normalized non-sparse + accessor decoding. +- Added regression tests for specification property names, skin remapping, + nested extensions, metadata preservation, deterministic encoding, large + indices, padded matrices, normalized accessors, and invalid strides and + references. +- Removed unused alpha-mode and MIME-type containers, the unused component-size + table, duplicate orphan cleanup and hierarchy checks during encoding, and the + `GLTFContent` alias module. Updated tests to use only the explicit document + pipeline instead of private exporter state. + +### XML + +- Removed the `XML`, `XMLReader`, `XMLWriter`, and `XMLElement` class hierarchy + in favor of the explicit `read_xml()`, `parse_xml()`, `write_xml()`, and + `xml_to_string()` functions. +- Removed the entire private `_xml` compatibility package for IronPython and + CPython versions older than 3.8. +- Reimplemented XML processing directly with `xml.etree.ElementTree`, while + retaining COMPAS path, URL, text-stream, and binary-stream support through + `_iotools`. +- Replaced `minidom` formatting with `ElementTree.indent()` and ensured pretty + serialization does not mutate the caller's element tree. +- Adopted standard ElementTree namespace semantics: expanded names and namespace + meaning survive roundtrips, but `xmlns` declarations are no longer injected + as artificial element attributes and original prefixes are not guaranteed. +- Added coverage for files, URLs, text and binary streams, string parsing, + string and byte serialization, nonmutating formatting, writing, and namespace + roundtrips. + +## Geometry + +### General + +- Modernized the geometry package for Python 3.9 with explicit annotations, + subclass-preserving `Self` return types, overloads for argument-dependent + results, modern `super()` calls, and MkDocs-compatible NumPy-style docstrings. +- Removed the remaining IronPython, Python 2, and old-CPython compatibility + paths, postponed-annotation imports, legacy type comments, and geometry + `DATASCHEMA` declarations. +- Added shared structural coordinate and transformation protocols. Public + geometry APIs accept the representations supported at runtime, while the + low-level `_core` functions remain typed against raw numerical structures and + do not depend on geometry classes. +- Standardized geometry properties and setters, documenting when coordinate, + point, vector, plane, and frame inputs are copied and ensuring mutable input + objects are not retained unexpectedly. +- Increased unit and doctest coverage throughout the geometry primitives, + parametric curves, intersections, and analytic surfaces, including subclass + construction, transformed coordinate systems, overloads, and boundary cases. + +### Linear algebra separation + +- Removed general vector, matrix, quaternion, decomposition, solver, and + transformation algebra from `compas.geometry._core`. Geometry now imports + these operations from the dedicated `compas.linalg` package described below. +- Kept `_core` focused on geometry-specific numerical algorithms such as + angles, centroids, distances, normals, predicates, sizes, tangents, and NURBS + helpers. + +### Primitives + +- Modernized `Point`, `Vector`, `Frame`, and `Plane` with complete typing, + documented arithmetic and sequence behavior, constructor examples, explicit + three-coordinate contracts, and independent-copy setter semantics. +- Documented all primitive properties and the behavior of forward, reflected, + and in-place arithmetic operators. Point and vector constructors continue to + accept integer coordinate values through float conversion but no longer + advertise strings as valid coordinates. +- Reworked shared coordinate protocols to describe indexable, iterable + three-component data without unnecessary runtime list allocation or casts. +- Made `Line` and `Polyline` lightweight geometry primitives instead of + subclasses of `Curve`, and moved them out of `compas.geometry.curves` to + `compas.geometry.line` and `compas.geometry.polyline`. The old curve-package + import paths were removed. +- Modernized line and polyline construction, properties, evaluation, length, + transformations, and documentation. Inputs to properties such as + `line.point` create independent geometry values. + +### Curves + +- Simplified `Curve` into the common parametric-curve contract used by analytic + curves and plugin-backed NURBS implementations, with typed domains, + evaluation, frames, tangents, normals, curvature, closest-point queries, + discretization, and native conversion boundaries. +- Modernized `Arc`, `Bezier`, `Circle`, `Ellipse`, `Hyperbola`, `Parabola`, and + `NurbsCurve`, including constructor examples, property documentation, + argument-controlled overloads, stricter invariant validation, and expanded + behavioral tests. +- Removed primitive `Line` and `Polyline` from the curve inheritance hierarchy. + Dedicated `LineCurve` and `PolylineCurve` wrappers may be introduced later + where a full parametric curve object is required. +- Restricted frame-valued curve inputs to actual `Frame` objects and moved + reusable path and typing aliases out of the `Curve` class. + +### Intersections + +- Added `IntersectionResult`, an immutable result container with geometry, + point, and line accessors, and introduced the predefined `intersection` + dispatcher for computing intersections by geometry-type pairs. +- Added a symmetric `Intersection` registration mechanism with reversed-argument + dispatch and method-resolution-order fallback. Registered initial line-line, + line-plane, and plane-plane implementations. +- Modernized the free intersection functions with explicit input and return + types, overloads, consistent three-dimensional input validation, and retained + XY-specific contracts for explicitly two-dimensional helpers. +- Removed intersection methods from `Plane` and `Surface` so intersection logic + has one dedicated home, and retained mesh/mesh and ray/mesh extension points + for backend implementations. +- Fixed segment typing in XY point predicates and slice-related typing in + intersection calculations without broadening the numerical `_core` APIs to + geometry object types. + +### Surfaces + +- Modernized `Surface` state, frame copying, transformations, domains, + discretization, native conversion, evaluation, isocurves, boundaries, and + closest-point overloads. Direct frame mutation now updates transformations + without a stale cache. +- Removed conflicting `Surface.aabb()` and `Surface.obb()` methods in favor of + the standard `Geometry` bounding-box properties and `compute_aabb()` and + `compute_obb()` implementation hooks. +- Removed unimplemented generic OBJ, STEP, intersection, and inconsistent + curvature placeholders from the base surface API. Backend-supported STEP and + BREP conversion hooks remain where their contracts are meaningful. +- Modernized `PlanarSurface`, `SphericalSurface`, `CylindricalSurface`, + `ConicalSurface`, and `ToroidalSurface` with typed properties, serialization, + subclass-preserving constructors, analytic point and normal evaluation, + frames, isocurves, area and volume formulas, and transformed-coordinate tests. +- Fixed planar frame origins, spherical meridian isocurves, cylindrical + generator domains and frames, and completed the previously partial cone and + torus analytic implementations. Regular cylinders and cones require positive + dimensions, and `ToroidalSurface` represents regular ring tori. +- Removed the empty, unreferenced `extrusion` and `revolution` surface modules. + +## Linear algebra + +- Introduced `compas.linalg` as the dedicated home for general-purpose linear + algebra that was previously mixed into `compas.geometry._core`, + `compas.linalg.py`, and `compas.matrices.py`. +- Added focused `vectors`, `matrices`, `operators`, `quaternions`, + `transformations`, `decompositions`, and `solvers` modules with a consolidated + public API in `compas.linalg`. +- Moved matrix construction and manipulation, vector arithmetic, quaternion + operations, transformation helpers, matrix decompositions, and linear-system + solvers to the new package and updated geometry, datastructure, file, and + Blender consumers to use it. +- Removed the old top-level `compas.linalg` module, `compas.matrices` module, + geometry `_algebra` module, and geometry `_core.quaternions` module. Imports + should now target the corresponding `compas.linalg` package API. +- Kept numerical behavior stable during the package move and migrated the + existing matrix tests to the new module layout. Known numerical edge cases + requiring behavioral decisions remain documented for dedicated follow-up + work. + +## Datastructures + +### Shared infrastructure + +- Typed the `Datastructure` base class, including serialization, inheritance, + bounding-box caches, transformation hooks, and copy-transform helpers. +- Replaced legacy return-type comments with subclass-preserving `Self` types and + made the implicit `to_points()` contract explicit. +- Prevented datastructures from retaining caller-owned attribute mappings. +- Typed the mutable-mapping compatibility implementation with generic key and + value types and overloads for `get()`, `pop()`, and `setdefault()`. +- Fixed keyword-only `MutableMapping.update()` calls, which previously ignored + all supplied values. +- Documented that avoiding abstract base classes and metaclasses was an + IronPython 2.7 workaround that should now be reconsidered. +- Typed the attribute-view hierarchy and fixed `custom_only=True` so lookup, + membership, iteration, and length expose the same keys. +- Removed redundant specialized attribute-view constructors and corrected the + `CellAttributeView` description. + +### Graph + +#### Type annotations and API documentation + +- Added shared graph types for hashable node identifiers, edges, and attribute + dictionaries. +- Added Python 3.9-compatible annotations throughout serialization, + constructors, conversions, accessors, attributes, topology, geometry, + transformations, matrices, duality, planarity, operations, and smoothing. +- Added overloads for node and edge accessors and getter/setter attribute APIs, + and preserved custom Graph subclasses in constructors and graph-producing + operations. +- Removed the legacy graph data schema and migrated docstrings away from Sphinx + roles and duplicated parameter types. + +#### Graph behavior and operations + +- Corrected automatic node-key tracking so only integer keys affect the next + generated key. +- Added support for explicitly storing `None` in node and edge attributes. +- Fixed connected-component edge grouping and graph explosion for mixed node-key + types. +- Added safe handling for empty and edgeless cycle searches and typed the cycle + and neighbor-ordering helpers. +- Improved edge splitting and degree-two edge joining, including validation, + default attributes, and preservation of remaining node attributes. +- Expanded polyline extraction coverage for empty, open, closed, disconnected, + branched, and explicitly split graphs. +- Typed and verified crossing detection, planarity checks, planar embeddings, + and centroid smoothing, including fixed nodes, damping, isolated nodes, and + callback validation. + +#### Tests + +- Added focused coverage for graph serialization, mixed key types, nullable + attributes, duality, topology operations, polylines, planarity, crossings, + smoothing, callbacks, and empty-graph behavior. + +### Tree and HashTree + +#### Tree + +- Added typed serialization, parent/child relationships, traversal strategies, + node lookup, hierarchy formatting, and Graph conversion for `Tree` and + `TreeNode`. +- Preserved subclasses in data construction and added correct optional return + types for roots, parents, owning trees, and name-based lookup. +- Fixed empty-tree deserialization and replaced breadth-first list popping with + a deque. +- Added explicit errors for unsupported traversal strategies and orders. +- Documented remaining design questions around ownership, cycles, reparenting, + mutable child lists, detached subtrees, subclass deserialization, and duplicate + Graph keys. +- Expanded tests for invalid traversal options, ancestors and descendants, + empty-tree round-trips, duplicate-name lookup, and limited-depth hierarchy + output. + +#### HashTree + +- Reworked `HashTree` and `HashNode` as independent immutable data types instead + of mutable subclasses of `Tree` and `TreeNode`. +- Added typed serialization, traversal, hierarchy formatting, Graph conversion, + signatures, and diffing. +- Made children immutable, defensively copied values, and distinguished an + explicit `None` value from a branch node without a value. +- Made signatures independent of dictionary insertion order and computed them + lazily from immutable node content. +- Added validation for values combined with children, duplicate sibling paths, + reused child nodes, invalid roots, missing diff roots, non-Data inputs, and + duplicate Graph keys. +- Added comprehensive serialization, immutability, signature, validation, + conversion, and diff regression tests. + +### Removed datastructures + +- Removed the deprecated `Assembly`, `Part`, and related assembly exceptions and + tests from `compas.datastructures`. + +### Mesh + +#### Type annotations and API documentation + +- Added shared mesh type aliases for vertices, faces, edges, attributes, and + point coordinates in `mesh/types.py`. +- Added Python 3.9-compatible annotations throughout the `Mesh` class, mesh + operations, Conway operators, duality, remeshing, slicing, smoothing, and + subdivision. +- Added overloads where return types depend on input options and used `Self` or + bounded type variables where functions preserve a custom `Mesh` subclass. +- Updated docstrings to use the current NumPy-style format and documented new + explicit failure modes. +- Removed legacy type comments, redundant compatibility syntax, unused imports, + and an empty `operations/extrude.py` module. + +#### Mesh class + +- Typed the internal halfedge, vertex, face, face-data, and edge-data mappings. +- Typed construction, serialization, attribute, topology, geometry, traversal, + and transformation methods. +- Improved return contracts for methods that can return no result and added + overloads for methods whose output depends on flags such as `data` and + `include_none`. +- Preserved subclass types in alternate constructors and copy-like operations. +- Added explicit validation around invalid vertices, faces, edges, and attribute + requests where the previous implementation failed indirectly. +- Expanded tests for construction, serialization, topology queries, attributes, + transformations, and edge cases. + +#### Topology operations + +- Added typed contracts to edge collapse, insertion, face merging, edge and face + splitting, vertex substitution, edge swapping, and unwelding. +- Standardized invalid-edge handling and documented operations that raise + `ValueError` for invalid input. +- Added explicit `RuntimeError` failures when an operation unexpectedly produces + an invalid face or cannot complete a requested topology change. +- Improved handling of boundary cases, fixed vertices, and optional operation + results. +- Added regression coverage for successful operations, rejected operations, + invalid inputs, boundaries, and fixed vertices. + +#### Conway operators + +- Added subclass-preserving return types to all Conway operators. +- Clarified that their documented topological relationships assume closed + manifold seed meshes. +- Improved boundary handling and removed assumptions that both sides of every + edge have an incident face. +- Simplified face construction and corrected connectivity used by several + operators. +- Added tests for topology counts, validity, source immutability, subclass + preservation, and open-mesh behavior. + +#### Duality + +- Added subclass-preserving overloads, including support for an explicitly + requested output mesh class. +- Removed unused constants and consolidated boundary discovery. +- Added an explicit, documented `RuntimeError` when boundary vertices are not + ordered consistently. +- Added coverage for closed and open meshes, geometry, oriented connectivity, + boundary elements, and output types. + +#### Remeshing + +- Added types for remeshing options, fixed vertices, and iteration callbacks. +- Replaced the accidental division failure for a nonpositive target length with + an explicit, documented `ValueError`. +- Clarified the meaning of the divergence threshold and callback arguments. +- Added coverage for target validation, boundary splitting, mesh validity, and + callback invocation. + +#### Mesh slicing + +- Added types to the slicing helper and intersection state. +- Preserved custom `Mesh` subclasses in both resulting submeshes. +- Added explicit failures when an intersected edge cannot be split or two + submeshes cannot be constructed. +- Narrowed face-splitting exception handling from all exceptions to the expected + `ValueError`. +- Added coverage for valid closed slices, non-intersections, and subclass + preservation. +- Documented that the current implementation assumes the input mesh represents a + closed volume, but does not check this condition. + +#### Smoothing + +- Added common types for fixed vertices and iteration callbacks across centroid, + center-of-mass, and area smoothing. +- Replaced generic callback exceptions with a documented `TypeError` for a + non-callable callback. +- Made callback handling consistently distinguish `None` from a supplied + callback. +- Added coverage for the geometry produced by every smoothing method, fixed + vertices, callback invocation, and callback validation. + +#### Subdivision + +- Added typed, subclass-preserving contracts for the dispatcher and all + subdivision schemes: triangle, quad, corner, Catmull-Clark, Doo-Sabin, frames, + and Loop. +- Improved unsupported-scheme errors by including the requested scheme. +- Added explicit, documented failures when Catmull-Clark or Loop subdivision + cannot split an edge, and when quad subdivision produces an invalid face. +- Changed frame offsets to accept any face-to-distance mapping, in addition to a + single distance. +- Corrected zero-level quad subdivision so that it returns an unchanged, + independent copy instead of adding internal path metadata. +- Documented that the Doo-Sabin `fixed` parameter is currently retained for API + compatibility but has no effect. +- Expanded tests to cover every scheme, expected topology counts, validity, + source immutability, zero subdivision levels, unsupported schemes, and custom + mesh subclasses. + +#### Known compatibility notes + +- Collection-based attribute methods still use the legacy behavior in which an + explicitly empty key collection may select all elements. This has not been + changed because existing callers may rely on it. +- Edge-data keys remain direction-independent serialized strings internally. + Centralizing canonical edge-key handling remains future work. +- Mesh slicing does not validate its closed-volume assumption. +- The Doo-Sabin `fixed` parameter remains nonfunctional. + +### VolMesh + +#### Type annotations and API documentation + +- Added shared VolMesh types for vertices, edges, faces, halffaces, cells, + attributes, coordinates, and cell topology. +- Added Python 3.9-compatible annotations throughout serialization, + construction, accessors, attributes, topology, geometry, boundaries, and + transformation. +- Added overloads for accessors and attribute methods whose return type depends + on a data or setter argument. +- Preserved custom VolMesh subclasses in constructors and data round-trips. +- Documented explicit validation failures and the currently unimplemented + `is_valid()` method. + +#### Data and construction + +- Fixed serialization of non-empty cell attributes. +- Prevented builders and default-attribute updates from mutating caller-owned + mappings. +- Changed `add_halfface()` to reject vertex identifiers that are not already + part of the VolMesh, avoiding vertices with implicit default geometry. +- Added canonical, direction-independent helpers for edge and face attribute + data keys. +- Documented that these local helpers should become a dedicated data-key API + used consistently by storage and serialization. + +#### Attributes and queries + +- Added support for explicitly storing `None` as a vertex, edge, face, or cell + attribute value. +- Prevented filtering methods from modifying caller-owned condition mappings. +- Added the missing `has_cell()` query and corrected reversed-edge handling in + `has_edge()`. +- Corrected semantic sample types, distinguishing faces from halffaces. + +#### Topology and geometry + +- Fixed `delete_vertex()` so that it removes the requested vertex after deleting + its incident cells. +- Fixed edge and face attribute cleanup so shared topology retains its data until + the final incident cell is removed. +- Fixed halfface manifold-neighbor traversal and support for unattached + halffaces. +- Added explicit validation for vertex and halfface neighborhood rings below 1. +- Fixed cell vertex-neighbor traversal on Python 3, deduplicated cell edges, and + deduplicated adjacent-cell results. +- Typed and verified vertex, edge, face, cell, boundary, and transformation + geometry APIs. + +#### Tests + +- Expanded VolMesh coverage for serialization, constructors, builders, + modifiers, samples, all attribute domains, filtering, topology, geometry, + boundaries, data cleanup, subclass preservation, and transformation. + +#### Known compatibility notes + +- `VolMesh.is_valid()` remains unimplemented and raises `NotImplementedError`. +- Edge and face data keys remain serialized strings internally pending a + dedicated canonical data-key API. + +### CellNetwork + +#### Type annotations and API documentation + +- Added shared CellNetwork types for vertices, edges, faces, cells, attributes, + and point coordinates. +- Added Python 3.9-compatible annotations throughout serialization, + construction, conversion, accessors, attributes, topology, boundaries, and + geometry. +- Added exhaustive overloads for accessors and attribute methods whose return + type depends on `data`, `names`, `values`, or setter arguments. +- Updated overloaded-function docstrings to document every return or yield + scenario separately. +- Converted docstrings from Sphinx roles and directives to the NumPy-style + syntax expected by Griffe and MkDocstrings, and removed duplicated parameter + type declarations. +- Preserved custom CellNetwork subclasses in data round-trips and alternate + constructors. + +#### Data and construction + +- Removed the legacy data schema declaration and typed the internal topology and + attribute mappings directly. +- Fixed serialization round-trips for edge orientation and complete cell data. +- Prevented builders, filters, and default-attribute updates from mutating + caller-owned mappings. +- Changed `add_face()` to reject missing vertices and faces with fewer than + three vertices before modifying topology. +- Added a canonical, direction-independent helper for edge attribute-data keys. +- Fixed edge insertion so adjacency is stored in the supplied direction, and + fixed edge deletion so its attributes are removed. + +#### Attributes and queries + +- Added support for explicitly storing `None` as a vertex, edge, face, or cell + attribute value. +- Made explicitly empty vertex, edge, face, and cell selections remain empty + instead of selecting every element. +- Fixed list-valued vertex filters so remaining conditions are still evaluated. +- Made `edges(data=True)` return the documented `EdgeAttributeView`, including + default attributes. +- Added the missing `has_cell()` query and corrected accessor return contracts, + including sample functions and predicate filters. + +#### Topology, geometry, and conversions + +- Added explicit validation for vertex neighborhood rings below 1. +- Fixed `vertex_neighbors()` to return the documented list. +- Fixed `edge_cells()` to inspect both edge directions and deduplicate cells. +- Fixed `cell_vertex_neighbors()` to use edge adjacency rather than vertex + attributes. +- Deduplicated cell edges and documented invalid cell-face membership with an + explicit `ValueError`. +- Fixed `faces_to_mesh()` for generator inputs and updated OBJ construction to + use the public parser data. +- Converted attribute views to dictionaries at Graph API boundaries and + preserved default attributes during graph conversion. +- Typed and verified vertex, edge, face, cell, boundary, and geometry queries. + +#### Tests + +- Expanded CellNetwork coverage for serialization, subclass preservation, + clearing, builders, validation, samples, attribute overload scenarios, + filtering, topology, geometry, boundaries, and graph and mesh conversions. + +#### Known compatibility notes + +- `CellNetwork.is_valid()` remains unimplemented and raises + `NotImplementedError`. diff --git a/conftest.py b/conftest.py index 8c573e700d4b..bac0857c9f20 100644 --- a/conftest.py +++ b/conftest.py @@ -1,10 +1,11 @@ -from pathlib import Path import math +from pathlib import Path + import numpy import pytest import compas -from compas.geometry import allclose +from compas.linalg import allclose def pytest_ignore_collect(collection_path: Path, config): diff --git a/docs/_static/PLACEHOLDER b/docs/_static/PLACEHOLDER deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 224f7bb03a2c..000000000000 --- a/docs/conf.py +++ /dev/null @@ -1,161 +0,0 @@ -# flake8: noqa -# -*- coding: utf-8 -*- - -from sphinx.writers import html, html5 -import sphinx_compas2_theme - -# -- General configuration ------------------------------------------------ - -project = "COMPAS" -copyright = "COMPAS Association" -author = "Tom Van Mele" -organization = "compas-dev" -package = "compas" - -master_doc = "index" -source_suffix = {".rst": "restructuredtext", ".md": "markdown"} -templates_path = sphinx_compas2_theme.get_autosummary_templates_path() -exclude_patterns = sphinx_compas2_theme.default_exclude_patterns -add_module_names = True -language = "en" - -latest_version = sphinx_compas2_theme.get_latest_version() - -if latest_version == "Unreleased": - release = "Unreleased" - version = "latest" -else: - release = latest_version - version = ".".join(release.split(".")[0:2]) # type: ignore - -# -- Extension configuration ------------------------------------------------ - -extensions = sphinx_compas2_theme.default_extensions - -# numpydoc options - -numpydoc_show_class_members = False -numpydoc_class_members_toctree = False -numpydoc_attributes_as_param_list = True -numpydoc_show_inherited_class_members = False - -# bibtex options - -# autodoc options - -autodoc_type_aliases = {} -autodoc_typehints_description_target = "documented" -autodoc_mock_imports = sphinx_compas2_theme.default_mock_imports -autodoc_default_options = { - "undoc-members": True, - "show-inheritance": True, -} -autodoc_member_order = "groupwise" -autodoc_typehints = "description" -autodoc_class_signature = "separated" - -autoclass_content = "class" - - -def setup(app): - app.connect("autodoc-skip-member", sphinx_compas2_theme.skip) - - -# autosummary options - -autosummary_generate = True -autosummary_mock_imports = sphinx_compas2_theme.default_mock_imports - -# graph options - -# plot options - -# intersphinx options - -intersphinx_mapping = { - "python": ("https://docs.python.org/", None), - "compas": ("https://compas.dev/compas/latest/", None), -} - -# linkcode - -linkcode_resolve = sphinx_compas2_theme.get_linkcode_resolve(organization, package) - -# extlinks - -extlinks = { - "rhino": ("https://developer.rhino3d.com/api/RhinoCommon/html/T_%s.htm", "%s"), - "blender": ("https://docs.blender.org/api/2.93/%s.html", "%s"), -} - -# from pytorch - -sphinx_compas2_theme.replace(html.HTMLTranslator) -sphinx_compas2_theme.replace(html5.HTML5Translator) - -# -- Options for HTML output ---------------------------------------------- - -html_theme = "multisection" -html_title = project -html_sidebars = {"index": []} - -favicons = [ - { - "rel": "icon", - "href": "compas.ico", - } -] - -html_theme_options = { - "external_links": [ - {"name": "COMPAS Framework", "url": "https://compas.dev"}, - ], - "icon_links": [ - { - "name": "GitHub", - "url": f"https://github.com/{organization}/{package}", - "icon": "fa-brands fa-github", - "type": "fontawesome", - }, - { - "name": "Discourse", - "url": "http://forum.compas-framework.org/", - "icon": "fa-brands fa-discourse", - "type": "fontawesome", - }, - { - "name": "PyPI", - "url": f"https://pypi.org/project/{package}/", - "icon": "fa-brands fa-python", - "type": "fontawesome", - }, - ], - "switcher": { - "json_url": f"https://raw.githubusercontent.com/{organization}/{package}/gh-pages/versions.json", - "version_match": version, - }, - "logo": { - "image_light": "_static/compas_icon_white.png", - "image_dark": "_static/compas_icon_white.png", - "text": "COMPAS docs", - }, - "navigation_depth": 2, -} - -html_context = { - "github_url": "https://github.com", - "github_user": organization, - "github_repo": package, - "github_version": "main", - "doc_path": "docs", -} - -html_static_path = sphinx_compas2_theme.get_html_static_path() + ["_static"] -html_css_files = [] -html_extra_path = [] -html_last_updated_fmt = "" -html_copy_source = False -html_show_sourcelink = True -html_permalinks = False -html_permalinks_icon = "" -html_compact_lists = True diff --git a/docs/javascripts/mathjax.js b/docs/javascripts/mathjax.js new file mode 100644 index 000000000000..a115aedea6d7 --- /dev/null +++ b/docs/javascripts/mathjax.js @@ -0,0 +1,16 @@ +window.MathJax = { + tex: { + inlineMath: [["\\(", "\\)"], ["$", "$"]], + displayMath: [["\\[", "\\]"], ["$$", "$$"]], + processEscapes: true, + processEnvironments: true, + }, + options: { + ignoreHtmlClass: "\\btex2jax_ignore\\b", + processHtmlClass: "\\btex2jax_process\\b", + }, +}; + +document$.subscribe(() => { + MathJax.typesetPromise(); +}); diff --git a/docs/sort__all__.py b/docs/sort__all__.py deleted file mode 100644 index 182eee9ae3dc..000000000000 --- a/docs/sort__all__.py +++ /dev/null @@ -1,40 +0,0 @@ -import compas -from compas_blender import conversions as module - -functions = [] -classes = [] -errors = [] -numpy = [] - -__newall__ = { - "classes": [], - "errors": [], - "functions": [], - "numpy": [], - "pluggables": [], - "plugins": [], -} - -for name in module.__all__: - obj = getattr(module, name) - - if name.endswith("_numpy"): - numpy.append(name) - continue - - if isinstance(obj, type): - classes.append(name) - else: - functions.append(name) - -for name in sorted(classes): - __newall__["classes"].append(name) - -for name in sorted(functions): - __newall__["functions"].append(name) - -for name in sorted(numpy): - __newall__["numpy"].append(name) - - -compas.json_dump(__newall__, f"docs/{module.__name__}__all__.json", pretty=True) diff --git a/docs/userguide/advanced.serialisation.rst b/docs/userguide/advanced.serialisation.rst index 1b814fe7b3f0..69d1889edd9a 100644 --- a/docs/userguide/advanced.serialisation.rst +++ b/docs/userguide/advanced.serialisation.rst @@ -190,43 +190,3 @@ If another user loads "custom_mesh.json" in an environment where the CustomMesh mesh = json_load('custom_mesh.json') assert isinstance(mesh, Mesh) # This will be True - - - -Validation -========== - -A somewhat experimental feature of the data package is data validation. -The base data class defines two unimplemented attributes :attr:`compas.data.Data.JSONSCHEMA` and :attr:`compas.data.Data.DATASCHEMA`. -The former is meant to define the name of the json schema in the ``schema`` folder of :mod:`compas.data`, -and the latter a Python schema using :mod:`schema.Schema`. - -If a deriving class implements those attributes, data sources can be validated against the two schemas to verify compatibility -of the available data with the object type. - -:: - - >>> from compas.data import validate_data - >>> from compas.geometry import Frame - >>> data = {'point': [0.0, 0.0, 0.0], 'xaxis': [1.0, 0.0, 0.0], 'zaxis': [0.0, 0.0, 1.0]} - >>> validate_data(data, Frame) - Validation against the JSON schema of this object failed. - Traceback (most recent call last): - ... - - jsonschema.exceptions.ValidationError: 'yaxis' is a required property - - Failed validating 'required' in schema: - {'$compas': '1.7.1', - '$id': 'frame.json', - '$schema': 'http://json-schema.org/draft-07/schema#', - 'properties': {'point': {'$ref': 'compas.json#/definitions/point'}, - 'xaxis': {'$ref': 'compas.json#/definitions/vector'}, - 'yaxis': {'$ref': 'compas.json#/definitions/vector'}}, - 'required': ['point', 'xaxis', 'yaxis'], - 'type': 'object'} - - On instance: - {'point': [0.0, 0.0, 0.0], - 'xaxis': [1.0, 0.0, 0.0], - 'zaxis': [0.0, 0.0, 1.0]} diff --git a/docs/write_rst.py b/docs/write_rst.py deleted file mode 100644 index e2619aabde3e..000000000000 --- a/docs/write_rst.py +++ /dev/null @@ -1,152 +0,0 @@ -# from pathlib import Path -from compas import datastructures as module - -TPL = """ -******************************************************************************** -{currentmodule} -******************************************************************************** - -.. currentmodule:: {currentmodule} - -.. rst-class:: lead - -{lead} -{sections} - -""" - -SECTION = """ -{title} -{line} - -{summary} - -.. autosummary:: - :toctree: generated/ - :nosignatures: - -{items} -""" - -__newall__ = { - "functions": [], - "classes": [], - "errors": [], - "numpy": [], - "pluggables": [], - "plugins": [], -} - -for name in module.__all__: - obj = getattr(module, name) - - if name.endswith("_numpy"): - __newall__["numpy"].append(name) - continue - - if issubclass(type(obj), Exception): - __newall__["errors"].append(name) - continue - - if hasattr(obj, "__pluggable__"): - __newall__["pluggables"].append(name) - continue - - if hasattr(obj, "__plugin__"): - __newall__["plugins"].append(name) - continue - - if isinstance(obj, type): - __newall__["classes"].append(name) - else: - __newall__["functions"].append(name) - - -currentmodule = module.__name__ - -lead = module.__doc__ - -classes = "" -for name in sorted(__newall__["classes"]): - classes += " {name}\n".format(name=name) - -if classes: - classes = SECTION.format( - title="Classes", - line="=" * len("Classes"), - summary="", - items=classes, - ) - -errors = "" -for name in sorted(__newall__["errors"]): - errors += " {name}\n".format(name=name) - -if errors: - errors = SECTION.format( - title="Exceptions", - line="=" * len("Exceptions"), - summary="", - items=errors, - ) - -functions = "" -for name in sorted(__newall__["functions"]): - functions += " {name}\n".format(name=name) - -if functions: - functions = SECTION.format( - title="Functions", - line="=" * len("Functions"), - summary="", - items=functions, - ) - -numpy = "" -for name in sorted(__newall__["numpy"]): - numpy += " {name}\n".format(name=name) - -if numpy: - numpy = SECTION.format( - title="Functions using Numpy", - line="=" * len("Functions using Numpy"), - summary="In environments where numpy is not available, these functions can still be accessed through RPC.", - items=numpy, - ) - -pluggables = "" -for name in sorted(__newall__["pluggables"]): - pluggables += " {name}\n".format(name=name) - -if pluggables: - pluggables = SECTION.format( - title="Pluggables", - line="=" * len("Pluggables"), - summary="Pluggables are functions that don't have an actual implementation, but receive an implementation from a plugin.", - items=pluggables, - ) - -plugins = "" -for name in sorted(__newall__["plugins"]): - plugins += " {name}\n".format(name=name) - -if plugins: - plugins = SECTION.format( - title="Plugins", - line="=" * len("Plugins"), - summary="Plugins provide implementations for pluggables. You can use the plugin directly, or through the pluggable.", - items=plugins, - ) - -sections = "".join([classes, errors, functions, numpy, pluggables, plugins]) - -# docs = Path(__file__).parent - -with open("/Users/vanmelet/Code/compas/docs/api/{name}.rst".format(name=module.__name__), "w") as f: - f.write( - TPL.format( - currentmodule=currentmodule, - lead=lead, - sections=sections, - ) - ) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 000000000000..1d6dd46c4f59 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,162 @@ +site_name: COMPAS +site_url: https://github.com/compas-dev/compas/ + +repo_name: compas-dev/compas +repo_url: https://github.com/compas-dev/compas/ + +copyright: Copyright © 2013 - 2027, ETH Zurich; Copyright © 2017 - 2026, COMPAS Association + +extra: + homepage: https://compas-dev.github.io/compas/ + +theme: + name: material + palette: + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Switch to light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: blue + accent: light blue + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: blue + accent: light blue + toggle: + icon: material/brightness-4 + name: Switch to light mode + font: + text: Roboto + code: Roboto Mono + logo: assets/logos/compas_icon_white.png + favicon: public/favicon.ico + features: + - content.code.copy + - content.footnote.tooltips + - navigation.expand + - navigation.footer + - navigation.sections + - navigation.top + - navigation.tabs + - search.highlight + - search.suggest + - toc.follow + +markdown_extensions: + - abbr + - attr_list + - admonition + - callouts: + strip_period: no + - footnotes + - md_in_html + - pymdownx.blocks.caption + - pymdownx.caret + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.keys + - pymdownx.mark + - pymdownx.arithmatex: + generic: true + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.tilde + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets: + check_paths: true + - pymdownx.superfences + - toc: + permalink: "¤" + +extra_javascript: + - javascripts/mathjax.js + - https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js + +plugins: + - search + - mkdocstrings: + default_handler: python + handlers: + python: + paths: [src] # search packages in the src folder + inventories: + - https://docs.python.org/3/objects.inv + options: + allow_inspection: true + backlinks: tree + docstring_options: + ignore_init_summary: true + trim_doctest_flags: true + docstring_style: numpy + docstring_section_style: table + filters: public + group_by_category: true + heading_level: 1 + inheritance_diagram_direction: TD + inherited_members: false + line_length: 88 + merge_init_into_class: true + modernize_annotations: true + parameter_headings: false + preload_modules: [mkdocstrings, compas] + relative_crossrefs: true + scoped_crossrefs: true + separate_signature: true + show_bases: false + show_category_heading: true + show_docstring_attributes: true + show_docstring_functions: true + show_docstring_modules: false + show_if_no_docstring: false + show_inheritance_diagram: false + show_root_heading: true + show_root_full_path: true + show_signature: true + show_signature_annotations: true + show_signature_type_parameters: true + show_source: false + show_submodules: false + show_symbol_type_heading: true + show_symbol_type_toc: true + signature_crossrefs: true + summary: + modules: false + type_parameter_headings: true + unwrap_annotated: true + +nav: + - Getting Started: + - Introduction: index.md + - Installation: installation.md + - License: license.md + - Acknowledgements: acknowledgements.md + - Changelog: changelog.md + - Examples: + - Booleans: examples/example_booleans.md + - API Reference: + - compas_cgal.booleans: api/compas_cgal.booleans.md + - compas_cgal.geodesics: api/compas_cgal.geodesics.md + - compas_cgal.intersections: api/compas_cgal.intersections.md + - compas_cgal.isolines: api/compas_cgal.isolines.md + - compas_cgal.measure: api/compas_cgal.measure.md + - compas_cgal.meshing: api/compas_cgal.meshing.md + - compas_cgal.polylines: api/compas_cgal.polylines.md + - compas_cgal.projection: api/compas_cgal.projection.md + - compas_cgal.reconstruction: api/compas_cgal.reconstruction.md + - compas_cgal.skeletonization: api/compas_cgal.skeletonization.md + - compas_cgal.slicer: api/compas_cgal.slicer.md + - compas_cgal.straight_skeleton_2: api/compas_cgal.straight_skeleton_2.md + - compas_cgal.subdivision: api/compas_cgal.subdivision.md + - compas_cgal.triangulation: api/compas_cgal.triangulation.md + - compas_cgal.types: api/compas_cgal.types.md + - Dev Guide: diff --git a/pyproject.toml b/pyproject.toml index ae06d4dc998b..c5c3c13efcbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ authors = [{ name = "tom van mele", email = "tom.v.mele@gmail.com" }] license = { file = "LICENSE" } readme = "README.md" requires-python = ">=3.9" -dynamic = ['dependencies', 'optional-dependencies', 'version'] +dynamic = ['version'] classifiers = [ "Development Status :: 5 - Production/Stable", "Topic :: Scientific/Engineering", @@ -28,6 +28,46 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] +dependencies = [ + "jsonschema", + "networkx >= 3.0", + "numpy >= 1.15.4", + "scipy >= 1.1", + "watchdog; sys_platform != 'emscripten'", +] + + +[project.optional-dependencies] +dev = [ + "build", + "bump-my-version", + "compas_invocations2", + "invoke >=0.14", + "pytest", + "pytest-cov", + "pytest-dependency", + "ruff", + "twine", + "wheel", +] +docs = [ + "markdown-callouts >=0.4", + "markdown-exec >=1.8", + "mike", + "mkdocs >=1.6", + "mkdocs-autorefs >=1.4", + "mkdocs-coverage >=1.0", + "mkdocs-git-revision-date-localized-plugin >=1.2", + "mkdocs-llmstxt >=0.2", + "mkdocs-material >=9.5", + "mkdocs-minify-plugin >=0.8", + "mkdocs-redirects >=1.2", + "mkdocs-section-index >=0.3", + "mkdocstrings[python]", + "pydantic >=2.10", + "tomli >=2.0; python_version < '3.11'", +] +viz = ["compas_notebook", "compas_viewer"] [project.urls] Homepage = "https://compas-dev.github.io/compas" diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 4d04ffd7c474..000000000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,12 +0,0 @@ -attrs >=17.4 -black >=22.12.0 -build -bump-my-version -compas_invocations2 -invoke >=0.14 -pytest-cov -pythonnet -ruff -sphinx_compas2_theme -twine -wheel \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 11f9a7736384..000000000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -# flake8: noqa -jsonschema -networkx >= 3.0 -numpy >= 1.15.4 -scipy >= 1.1 -watchdog; sys_platform != 'emscripten' diff --git a/src/compas/__init__.py b/src/compas/__init__.py index edc2cfc21805..845670a47c8e 100644 --- a/src/compas/__init__.py +++ b/src/compas/__init__.py @@ -1,4 +1,4 @@ -from __future__ import print_function +# ruff: noqa: F401 import os @@ -103,34 +103,6 @@ pass -__all__ = [ - "WINDOWS", - "LINUX", - "OSX", - "MONO", - "IPY", - "RHINO", - "BLENDER", - "PY2", - "PY3", - "devday", - "is_windows", - "is_linux", - "is_osx", - "is_mono", - "is_ironpython", - "is_rhino", - "is_blender", - "is_grasshopper", - "get", - "json_dump", - "json_load", - "json_dumps", - "json_dumpz", - "json_loads", - "json_loadz", -] - __all_plugins__ = [ "compas.geometry.booleans_shapely", "compas.scene", diff --git a/src/compas/_iotools.py b/src/compas/_iotools.py index e4bcc1d602d0..7a61f52c5f15 100644 --- a/src/compas/_iotools.py +++ b/src/compas/_iotools.py @@ -2,11 +2,60 @@ import io from contextlib import contextmanager +from os import PathLike +from typing import BinaryIO +from typing import TextIO +from typing import Union +from typing import cast +from urllib.request import urlopen -try: - from urllib.request import urlopen -except ImportError: - from urllib2 import urlopen +IOSource = Union[str, PathLike[str], TextIO, BinaryIO] +IOTarget = Union[str, PathLike[str], TextIO, BinaryIO] + + +def read_bytes(source: IOSource, encoding: str = "utf-8") -> bytes: + """Read a path, URL, or stream as bytes. + + Parameters + ---------- + source + Path, URL, text stream, or binary stream. + encoding + Encoding used to convert text-stream data. + + Returns + ------- + bytes + Complete source data. + + """ + with open_file(source, "rb") as stream: + data = stream.read() + return data.encode(encoding) if isinstance(data, str) else data + + +def write_bytes(target: IOTarget, data: bytes, encoding: str = "utf-8") -> None: + """Write bytes to a path or text or binary stream. + + Parameters + ---------- + target + Path or writable text or binary stream. + data + Data to write. + encoding + Encoding used to convert data for a text stream. + + Returns + ------- + None + + """ + with open_file(target, "wb") as stream: + try: + cast(BinaryIO, stream).write(data) + except TypeError: + cast(TextIO, stream).write(data.decode(encoding)) @contextmanager @@ -36,6 +85,7 @@ def open_file(file_or_filename, mode="r"): ------- file-like object File object already opened. + """ file = file_or_filename close_source = False @@ -72,6 +122,7 @@ def iter_file(file, size=65536): ------- bytes Byte array chunks read from the file. + """ while True: data = file.read(size) diff --git a/src/compas/_os.py b/src/compas/_os.py index d09b801c6e9a..1c78fff67b61 100644 --- a/src/compas/_os.py +++ b/src/compas/_os.py @@ -2,6 +2,7 @@ """ These are internal functions of the framework. Not intended to be used outside compas* packages. + """ import os @@ -193,6 +194,7 @@ def select_python(python_executable): python_executable : str Select which python executable you want to use, either `python` or `pythonw`. + """ if PYTHON_DIRECTORY and os.path.exists(PYTHON_DIRECTORY): python_executables = [python_executable] if python_executable else ["pythonw", "python"] @@ -237,6 +239,7 @@ def prepare_environment(env=None): ------- dict Updated environment variable dictionary. + """ if env is None: @@ -268,6 +271,7 @@ def realpath(path): except when inside IronPython because (guess what?) it is broken and doesn't really eliminate sym links, so, we fallback to a different way to identifying symlinks in that situation. + """ if not PY3 and is_ironpython(): if is_windows(): @@ -390,6 +394,7 @@ def create_symlink(source, link_name): ----- This function is a polyfill of the native ``os.symlink`` for Python 2.x on Windows platforms. + """ create_symlinks([(source, link_name)], raise_on_error=True) @@ -401,6 +406,7 @@ def create_symlinks(symlinks, raise_on_error=False): ---------- symlinks: list[str]ing tuples List of ``source`` and ``link_name`` of the symlinks as tuples. + """ symlink, allow_polyfill_retry = _get_symlink_function() @@ -421,6 +427,7 @@ def remove_symlink(symlink): ---------- symlink : str Symlink to remove. + """ # Broken links return False on .exists(), so we need to check .islink() as well if not (os.path.islink(symlink) or os.path.exists(symlink)): @@ -456,6 +463,7 @@ def remove_symlinks(symlinks, raise_on_error=False): list If ``raise_on_error`` is ``False``, returns a list of bools indicating which links were successfully removed. + """ result = [] @@ -512,6 +520,7 @@ def is_admin(): ------- bool True if the user is administrator, otherwise False. + """ if not is_windows(): return os.getuid() == 0 @@ -531,6 +540,7 @@ def _run_command_as_admin(command, arguments): Command name. arguments : list[str] List of arguments. + """ _handle, temp_path = tempfile.mkstemp(suffix=".cmd", text=True) @@ -553,6 +563,7 @@ def _run_as_admin(command): ------- int Exit code of the process. + """ if not is_windows(): @@ -619,6 +630,7 @@ def user_data_dir(appname=None, appauthor=None, version=None, roaming=False): ------- str Full path to the user-specific data dir. + """ if is_windows(): if appauthor is None: @@ -657,6 +669,7 @@ def _get_win_folder_from_registry(csidl_name): """This is a fallback technique at best. I'm not sure if using the registry for this guarantees us the correct answer for all CSIDL_* names. + """ if PY3: import winreg as _winreg diff --git a/src/compas/_typing.py b/src/compas/_typing.py new file mode 100644 index 000000000000..1383643150e6 --- /dev/null +++ b/src/compas/_typing.py @@ -0,0 +1,40 @@ +from os import PathLike +from typing import Iterator +from typing import Protocol +from typing import Sequence +from typing import Union +from typing import runtime_checkable + +FilePath = Union[str, PathLike[str]] + + +class FloatSequence(Protocol): + def __len__(self) -> int: ... + + def __getitem__(self, key: int) -> float: ... + + def __iter__(self) -> Iterator[float]: ... + + +FloatSequenceType = Union[FloatSequence, Sequence[float]] +RawCoordinateType = Union[list[float], tuple[float, float, float]] + + +@runtime_checkable +class Coordinate(FloatSequence, Protocol): + pass + + +CoordinateType = Union[Coordinate, Sequence[float]] + + +@runtime_checkable +class Coordinates(Protocol): + def __len__(self) -> int: ... + + def __getitem__(self, key: int) -> CoordinateType: ... + + def __iter__(self) -> Iterator[CoordinateType]: ... + + +CoordinatesType = Union[Coordinates, Sequence[CoordinateType]] diff --git a/src/compas/colors/__init__.py b/src/compas/colors/__init__.py index b9b5b290c1a4..814adf57e482 100644 --- a/src/compas/colors/__init__.py +++ b/src/compas/colors/__init__.py @@ -1,14 +1,5 @@ -""" -This package defines a color and color map class, -that can be used to work wihth colors in a consistent way across color spaces. -""" - -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +# ruff: noqa: F401 from .color import Color from .colormap import ColorMap from .colordict import ColorDict - -__all__ = ["Color", "ColorMap", "ColorDict"] diff --git a/src/compas/colors/color.py b/src/compas/colors/color.py index 5035ddea8120..879743adfe1b 100644 --- a/src/compas/colors/color.py +++ b/src/compas/colors/color.py @@ -1,97 +1,98 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -try: - basestring # type: ignore -except NameError: - basestring = str - import colorsys import re +from typing import Annotated +from typing import Iterator +from typing import Optional +from typing import Sequence +from typing import Union +from typing import cast + +from typing_extensions import Self from compas.colors.html_colors import HTML_TO_RGB255 from compas.data import Data from compas.tolerance import TOL -BASE16 = "0123456789abcdef" +BASE16: str = "0123456789abcdef" -try: - HEX_DEC = {v: int(v, base=16) for v in [x + y for x in BASE16 for y in BASE16]} -except Exception: - HEX_DEC = {v: int(v, 16) for v in [x + y for x in BASE16 for y in BASE16]} +HEX_DEC: dict[str, int] = {v: int(v, base=16) for v in [x + y for x in BASE16 for y in BASE16]} class ColorError(Exception): """Raise if color input is not a color.""" +ColorLikeSequence = Union[Annotated[Sequence[int], 3], Annotated[Sequence[float], 3]] +ColorLike = Union[str, ColorLikeSequence] +ColorInput = Union["Color", ColorLike] + + class Color(Data): """Class for working with colors. Parameters ---------- - red : float + red The red component in the range ``[0.0, 1.0]``. - green : float + green The green component in the range of ``[0.0, 1.0]``. - blue : float + blue The blue component in the range of ``[0.0, 1.0]``. - alpha : float, optional + alpha Transparency setting. If ``alpha = 0.0``, the color is fully transparent. If ``alpha = 1.0``, the color is fully opaque. - name : str, optional + name The name of the color. Attributes ---------- - r : float + r Red component of the color in RGB1 color space. - g : float + g Green component of the color in RGB1 color space. - b : float + b Blue component of the color in RGB1 color space. - a : float + a Transparency in RGB1 color space. - rgb : tuple[float, float, float] + rgb RGB1 color tuple, with components in the range ``[0.0, 1.0]``. - rgba : tuple[float, float, float, float] + rgba RGBA1 color tuple (including alpha), with components in the range ``[0.0, 1.0]``. - rgb255 : tuple[int, int, int] + rgb255 RGB255 color tuple, with components in the range ``[0, 255]``. - rgba255 : tuple[int, int, int, int] + rgba255 RGBA255 color tuple (including alpha), with components in the range ``[0, 255]``. - hex : str + hex Hexadecimal color string. - hls : tuple[float, float, float] + hls Hue, Lightness, Saturation. - hsv : tuple[float, float, float] + hsv Hue, Saturation, Value / Brightness. - lightness : float + lightness How much white the color appears to contain. This is the "Lightness" in HLS. Making a color "lighter" is like adding more white. - brightness : float + brightness How well-lit the color appears to be. This is the "Value" in HSV. Making a color "brighter" is like shining a stronger light on it, or illuminating it better. - yuv : tuple[float, float, float] + yuv Luma and chroma components, with chroma defined by the blue and red projections. - luma : float + luma The brightness of a yuv signal. - chroma : tuple[float, float] + chroma The color of a yuv signal. "How different from a grey of the same lightness the color appears to be." - luminance : float + luminance The amount of light that passes through, is emitted from, or is reflected from a particular area. Here, it expresses the preceived brightness of the color. Note that this is not the same as the "Lightness" of HLS or the "Value/Brightness" of HSV. - saturation : float + saturation The perceived freedom of whiteness. - is_light : bool + is_light If True, the color is considered light. - contrast : :class:`compas.colors.Color` + contrast The contrasting color to the current color. Examples @@ -108,7 +109,7 @@ class Color(Data): ... ValueError: Components of an RGBA color should be in the range 0-1. - To create a color with components in the range ``[0, 255]``, use the :meth:`from_rgb255` constructor. + To create a color with components in the range ``[0, 255]``, use the `from_rgb255` constructor. >>> Color.from_rgb255(255, 0, 0) Color(red=1.0, green=0.0, blue=0.0, alpha=1.0) @@ -142,40 +143,29 @@ class Color(Data): See Also -------- - :class:`compas.colors.ColorMap` + compas.colors.ColorMap """ - DATASCHEMA = { - "type": "object", - "properties": { - "red": {"type": "number", "minimum": 0.0, "maximum": 1.0}, - "green": {"type": "number", "minimum": 0.0, "maximum": 1.0}, - "blue": {"type": "number", "minimum": 0.0, "maximum": 1.0}, - "alpha": {"type": "number", "minimum": 0.0, "maximum": 1.0}, - }, - "required": ["red", "green", "blue", "alpha"], - } - @property - def __data__(self): + def __data__(self) -> dict[str, float]: return {"red": self.r, "green": self.g, "blue": self.b, "alpha": self.a} - def __init__(self, red, green, blue, alpha=1.0, name=None): - super(Color, self).__init__(name=name) - self._r = 1.0 - self._g = 1.0 - self._b = 1.0 - self._a = 1.0 + def __init__(self, red: float, green: float, blue: float, alpha: float = 1.0, name: Optional[str] = None) -> None: + super().__init__(name=name) + self._r: float = 1.0 + self._g: float = 1.0 + self._b: float = 1.0 + self._a: float = 1.0 self.r = red self.g = green self.b = blue self.a = alpha - def __repr__(self): + def __repr__(self) -> str: return "{0}(red={1}, green={2}, blue={3}, alpha={4})".format(type(self).__name__, self.r, self.g, self.b, self.a) - def __str__(self): + def __str__(self) -> str: return "{0}(red={1}, green={2}, blue={3}, alpha={4})".format( type(self).__name__, TOL.format_number(self.r), @@ -184,7 +174,7 @@ def __str__(self): TOL.format_number(self.a), ) - def __getitem__(self, key): + def __getitem__(self, key: int) -> float: if key == 0: return self.r if key == 1: @@ -193,13 +183,14 @@ def __getitem__(self, key): return self.b raise KeyError - def __len__(self): + def __len__(self) -> int: return 3 - def __iter__(self): + def __iter__(self) -> Iterator[float]: return iter(self.rgb) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + other = cast(Union["Color", ColorLikeSequence], other) return all(a == b for a, b in zip(self, other)) # -------------------------------------------------------------------------- @@ -207,124 +198,124 @@ def __eq__(self, other): # -------------------------------------------------------------------------- @property - def r(self): + def r(self) -> float: return self._r @r.setter - def r(self, red): + def r(self, red: float) -> None: if red > 1.0 or red < 0.0: raise ValueError("Components of an RGBA color should be in the range 0-1.") self._r = red @property - def g(self): + def g(self) -> float: return self._g @g.setter - def g(self, green): + def g(self, green: float) -> None: if green > 1.0 or green < 0.0: raise ValueError("Components of an RGBA color should be in the range 0-1.") self._g = green @property - def b(self): + def b(self) -> float: return self._b @b.setter - def b(self, blue): + def b(self, blue: float) -> None: if blue > 1.0 or blue < 0.0: raise ValueError("Components of an RGBA color should be in the range 0-1.") self._b = blue @property - def a(self): + def a(self) -> float: return self._a @a.setter - def a(self, alpha): + def a(self, alpha: float) -> None: if alpha > 1.0 or alpha < 0.0: raise ValueError("Components of an RGBA color should be in the range 0-1.") self._a = alpha @property - def rgb(self): + def rgb(self) -> tuple[float, float, float]: r = self.r g = self.g b = self.b return r, g, b @property - def rgb255(self): + def rgb255(self) -> tuple[int, int, int]: r = int(self.r * 255) g = int(self.g * 255) b = int(self.b * 255) return r, g, b @property - def rgba(self): + def rgba(self) -> tuple[float, float, float, float]: r, g, b = self.rgb a = self.a return r, g, b, a @property - def rgba255(self): + def rgba255(self) -> tuple[int, int, int, int]: r, g, b = self.rgb255 a = int(self.a * 255) return r, g, b, a @property - def hex(self): + def hex(self) -> str: return "#{0:02x}{1:02x}{2:02x}".format(*self.rgb255) @property - def hls(self): + def hls(self) -> tuple[float, float, float]: return colorsys.rgb_to_hls(*self.rgb) @property - def hsv(self): + def hsv(self) -> tuple[float, float, float]: return colorsys.rgb_to_hsv(*self.rgb) @property - def lightness(self): + def lightness(self) -> float: return self.hls[1] @property - def brightness(self): + def brightness(self) -> float: return self.hsv[2] @property - def is_light(self): + def is_light(self) -> bool: return self.luminance > 0.179 @property - def yuv(self): + def yuv(self) -> tuple[float, float, float]: y = self.luma u, v = self.chroma return y, u, v @property - def luma(self): + def luma(self) -> float: return 0.299 * self.r + 0.587 * self.g + 0.114 * self.b @property - def chroma(self): + def chroma(self) -> tuple[float, float]: y = self.luma u = 0.492 * (self.b - y) v = 0.877 * (self.r - y) return u, v @property - def luminance(self): + def luminance(self) -> float: return 0.2126 * self.r + 0.7152 * self.g + 0.0722 * self.b @property - def saturation(self): + def saturation(self) -> float: maxval = max(self.r, self.g, self.b) minval = min(self.r, self.g, self.b) return (maxval - minval) / maxval @property - def contrast(self): + def contrast(self) -> Self: return self.darkened(25) if self.is_light else self.lightened(50) # -------------------------------------------------------------------------- @@ -332,41 +323,41 @@ def contrast(self): # -------------------------------------------------------------------------- @classmethod - def from_rgb255(cls, r, g, b): # type: (int, int, int) -> Color + def from_rgb255(cls, r: int, g: int, b: int) -> Self: """Construct a color from RGB255 components. Parameters ---------- - r : int & valuerange[0, 255] - Red component. - g : int & valuerange[0, 255] - Green component. - b : int & valuerange[0, 255] - Blue component. + r + Red component in the range ``[0, 255]``. + g + Green component in the range ``[0, 255]``. + b + Blue component in the range ``[0, 255]``. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(r / 255, g / 255, b / 255) @classmethod - def from_hls(cls, hue, luminance, saturation): # type: (float, float, float) -> Color + def from_hls(cls, hue: float, luminance: float, saturation: float) -> Self: """Construct a color from Hue, Lightness, and Saturation. Parameters ---------- - hue : float + hue Hue. - lightness : float + luminance Lightness. - saturation : float + saturation Saturation. Returns ------- - :class:`compas.colors.Color` + Color References ---------- @@ -377,21 +368,21 @@ def from_hls(cls, hue, luminance, saturation): # type: (float, float, float) -> return cls(r, g, b) @classmethod - def from_hsv(cls, h, s, v): # type: (float, float, float) -> Color + def from_hsv(cls, h: float, s: float, v: float) -> Self: """Construct a color from Hue, Saturation, and Value. Parameters ---------- - h : float + h Hue. - s : float + s Saturation. - v : float + v Value. Returns ------- - :class:`compas.colors.Color` + Color References ---------- @@ -402,21 +393,21 @@ def from_hsv(cls, h, s, v): # type: (float, float, float) -> Color return cls(r, g, b) @classmethod - def from_yiq(cls, y, i, q): # type: (float, float, float) -> Color + def from_yiq(cls, y: float, i: float, q: float) -> Self: """Construct a color from components in the YIQ color space. Parameters ---------- - y : float + y Luma. - i : float + i Orange-blue chroma. - q : float + q Purple-green chroma. Returns ------- - :class:`compas.colors.Color` + Color References ---------- @@ -427,21 +418,21 @@ def from_yiq(cls, y, i, q): # type: (float, float, float) -> Color return cls(r, g, b) @classmethod - def from_yuv(cls, y, u, v): # type: (float, float, float) -> Color + def from_yuv(cls, y: float, u: float, v: float) -> Self: """Construct a color from components in the YUV color space. Parameters ---------- - y : float + y Luma. - u : float + u Blue projection chroma. - v : float + v Red projection chroma. Returns ------- - :class:`compas.colors.Color` + Color References ---------- @@ -454,17 +445,17 @@ def from_yuv(cls, y, u, v): # type: (float, float, float) -> Color return cls(r, g, b) @classmethod - def from_number(cls, number): # type: (float) -> Color + def from_number(cls, number: float) -> Self: """Construct a color from a single number in the range 0-1. Parameters ---------- - number : float + number Number in the range 0-1, representing the color. Returns ------- - :class:`compas.colors.Color` + Color """ if number == 0.0: @@ -492,17 +483,17 @@ def from_number(cls, number): # type: (float) -> Color from_i = from_number @classmethod - def from_hex(cls, value): # type: (str) -> Color + def from_hex(cls, value: str) -> Self: """Construct a color from a hexadecimal color value. Parameters ---------- - value : str + value The hexadecimal color. Returns ------- - :class:`compas.colors.Color` + Color """ value = value.lstrip("#").lower() @@ -512,17 +503,17 @@ def from_hex(cls, value): # type: (str) -> Color return cls(r / 255.0, g / 255.0, b / 255.0) @classmethod - def from_name(cls, name): # type: (str) -> Color + def from_name(cls, name: str) -> Self: """Construct a color from a name in the extended color table of HTML/CSS/SVG. Parameters ---------- - name : str + name The color name. The name is case-insensitive. Returns ------- - :class:`compas.colors.Color` + Color References ---------- @@ -535,21 +526,21 @@ def from_name(cls, name): # type: (str) -> Color return cls.from_rgb255(*rgb255) @classmethod - def from_unknown(cls, unknown): + def from_unknown(cls, unknown: ColorInput) -> Optional["Color"]: """Construct a color from an unknown input. Parameters ---------- - unknown : str | tuple[int, int, int] | tuple[float, float, float] | :class:`compas.colors.Color` + unknown The color input. Returns ------- - :class:`compas.colors.Color` | None + Color | None Raises ------ - :class:`ColorError` + ColorError """ if not unknown: @@ -559,31 +550,33 @@ def from_unknown(cls, unknown): return unknown if Color._is_rgb255(unknown): - return cls.from_rgb255(*list(unknown)) + rgb255 = cast(tuple[int, int, int], list(cast(Sequence[int], unknown))) + return cls.from_rgb255(*rgb255) if Color._is_hex(unknown): - return cls.from_hex(unknown) + return cls.from_hex(cast(str, unknown)) if Color._is_rgb1(unknown): - return cls(*list(unknown)) + rgb1 = cast(tuple[float, float, float], list(cast(Sequence[float], unknown))) + return cls(*rgb1) - if isinstance(unknown, basestring): + if isinstance(unknown, str): return cls.from_name(unknown) raise ColorError @staticmethod - def coerce(color): + def coerce(color: ColorInput) -> Optional["Color"]: """Coerce a color input into a color. Parameters ---------- - color : str | tuple[int, int, int] | tuple[float, float, float] | :class:`compas.colors.Color` + color The color input. Returns ------- - :class:`compas.colors.Color` | None + Color | None Raises ------ @@ -594,16 +587,18 @@ def coerce(color): return if isinstance(color, Color): return color - if Color._is_rgb255(color): - return Color.from_rgb255(*list(color)) if Color._is_hex(color): - return Color.from_hex(color) + return Color.from_hex(cast(str, color)) if Color._is_rgb1(color): - return Color(*list(color)) + rgb1 = cast(tuple[float, float, float], color) + return Color(*rgb1) + if Color._is_rgb255(color): + rgb255 = cast(tuple[int, int, int], color) + return Color.from_rgb255(*rgb255) raise ColorError @staticmethod - def _is_rgb1(color): + def _is_rgb1(color: object) -> bool: """Verify that the color is in the RGB 1 color space. Returns @@ -611,10 +606,13 @@ def _is_rgb1(color): bool """ - return color and all(isinstance(c, float) and (c >= 0 and c <= 1) for c in color) + if not color: + return False + color = cast(ColorLikeSequence, color) + return all(isinstance(c, float) and (c >= 0 and c <= 1) for c in color) @staticmethod - def _is_rgb255(color): + def _is_rgb255(color: object) -> bool: """Verify that the color is in the RGB 255 color space. Returns @@ -622,10 +620,13 @@ def _is_rgb255(color): bool """ - return color and all(isinstance(c, int) and (c >= 0 and c <= 255) for c in color) + if not color: + return False + color = cast(ColorLikeSequence, color) + return all(isinstance(c, int) and (c >= 0 and c <= 255) for c in color) @staticmethod - def _is_hex(color): + def _is_hex(color: object) -> bool: """Verify that the color is in hexadecimal format. Returns @@ -633,7 +634,7 @@ def _is_hex(color): bool """ - if isinstance(color, basestring): + if isinstance(color, str): match = re.search(r"^#(?:[0-9a-fA-F]{3}){1,2}$", color) if match: return True @@ -645,166 +646,166 @@ def _is_hex(color): # -------------------------------------------------------------------------- @classmethod - def white(cls): + def white(cls) -> Self: """Construct the color white. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(1.0, 1.0, 1.0) @classmethod - def black(cls): + def black(cls) -> Self: """Construct the color black. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(0.0, 0.0, 0.0) @classmethod - def grey(cls): + def grey(cls) -> Self: """Construct the color grey. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(0.5, 0.5, 0.5) @classmethod - def red(cls): + def red(cls) -> Self: """Construct the color red. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(1.0, 0.0, 0.0) @classmethod - def orange(cls): + def orange(cls) -> Self: """Construct the color orange. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(1.0, 0.5, 0.0) @classmethod - def yellow(cls): + def yellow(cls) -> Self: """Construct the color yellow. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(1.0, 1.0, 0.0) @classmethod - def lime(cls): + def lime(cls) -> Self: """Construct the color lime (or chartreuse green). Returns ------- - :class:`compas.colors.Color` + Color """ return cls(0.5, 1.0, 0.0) @classmethod - def green(cls): + def green(cls) -> Self: """Construct the color green. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(0.0, 1.0, 0.0) @classmethod - def mint(cls): + def mint(cls) -> Self: """Construct the color mint (or spring green). Returns ------- - :class:`compas.colors.Color` + Color """ return cls(0.0, 1.0, 0.5) @classmethod - def cyan(cls): + def cyan(cls) -> Self: """Construct the color cyan. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(0.0, 1.0, 1.0) @classmethod - def azure(cls): + def azure(cls) -> Self: """Construct the color azure. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(0.0, 0.5, 1.0) @classmethod - def blue(cls): + def blue(cls) -> Self: """Construct the color blue. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(0.0, 0.0, 1.0) @classmethod - def violet(cls): + def violet(cls) -> Self: """Construct the color violet. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(0.5, 0.0, 1.0) @classmethod - def magenta(cls): + def magenta(cls) -> Self: """Construct the color magenta. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(1.0, 0.0, 1.0) @classmethod - def pink(cls): + def pink(cls) -> Self: """Construct the color pink. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(1.0, 0.0, 0.5) @@ -814,77 +815,78 @@ def pink(cls): # -------------------------------------------------------------------------- @classmethod - def maroon(cls): + def maroon(cls) -> Self: """Construct the color maroon. + Returns ------- - :class:`compas.colors.Color` + Color """ return cls(0.5, 0.0, 0.0) @classmethod - def brown(cls): + def brown(cls) -> Self: """Construct the color brown. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(0.5, 0.25, 0.0) @classmethod - def olive(cls): + def olive(cls) -> Self: """Construct the color olive. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(0.5, 0.5, 0.0) @classmethod - def teal(cls): + def teal(cls) -> Self: """Construct the color teal. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(0.0, 0.5, 0.5) @classmethod - def navy(cls): + def navy(cls) -> Self: """Construct the color navy. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(0.0, 0.0, 0.5) @classmethod - def purple(cls): + def purple(cls) -> Self: """Construct the color purple. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(0.5, 0.0, 0.5) @classmethod - def silver(cls): + def silver(cls) -> Self: """Construct the color silver. Returns ------- - :class:`compas.colors.Color` + Color """ return cls(0.75, 0.75, 0.75) @@ -901,12 +903,12 @@ def silver(cls): # Methods # -------------------------------------------------------------------------- - def lighten(self, factor=10): + def lighten(self, factor: float = 10.0) -> None: """Lighten the color. Parameters ---------- - factor : float, optional + factor Percentage of lightness increase. Returns @@ -930,17 +932,17 @@ def lighten(self, factor=10): self.g = g self.b = b - def lightened(self, factor=10): + def lightened(self, factor: float = 10.0) -> Self: """Return a lightened copy of the color. Parameters ---------- - factor : float, optional + factor Percentage of lightness increase. Returns ------- - :class:`compas.colors.Color` + Color Raises ------ @@ -952,12 +954,12 @@ def lightened(self, factor=10): color.lighten(factor=factor) return color - def darken(self, factor=10): + def darken(self, factor: float = 10.0) -> None: """Darken the color. Parameters ---------- - factor : float, optional + factor Percentage of lightness reduction. Returns @@ -981,17 +983,17 @@ def darken(self, factor=10): self.g = g self.b = b - def darkened(self, factor=10): + def darkened(self, factor: float = 10.0) -> Self: """Return a darkened copy of the color. Parameters ---------- - factor : float, optional + factor Percentage of lightness reduction. Returns ------- - :class:`compas.colors.Color` + Color Raises ------ @@ -1003,7 +1005,7 @@ def darkened(self, factor=10): color.darken(factor=factor) return color - def invert(self): + def invert(self) -> None: """Invert the current color wrt to the RGB color circle. Returns @@ -1015,24 +1017,24 @@ def invert(self): self.g = 1.0 - self.g self.b = 1.0 - self.b - def inverted(self): + def inverted(self) -> Self: """Return an inverted copy of the color. Returns ------- - :class:`compas.colors.Color` + Color """ color = self.copy() color.invert() return color - def saturate(self, factor=10): + def saturate(self, factor: float = 10.0) -> None: """Saturate the color by a given percentage. Parameters ---------- - factor : float, optional + factor Percentage of saturation increase. Returns @@ -1056,17 +1058,17 @@ def saturate(self, factor=10): self.g = g self.b = b - def saturated(self, factor=10): + def saturated(self, factor: float = 10.0) -> Self: """Return a saturated copy of the color. Parameters ---------- - factor : float, optional + factor Percentage of saturation increase. Returns ------- - :class:`compas.colors.Color` + Color Raises ------ @@ -1078,12 +1080,12 @@ def saturated(self, factor=10): color.saturate(factor=factor) return color - def desaturate(self, factor=10): + def desaturate(self, factor: float = 10.0) -> None: """Desaturate the color by a given percentage. Parameters ---------- - factor : float, optional + factor Percentage of saturation reduction. Returns @@ -1107,17 +1109,17 @@ def desaturate(self, factor=10): self.g = g self.b = b - def desaturated(self, factor=10): + def desaturated(self, factor: float = 10.0) -> Self: """Return a desaturated copy of the color. Parameters ---------- - factor : float, optional + factor Percentage of saturation reduction. Returns ------- - :class:`compas.colors.Color` + Color Raises ------ diff --git a/src/compas/colors/colordict.py b/src/compas/colors/colordict.py index 18a69bd587e9..5e451e568802 100644 --- a/src/compas/colors/colordict.py +++ b/src/compas/colors/colordict.py @@ -1,6 +1,8 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import Iterator +from typing import Optional +from typing import Union + +from typing_extensions import Self from compas.data import Data @@ -12,14 +14,14 @@ class ColorDict(Data): Parameters ---------- - default : :class:`compas.colors.Color` + default The default color to use if the requested key is not in the dictionary. - name : str, optional + name The name of the color dictionary. Attributes ---------- - default : :class:`compas.colors.Color` + default The default color to use if the requested key is not in the dictionary. """ @@ -31,57 +33,57 @@ class ColorDict(Data): } @property - def __data__(self): + def __data__(self) -> dict: return { "default": self.default, "dict": self._dict, } @classmethod - def __from_data__(cls, data): + def __from_data__(cls, data: dict) -> Self: colordict = cls(data["default"]) colordict.update(data["dict"]) return colordict - def __init__(self, default, name=None): - super(ColorDict, self).__init__(name=name) + def __init__(self, default: Color, name: Optional[str] = None): + super().__init__(name=name) self._default = None self.default = default self._dict = {} @property - def default(self): + def default(self) -> Color: if not self._default: self._default = Color(0, 0, 0) return self._default @default.setter - def default(self, default): + def default(self, default: Color) -> None: if default and not isinstance(default, Color): default = Color.coerce(default) self._default = default - def keymapper(self, key): + def keymapper(self, key: Union[int, tuple, list, str]) -> str: if key.__class__ in self.KEYMAP: return self.KEYMAP[key.__class__](key) - return key + return key # type: ignore - def __getitem__(self, key): + def __getitem__(self, key: Union[int, tuple, list, str]) -> Color: return self._dict.get(self.keymapper(key), self.default) - def __setitem__(self, key, value): + def __setitem__(self, key: Union[int, tuple, list, str], value: Color) -> None: self._dict[self.keymapper(key)] = Color.coerce(value) - def __delitem__(self, key): + def __delitem__(self, key: Union[int, tuple, list, str]) -> None: del self._dict[self.keymapper(key)] - def __iter__(self): + def __iter__(self) -> Iterator[str]: return iter(self._dict) - def __len__(self): + def __len__(self) -> int: return len(self._dict) - def __contains__(self, key): + def __contains__(self, key: Union[int, tuple, list, str]) -> bool: return self.keymapper(key) in self._dict def items(self): @@ -93,10 +95,10 @@ def keys(self): def values(self): return self._dict.values() - def get(self, key, default=None): + def get(self, key: Union[int, tuple, list, str], default=None) -> Color: return self._dict.get(self.keymapper(key), default or self.default) - def clear(self): + def clear(self) -> None: """Clear the previously stored items. Returns @@ -106,12 +108,12 @@ def clear(self): """ self._dict = {} - def update(self, other): + def update(self, other: Union[dict, "ColorDict"]) -> None: """Update the dictionary with the items from another dictionary. Parameters ---------- - other : dict or :class:`compas.scene.ColorDict` + other The other dictionary. Returns diff --git a/src/compas/colors/colormap.py b/src/compas/colors/colormap.py index 0b0ac1bba274..c41991d775b2 100644 --- a/src/compas/colors/colormap.py +++ b/src/compas/colors/colormap.py @@ -1,8 +1,9 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import os +from typing import Literal +from typing import Sequence +from typing import Union + +from typing_extensions import Self from compas.itertools import linspace @@ -19,19 +20,23 @@ "viridis": _viridis_data, } +ColorMapColor = Union[Sequence[float], Color] +ColorMapName = Literal["magma", "inferno", "plasma", "viridis"] +ColorRangeType = Literal["full", "light", "dark"] + -class ColorMap(object): +class ColorMap: """Class providing a map for 256 distinct colors of a specific color palette. Parameters ---------- - colors : sequence[tuple[float, float, float]] + colors A sequence of colors forming the map. The number of colors in the sequence should be 256. Attributes ---------- - colors : list[:class:`compas.colors.Color`] + colors The colors of the map. Raises @@ -53,12 +58,12 @@ class ColorMap(object): See Also -------- - :class:`compas.colors.Color` + compas.colors.Color """ - def __init__(self, colors): - self._colors = [] + def __init__(self, colors: Sequence[ColorMapColor]) -> None: + self._colors: list[Color] = [] self.colors = colors # -------------------------------------------------------------------------- @@ -66,11 +71,11 @@ def __init__(self, colors): # -------------------------------------------------------------------------- @property - def colors(self): + def colors(self) -> list[Color]: return self._colors @colors.setter - def colors(self, colors): + def colors(self, colors: Sequence[ColorMapColor]) -> None: if len(colors) != 256: raise ValueError("The color map should have 256 colors.") self._colors = [Color(r, g, b) for r, g, b in colors] @@ -79,21 +84,21 @@ def colors(self, colors): # customization # -------------------------------------------------------------------------- - def __call__(self, value, minval=0.0, maxval=1.0): + def __call__(self, value: float, minval: float = 0.0, maxval: float = 1.0) -> Color: """Returns the color in the map corresponding to the given value in the range ``[minval, maxval]``. Parameters ---------- - value : float + value The data value for which a color should be computed. - minval : float, optional + minval The minimum value of the data range. - maxval : float, optional + maxval The maximum value of the data range. Returns ------- - :class:`compas.colors.Color` + Color Raises ------ @@ -112,17 +117,17 @@ def __call__(self, value, minval=0.0, maxval=1.0): # -------------------------------------------------------------------------- @classmethod - def from_palette(cls, name): + def from_palette(cls, name: str) -> Self: """Construct a color map from a named palette. Parameters ---------- - name : str + name The name of the palette. Returns ------- - :class:`compas.colors.ColorMap` + ColorMap A color map with 256 colors. Raises @@ -139,7 +144,7 @@ def from_palette(cls, name): """ here = os.path.dirname(__file__) path = os.path.join(here, "cmcrameri", "{}.txt".format(name)) - colors = [] + colors: list[tuple[float, float, float]] = [] with open(path, "r") as f: for line in f: if line: @@ -153,17 +158,17 @@ def from_palette(cls, name): return cmap @classmethod - def from_mpl(cls, name): + def from_mpl(cls, name: ColorMapName) -> Self: """Construct a color map from matplotlib. Parameters ---------- - name : Literal['magma', 'inferno', 'plasma', 'viridis'] + name The name of the mpl colormap. Returns ------- - :class:`compas.colors.ColorMap` + ColorMap A color map with 256 colors. Raises @@ -182,21 +187,21 @@ def from_mpl(cls, name): return cls(colors) @classmethod - def from_color(cls, color, rangetype="full"): + def from_color(cls, color: Color, rangetype: ColorRangeType = "full") -> Self: """Construct a color map from a single color by varying luminance. Parameters ---------- - color : :class:`compas.colors.Color` + color The base color. - rangetype : Literal['full', 'light', 'dark'], optional + rangetype If ``'full'``, use the full luminance range (0.0 - 1.0). If ``'light'``, use only the "light" part of the luminance range (0.5 - 1.0). If ``'dark'``, use only the "dark" part of the luminance range (0.0 - 0.5). Returns ------- - :class:`compas.colors.Color` + ColorMap A color map with 256 colors. """ @@ -221,25 +226,25 @@ def from_color(cls, color, rangetype="full"): raise ValueError("`rangetype` should be one of 'full', 'light', 'dark'.") @classmethod - def from_two_colors(cls, c1, c2, diverging=False): + def from_two_colors(cls, c1: Color, c2: Color, diverging: bool = False) -> Self: """Create a color map from two colors. Parameters ---------- - c1 : :class:`compas.colors.Color` + c1 The first color. - c2 : :class:`compas.colors.Color` + c2 The second color. - diverging : bool, optional + diverging If True, use white as transition color in the middle. Returns ------- - :class:`compas.colors.ColorMap` + ColorMap A color map with 256 colors. """ - colors = [] + colors: list[Color] = [] if diverging: for i in linspace(0, 1.0, 128): r = c1[0] * (1 - i) + 1.0 * i @@ -260,25 +265,25 @@ def from_two_colors(cls, c1, c2, diverging=False): return cls(colors) @classmethod - def from_three_colors(cls, c1, c2, c3): + def from_three_colors(cls, c1: Color, c2: Color, c3: Color) -> Self: """Construct a color map from three colors. Parameters ---------- - c1 : :class:`compas.colors.Color` + c1 The first color. - c2 : :class:`compas.colors.Color` + c2 The second color. - c3 : :class:`compas.colors.Color` + c3 The third color. Returns ------- - :class:`compas.colors.ColorMap` + ColorMap A color map with 256 colors. """ - colors = [] + colors: list[Color] = [] for i in linspace(0, 1.0, 128): r = c1[0] * (1 - i) + c2[0] * i g = c1[1] * (1 - i) + c2[1] * i @@ -292,15 +297,15 @@ def from_three_colors(cls, c1, c2, c3): return cls(colors) @classmethod - def from_rgb(cls): + def from_rgb(cls) -> Self: """Construct a color map from the complete rgb color space. Returns ------- - :class:`compas.colors.Color` + ColorMap """ - colors = [] + colors: list[Color] = [] for i in linspace(0, 1.0, 256): colors.append(Color.from_i(i)) return cls(colors) diff --git a/src/compas/colors/mpl_colormap.py b/src/compas/colors/mpl_colormap.py index 00b4703368a6..9df30aeccfba 100644 --- a/src/compas/colors/mpl_colormap.py +++ b/src/compas/colors/mpl_colormap.py @@ -1047,18 +1047,3 @@ [0.983868, 0.904867, 0.136897], [0.993248, 0.906157, 0.143936], ] - -# from matplotlib.colors import ListedColormap - -# cmaps = {} -# for (name, data) in (('magma', _magma_data), -# ('inferno', _inferno_data), -# ('plasma', _plasma_data), -# ('viridis', _viridis_data)): - -# cmaps[name] = ListedColormap(data, name=name) - -# magma = cmaps['magma'] -# inferno = cmaps['inferno'] -# plasma = cmaps['plasma'] -# viridis = cmaps['viridis'] diff --git a/src/compas/data/__init__.py b/src/compas/data/__init__.py index 1d6786efa566..49662b2439be 100644 --- a/src/compas/data/__init__.py +++ b/src/compas/data/__init__.py @@ -1,31 +1,7 @@ -""" -This package defines the core infrastructure for data serialisation in the COMPAS framework. -It provides a base class for data objects, a JSON encoder and decoder, serialisers and deserialisers, and schema validation. -""" - -from __future__ import absolute_import +# ruff: noqa: F401 from .exceptions import DecoderError from .encoders import DataEncoder from .encoders import DataDecoder from .data import Data from .json import json_load, json_loads, json_loadz, json_dump, json_dumps, json_dumpz -from .schema import dataclass_dataschema, dataclass_typeschema, dataclass_jsonschema -from .schema import compas_dataclasses - -__all__ = [ - "Data", - "DataEncoder", - "DataDecoder", - "DecoderError", - "json_load", - "json_loads", - "json_loadz", - "json_dump", - "json_dumps", - "json_dumpz", - "dataclass_dataschema", - "dataclass_typeschema", - "dataclass_jsonschema", - "compas_dataclasses", -] diff --git a/src/compas/data/coercion.py b/src/compas/data/coercion.py index 80581dc17ef8..3222f5bec3f4 100644 --- a/src/compas/data/coercion.py +++ b/src/compas/data/coercion.py @@ -1,16 +1,15 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import Any +from typing import Sequence from .validators import is_item_iterable -def coerce_sequence_of_tuple(sequence): +def coerce_sequence_of_tuple(sequence: Sequence[Any]) -> list[tuple[Any, ...]]: """Make sure all items of a sequence are of type tuple. Parameters ---------- - sequence : sequence + sequence A sequence of items. Returns @@ -20,8 +19,14 @@ def coerce_sequence_of_tuple(sequence): with each iterable item converted to a tuple, and non-iterable items wrapped in a tuple. + Examples + -------- + >>> items = coerce_sequence_of_tuple(["a", 1, (None,), [2.0, 3.0]]) + >>> all(isinstance(item, tuple) for item in items) + True + """ - items = [] + items: list[tuple[Any, ...]] = [] for item in sequence: if not isinstance(item, tuple): if not is_item_iterable(item): @@ -32,12 +37,12 @@ def coerce_sequence_of_tuple(sequence): return items -def coerce_sequence_of_list(sequence): +def coerce_sequence_of_list(sequence: Sequence[Any]) -> list[list[Any]]: """Make sure all items of a sequence are of type list. Parameters ---------- - sequence : sequence + sequence A sequence of items. Returns @@ -47,8 +52,14 @@ def coerce_sequence_of_list(sequence): with each iterable item converted to a list, and non-iterable items wrapped in a list. + Examples + -------- + >>> items = coerce_sequence_of_list(["a", 1, (None,), [2.0, 3.0]]) + >>> all(isinstance(item, list) for item in items) + True + """ - items = [] + items: list[list[Any]] = [] for item in sequence: if not isinstance(item, list): if not is_item_iterable(item): diff --git a/src/compas/data/data.py b/src/compas/data/data.py index aaaf562f0bd1..861d7745199f 100644 --- a/src/compas/data/data.py +++ b/src/compas/data/data.py @@ -1,94 +1,106 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -try: - from typing import TypeVar # noqa: F401 - - D = TypeVar("D", bound="Data") -except ImportError: - pass - import hashlib +import os from copy import deepcopy +from typing import IO +from typing import Any +from typing import Optional +from typing import Type +from typing import Union from uuid import UUID from uuid import uuid4 -import compas - -# ============================================================================== -# If you ever feel tempted to use ABCMeta in your code: don't, just DON'T. -# Assigning __metaclass__ = ABCMeta to a class causes a severe memory leak/performance -# degradation on IronPython 2.7. +from typing_extensions import Self -# See these issues for more details: -# - https://github.com/compas-dev/compas/issues/562 -# - https://github.com/compas-dev/compas/issues/649 +import compas -# ============================================================================== +JSONFile = Union[str, os.PathLike[str], IO[str]] -class Data(object): +class Data: """Abstract base class for all COMPAS data objects. Parameters ---------- - name : str, optional + name The name of the object. Attributes ---------- - guid : str, read-only + guid The globally unique identifier of the object. The guid is generated with ``uuid.uuid4()``. - name : str + name The name of the object. This name is not necessarily unique and can be set by the user. The default value is the object's class name: ``self.__class__.__name__``. See Also -------- - :class:`compas.data.DataEncoder` - :class:`compas.data.DataDecoder` + compas.data.DataEncoder + compas.data.DataDecoder Notes ----- Objects created from classes that implement this data class can be serialized to JSON and unserialized without loss of information using: - * :func:`compas.data.json_dump` - * :func:`compas.data.json_dumps` - * :func:`compas.data.json_load` - * :func:`compas.data.json_loads` + * `compas.data.json_dump` + * `compas.data.json_dumps` + * `compas.data.json_load` + * `compas.data.json_loads` """ - DATASCHEMA = {} - - def __init__(self, name=None): - self._guid = None - self._name = None + def __init__(self, name: Optional[str] = None) -> None: + self._guid: Optional[UUID] = None + self._name: Optional[str] = None if name: self.name = name @property - def __dtype__(self): + def __dtype__(self) -> str: + """Return the data type identifier used by COMPAS JSON serialization. + + Returns + ------- + str + + """ return "{}/{}".format(".".join(self.__class__.__module__.split(".")[:2]), self.__class__.__name__) @classmethod - def __clstype__(cls): + def __clstype__(cls) -> str: + """Return the class type identifier used by COMPAS JSON serialization. + + Returns + ------- + str + + """ return "{}/{}".format(".".join(cls.__module__.split(".")[:2]), cls.__name__) @property - def __data__(self): + def __data__(self) -> dict: + """Return the data representation used by COMPAS JSON serialization. + + Returns + ------- + dict + + Raises + ------ + NotImplementedError + If the subclass does not implement this property. + + """ raise NotImplementedError - def __jsondump__(self, minimal=False): - """Return the required information for serialization with the COMPAS JSON serializer. + def __jsondump__(self, minimal: bool = False) -> dict[str, Any]: + """Return the object state used by COMPAS JSON serialization. Parameters ---------- - minimal : bool, optional + minimal If True, exclude the GUID from the dump dict. Returns @@ -108,21 +120,21 @@ def __jsondump__(self, minimal=False): return state @classmethod - def __jsonload__(cls, data, guid=None, name=None): - """Construct an object of this type from the provided data to support COMPAS JSON serialization. + def __jsonload__(cls, data: dict[str, Any], guid: Optional[str] = None, name: Optional[str] = None) -> Self: + """Construct an object from COMPAS JSON serialization data. Parameters ---------- - data : dict + data The raw Python data representing the object. - guid : str, optional + guid The GUID of the object. - name : str, optional + name The name of the object. Returns ------- - object + Data """ obj = cls.__from_data__(data) @@ -132,12 +144,32 @@ def __jsonload__(cls, data, guid=None, name=None): obj.name = name return obj - def __getstate__(self): + def __getstate__(self) -> dict[str, Any]: + """Return the state used by pickle. + + Returns + ------- + dict + The JSON dump extended with the object's instance dictionary. + + """ state = self.__jsondump__() state["__dict__"] = self.__dict__ return state - def __setstate__(self, state): + def __setstate__(self, state: dict[str, Any]) -> None: + """Set the object state from pickle data. + + Parameters + ---------- + state + The pickled state. + + Returns + ------- + None + + """ self.__dict__.update(state["__dict__"]) if "guid" in state: self._guid = UUID(state["guid"]) @@ -145,24 +177,24 @@ def __setstate__(self, state): self.name = state["name"] @classmethod - def __from_data__(cls, data): # type: (dict) -> Data + def __from_data__(cls, data: dict[str, Any]) -> Self: """Construct an object of this type from the provided data. Parameters ---------- - data : dict + data The data dictionary. Returns ------- - :class:`compas.data.Data` + Data An instance of this object type if the data contained in the dict has the correct schema. """ return cls(**data) - def ToString(self): - """Converts the instance to a string. + def ToString(self) -> str: + """Convert the instance to a string. This method exists for .NET compatibility. When using IronPython, the implicit string conversion that usually takes place in CPython @@ -172,41 +204,60 @@ def ToString(self): display proper string representations when the objects are printed or connected to a panel or other type of string output. + Returns + ------- + str + The string representation of the object. + """ return str(self) @property - def guid(self): + def guid(self) -> UUID: + """Return the globally unique identifier of the object. + + Returns + ------- + UUID + + """ if not self._guid: self._guid = uuid4() return self._guid @property - def name(self): + def name(self) -> str: + """Return the name of the object. + + Returns + ------- + str + + """ return self._name or self.__class__.__name__ @name.setter - def name(self, name): + def name(self, name: str) -> None: self._name = name @classmethod - def from_json(cls, filepath): # type: (...) -> Data + def from_json(cls, filepath: JSONFile) -> Self: """Construct an object of this type from a JSON file. Parameters ---------- - filepath : str - The path to the JSON file. + filepath + The path to the JSON file, URL string, or readable file-like object. Returns ------- - :class:`compas.data.Data` + Data An instance of this object type if the data contained in the file has the correct schema. Raises ------ TypeError - If the data in the file is not a :class:`compas.data.Data`. + If the data in the file is not a Data object. """ data = compas.json_load(filepath) @@ -214,41 +265,54 @@ def from_json(cls, filepath): # type: (...) -> Data raise TypeError("The data in the file is not a {}.".format(cls)) return data - def to_json(self, filepath, pretty=False, compact=False, minimal=False): + def to_json(self, filepath: JSONFile, pretty: bool = False, compact: bool = False, minimal: bool = False) -> None: """Convert an object to its native data representation and save it to a JSON file. Parameters ---------- - filepath : str - The path to the JSON file. - pretty : bool, optional + filepath + The path to the JSON file or writable file-like object. + pretty If True, format the output with newlines and indentation. - compact : bool, optional + compact If True, format the output without any whitespace. - minimal : bool, optional + minimal If True, exclude the GUID from the JSON output. + Returns + ------- + None + """ compas.json_dump(self, filepath, pretty=pretty, compact=compact, minimal=minimal) @classmethod - def from_jsonstring(cls, string): # type: (...) -> Data + def from_jsonstring(cls, string: str) -> Self: """Construct an object of this type from a JSON string. Parameters ---------- - string : str + string The JSON string. Returns ------- - :class:`compas.data.Data` + Data An instance of this object type if the data contained in the string has the correct schema. Raises ------ TypeError - If the data in the string is not a :class:`compas.data.Data`. + If the data in the string is not a Data object. + + Examples + -------- + >>> from compas.geometry import Point + >>> point = Point.from_jsonstring(Point(1, 2, 3).to_jsonstring()) + >>> point.x + 1.0 + >>> isinstance(point, Point) + True """ data = compas.json_loads(string) @@ -256,16 +320,16 @@ def from_jsonstring(cls, string): # type: (...) -> Data raise TypeError("The data in the string is not a {}.".format(cls)) return data - def to_jsonstring(self, pretty=False, compact=False, minimal=False): + def to_jsonstring(self, pretty: bool = False, compact: bool = False, minimal: bool = False) -> str: """Convert an object to its native data representation and save it to a JSON string. Parameters ---------- - pretty : bool, optional + pretty If True, format the output with newlines and indentation. - compact : bool, optional + compact If True, format the output without any whitespace. - minimal : bool, optional + minimal If True, exclude the GUID from the JSON output. Returns @@ -273,25 +337,47 @@ def to_jsonstring(self, pretty=False, compact=False, minimal=False): str The JSON string. + Examples + -------- + >>> class Example(Data): + ... @property + ... def __data__(self): + ... return {} + >>> '"dtype": "compas.data/Example"' in Example().to_jsonstring() + True + """ return compas.json_dumps(self, pretty=pretty, compact=compact, minimal=minimal) - def copy(self, cls=None, copy_guid=False): # type: (...) -> D + def copy(self, cls: Optional[Type[Self]] = None, copy_guid: bool = False) -> Self: """Make an independent copy of the data object. Parameters ---------- - cls : Type[:class:`compas.data.Data`], optional + cls The type of data object to return. Defaults to the type of the current data object. - copy_guid : bool, optional + copy_guid If True, the copy will have the same guid as the original. Returns ------- - :class:`compas.data.Data` + Data An independent copy of this object. + Examples + -------- + >>> class Example(Data): + ... @property + ... def __data__(self): + ... return {} + >>> a = Example(name="A") + >>> b = a.copy() + >>> a is b + False + >>> b.name + 'A' + """ if not cls: cls = type(self) @@ -300,14 +386,14 @@ def copy(self, cls=None, copy_guid=False): # type: (...) -> D obj._name = self.name if copy_guid: obj._guid = self.guid - return obj # type: ignore + return obj - def sha256(self, as_string=False): + def sha256(self, as_string: bool = False) -> Union[str, bytes]: """Compute a hash of the data for comparison during version control using the sha256 algorithm. Parameters ---------- - as_string : bool, optional + as_string If True, return the digest in hexadecimal format rather than as bytes. Returns @@ -316,16 +402,15 @@ def sha256(self, as_string=False): Examples -------- - >>> from compas.datastructures import Mesh - >>> mesh = Mesh.from_obj(compas.get("faces.obj")) - >>> v1 = mesh.sha256() - >>> v2 = mesh.sha256() - >>> mesh.vertex_attribute(mesh.vertex_sample(1)[0], "z", 1) - >>> v3 = mesh.sha256() - >>> v1 == v2 + >>> class Example(Data): + ... @property + ... def __data__(self): + ... return {} + >>> a = Example() + >>> a.sha256() == a.sha256() + True + >>> isinstance(a.sha256(as_string=True), str) True - >>> v2 == v3 - False """ h = hashlib.sha256() @@ -333,25 +418,3 @@ def sha256(self, as_string=False): if as_string: return h.hexdigest() return h.digest() - - @classmethod - def validate_data(cls, data): - """Validate the data against the object's data schema. - - The data is the raw data that can be used to construct an object of this type with the classmethod ``__from_data__``. - - Parameters - ---------- - data : Any - The data for validation. - - Returns - ------- - Any - - """ - from jsonschema import Draft202012Validator - - validator = Draft202012Validator(cls.DATASCHEMA) # type: ignore - validator.validate(data) - return data diff --git a/src/compas/data/encoders.py b/src/compas/data/encoders.py index c77bcdca26ce..2b992313f330 100644 --- a/src/compas/data/encoders.py +++ b/src/compas/data/encoders.py @@ -1,59 +1,32 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -try: - from typing import Type # noqa: F401 -except ImportError: - pass - import json -import platform +from typing import Any +from typing import Optional +from typing import Type + +import numpy as np -from .data import Data # noqa: F401 +from .data import Data from .exceptions import DecoderError IDictionary = None numpy_support = False dotnet_support = False -# We don't do this from `compas.IPY` to avoid circular imports -if "ironpython" == platform.python_implementation().lower(): - dotnet_support = True - - try: - import System # type: ignore - from System.Collections.Generic import IDictionary # type: ignore - except: # noqa: E722 - pass - -try: - import numpy as np - try: - np_float = np.float_ - except AttributeError: - np_float = np.float64 - - numpy_support = True -except (ImportError, SyntaxError): - numpy_support = False - - -def cls_from_dtype(dtype, inheritance=None): # type: (...) -> Type[Data] +def cls_from_dtype(dtype: str, inheritance: Optional[list[str]] = None) -> Type[Data]: """Get the class object corresponding to a COMPAS data type specification. Parameters ---------- - dtype : str + dtype The data type of the COMPAS object in the following format: '{}/{}'.format(o.__class__.__module__, o.__class__.__name__). - inheritance : list[str], optional + inheritance The inheritance chain of this class, a list of superclasses that can be used if given dtype is not found. Returns ------- - :class:`compas.data.Data` + Data Raises ------ @@ -91,7 +64,7 @@ class DataEncoder(json.JSONEncoder): * Numpy objects to their Python equivalents; * iterables to lists; and - * :class:`compas.data.Data` objects, + * `compas.data.Data` objects, such as geometric primitives and shapes, data structures, robots, ..., to a dict with the following structure: ``{'dtype': o.__dtype__, 'data': o.__data__}`` @@ -122,17 +95,17 @@ class DataEncoder(json.JSONEncoder): minimal = False - def default(self, o): + def default(self, o: Any) -> Any: """Return an object in serialized form. Parameters ---------- - o : object + o The object to serialize. Returns ------- - str + object The serialized object. """ @@ -144,36 +117,31 @@ def default(self, o): if hasattr(o, "__next__"): return list(o) - if numpy_support: - if isinstance(o, np.ndarray): - return o.tolist() - if isinstance( - o, - ( - np.int_, - np.intc, - np.intp, - np.int8, - np.int16, - np.int32, - np.int64, - np.uint8, - np.uint16, - np.uint32, - np.uint64, - ), # type: ignore - ): - return int(o) - if isinstance(o, (np_float, np.float16, np.float32, np.float64)): # type: ignore - return float(o) - if isinstance(o, np.bool_): - return bool(o) - if isinstance(o, np.void): - return None - - if dotnet_support: - if isinstance(o, (System.Decimal, System.Double, System.Single)): - return float(o) + if isinstance(o, np.ndarray): + return o.tolist() + if isinstance( + o, + ( + np.int_, + np.intc, + np.intp, + np.int8, + np.int16, + np.int32, + np.int64, + np.uint8, + np.uint16, + np.uint32, + np.uint64, + ), # type: ignore + ): + return int(o) + if isinstance(o, (np.float_, np.float16, np.float32, np.float64)): # type: ignore + return float(o) + if isinstance(o, np.bool_): + return bool(o) + if isinstance(o, np.void): + return None if isinstance(o, AttributeView): return dict(o) @@ -185,7 +153,7 @@ class DataDecoder(json.JSONDecoder): """Data decoder for custom JSON serialization with support for COMPAS data structures and geometric primitives. The decoder hooks into the JSON deserialisation process - to reconstruct :class:`compas.data.Data` objects, + to reconstruct `compas.data.Data` objects, such as geometric primitives and shapes, data structures, robots, ..., from the serialized data when possible. @@ -216,15 +184,16 @@ class DataDecoder(json.JSONDecoder): """ - def __init__(self, *args, **kwargs): - super(DataDecoder, self).__init__(object_hook=self.object_hook, *args, **kwargs) + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(object_hook=self.object_hook, *args, **kwargs) - def object_hook(self, o): + def object_hook(self, o: dict[str, Any]) -> Any: # pyright: ignore[reportIncompatibleMethodOverride] """Reconstruct a deserialized object. Parameters ---------- - o : object + o + The decoded JSON object. Returns ------- diff --git a/src/compas/data/json.py b/src/compas/data/json.py index 73bda1e24844..e9ec68695498 100644 --- a/src/compas/data/json.py +++ b/src/compas/data/json.py @@ -1,33 +1,34 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import json +import os import zipfile +from typing import IO +from typing import Any +from typing import Union from compas import _iotools -from compas.data import Data # noqa: F401 from compas.data import DataDecoder from compas.data import DataEncoder _JSON_CONTENT_FILENAME = "content.json" +JSONFile = Union[str, os.PathLike[str], IO[str]] +ZipFile = Union[str, os.PathLike[str], IO[bytes]] -def json_dump(data, fp, pretty=False, compact=False, minimal=False): +def json_dump(data: Any, fp: JSONFile, pretty: bool = False, compact: bool = False, minimal: bool = False) -> None: """Write a collection of COMPAS object data to a JSON file. Parameters ---------- - data : object + data Any JSON serializable object. This includes any (combination of) COMPAS object(s). - fp : path string or file-like object - A writeable file-like object or the path to a file. - pretty : bool, optional + fp + A writable file-like object or the path to a file. + pretty If True, format the output with newlines and indentation. - compact : bool, optional + compact If True, format the output without any whitespace. - minimal : bool, optional + minimal If True, exclude the GUID from the JSON output. Returns @@ -36,9 +37,9 @@ def json_dump(data, fp, pretty=False, compact=False, minimal=False): See Also -------- - :class:`compas.data.json_dumps` - :class:`compas.data.json_load` - :class:`compas.data.json_loads` + compas.data.json_dumps + compas.data.json_load + compas.data.json_loads Examples -------- @@ -66,19 +67,19 @@ def json_dump(data, fp, pretty=False, compact=False, minimal=False): return json.dump(data, f, cls=DataEncoder, **kwargs) -def json_dumps(data, pretty=False, compact=False, minimal=False): # type: (...) -> str +def json_dumps(data: Any, pretty: bool = False, compact: bool = False, minimal: bool = False) -> str: """Write a collection of COMPAS objects to a JSON string. Parameters ---------- - data : object + data Any JSON serializable object. This includes any (combination of) COMPAS object(s). - pretty : bool, optional + pretty If True, format the output with newlines and indentation. - compact : bool, optional + compact If True, format the output without any whitespace. - minimal : bool, optional + minimal If True, exclude the GUID from the JSON output. Returns @@ -87,9 +88,9 @@ def json_dumps(data, pretty=False, compact=False, minimal=False): # type: (...) See Also -------- - :class:`compas.data.json_dump` - :class:`compas.data.json_load` - :class:`compas.data.json_loads` + compas.data.json_dump + compas.data.json_load + compas.data.json_loads Examples -------- @@ -114,31 +115,34 @@ def json_dumps(data, pretty=False, compact=False, minimal=False): # type: (...) return json.dumps(data, cls=DataEncoder, **kwargs) -def json_dumpz(data, zip_filename, pretty=False, compact=False, minimal=False): +def json_dumpz(data: Any, zip_filename: ZipFile, pretty: bool = False, compact: bool = False, minimal: bool = False) -> None: """Write a collection of COMPAS objects to a compressed JSON file (using ZIP compression). Parameters ---------- - data : object + data Any JSON serializable object. This includes any (combination of) COMPAS object(s). - pretty : bool, optional + zip_filename + A writable file-like object or the path to a ZIP file. + pretty If True, format the output with newlines and indentation. - compact : bool, optional + compact If True, format the output without any whitespace. - minimal : bool, optional + minimal If True, exclude the GUID from the JSON output. Returns ------- - str + None See Also -------- - :class:`compas.data.json_dump` - :class:`compas.data.json_load` - :class:`compas.data.json_loads` - :class:`compas.data.json_loadz` + compas.data.json_dump + compas.data.json_load + compas.data.json_loads + compas.data.json_loadz + """ json_str = json_dumps(data, pretty=pretty, compact=compact, minimal=minimal) @@ -146,12 +150,12 @@ def json_dumpz(data, zip_filename, pretty=False, compact=False, minimal=False): zf.writestr(_JSON_CONTENT_FILENAME, json_str) -def json_loadz(zip_file): +def json_loadz(zip_file: ZipFile) -> Any: """Read COMPAS object data from a compressed JSON file (ZIP). Parameters ---------- - zip_file : path string | file-like object + zip_file A readable path or a file-like object pointing to a ZIP file. Returns @@ -161,10 +165,11 @@ def json_loadz(zip_file): See Also -------- - :class:`compas.data.json_dump` - :class:`compas.data.json_dumps` - :class:`compas.data.json_dumpz` - :class:`compas.data.json_loads` + compas.data.json_dump + compas.data.json_dumps + compas.data.json_dumpz + compas.data.json_loads + """ with zipfile.ZipFile(zip_file) as zf: with zf.open(_JSON_CONTENT_FILENAME) as f: @@ -173,12 +178,12 @@ def json_loadz(zip_file): return json_loads(json_str) -def json_load(fp): # type: (...) -> dict +def json_load(fp: JSONFile) -> Any: """Read COMPAS object data from a JSON file. Parameters ---------- - fp : path string | file-like object | URL string + fp A readable path, a file-like object or a URL pointing to a file. Returns @@ -188,9 +193,9 @@ def json_load(fp): # type: (...) -> dict See Also -------- - :class:`compas.data.json_dump` - :class:`compas.data.json_dumps` - :class:`compas.data.json_loads` + compas.data.json_dump + compas.data.json_dumps + compas.data.json_loads Examples -------- @@ -207,12 +212,12 @@ def json_load(fp): # type: (...) -> dict return json.load(f, cls=DataDecoder) -def json_loads(s): # type: (...) -> dict +def json_loads(s: str) -> Any: """Read COMPAS object data from a JSON string. Parameters ---------- - s : str + s A JSON data string. Returns @@ -222,9 +227,9 @@ def json_loads(s): # type: (...) -> dict See Also -------- - :class:`compas.data.json_dump` - :class:`compas.data.json_dumps` - :class:`compas.data.json_load` + compas.data.json_dump + compas.data.json_dumps + compas.data.json_load Examples -------- diff --git a/src/compas/data/schema.py b/src/compas/data/schema.py deleted file mode 100644 index dd051be09a40..000000000000 --- a/src/compas/data/schema.py +++ /dev/null @@ -1,137 +0,0 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -import json -import os - - -def dataclass_dataschema(cls): # type: (...) -> dict - """Generate a JSON schema for a COMPAS object class. - - Parameters - ---------- - cls : :class:`compas.data.Data` - The COMPAS object class. - - Returns - ------- - dict - The JSON schema. - - """ - return cls.DATASCHEMA - - -def dataclass_typeschema(cls): # type: (...) -> dict - """Generate a JSON schema for the data type of a COMPAS object class. - - Parameters - ---------- - cls : :class:`compas.data.Data` - The COMPAS object class. - - Returns - ------- - dict - The JSON schema. - - """ - return { - "type": "string", - "const": "{}/{}".format(".".join(cls.__module__.split(".")[:2]), cls.__name__), - } - - -def dataclass_jsonschema(cls, filepath=None, draft=None): # type: (...) -> dict - """Generate a JSON schema for a COMPAS object class. - - Parameters - ---------- - cls : :class:`compas.data.Data` - The COMPAS object class. - filepath : str, optional - The path to the file where the schema should be saved. - draft : str, optional - The JSON schema draft to use. - - Returns - ------- - dict - The JSON schema. - - """ - import compas - - draft = draft or "https://json-schema.org/draft/2020-12/schema" - - schema = { - "$schema": draft, - "$id": "{}.json".format(cls.__name__), - "$compas": "{}".format(compas.__version__), - "type": "object", - "properties": { - "dtype": dataclass_typeschema(cls), - "data": dataclass_dataschema(cls), - "guid": {"type": "string", "format": "uuid"}, - }, - "required": ["dtype", "data"], - } - - if filepath: - with open(filepath, "w") as f: - json.dump(schema, f, indent=4) - - return schema - - -def compas_jsonschema(dirname=None): # type: (...) -> list - """Generate a JSON schema for the COMPAS data model. - - Parameters - ---------- - dirname : str, optional - The path to the directory where the schemas should be saved. - - Returns - ------- - list - A list of JSON schemas. - - """ - schemas = [] - dataclasses = compas_dataclasses() - for cls in dataclasses: - filepath = None - if dirname: - filepath = os.path.join(dirname, "{}.json".format(cls.__name__)) - schema = dataclass_jsonschema(cls, filepath=filepath) - schemas.append(schema) - return schemas - - -def compas_dataclasses(): # type: (...) -> list - """Find all classes in the COMPAS data model. - - Returns - ------- - list - - """ - from collections import deque - - import compas.colors # noqa: F401 - import compas.datastructures # noqa: F401 - import compas.geometry # noqa: F401 - from compas.data import Data - - tovisit = deque([Data]) - dataclasses = [] - - while tovisit: - cls = tovisit.popleft() - dataclasses.append(cls) - for subcls in cls.__subclasses__(): - tovisit.append(subcls) - - return dataclasses diff --git a/src/compas/data/validators.py b/src/compas/data/validators.py index acc6f47bf168..9eb79a2d61ec 100644 --- a/src/compas/data/validators.py +++ b/src/compas/data/validators.py @@ -1,19 +1,15 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import Any +from typing import Iterable +from typing import Sequence +from typing import cast -try: - basestring # type: ignore -except NameError: - basestring = str - -def is_sequence_of_str(items): +def is_sequence_of_str(items: Iterable[Any]) -> bool: """Verify that the sequence contains only items of type str. Parameters ---------- - items : sequence + items The sequence of items. Returns @@ -22,112 +18,161 @@ def is_sequence_of_str(items): True if all items are strings. False otherwise. + Examples + -------- + >>> is_sequence_of_str(["a", "b", "c"]) + True + >>> is_sequence_of_str(["a", 1, "c"]) + False + """ - return all(isinstance(item, basestring) for item in items) + return all(isinstance(item, str) for item in items) -def is_sequence_of_int(items): +def is_sequence_of_int(items: Iterable[Any]) -> bool: """Verify that the sequence contains only integers. Parameters ---------- - items : sequence + items The sequence of items. Returns ------- bool + Examples + -------- + >>> is_sequence_of_int([1, 2, 3]) + True + >>> is_sequence_of_int([1, 2.0, 3]) + False + """ return all(isinstance(item, int) for item in items) -def is_int3(items): +def is_int3(items: Sequence[Any]) -> bool: """Verify that the sequence contains 3 integers. Parameters ---------- - items : sequence + items The sequence of items. Returns ------- bool + Examples + -------- + >>> is_int3([1, 2, 3]) + True + >>> is_int3([1, 2, 3, 4]) + False + """ return len(items) == 3 and all(isinstance(item, int) for item in items) -def is_sequence_of_float(items): +def is_sequence_of_float(items: Iterable[Any]) -> bool: """Verify that the sequence contains only floats. Parameters ---------- - items : sequence + items The sequence of items. Returns ------- bool + Examples + -------- + >>> is_sequence_of_float([1.0, 2.0, 3.0]) + True + >>> is_sequence_of_float([1.0, 2, 3.0]) + False + """ return all(isinstance(item, float) for item in items) -def is_sequence_of_uint(items): +def is_sequence_of_uint(items: Iterable[Any]) -> bool: """Verify that the sequence contains only unsigned integers. Parameters ---------- - items : sequence + items The sequence of items. Returns ------- bool + Examples + -------- + >>> is_sequence_of_uint([0, 1, 2]) + True + >>> is_sequence_of_uint([0, -1, 2]) + False + """ return all(isinstance(item, int) and item >= 0 for item in items) -def is_float3(items): +def is_float3(items: Sequence[Any]) -> bool: """Verify that the sequence contains 3 floats. Parameters ---------- - items : sequence + items The sequence of items. Returns ------- bool + Examples + -------- + >>> is_float3([1.0, 2.0, 3.0]) + True + >>> is_float3([1.0, 2.0, 3]) + False + """ return len(items) == 3 and all(isinstance(item, float) for item in items) -def is_float4x4(items): +def is_float4x4(items: Sequence[Sequence[Any]]) -> bool: """Verify that the sequence contains 4 sequences of each 4 floats. Parameters ---------- - items : sequence + items The sequence of items. Returns ------- bool + Examples + -------- + >>> is_float4x4([[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]]) + True + >>> is_float4x4([[1.0, 0.0], [0.0, 1.0]]) + False + """ return len(items) == 4 and all(len(item) == 4 and all(isinstance(i, float) for i in item) for item in items) -def is_sequence_of_list(items): +def is_sequence_of_list(items: Iterable[Any]) -> bool: """Verify that the sequence contains only items of type list. Parameters ---------- - items : sequence + items The items. Returns @@ -140,17 +185,19 @@ def is_sequence_of_list(items): -------- >>> is_sequence_of_list([[1], [1], [1]]) True + >>> is_sequence_of_list([[1], (1,), [1]]) + False """ return all(isinstance(item, list) for item in items) -def is_sequence_of_tuple(items): +def is_sequence_of_tuple(items: Iterable[Any]) -> bool: """Verify that the sequence contains only items of type tuple. Parameters ---------- - items : sequence + items The sequence of items. Returns @@ -163,17 +210,19 @@ def is_sequence_of_tuple(items): -------- >>> is_sequence_of_tuple([(1,), (1,), (1,)]) True + >>> is_sequence_of_tuple([(1,), [1], (1,)]) + False """ return all(isinstance(item, tuple) for item in items) -def is_sequence_of_dict(items): +def is_sequence_of_dict(items: Iterable[Any]) -> bool: """Verify that the sequence contains only items of type dict. Parameters ---------- - items : sequence + items The sequence of items. Returns @@ -186,17 +235,19 @@ def is_sequence_of_dict(items): -------- >>> is_sequence_of_dict([{"a": 1}, {"b": 2}, {"c": 3}]) True + >>> is_sequence_of_dict([{"a": 1}, ["b", 2], {"c": 3}]) + False """ return all(isinstance(item, dict) for item in items) -def is_item_iterable(item): +def is_item_iterable(item: object) -> bool: """Verify that an item is iterable. Parameters ---------- - item : object + item The item to test. Returns @@ -207,25 +258,25 @@ def is_item_iterable(item): Examples -------- - >>> is_item_iterable(1.0) - False >>> is_item_iterable("abc") True + >>> is_item_iterable(1.0) + False """ try: - _ = [_ for _ in item] + _ = [_ for _ in cast(Iterable[Any], item)] except TypeError: return False return True -def is_sequence_of_iterable(items): +def is_sequence_of_iterable(items: Iterable[Any]) -> bool: """Verify that the sequence contains only iterable items. Parameters ---------- - items : sequence + items The items. Returns @@ -238,6 +289,8 @@ def is_sequence_of_iterable(items): -------- >>> is_sequence_of_iterable(["abc", [1.0], (2, "a", None)]) True + >>> is_sequence_of_iterable(["abc", 1.0, (2, "a", None)]) + False """ return all(is_item_iterable(item) for item in items) diff --git a/src/compas/datastructures/__init__.py b/src/compas/datastructures/__init__.py index 693dba34b809..22dde0390b48 100644 --- a/src/compas/datastructures/__init__.py +++ b/src/compas/datastructures/__init__.py @@ -1,23 +1,13 @@ """ This package defines the core data structures of the COMPAS framework. The data structures provide a structured way of storing and accessing data on individual components of both topological and geometrical objects. -""" -from __future__ import absolute_import +""" +# ruff: noqa: F401 from .datastructure import Datastructure -# ============================================================================= -# Graphs -# ============================================================================= - -from .graph.planarity import graph_embed_in_plane_proxy # noqa: F401 - -# ============================================================================= -# Meshes -# ============================================================================= - -from .mesh.conway import ( # noqa: F401 +from .mesh.conway import ( mesh_conway_ambo, mesh_conway_bevel, mesh_conway_dual, @@ -32,47 +22,12 @@ mesh_conway_truncate, mesh_conway_zip, ) -from .mesh.smoothing import mesh_smooth_centerofmass # noqa: F401 -from .mesh.subdivision import trimesh_subdivide_loop # noqa: F401 - -# ============================================================================= -# Halffaces -# ============================================================================= - -# ============================================================================= -# Volmeshes -# ============================================================================= - -# ============================================================================= -# Class APIs -# ============================================================================= +from .mesh.smoothing import mesh_smooth_centerofmass +from .mesh.subdivision import trimesh_subdivide_loop from .graph.graph import Graph from .mesh.mesh import Mesh from .volmesh.volmesh import VolMesh -from .assembly.exceptions import AssemblyError, FeatureError -from .assembly.assembly import Assembly -from .assembly.part import Feature, GeometricFeature, ParametricFeature, Part from .cell_network.cell_network import CellNetwork from .tree.tree import Tree, TreeNode from .tree.hashtree import HashTree, HashNode - -Network = Graph - -__all__ = [ - "Datastructure", - "CellNetwork", - "Mesh", - "VolMesh", - "Assembly", - "Part", - "AssemblyError", - "FeatureError", - "Feature", - "GeometricFeature", - "ParametricFeature", - "Tree", - "TreeNode", - "HashTree", - "HashNode", -] diff --git a/src/compas/datastructures/_mutablemapping.py b/src/compas/datastructures/_mutablemapping.py index d9d2273552e4..d3f693fa00ba 100644 --- a/src/compas/datastructures/_mutablemapping.py +++ b/src/compas/datastructures/_mutablemapping.py @@ -8,37 +8,63 @@ See these issues for more details: - https://github.com/compas-dev/compas/issues/562 - https://github.com/compas-dev/compas/issues/649 -""" -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +Notes +----- +The decision to avoid abstract base classes and metaclasses should be revisited. +It was made to work around performance and compatibility problems in IronPython +2.7, but those constraints no longer apply as support for Python 2.7-era +environments is phased out. -import compas +""" -if compas.PY2: - import collections as stdlib_collections -else: - import collections.abc as stdlib_collections +import collections.abc as stdlib_collections +from typing import Any +from typing import Generic +from typing import Iterator +from typing import Optional +from typing import TypeVar +from typing import Union +from typing import overload +K = TypeVar("K") +V = TypeVar("V") +T = TypeVar("T") -class Mapping(object): - __slots__ = () +class Mapping(Generic[K, V]): """A Mapping is a generic container for associating key/value pairs. This class provides concrete generic implementations of all methods except for __getitem__, __iter__, and __len__. + """ - def get(self, key, default=None): + __slots__ = () + + def __getitem__(self, key: K) -> V: + raise NotImplementedError + + def __iter__(self) -> Iterator[K]: + raise NotImplementedError + + def __len__(self) -> int: + raise NotImplementedError + + @overload + def get(self, key: K) -> Optional[V]: ... + + @overload + def get(self, key: K, default: T) -> Union[V, T]: ... + + def get(self, key: K, default: Any = None) -> Any: """D.get(k[,d]) => D[k] if k in D, else d. d defaults to None.""" try: return self[key] except KeyError: return default - def __contains__(self, key): + def __contains__(self, key: Any) -> bool: try: self[key] except KeyError: @@ -46,19 +72,19 @@ def __contains__(self, key): else: return True - def keys(self): + def keys(self) -> stdlib_collections.KeysView[K]: """D.keys() => a set-like object providing a view on D's keys""" return stdlib_collections.KeysView(self) - def items(self): + def items(self) -> stdlib_collections.ItemsView[K, V]: """D.items() => a set-like object providing a view on D's items""" return stdlib_collections.ItemsView(self) - def values(self): + def values(self) -> stdlib_collections.ValuesView[V]: """D.values() => an object providing a view on D's values""" return stdlib_collections.ValuesView(self) - def __eq__(self, other): + def __eq__(self, other: object) -> Any: if not isinstance(other, (Mapping, stdlib_collections.Mapping)): return NotImplemented return dict(self.items()) == dict(other.items()) @@ -66,21 +92,35 @@ def __eq__(self, other): __reversed__ = None -class MutableMapping(Mapping): - __slots__ = () - +class MutableMapping(Mapping[K, V]): """A MutableMapping is a generic container for associating key/value pairs. This class provides concrete generic implementations of all methods except for __getitem__, __setitem__, __delitem__, __iter__, and __len__. + """ + __slots__ = () + __marker = object() - def pop(self, key, default=__marker): + def __setitem__(self, key: K, value: V) -> None: + raise NotImplementedError + + def __delitem__(self, key: K) -> None: + raise NotImplementedError + + @overload + def pop(self, key: K) -> V: ... + + @overload + def pop(self, key: K, default: T) -> Union[V, T]: ... + + def pop(self, key: K, default: Any = __marker) -> Any: """D.pop(k[,d]) => v, remove specified key and return the corresponding value. If key is not found, d is returned if given, otherwise KeyError is raised. + """ try: value = self[key] @@ -92,9 +132,10 @@ def pop(self, key, default=__marker): del self[key] return value - def popitem(self): + def popitem(self) -> tuple[K, V]: """D.popitem() => (k, v), remove and return some (key, value) pair as a 2-tuple; but raise KeyError if D is empty. + """ try: key = next(iter(self)) @@ -104,7 +145,7 @@ def popitem(self): del self[key] return key, value - def clear(self): + def clear(self) -> None: """D.clear() => None. Remove all items from D.""" try: while True: @@ -112,12 +153,13 @@ def clear(self): except KeyError: pass - def update(*args, **kwargs): + def update(*args: Any, **kwargs: V) -> None: """D.update([E, ]**F) => None. Update D from mapping/iterable E and F. If E present and has a .keys() method, does: for k in E: D[k] = E[k] If E present and lacks .keys() method, does: for (k, v) in E: D[k] = v In either case, this is followed by: for k, v in F.items(): D[k] = v + """ if not args: raise TypeError("'update' of 'MutableMapping' object needs an argument") @@ -136,10 +178,16 @@ def update(*args, **kwargs): else: for key, value in other: self[key] = value - for key, value in kwargs.items(): - self[key] = value + for key, value in kwargs.items(): + self[key] = value + + @overload + def setdefault(self, key: K) -> Optional[V]: ... + + @overload + def setdefault(self, key: K, default: T) -> Union[V, T]: ... - def setdefault(self, key, default=None): + def setdefault(self, key: K, default: Any = None) -> Any: """D.setdefault(k[,d]) => D.get(k,d), also set D[k]=d if k not in D""" try: return self[key] diff --git a/src/compas/datastructures/assembly/__init__.py b/src/compas/datastructures/assembly/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/src/compas/datastructures/assembly/assembly.py b/src/compas/datastructures/assembly/assembly.py deleted file mode 100644 index 66a825cf5c9f..000000000000 --- a/src/compas/datastructures/assembly/assembly.py +++ /dev/null @@ -1,234 +0,0 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from compas.datastructures import Datastructure -from compas.datastructures import Graph - -from .exceptions import AssemblyError - - -class Assembly(Datastructure): - """A data structure for managing the connections between different parts of an assembly. - - Parameters - ---------- - name : str, optional - The name of the assembly. - **kwargs : dict, optional - Additional keyword arguments, which are stored in the attributes dict. - - Attributes - ---------- - graph : :class:`compas.datastructures.Graph` - The graph that is used under the hood to store the parts and their connections. - - See Also - -------- - :class:`compas.datastructures.Graph` - :class:`compas.datastructures.Mesh` - :class:`compas.datastructures.VolMesh` - - """ - - DATASCHEMA = { - "type": "object", - "properties": { - "graph": Graph.DATASCHEMA, - "attributes": {"type": "object"}, - }, - "required": ["graph", "attributes"], - } - - @property - def __data__(self): - return { - "graph": self.graph.__data__, - "attributes": self.attributes, - } - - @classmethod - def __from_data__(cls, data): - assembly = cls() - assembly.attributes.update(data["attributes"] or {}) - assembly.graph = Graph.__from_data__(data["graph"]) - assembly._parts = {part.guid: part.key for part in assembly.parts()} # type: ignore - return assembly - - def __init__(self, name=None, **kwargs): - super(Assembly, self).__init__(kwargs, name=name) - self.graph = Graph() - self._parts = {} - - def __str__(self): - tpl = "" - return tpl.format(self.graph.number_of_nodes(), self.graph.number_of_edges()) - - # ========================================================================== - # Constructors - # ========================================================================== - - # ========================================================================== - # Methods - # ========================================================================== - - def add_part(self, part, key=None, **kwargs): - """Add a part to the assembly. - - Parameters - ---------- - part : :class:`compas.datastructures.Part` - The part to add. - key : int | str, optional - The identifier of the part in the assembly. - Note that the key is unique only in the context of the current assembly. - Nested assemblies may have the same `key` value for one of their parts. - Default is None in which case the key will be an automatically assigned integer value. - **kwargs: dict[str, Any], optional - Additional named parameters collected in a dict. - - Returns - ------- - int | str - The identifier of the part in the current assembly graph. - - """ - if part.guid in self._parts: - raise AssemblyError("Part already added to the assembly") - key = self.graph.add_node(key=key, part=part, **kwargs) - part.key = key - self._parts[part.guid] = part.key - return key - - def add_connection(self, a, b, **kwargs): - """Add a connection between two parts. - - Parameters - ---------- - a : :class:`compas.datastructures.Part` - The "from" part. - b : :class:`compas.datastructures.Part` - The "to" part. - **kwargs : dict[str, Any], optional - Attribute dict compiled from named arguments. - - Returns - ------- - tuple[int | str, int | str] - The tuple of node identifiers that identifies the connection. - - Raises - ------ - :class:`AssemblyError` - If `a` and/or `b` are not in the assembly. - - """ - error_msg = "Both parts have to be added to the assembly before a connection can be created." - if a.key is None or b.key is None: - raise AssemblyError(error_msg) - if not self.graph.has_node(a.key) or not self.graph.has_node(b.key): - raise AssemblyError(error_msg) - return self.graph.add_edge(a.key, b.key, **kwargs) - - def delete_part(self, part): - """Remove a part from the assembly. - - Parameters - ---------- - part : :class:`compas.datastructures.Part` - The part to add. - - Returns - ------- - None - - """ - del self._parts[part.guid] - self.graph.delete_node(key=part.key) - - def delete_connection(self, edge): - """Delete a connection between two parts. - - Parameters - ---------- - edge : :class:`compas.datastructures.Part` - The part to add. - - Returns - ------- - None - - """ - self.graph.delete_edge(edge=edge) - - def parts(self): - """The parts of the assembly. - - Yields - ------ - :class:`compas.datastructures.Part` - The individual parts of the assembly. - - """ - for node in self.graph.nodes(): - yield self.graph.node_attribute(node, "part") - - def connections(self, data=False): - """Iterate over the connections between the parts. - - Parameters - ---------- - data : bool, optional - If True, yield the connection attributes in addition to the connection identifiers. - - Yields - ------ - tuple[int | str, int | str] | tuple[tuple[int | str, int | str], dict[str, Any]] - If `data` is False, the next connection identifier (u, v). - If `data` is True, the next connector identifier and its attributes as a ((u, v), attr) tuple. - - """ - return self.graph.edges(data) - - def find(self, guid): - """Find a part in the assembly by its GUID. - - Parameters - ---------- - guid : str - A globally unique identifier. - This identifier is automatically assigned when parts are created. - - Returns - ------- - :class:`compas.datastructures.Part` | None - The identified part, - or None if the part can't be found. - - """ - key = self._parts.get(guid) - - if key is None: - return None - - return self.graph.node_attribute(key, "part") - - def find_by_key(self, key): - """Find a part in the assembly by its key. - - Parameters - ---------- - key : int | str, optional - The identifier of the part in the assembly. - - Returns - ------- - :class:`compas.datastructures.Part` | None - The identified part, - or None if the part can't be found. - - """ - if key not in self.graph.node: - return None - - return self.graph.node_attribute(key, "part") diff --git a/src/compas/datastructures/assembly/exceptions.py b/src/compas/datastructures/assembly/exceptions.py deleted file mode 100644 index e48e5232b854..000000000000 --- a/src/compas/datastructures/assembly/exceptions.py +++ /dev/null @@ -1,6 +0,0 @@ -class AssemblyError(Exception): - pass - - -class FeatureError(Exception): - pass diff --git a/src/compas/datastructures/assembly/part.py b/src/compas/datastructures/assembly/part.py deleted file mode 100644 index 1e25a93db05b..000000000000 --- a/src/compas/datastructures/assembly/part.py +++ /dev/null @@ -1,231 +0,0 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from compas.data import Data -from compas.datastructures import Datastructure -from compas.geometry import Brep -from compas.geometry import Frame -from compas.geometry import Polyhedron - - -class Feature(Data): - """Base class for a feature which may be applied to a :class:`compas.datastructures.Part`.""" - - def apply(self, part): - """Apply this Feature to the given part. - - Parameters - ---------- - part : :class:`compas.datastructures.Part` - The part onto which this feature should be applied. - - """ - raise NotImplementedError - - -class GeometricFeature(Feature): - """Base class for geometric feature which may be applied to a :class:`compas.datastructures.Part`. - - Applies a binary operation on Part's current geometry and the feature's. - - An implementation of this class may offer support for various geometry types by adding an entry to OPERATIONS - mapping the geometry type to its corresponding operation. - - Examples - -------- - >>> from compas.geometry import Brep - >>> from compas.datastructures import Mesh - >>> - >>> def trim_brep_plane(brep, plane): - ... pass - >>> def trim_mesh_plane(mesh, plane): - ... pass - >>> class TrimmingFeature(GeometricFeature): - ... OPERATIONS = {Brep: trim_brep_plane, Mesh: trim_mesh_plane} - ... - ... def __init__(self, trimming_plane): - ... super(TrimmingFeature, self).__init__() - ... self._geometry = trimming_plane - ... - ... def apply(self, part): - ... part_geometry = part.get_geometry(with_features=True) - ... type_ = Brep if isinstance(part_geometry, Brep) else Mesh - ... operation = OPERATIONS[type_] - ... return operation(part_geometry, self._geometry) - >>> - - """ - - OPERATIONS = { - Brep: None, - Polyhedron: None, - } - - def __init__(self, *args, **kwargs): - super(GeometricFeature, self).__init__(*args, **kwargs) - self._geometry = None - - @property - def __data__(self): - return {"geometry": self._geometry} - - @classmethod - def __from_data__(cls, data): - feature = cls() - feature._geometry = data["geometry"] # this will work but is not consistent with validation - return feature - - -class ParametricFeature(Feature): - """Base class for Features that may be applied to the parametric definition of a :class:`compas.datastructures.Part`. - - Examples - -------- - >>> class ExtensionFeature(ParametricFeature): - ... def __init__(self, extend_by): - ... super(ExtensionFeature, self).__init__() - ... self.extend_by = extend_by - ... - ... def apply(self, part): - ... part.length += self._extend_by - ... - ... def restore(self, part): - ... part.length -= self._extend_by - ... - ... def accumulate(self, other): - ... return BeamExtensionFeature(max(self.extend_by, other.extend_by)) - >>> - - """ - - def __init__(self, *args, **kwargs): - super(ParametricFeature, self).__init__(*args, **kwargs) - - def restore(self, part): - """Reverses the effect this ParametricFeature has incured onto the given part. - - Parameters - ---------- - part : :class:`compas.datastructures.Part` - The part onto which this feature has been previously applied and should now be reverted. - - """ - raise NotImplementedError - - def accumulate(self, feature): - """Returns a new ParametricFeature which has the accumulative effect of this and the given feature. - - A TypeError is raised if the given feature is not compatible with this. - - Parameters - ---------- - feature : :class:`compas.datastructures.ParametricFeature` - Another compatible ParametricFeature whose effect should be accumulated with this one's. - - Returns - ------- - :class:`compas.datastructures.ParametricFeatures` - - """ - raise NotImplementedError - - -class Part(Datastructure): - """A data structure for representing assembly parts. - - Parameters - ---------- - name : str, optional - The name of the part. - The name will be stored in :attr:`Part.attributes`. - frame : :class:`compas.geometry.Frame`, optional - The local coordinate system of the part. - - Attributes - ---------- - attributes : dict[str, Any] - General data structure attributes that will be included in the data dict and serialization. - key : int or str - The identifier of the part in the connectivity graph of the parent assembly. - frame : :class:`compas.geometry.Frame` - The local coordinate system of the part. - features : list(:class:`compas.datastructures.Feature`) - The features added to the base shape of the part's geometry. - - """ - - DATASCHEMA = { - "type": "object", - "properties": { - "attributes": {"type": "object"}, - "key": {"type": ["integer", "string"]}, - "frame": Frame.DATASCHEMA, - "features": {"type": "array"}, - }, - "required": ["key", "frame"], - } - - @property - def __data__(self): - return { - "attributes": self.attributes, - "key": self.key, - "frame": self.frame.__data__, - "features": self.features, - } - - @classmethod - def __from_data__(cls, data): - part = cls() - part.attributes.update(data["attributes"] or {}) - part.key = data["key"] - part.frame = Frame.__from_data__(data["frame"]) - part.features = data["features"] or [] - return part - - def __init__(self, name=None, frame=None, **kwargs): - super(Part, self).__init__() - self.attributes = {"name": name or "Part"} - self.attributes.update(kwargs) - self.key = None - self.frame = frame or Frame.worldXY() - self.features = [] - - def get_geometry(self, with_features=False): - """ - Returns a transformed copy of the part's geometry. - - The returned type can be drawn with a scene object. - - Parameters - ---------- - with_features : bool - True if geometry should include all the available features. - - Returns - ------- - :class:`compas.geometry.Geometry` - - """ - raise NotImplementedError - - def clear_features(self, features_to_clear=None): - raise NotImplementedError - - def add_feature(self, feature, apply=False): - """Add a Feature to this Part. - - Parameters - ---------- - feature : :class:`compas.assembly.Feature` - The feature to add - apply : :bool: - If True, feature is also applied. Otherwise, feature is only added and user must call `apply_features`. - - Returns - ------- - None - - """ - raise NotImplementedError diff --git a/src/compas/datastructures/attributes.py b/src/compas/datastructures/attributes.py index 97156bfa5a18..12321b402f45 100644 --- a/src/compas/datastructures/attributes.py +++ b/src/compas/datastructures/attributes.py @@ -1,41 +1,49 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from collections.abc import MutableMapping +from typing import Any +from typing import Iterator +from typing import Mapping -from compas.datastructures._mutablemapping import MutableMapping - -class AttributeView(MutableMapping): +class AttributeView(MutableMapping[str, Any]): """Base class for attribute dict views.""" - def __init__(self, defaults, attr, custom_only=False): - super(AttributeView, self).__init__() + def __init__( + self, + defaults: Mapping[str, Any], + attr: MutableMapping[str, Any], + custom_only: bool = False, + ) -> None: + super().__init__() self.defaults = defaults self.attr = attr self.custom_only = custom_only - def __str__(self): + def __str__(self) -> str: s = [] for k, v in self.items(): s.append("{}: {}".format(repr(k), repr(v))) return "{" + ", ".join(s) + "}" - def __len__(self): + def __len__(self) -> int: + if self.custom_only: + return len(self.attr) return len(set(self.defaults).union(self.attr)) - def __getitem__(self, name): + def __getitem__(self, name: str) -> Any: + if self.custom_only: + return self.attr[name] if name not in self.attr: if name not in self.defaults: - raise KeyError + raise KeyError(name) return self.attr.get(name, self.defaults.get(name)) - def __setitem__(self, name, value): + def __setitem__(self, name: str, value: Any) -> None: self.attr[name] = value - def __delitem__(self, name): + def __delitem__(self, name: str) -> None: del self.attr[name] - def __iter__(self): + def __iter__(self) -> Iterator[str]: if self.custom_only: for name in self.attr: yield name @@ -47,39 +55,34 @@ def __iter__(self): class NodeAttributeView(AttributeView): """Mutable Mapping that provides a read/write view of the custom attributes of a node - combined with the default attributes of all nodes.""" + combined with the default attributes of all nodes. - def __init__(self, defaults, attr, custom_only=False): - super(NodeAttributeView, self).__init__(defaults, attr, custom_only) + """ class VertexAttributeView(AttributeView): """Mutable Mapping that provides a read/write view of the custom attributes of a vertex - combined with the default attributes of all vertices.""" + combined with the default attributes of all vertices. - def __init__(self, defaults, attr, custom_only=False): - super(VertexAttributeView, self).__init__(defaults, attr, custom_only) + """ class EdgeAttributeView(AttributeView): """Mutable Mapping that provides a read/write view of the custom attributes of an edge - combined with the default attributes of all edges.""" + combined with the default attributes of all edges. - def __init__(self, defaults, attr, custom_only=False): - super(EdgeAttributeView, self).__init__(defaults, attr, custom_only) + """ class FaceAttributeView(AttributeView): """Mutable Mapping that provides a read/write view of the custom attributes of a face - combined with the default attributes of all faces.""" + combined with the default attributes of all faces. - def __init__(self, defaults, attr, custom_only=False): - super(FaceAttributeView, self).__init__(defaults, attr, custom_only) + """ class CellAttributeView(AttributeView): """Mutable Mapping that provides a read/write view of the custom attributes of a cell - combined with the default attributes of all faces.""" + combined with the default attributes of all cells. - def __init__(self, defaults, attr, custom_only=False): - super(CellAttributeView, self).__init__(defaults, attr, custom_only) + """ diff --git a/src/compas/datastructures/cell_network/cell_network.py b/src/compas/datastructures/cell_network/cell_network.py index 6d6cb9d173cb..69b01f840f94 100644 --- a/src/compas/datastructures/cell_network/cell_network.py +++ b/src/compas/datastructures/cell_network/cell_network.py @@ -1,10 +1,17 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from ast import literal_eval from random import sample +from typing import Any +from typing import Callable +from typing import Iterable +from typing import Iterator +from typing import Literal +from typing import Mapping +from typing import Optional +from typing import Sequence +from typing import Union +from typing import overload + +from typing_extensions import Self from compas.datastructures import Graph from compas.datastructures import Mesh @@ -13,47 +20,62 @@ from compas.datastructures.attributes import FaceAttributeView from compas.datastructures.attributes import VertexAttributeView from compas.datastructures.datastructure import Datastructure -from compas.files import OBJ +from compas.files import read_obj +from compas.files import weld_obj_data from compas.geometry import Line from compas.geometry import Plane from compas.geometry import Point from compas.geometry import Polygon from compas.geometry import Polyhedron from compas.geometry import Vector -from compas.geometry import add_vectors from compas.geometry import bestfit_plane from compas.geometry import bounding_box from compas.geometry import centroid_points from compas.geometry import centroid_polygon from compas.geometry import centroid_polyhedron from compas.geometry import distance_point_point -from compas.geometry import length_vector from compas.geometry import normal_polygon -from compas.geometry import normalize_vector from compas.geometry import project_point_plane -from compas.geometry import scale_vector -from compas.geometry import subtract_vectors from compas.geometry import volume_polyhedron from compas.itertools import pairwise +from compas.linalg.vectors import add_vectors +from compas.linalg.vectors import length_vector +from compas.linalg.vectors import normalize_vector +from compas.linalg.vectors import scale_vector +from compas.linalg.vectors import subtract_vectors from compas.tolerance import TOL +from .types import AttributeDict +from .types import Cell +from .types import Edge +from .types import Face +from .types import PointCoordinates +from .types import Vertex + +_MISSING = object() + + +def _edge_data_key(edge: Edge) -> Edge: + u, v = edge + return (u, v) if u < v else (v, u) + class CellNetwork(Datastructure): """Geometric implementation of a data structure for a collection of mixed topologic entities such as cells, faces, edges and nodes. Parameters ---------- - default_vertex_attributes: dict, optional + default_vertex_attributes Default values for vertex attributes. - default_edge_attributes: dict, optional + default_edge_attributes Default values for edge attributes. - default_face_attributes: dict, optional + default_face_attributes Default values for face attributes. - default_cell_attributes: dict, optional + default_cell_attributes Default values for cell attributes. - name : str, optional + name The name of the cell network. - **kwargs : dict, optional + **kwargs Additional keyword arguments, which are stored in the attributes dict. Attributes @@ -85,97 +107,9 @@ class CellNetwork(Datastructure): """ - DATASCHEMA = { - "type": "object", - "properties": { - "attributes": {"type": "object"}, - "default_vertex_attributes": {"type": "object"}, - "default_edge_attributes": {"type": "object"}, - "default_face_attributes": {"type": "object"}, - "default_cell_attributes": {"type": "object"}, - "vertex": { - "type": "object", - "patternProperties": {"^[0-9]+$": {"type": "object"}}, - "additionalProperties": False, - }, - "edge": { - "type": "object", - "patternProperties": { - "^[0-9]+$": { - "type": "object", - "patternProperties": {"^[0-9]+$": {"type": "object"}}, - "additionalProperties": False, - } - }, - "additionalProperties": False, - }, - "face": { - "type": "object", - "patternProperties": { - "^[0-9]+$": { - "type": "array", - "items": {"type": "integer", "minimum": 0}, - "minItems": 3, - } - }, - "additionalProperties": False, - }, - "cell": { - "type": "object", - "patternProperties": { - "^[0-9]+$": { - "type": "array", - "minItems": 4, - "items": { - "type": "array", - "minItems": 3, - "items": {"type": "integer", "minimum": 0}, - }, - } - }, - "additionalProperties": False, - }, - "edge_data": { - "type": "object", - "patternProperties": {"^\\([0-9]+(, [0-9]+){3, }\\)$": {"type": "object"}}, - "additionalProperties": False, - }, - "face_data": { - "type": "object", - "patternProperties": {"^\\([0-9]+(, [0-9]+){3, }\\)$": {"type": "object"}}, - "additionalProperties": False, - }, - "cell_data": { - "type": "object", - "patternProperties": {"^[0-9]+$": {"type": "object"}}, - "additionalProperties": False, - }, - "max_vertex": {"type": "number", "minimum": -1}, - "max_face": {"type": "number", "minimum": -1}, - "max_cell": {"type": "number", "minimum": -1}, - }, - "required": [ - "attributes", - "default_vertex_attributes", - "default_edge_attributes", - "default_face_attributes", - "default_cell_attributes", - "vertex", - "edge", - "face", - "cell", - "edge_data", - "face_data", - "cell_data", - "max_vertex", - "max_face", - "max_cell", - ], - } - @property - def __data__(self): - cell = {} + def __data__(self) -> dict[str, Any]: + cell: dict[Cell, list[Face]] = {} for c in self._cell: faces = set() for u in self._cell[c]: @@ -202,7 +136,7 @@ def __data__(self): } @classmethod - def __from_data__(cls, data): + def __from_data__(cls, data: dict[str, Any]) -> Self: cell_network = cls( default_vertex_attributes=data.get("default_vertex_attributes"), default_edge_attributes=data.get("default_edge_attributes"), @@ -222,7 +156,7 @@ def __from_data__(cls, data): edge_data = {literal_eval(k): v for k, v in data.get("edge_data", {}).items()} for u in edge: for v in edge[u]: - attr = edge_data.get(tuple(sorted((int(u), int(v)))), {}) + attr = edge_data.get(_edge_data_key((int(u), int(v))), {}) cell_network.add_edge(int(u), int(v), attr_dict=attr) face_data = data.get("face_data") or {} @@ -239,19 +173,27 @@ def __from_data__(cls, data): return cell_network - def __init__(self, default_vertex_attributes=None, default_edge_attributes=None, default_face_attributes=None, default_cell_attributes=None, name=None, **kwargs): # fmt: skip - super(CellNetwork, self).__init__(kwargs, name=name) + def __init__( + self, + default_vertex_attributes: Optional[AttributeDict] = None, + default_edge_attributes: Optional[AttributeDict] = None, + default_face_attributes: Optional[AttributeDict] = None, + default_cell_attributes: Optional[AttributeDict] = None, + name: Optional[str] = None, + **kwargs: Any, + ) -> None: + super().__init__(kwargs, name=name) self._max_vertex = -1 self._max_face = -1 self._max_cell = -1 - self._vertex = {} - self._edge = {} - self._face = {} - self._plane = {} - self._cell = {} - self._edge_data = {} - self._face_data = {} - self._cell_data = {} + self._vertex: dict[Vertex, AttributeDict] = {} + self._edge: dict[Vertex, dict[Vertex, AttributeDict]] = {} + self._face: dict[Face, list[Vertex]] = {} + self._plane: dict[Vertex, dict[Vertex, dict[Face, Optional[Cell]]]] = {} + self._cell: dict[Cell, dict[Vertex, dict[Vertex, Face]]] = {} + self._edge_data: dict[Edge, AttributeDict] = {} + self._face_data: dict[Face, AttributeDict] = {} + self._cell_data: dict[Cell, AttributeDict] = {} self.default_vertex_attributes = {"x": 0.0, "y": 0.0, "z": 0.0} self.default_edge_attributes = {} self.default_face_attributes = {} @@ -265,7 +207,7 @@ def __init__(self, default_vertex_attributes=None, default_edge_attributes=None, if default_cell_attributes: self.default_cell_attributes.update(default_cell_attributes) - def __str__(self): + def __str__(self) -> str: tpl = "" return tpl.format( self.number_of_vertices(), @@ -278,7 +220,7 @@ def __str__(self): # Helpers # -------------------------------------------------------------------------- - def clear(self): + def clear(self) -> None: """Clear all the volmesh data. Returns @@ -291,6 +233,7 @@ def clear(self): del self._face del self._cell del self._plane + del self._edge_data del self._face_data del self._cell_data self._vertex = {} @@ -298,18 +241,19 @@ def clear(self): self._face = {} self._cell = {} self._plane = {} + self._edge_data = {} self._face_data = {} self._cell_data = {} self._max_vertex = -1 self._max_face = -1 self._max_cell = -1 - def vertex_sample(self, size=1): + def vertex_sample(self, size: int = 1) -> list[Vertex]: """Get the identifiers of a set of random vertices. Parameters ---------- - size : int, optional + size The size of the sample. Returns @@ -319,17 +263,17 @@ def vertex_sample(self, size=1): See Also -------- - :meth:`edge_sample`, :meth:`face_sample`, :meth:`cell_sample` + edge_sample, face_sample, cell_sample """ return sample(list(self.vertices()), size) - def edge_sample(self, size=1): + def edge_sample(self, size: int = 1) -> list[Edge]: """Get the identifiers of a set of random edges. Parameters ---------- - size : int, optional + size The size of the sample. Returns @@ -339,17 +283,17 @@ def edge_sample(self, size=1): See Also -------- - :meth:`vertex_sample`, :meth:`face_sample`, :meth:`cell_sample` + vertex_sample, face_sample, cell_sample """ return sample(list(self.edges()), size) - def face_sample(self, size=1): + def face_sample(self, size: int = 1) -> list[Face]: """Get the identifiers of a set of random faces. Parameters ---------- - size : int, optional + size The size of the sample. Returns @@ -359,17 +303,17 @@ def face_sample(self, size=1): See Also -------- - :meth:`vertex_sample`, :meth:`edge_sample`, :meth:`cell_sample` + vertex_sample, edge_sample, cell_sample """ return sample(list(self.faces()), size) - def cell_sample(self, size=1): + def cell_sample(self, size: int = 1) -> list[Cell]: """Get the identifiers of a set of random cells. Parameters ---------- - size : int, optional + size The size of the sample. Returns @@ -379,12 +323,12 @@ def cell_sample(self, size=1): See Also -------- - :meth:`vertex_sample`, :meth:`edge_sample`, :meth:`face_sample` + vertex_sample, edge_sample, face_sample """ return sample(list(self.cells()), size) - def vertex_index(self): + def vertex_index(self) -> dict[Vertex, int]: """Returns a dictionary that maps vertex identifiers to the corresponding index in a vertex list or array. Returns @@ -394,12 +338,12 @@ def vertex_index(self): See Also -------- - :meth:`index_vertex` + index_vertex """ return {key: index for index, key in enumerate(self.vertices())} - def index_vertex(self): + def index_vertex(self) -> dict[int, Vertex]: """Returns a dictionary that maps the indices of a vertex list to vertex identifiers. Returns @@ -409,19 +353,19 @@ def index_vertex(self): See Also -------- - :meth:`vertex_index` + vertex_index """ return dict(enumerate(self.vertices())) - def vertex_gkey(self, precision=None): + def vertex_gkey(self, precision: Optional[int] = None) -> dict[Vertex, str]: """Returns a dictionary that maps vertex identifiers to the corresponding *geometric key* up to a certain precision. Parameters ---------- - precision : int, optional + precision Precision for converting numbers to strings. - Default is :attr:`TOL.precision`. + Default is `TOL.precision`. Returns ------- @@ -430,21 +374,21 @@ def vertex_gkey(self, precision=None): See Also -------- - :meth:`gkey_vertex` + gkey_vertex """ gkey = TOL.geometric_key xyz = self.vertex_coordinates return {vertex: gkey(xyz(vertex), precision) for vertex in self.vertices()} - def gkey_vertex(self, precision=None): + def gkey_vertex(self, precision: Optional[int] = None) -> dict[str, Vertex]: """Returns a dictionary that maps *geometric keys* of a certain precision to the corresponding vertex identifiers. Parameters ---------- - precision : int, optional + precision Precision for converting numbers to strings. - Default is :attr:`TOL.precision`. + Default is `TOL.precision`. Returns ------- @@ -453,7 +397,7 @@ def gkey_vertex(self, precision=None): See Also -------- - :meth:`vertex_gkey` + vertex_gkey """ gkey = TOL.geometric_key @@ -464,18 +408,23 @@ def gkey_vertex(self, precision=None): # Builders # -------------------------------------------------------------------------- - def add_vertex(self, key=None, attr_dict=None, **kwattr): + def add_vertex( + self, + key: Optional[Vertex] = None, + attr_dict: Optional[Mapping[str, Any]] = None, + **kwattr: Any, + ) -> Vertex: """Add a vertex and specify its attributes. Parameters ---------- - key : int, optional + key The identifier of the vertex. Defaults to None. - attr_dict : dict, optional + attr_dict A dictionary of vertex attributes. Defaults to None. - **kwattr : dict, optional + **kwattr A dictionary of additional attributes compiled of remaining named arguments. Returns @@ -485,7 +434,7 @@ def add_vertex(self, key=None, attr_dict=None, **kwattr): See Also -------- - :meth:`add_face`, :meth:`add_cell`, :meth:`add_edge` + add_face, add_cell, add_edge """ if key is None: @@ -499,24 +448,30 @@ def add_vertex(self, key=None, attr_dict=None, **kwattr): self._edge[key] = {} self._plane[key] = {} - attr = attr_dict or {} + attr = dict(attr_dict or {}) attr.update(kwattr) self._vertex[key].update(attr) return key - def add_edge(self, u, v, attr_dict=None, **kwattr): + def add_edge( + self, + u: Vertex, + v: Vertex, + attr_dict: Optional[Mapping[str, Any]] = None, + **kwattr: Any, + ) -> Edge: """Add an edge and specify its attributes. Parameters ---------- - u : int + u The identifier of the first node of the edge. - v : int + v The identifier of the second node of the edge. - attr_dict : dict[str, Any], optional + attr_dict A dictionary of edge attributes. - **kwattr : dict[str, Any], optional + **kwattr A dictionary of additional attributes compiled of remaining named arguments. Returns @@ -538,25 +493,19 @@ def add_edge(self, u, v, attr_dict=None, **kwattr): if u not in self._vertex: raise ValueError("Cannot add edge {}, {} has no vertex {}".format((u, v), self.name, u)) if v not in self._vertex: - raise ValueError("Cannot add edge {}, {} has no vertex {}".format((u, v), self.name, u)) + raise ValueError("Cannot add edge {}, {} has no vertex {}".format((u, v), self.name, v)) - attr = attr_dict or {} + attr = dict(attr_dict or {}) attr.update(kwattr) - uv = tuple(sorted((u, v))) + uv = _edge_data_key((u, v)) data = self._edge_data.get(uv, {}) data.update(attr) self._edge_data[uv] = data - # @Romana - # should the data not be added to this edge as well? - # if that is the case, should we not store the data in an edge_data dict to avoid duplication? - # True, but then _edge does not hold anything, we could also store the attr right here. - # but I leave this to you as you have a better overview - if v not in self._edge[u]: - self._edge[v][u] = {} + self._edge[u][v] = {} if v not in self._plane[u]: self._plane[u][v] = {} if u not in self._plane[v]: @@ -564,19 +513,24 @@ def add_edge(self, u, v, attr_dict=None, **kwattr): return u, v - def add_face(self, vertices, fkey=None, attr_dict=None, **kwattr): + def add_face( + self, + vertices: Sequence[Vertex], + fkey: Optional[Face] = None, + attr_dict: Optional[Mapping[str, Any]] = None, + **kwattr: Any, + ) -> Face: """Add a face to the cell network. Parameters ---------- - vertices : list[int] + vertices A list of ordered vertex keys representing the face. - For every vertex that does not yet exist, a new vertex is created. - fkey : int, optional + fkey The face identifier. - attr_dict : dict[str, Any], optional + attr_dict dictionary of halfface attributes. - **kwattr : dict[str, Any], optional + **kwattr A dictionary of additional attributes compiled of remaining named arguments. Returns @@ -584,9 +538,15 @@ def add_face(self, vertices, fkey=None, attr_dict=None, **kwattr): int The key of the face. + Raises + ------ + ValueError + If the face has fewer than three vertices, or if a vertex is not part + of the cell network. + See Also -------- - :meth:`add_vertex`, :meth:`add_cell`, :meth:`add_edge` + add_vertex, add_cell, add_edge Notes ----- @@ -603,12 +563,16 @@ def add_face(self, vertices, fkey=None, attr_dict=None, **kwattr): """ if len(vertices) < 3: - return + raise ValueError("A face should have at least 3 vertices.") if vertices[-1] == vertices[0]: vertices = vertices[:-1] vertices = [int(key) for key in vertices] + missing = [vertex for vertex in vertices if vertex not in self._vertex] + if missing: + raise ValueError(f"The following vertices are not part of the cell network: {missing}") + if fkey is None: fkey = self._max_face = self._max_face + 1 fkey = int(fkey) @@ -617,7 +581,7 @@ def add_face(self, vertices, fkey=None, attr_dict=None, **kwattr): self._face[fkey] = vertices - attr = attr_dict or {} + attr = dict(attr_dict or {}) attr.update(kwattr) for name, value in attr.items(): self.face_attribute(fkey, name, value) @@ -635,7 +599,7 @@ def add_face(self, vertices, fkey=None, attr_dict=None, **kwattr): return fkey - def _faces_to_unified_mesh(self, faces): + def _faces_to_unified_mesh(self, faces: Iterable[Face]) -> Optional[Mesh]: faces = list(set(faces)) # 0. Check if all the faces have been added for face in faces: @@ -649,14 +613,20 @@ def _faces_to_unified_mesh(self, faces): return None return mesh - def is_faces_closed(self, faces): + def is_faces_closed(self, faces: Iterable[Face]) -> bool: """Checks if the faces form a closed cell.""" mesh = self._faces_to_unified_mesh(faces) if mesh: return True return False - def add_cell(self, faces, ckey=None, attr_dict=None, **kwattr): + def add_cell( + self, + faces: Iterable[Face], + ckey: Optional[Cell] = None, + attr_dict: Optional[Mapping[str, Any]] = None, + **kwattr: Any, + ) -> Cell: """Add a cell to the cell network object. In order to add a valid cell to the network, the faces must form a closed mesh. @@ -664,13 +634,13 @@ def add_cell(self, faces, ckey=None, attr_dict=None, **kwattr): Parameters ---------- - faces : list[int] + faces The face keys of the cell. - ckey : int, optional + ckey The cell identifier. - attr_dict : dict[str, Any], optional + attr_dict A dictionary of cell attributes. - **kwattr : dict[str, Any], optional + **kwattr A dictionary of additional attributes compiled of remaining named arguments. Returns @@ -695,6 +665,7 @@ def add_cell(self, faces, ckey=None, attr_dict=None, **kwattr): highest integer key value, then the highest integer value is updated accordingly. """ + faces = list(faces) mesh = self._faces_to_unified_mesh(faces) if mesh is None: raise ValueError("Cannot add cell, faces {} do not form a closed cell.".format(faces)) @@ -713,7 +684,7 @@ def add_cell(self, faces, ckey=None, attr_dict=None, **kwattr): self._cell[ckey] = {} - attr = attr_dict or {} + attr = dict(attr_dict or {}) attr.update(kwattr) for name, value in attr.items(): self.cell_attribute(ckey, name, value) @@ -746,18 +717,18 @@ def add_cell(self, faces, ckey=None, attr_dict=None, **kwattr): # See Also # -------- - # :meth:`delete_halfface`, :meth:`delete_cell` + # delete_halfface, delete_cell # """ # for cell in self.vertex_cells(vertex): # self.delete_cell(cell) - def delete_edge(self, edge): + def delete_edge(self, edge: Edge) -> None: """Delete an edge from the cell network. Parameters ---------- - edge : tuple + edge The identifier of the edge. Returns @@ -784,18 +755,22 @@ def delete_edge(self, edge): del self._plane[u][v] if u in self._plane[v]: del self._plane[v][u] + key = _edge_data_key(edge) + if key in self._edge_data: + del self._edge_data[key] - def delete_face(self, face): + def delete_face(self, face: Face) -> None: """Delete a face from the cell network. Parameters ---------- - face : int + face The identifier of the face. Returns ------- None + """ vertices = self.face_vertices(face) # check first @@ -813,12 +788,12 @@ def delete_face(self, face): if face in self._face_data: del self._face_data[face] - def delete_cell(self, cell): + def delete_cell(self, cell: Cell) -> None: """Delete a cell from the cell network. Parameters ---------- - cell : int + cell The identifier of the cell. Returns @@ -827,7 +802,7 @@ def delete_cell(self, cell): See Also -------- - :meth:`delete_vertex`, :meth:`delete_halfface` + delete_vertex, delete_halfface """ # remove the cell from the faces @@ -888,11 +863,11 @@ def delete_cell(self, cell): # Returns # ------- - # :class:`compas.datastructures.VolMesh` + # VolMesh # See Also # -------- - # :meth:`from_obj`, :meth:`from_vertices_and_cells` + # from_obj, from_vertices_and_cells # """ # dy = dy or dx @@ -929,32 +904,32 @@ def delete_cell(self, cell): # return cls.from_vertices_and_cells(vertices, cells) @classmethod - def from_obj(cls, filepath, precision=None): + def from_obj(cls, filepath: Any, precision: Optional[int] = None) -> Self: """Construct a cell network object from the data described in an OBJ file. Parameters ---------- - filepath : path string | file-like object | URL string + filepath A path, a file-like object or a URL pointing to a file. - precision: str, optional + precision The precision of the geometric map that is used to connect the lines. Returns ------- - :class:`compas.datastructures.VolMesh` + VolMesh A cell network object. See Also -------- - :meth:`to_obj` - :meth:`from_meshgrid`, :meth:`from_vertices_and_cells` - :class:`compas.files.OBJ` + to_obj + from_meshgrid, from_vertices_and_cells + read_obj """ - obj = OBJ(filepath, precision) - vertices = obj.parser.vertices or [] # type: ignore - faces = obj.parser.faces or [] # type: ignore - groups = obj.parser.groups or [] # type: ignore + data = weld_obj_data(read_obj(filepath), precision) + vertices = data.vertices + faces = data.faces + groups = data.groups cells = [] for name in groups: group = groups[name] @@ -968,25 +943,29 @@ def from_obj(cls, filepath, precision=None): return cls.from_vertices_and_cells(vertices, cells) @classmethod - def from_vertices_and_cells(cls, vertices, cells): + def from_vertices_and_cells( + cls, + vertices: Sequence[PointCoordinates], + cells: Sequence[Sequence[Sequence[Vertex]]], + ) -> Self: """Construct a cell network object from vertices and cells. Parameters ---------- - vertices : list[list[float]] + vertices Ordered list of vertices, represented by their XYZ coordinates. - cells : list[list[list[int]]] + cells List of cells defined by their faces. Returns ------- - :class:`compas.datastructures.VolMesh` + VolMesh A cell network object. See Also -------- - :meth:`to_vertices_and_cells` - :meth:`from_obj` + to_vertices_and_cells + from_obj """ cellnetwork = cls() @@ -994,8 +973,8 @@ def from_vertices_and_cells(cls, vertices, cells): cellnetwork.add_vertex(x=x, y=y, z=z) for cell in cells: faces = [] - for vertices in cell: - face = cellnetwork.add_face(vertices) + for face_vertices in cell: + face = cellnetwork.add_face(face_vertices) faces.append(face) cellnetwork.add_cell(faces) return cellnetwork @@ -1024,7 +1003,7 @@ def from_vertices_and_cells(cls, vertices, cells): # See Also # -------- - # :meth:`from_obj` + # from_obj # Warnings # -------- @@ -1032,8 +1011,7 @@ def from_vertices_and_cells(cls, vertices, cells): # the faces to the file. # """ - # obj = OBJ(filepath, precision=precision) - # obj.write(self, **kwargs) + # write_obj(filepath, self, precision=precision, **kwargs) # def to_vertices_and_cells(self): # """Return the vertices and cells of a cell network. @@ -1047,7 +1025,7 @@ def from_vertices_and_cells(cls, vertices, cells): # See Also # -------- - # :meth:`from_vertices_and_cells` + # from_vertices_and_cells # """ # vertex_index = self.vertex_index() @@ -1060,47 +1038,48 @@ def from_vertices_and_cells(cls, vertices, cells): # cells.append(faces) # return vertices, cells - def edges_to_graph(self): + def edges_to_graph(self) -> Graph: """Convert the edges of the cell network to a graph. Returns ------- - :class:`compas.datastructures.Graph` + Graph A graph object. """ graph = Graph() for vertex, attr in self.vertices(data=True): x, y, z = self.vertex_coordinates(vertex) - graph.add_node(key=vertex, x=x, y=y, z=z, attr_dict=attr) + graph.add_node(key=vertex, x=x, y=y, z=z, attr_dict=dict(attr)) for (u, v), attr in self.edges(data=True): - graph.add_edge(u, v, attr_dict=attr) + graph.add_edge(u, v, attr_dict=dict(attr)) return graph - def cells_to_graph(self): + def cells_to_graph(self) -> Graph: """Convert the cells the cell network to a graph. Returns ------- - :class:`compas.datastructures.Graph` + Graph A graph object. """ graph = Graph() for cell, attr in self.cells(data=True): x, y, z = self.cell_centroid(cell) - graph.add_node(key=cell, x=x, y=y, z=z, attr_dict=attr) + graph.add_node(key=cell, x=x, y=y, z=z, attr_dict=dict(attr)) for cell in self.cells(): for nbr in self.cell_neighbors(cell): - graph.add_edge(*sorted([cell, nbr])) + u, v = _edge_data_key((cell, nbr)) + graph.add_edge(u, v) return graph - def cell_to_vertices_and_faces(self, cell): + def cell_to_vertices_and_faces(self, cell: Cell) -> tuple[list[list[float]], list[list[Vertex]]]: """Return the vertices and faces of a cell. Parameters ---------- - cell : int + cell Identifier of the cell. Returns @@ -1112,7 +1091,7 @@ def cell_to_vertices_and_faces(self, cell): See Also -------- - :meth:`cell_to_mesh` + cell_to_mesh """ vertices = self.cell_vertices(cell) @@ -1124,41 +1103,42 @@ def cell_to_vertices_and_faces(self, cell): faces.append([vertex_index[vertex] for vertex in self.cell_face_vertices(cell, face)]) return vertices, faces - def cell_to_mesh(self, cell): + def cell_to_mesh(self, cell: Cell) -> Mesh: """Construct a mesh object from from a cell of a cell network. Parameters ---------- - cell : int + cell Identifier of the cell. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A mesh object. See Also -------- - :meth:`cell_to_vertices_and_faces` + cell_to_vertices_and_faces """ vertices, faces = self.cell_to_vertices_and_faces(cell) return Mesh.from_vertices_and_faces(vertices, faces) - def faces_to_mesh(self, faces, data=False): + def faces_to_mesh(self, faces: Iterable[Face], data: bool = False) -> Mesh: """Construct a mesh from a list of faces. Parameters ---------- - faces : list + faces A list of face identifiers. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A mesh. """ + faces = list(faces) faces_vertices = [self.face_vertices(face) for face in faces] mesh = Mesh() for fkey, vertices in zip(faces, faces_vertices): @@ -1171,7 +1151,7 @@ def faces_to_mesh(self, faces, data=False): mesh.add_face(vertices, fkey=fkey) return mesh - def vertices_to_points(self): + def vertices_to_points(self) -> list[list[float]]: """Convert the vertices of the cell network to a collection of points. Returns @@ -1186,18 +1166,18 @@ def vertices_to_points(self): # General # -------------------------------------------------------------------------- - def centroid(self): + def centroid(self) -> Point: """Compute the centroid of the cell network. Returns ------- - :class:`compas.geometry.Point` + Point The point at the centroid. """ return Point(*centroid_points([self.vertex_coordinates(vertex) for vertex in self.vertices()])) - def aabb(self): + def aabb(self) -> list[list[float]]: """Calculate the axis aligned bounding box of the mesh. Returns @@ -1209,7 +1189,7 @@ def aabb(self): xyz = self.vertices_attributes("xyz") return bounding_box(xyz) - def number_of_vertices(self): + def number_of_vertices(self) -> int: """Count the number of vertices in the cell network. Returns @@ -1219,12 +1199,12 @@ def number_of_vertices(self): See Also -------- - :meth:`number_of_edges`, :meth:`number_of_faces`, :meth:`number_of_cells` + number_of_edges, number_of_faces, number_of_cells """ return len(list(self.vertices())) - def number_of_edges(self): + def number_of_edges(self) -> int: """Count the number of edges in the cell network. Returns @@ -1234,12 +1214,12 @@ def number_of_edges(self): See Also -------- - :meth:`number_of_vertices`, :meth:`number_of_faces`, :meth:`number_of_cells` + number_of_vertices, number_of_faces, number_of_cells """ return len(list(self.edges())) - def number_of_faces(self): + def number_of_faces(self) -> int: """Count the number of faces in the cell network. Returns @@ -1249,12 +1229,12 @@ def number_of_faces(self): See Also -------- - :meth:`number_of_vertices`, :meth:`number_of_edges`, :meth:`number_of_cells` + number_of_vertices, number_of_edges, number_of_cells """ return len(list(self.faces())) - def number_of_cells(self): + def number_of_cells(self) -> int: """Count the number of faces in the cell network. Returns @@ -1264,12 +1244,12 @@ def number_of_cells(self): See Also -------- - :meth:`number_of_vertices`, :meth:`number_of_edges`, :meth:`number_of_faces` + number_of_vertices, number_of_edges, number_of_faces """ return len(list(self.cells())) - def is_valid(self): + def is_valid(self) -> bool: """Verify that the cell network is valid. Returns @@ -1278,6 +1258,11 @@ def is_valid(self): True if the cell network is valid. False otherwise. + Raises + ------ + NotImplementedError + This validation method is not implemented yet. + """ raise NotImplementedError @@ -1285,23 +1270,33 @@ def is_valid(self): # Vertex Accessors # -------------------------------------------------------------------------- - def vertices(self, data=False): + @overload + def vertices(self, data: Literal[False] = False) -> Iterator[Vertex]: ... + + @overload + def vertices(self, data: Literal[True]) -> Iterator[tuple[Vertex, VertexAttributeView]]: ... + + @overload + def vertices(self, data: bool) -> Iterator[Union[Vertex, tuple[Vertex, VertexAttributeView]]]: ... + + def vertices(self, data: bool = False) -> Iterator[Any]: """Iterate over the vertices of the cell network. Parameters ---------- - data : bool, optional + data If True, yield the vertex attributes in addition to the vertex identifiers. Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next vertex identifier. - If `data` is True, the next vertex as a (vertex, attr) a tuple. + int + The vertex identifier if `data` is `False`. + tuple[int, VertexAttributeView] + The vertex identifier and its attributes if `data` is `True`. See Also -------- - :meth:`edges`, :meth:`faces`, :meth:`cells` + edges, faces, cells """ for vertex in self._vertex: @@ -1310,33 +1305,71 @@ def vertices(self, data=False): else: yield vertex, self.vertex_attributes(vertex) - def vertices_where(self, conditions=None, data=False, **kwargs): + @overload + def vertices_where( + self, + conditions: Optional[Mapping[str, Any]] = None, + data: Literal[False] = False, + **kwargs: Any, + ) -> Iterator[Vertex]: ... + + @overload + def vertices_where( + self, + conditions: Optional[Mapping[str, Any]], + data: Literal[True], + **kwargs: Any, + ) -> Iterator[tuple[Vertex, VertexAttributeView]]: ... + + @overload + def vertices_where( + self, + *, + data: Literal[True], + **kwargs: Any, + ) -> Iterator[tuple[Vertex, VertexAttributeView]]: ... + + @overload + def vertices_where( + self, + conditions: Optional[Mapping[str, Any]], + data: bool, + **kwargs: Any, + ) -> Iterator[Union[Vertex, tuple[Vertex, VertexAttributeView]]]: ... + + def vertices_where( + self, + conditions: Optional[Mapping[str, Any]] = None, + data: bool = False, + **kwargs: Any, + ) -> Iterator[Any]: """Get vertices for which a certain condition or set of conditions is true. Parameters ---------- - conditions : dict, optional + conditions A set of conditions in the form of key-value pairs. The keys should be attribute names. The values can be attribute values or ranges of attribute values in the form of min/max pairs. - data : bool, optional + data If True, yield the vertex attributes in addition to the identifiers. - **kwargs : dict[str, Any], optional + **kwargs Additional conditions provided as named function arguments. Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next vertex that matches the condition. - If `data` is True, the next vertex and its attributes. + int + A matching vertex identifier if `data` is `False`. + tuple[int, VertexAttributeView] + A matching vertex identifier and its attributes if `data` is `True`. See Also -------- - :meth:`vertices_where_predicate` - :meth:`edges_where`, :meth:`faces_where`, :meth:`cells_where` + vertices_where_predicate + edges_where, faces_where, cells_where """ - conditions = conditions or {} + conditions = dict(conditions or {}) conditions.update(kwargs) for key, attr in self.vertices(True): @@ -1354,7 +1387,7 @@ def vertices_where(self, conditions=None, data=False, **kwargs): if value not in val: is_match = False break - break + continue if isinstance(value, (tuple, list)): minval, maxval = value @@ -1375,7 +1408,7 @@ def vertices_where(self, conditions=None, data=False, **kwargs): if value not in attr[name]: is_match = False break - break + continue if isinstance(value, (tuple, list)): minval, maxval = value @@ -1393,27 +1426,53 @@ def vertices_where(self, conditions=None, data=False, **kwargs): else: yield key - def vertices_where_predicate(self, predicate, data=False): + @overload + def vertices_where_predicate( + self, + predicate: Callable[[Vertex, VertexAttributeView], bool], + data: Literal[False] = False, + ) -> Iterator[Vertex]: ... + + @overload + def vertices_where_predicate( + self, + predicate: Callable[[Vertex, VertexAttributeView], bool], + data: Literal[True], + ) -> Iterator[tuple[Vertex, VertexAttributeView]]: ... + + @overload + def vertices_where_predicate( + self, + predicate: Callable[[Vertex, VertexAttributeView], bool], + data: bool, + ) -> Iterator[Union[Vertex, tuple[Vertex, VertexAttributeView]]]: ... + + def vertices_where_predicate( + self, + predicate: Callable[[Vertex, VertexAttributeView], bool], + data: bool = False, + ) -> Iterator[Any]: """Get vertices for which a certain condition or set of conditions is true using a lambda function. Parameters ---------- - predicate : callable + predicate The condition you want to evaluate. The callable takes 2 parameters: the vertex identifier and the vertex attributes, and should return True or False. - data : bool, optional + data If True, yield the vertex attributes in addition to the identifiers. Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next vertex that matches the condition. - If `data` is True, the next vertex and its attributes. + int + A matching vertex identifier if `data` is `False`. + tuple[int, VertexAttributeView] + A matching vertex identifier and its attributes if `data` is `True`. See Also -------- - :meth:`vertices_where` - :meth:`edges_where_predicate`, :meth:`faces_where_predicate`, :meth:`cells_where_predicate` + vertices_where + edges_where_predicate, faces_where_predicate, cells_where_predicate """ for key, attr in self.vertices(True): @@ -1427,14 +1486,18 @@ def vertices_where_predicate(self, predicate, data=False): # Vertex Attributes # -------------------------------------------------------------------------- - def update_default_vertex_attributes(self, attr_dict=None, **kwattr): + def update_default_vertex_attributes( + self, + attr_dict: Optional[Mapping[str, Any]] = None, + **kwattr: Any, + ) -> None: """Update the default vertex attributes. Parameters ---------- - attr_dict : dict[str, Any], optional + attr_dict A dictionary of attributes with their default values. - **kwattr : dict[str, Any], optional + **kwattr A dictionary of additional attributes compiled of remaining named arguments. Returns @@ -1443,35 +1506,41 @@ def update_default_vertex_attributes(self, attr_dict=None, **kwattr): See Also -------- - :meth:`update_default_edge_attributes`, :meth:`update_default_face_attributes`, :meth:`update_default_cell_attributes` + update_default_edge_attributes, update_default_face_attributes, update_default_cell_attributes Notes ----- Named arguments overwrite correpsonding name-value pairs in the attribute dictionary. """ - if not attr_dict: - attr_dict = {} + attr_dict = dict(attr_dict or {}) attr_dict.update(kwattr) self.default_vertex_attributes.update(attr_dict) - def vertex_attribute(self, vertex, name, value=None): + @overload + def vertex_attribute(self, vertex: Vertex, name: str) -> Any: ... + + @overload + def vertex_attribute(self, vertex: Vertex, name: str, value: Any) -> None: ... + + def vertex_attribute(self, vertex: Vertex, name: str, value: Any = _MISSING) -> Any: """Get or set an attribute of a vertex. Parameters ---------- - vertex : int + vertex The vertex identifier. - name : str + name The name of the attribute - value : object, optional + value The value of the attribute. Returns ------- - object | None - The value of the attribute, - or None when the function is used as a "setter". + Any + The attribute value when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -1480,14 +1549,14 @@ def vertex_attribute(self, vertex, name, value=None): See Also -------- - :meth:`unset_vertex_attribute` - :meth:`vertex_attributes`, :meth:`vertices_attribute`, :meth:`vertices_attributes` - :meth:`edge_attribute`, :meth:`face_attribute`, :meth:`cell_attribute` + unset_vertex_attribute + vertex_attributes, vertices_attribute, vertices_attributes + edge_attribute, face_attribute, cell_attribute """ if vertex not in self._vertex: raise KeyError(vertex) - if value is not None: + if value is not _MISSING: self._vertex[vertex][name] = value return None if name in self._vertex[vertex]: @@ -1496,14 +1565,14 @@ def vertex_attribute(self, vertex, name, value=None): if name in self.default_vertex_attributes: return self.default_vertex_attributes[name] - def unset_vertex_attribute(self, vertex, name): + def unset_vertex_attribute(self, vertex: Vertex, name: str) -> None: """Unset the attribute of a vertex. Parameters ---------- - vertex : int + vertex The vertex identifier. - name : str + name The name of the attribute. Returns @@ -1517,7 +1586,7 @@ def unset_vertex_attribute(self, vertex, name): See Also -------- - :meth:`vertex_attribute` + vertex_attribute Notes ----- @@ -1528,26 +1597,43 @@ def unset_vertex_attribute(self, vertex, name): if name in self._vertex[vertex]: del self._vertex[vertex][name] - def vertex_attributes(self, vertex, names=None, values=None): + @overload + def vertex_attributes(self, vertex: Vertex, names: None = None, values: None = None) -> VertexAttributeView: ... + + @overload + def vertex_attributes(self, vertex: Vertex, names: None, values: Sequence[Any]) -> VertexAttributeView: ... + + @overload + def vertex_attributes(self, vertex: Vertex, names: Sequence[str], values: None = None) -> list[Any]: ... + + @overload + def vertex_attributes(self, vertex: Vertex, names: Sequence[str], values: Sequence[Any]) -> None: ... + + def vertex_attributes( + self, + vertex: Vertex, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + ) -> Any: """Get or set multiple attributes of a vertex. Parameters ---------- - vertex : int + vertex The identifier of the vertex. - names : list[str], optional + names A list of attribute names. - values : list[Any], optional + values A list of attribute values. Returns ------- - dict[str, Any] | list[Any] | None - If the parameter `names` is empty, - the function returns a dictionary of all attribute name-value pairs of the vertex. - If the parameter `names` is not empty, - the function returns a list of the values corresponding to the requested attribute names. - The function returns None if it is used as a "setter". + VertexAttributeView + All attributes when `names` is not provided. + list[Any] + The requested attribute values when `names` is provided and `values` is not provided. + None + When both `names` and `values` are provided. Raises ------ @@ -1556,8 +1642,8 @@ def vertex_attributes(self, vertex, names=None, values=None): See Also -------- - :meth:`vertex_attribute`, :meth:`vertices_attribute`, :meth:`vertices_attributes` - :meth:`edge_attributes`, :meth:`face_attributes`, :meth:`cell_attributes` + vertex_attribute, vertices_attribute, vertices_attributes + edge_attributes, face_attributes, cell_attributes """ if vertex not in self._vertex: @@ -1581,24 +1667,46 @@ def vertex_attributes(self, vertex, names=None, values=None): values.append(None) return values - def vertices_attribute(self, name, value=None, keys=None): + @overload + def vertices_attribute( + self, + name: str, + *, + keys: Optional[Iterable[Vertex]] = None, + ) -> list[Any]: ... + + @overload + def vertices_attribute( + self, + name: str, + value: Any, + keys: Optional[Iterable[Vertex]] = None, + ) -> None: ... + + def vertices_attribute( + self, + name: str, + value: Any = _MISSING, + keys: Optional[Iterable[Vertex]] = None, + ) -> Optional[list[Any]]: """Get or set an attribute of multiple vertices. Parameters ---------- - name : str + name The name of the attribute. - value : object, optional + value The value of the attribute. Default is None. - keys : list[int], optional + keys A list of vertex identifiers. Returns ------- - list[Any] | None - The value of the attribute for each vertex, - or None if the function is used as a "setter". + list[Any] + The attribute values when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -1607,39 +1715,76 @@ def vertices_attribute(self, name, value=None, keys=None): See Also -------- - :meth:`vertex_attribute`, :meth:`vertex_attributes`, :meth:`vertices_attributes` - :meth:`edges_attribute`, :meth:`faces_attribute`, :meth:`cells_attribute` + vertex_attribute, vertex_attributes, vertices_attributes + edges_attribute, faces_attribute, cells_attribute """ - vertices = keys or self.vertices() - if value is not None: + vertices = self.vertices() if keys is None else keys + if value is not _MISSING: for vertex in vertices: self.vertex_attribute(vertex, name, value) return return [self.vertex_attribute(vertex, name) for vertex in vertices] - def vertices_attributes(self, names=None, values=None, keys=None): + @overload + def vertices_attributes( + self, + names: None = None, + values: None = None, + keys: Optional[Iterable[Vertex]] = None, + ) -> list[VertexAttributeView]: ... + + @overload + def vertices_attributes( + self, + names: Sequence[str], + values: None = None, + keys: Optional[Iterable[Vertex]] = None, + ) -> list[list[Any]]: ... + + @overload + def vertices_attributes( + self, + names: Sequence[str], + values: Sequence[Any], + keys: Optional[Iterable[Vertex]] = None, + ) -> None: ... + + @overload + def vertices_attributes( + self, + names: None, + values: Sequence[Any], + keys: Optional[Iterable[Vertex]] = None, + ) -> None: ... + + def vertices_attributes( + self, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + keys: Optional[Iterable[Vertex]] = None, + ) -> Optional[list[Any]]: """Get or set multiple attributes of multiple vertices. Parameters ---------- - names : list[str], optional + names The names of the attribute. Default is None. - values : list[Any], optional + values The values of the attributes. Default is None. - key : list[Any], optional + key A list of vertex identifiers. Returns ------- - list[dict[str, Any]] | list[list[Any]] | None - If the parameter `names` is empty, - the function returns a list containing an attribute dict per vertex. - If the parameter `names` is not empty, - the function returns a list containing a list of attribute values per vertex corresponding to the provided attribute names. - The function returns None if it is used as a "setter". + list[VertexAttributeView] + All attributes when `names` is not provided. + list[list[Any]] + The requested attribute values when `names` is provided and `values` is not provided. + None + When `values` is provided. Raises ------ @@ -1648,12 +1793,12 @@ def vertices_attributes(self, names=None, values=None, keys=None): See Also -------- - :meth:`vertex_attribute`, :meth:`vertex_attributes`, :meth:`vertices_attribute` - :meth:`edges_attributes`, :meth:`faces_attributes`, :meth:`cells_attributes` + vertex_attribute, vertex_attributes, vertices_attribute + edges_attributes, faces_attributes, cells_attributes """ - vertices = keys or self.vertices() - if values: + vertices = self.vertices() if keys is None else keys + if values is not None: for vertex in vertices: self.vertex_attributes(vertex, names, values) return @@ -1663,12 +1808,12 @@ def vertices_attributes(self, names=None, values=None, keys=None): # Vertex Topology # -------------------------------------------------------------------------- - def has_vertex(self, vertex): + def has_vertex(self, vertex: Vertex) -> bool: """Verify that a vertex is in the cell network. Parameters ---------- - vertex : int + vertex The identifier of the vertex. Returns @@ -1679,17 +1824,17 @@ def has_vertex(self, vertex): See Also -------- - :meth:`has_edge`, :meth:`has_face`, :meth:`has_cell` + has_edge, has_face, has_cell """ return vertex in self._vertex - def vertex_neighbors(self, vertex): + def vertex_neighbors(self, vertex: Vertex) -> list[Vertex]: """Return the vertex neighbors of a vertex. Parameters ---------- - vertex : int + vertex The identifier of the vertex. Returns @@ -1699,21 +1844,21 @@ def vertex_neighbors(self, vertex): See Also -------- - :meth:`vertex_degree`, :meth:`vertex_min_degree`, :meth:`vertex_max_degree` - :meth:`vertex_faces`, :meth:`vertex_halffaces`, :meth:`vertex_cells` - :meth:`vertex_neighborhood` + vertex_degree, vertex_min_degree, vertex_max_degree + vertex_faces, vertex_halffaces, vertex_cells + vertex_neighborhood """ - return self._edge[vertex].keys() + return list(self._edge[vertex]) - def vertex_neighborhood(self, vertex, ring=1): + def vertex_neighborhood(self, vertex: Vertex, ring: int = 1) -> list[Vertex]: """Return the vertices in the neighborhood of a vertex. Parameters ---------- - vertex : int + vertex The identifier of the vertex. - ring : int, optional + ring The number of neighborhood rings to include. Returns @@ -1723,13 +1868,20 @@ def vertex_neighborhood(self, vertex, ring=1): See Also -------- - :meth:`vertex_neighbors` + vertex_neighbors Notes ----- The vertices in the neighborhood are unordered. + Raises + ------ + ValueError + If `ring` is smaller than 1. + """ + if ring < 1: + raise ValueError("The neighborhood ring should be at least 1.") nbrs = set(self.vertex_neighbors(vertex)) i = 1 while True: @@ -1742,12 +1894,12 @@ def vertex_neighborhood(self, vertex, ring=1): i += 1 return list(nbrs - set([vertex])) - def vertex_degree(self, vertex): + def vertex_degree(self, vertex: Vertex) -> int: """Count the neighbors of a vertex. Parameters ---------- - vertex : int + vertex The identifier of the vertex. Returns @@ -1757,12 +1909,12 @@ def vertex_degree(self, vertex): See Also -------- - :meth:`vertex_neighbors`, :meth:`vertex_min_degree`, :meth:`vertex_max_degree` + vertex_neighbors, vertex_min_degree, vertex_max_degree """ return len(self.vertex_neighbors(vertex)) - def vertex_min_degree(self): + def vertex_min_degree(self) -> int: """Compute the minimum degree of all vertices. Returns @@ -1772,14 +1924,14 @@ def vertex_min_degree(self): See Also -------- - :meth:`vertex_degree`, :meth:`vertex_max_degree` + vertex_degree, vertex_max_degree """ if not self._vertex: return 0 return min(self.vertex_degree(vertex) for vertex in self.vertices()) - def vertex_max_degree(self): + def vertex_max_degree(self) -> int: """Compute the maximum degree of all vertices. Returns @@ -1789,19 +1941,19 @@ def vertex_max_degree(self): See Also -------- - :meth:`vertex_degree`, :meth:`vertex_min_degree` + vertex_degree, vertex_min_degree """ if not self._vertex: return 0 return max(self.vertex_degree(vertex) for vertex in self.vertices()) - def vertex_faces(self, vertex): + def vertex_faces(self, vertex: Vertex) -> list[Face]: """Return all faces connected to a vertex. Parameters ---------- - vertex : int + vertex The identifier of the vertex. Returns @@ -1811,7 +1963,7 @@ def vertex_faces(self, vertex): See Also -------- - :meth:`vertex_neighbors`, :meth:`vertex_cells` + vertex_neighbors, vertex_cells """ faces = [] @@ -1821,12 +1973,12 @@ def vertex_faces(self, vertex): faces.append(face) return faces - def vertex_cells(self, vertex): + def vertex_cells(self, vertex: Vertex) -> list[Cell]: """Return all cells connected to a vertex. Parameters ---------- - vertex : int + vertex The identifier of the vertex. Returns @@ -1836,7 +1988,7 @@ def vertex_cells(self, vertex): See Also -------- - :meth:`vertex_neighbors`, :meth:`vertex_faces`, :meth:`vertex_halffaces` + vertex_neighbors, vertex_faces, vertex_halffaces """ cells = set() @@ -1862,7 +2014,7 @@ def vertex_cells(self, vertex): # See Also # -------- - # :meth:`is_edge_on_boundary`, :meth:`is_face_on_boundary`, :meth:`is_cell_on_boundary` + # is_edge_on_boundary, is_face_on_boundary, is_cell_on_boundary # """ # halffaces = self.vertex_halffaces(vertex) @@ -1875,14 +2027,14 @@ def vertex_cells(self, vertex): # Vertex Geometry # -------------------------------------------------------------------------- - def vertex_coordinates(self, vertex, axes="xyz"): + def vertex_coordinates(self, vertex: Vertex, axes: str = "xyz") -> list[float]: """Return the coordinates of a vertex. Parameters ---------- - vertex : int + vertex 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. @@ -1890,17 +2042,18 @@ def vertex_coordinates(self, vertex, axes="xyz"): ------- list[float] Coordinates of the vertex. + """ return [self._vertex[vertex][axis] for axis in axes] - def vertices_coordinates(self, vertices, axes="xyz"): + def vertices_coordinates(self, vertices: Iterable[Vertex], axes: str = "xyz") -> list[list[float]]: """Return the coordinates of multiple vertices. Parameters ---------- - vertices : list of int + vertices The vertex identifiers. - axes : str, optional + axes The axes alon which to take the coordinates. Should be a combination of x, y, and z. @@ -1908,36 +2061,39 @@ def vertices_coordinates(self, vertices, axes="xyz"): ------- list of list[float] Coordinates of the vertices. + """ return [self.vertex_coordinates(vertex, axes=axes) for vertex in vertices] - def vertex_point(self, vertex): + def vertex_point(self, vertex: Vertex) -> Point: """Return the point representation of a vertex. Parameters ---------- - vertex : int + vertex The identifier of the vertex. Returns ------- - :class:`compas.geometry.Point` + Point The point. + """ return Point(*self.vertex_coordinates(vertex)) - def vertices_points(self, vertices): + def vertices_points(self, vertices: Iterable[Vertex]) -> list[Point]: """Returns the point representation of multiple vertices. Parameters ---------- - vertices : list of int + vertices The vertex identifiers. Returns ------- - list of :class:`compas.geometry.Point` + list of Point The points. + """ return [self.vertex_point(vertex) for vertex in vertices] @@ -1945,19 +2101,29 @@ def vertices_points(self, vertices): # Edge Accessors # -------------------------------------------------------------------------- - def edges(self, data=False): + @overload + def edges(self, data: Literal[False] = False) -> Iterator[Edge]: ... + + @overload + def edges(self, data: Literal[True]) -> Iterator[tuple[Edge, EdgeAttributeView]]: ... + + @overload + def edges(self, data: bool) -> Iterator[Union[Edge, tuple[Edge, EdgeAttributeView]]]: ... + + def edges(self, data: bool = False) -> Iterator[Any]: """Iterate over the edges of the cell network. Parameters ---------- - data : bool, optional + data If True, yield the edge attributes in addition to the edge identifiers. Yields ------ - tuple[int, int] | tuple[tuple[int, int], dict[str, Any]] - If `data` is False, the next edge identifier (u, v). - If `data` is True, the next edge identifier and its attributes as a ((u, v), attr) tuple. + tuple[int, int] + The edge identifier if `data` is `False`. + tuple[tuple[int, int], EdgeAttributeView] + The edge identifier and its attributes if `data` is `True`. """ seen = set() @@ -1968,38 +2134,75 @@ def edges(self, data=False): seen.add((u, v)) seen.add((v, u)) if data: - attr = self._edge_data[tuple(sorted([u, v]))] - yield (u, v), attr + yield (u, v), self.edge_attributes((u, v)) else: yield u, v - def edges_where(self, conditions=None, data=False, **kwargs): + @overload + def edges_where( + self, + conditions: Optional[Mapping[str, Any]] = None, + data: Literal[False] = False, + **kwargs: Any, + ) -> Iterator[Edge]: ... + + @overload + def edges_where( + self, + conditions: Optional[Mapping[str, Any]], + data: Literal[True], + **kwargs: Any, + ) -> Iterator[tuple[Edge, EdgeAttributeView]]: ... + + @overload + def edges_where( + self, + *, + data: Literal[True], + **kwargs: Any, + ) -> Iterator[tuple[Edge, EdgeAttributeView]]: ... + + @overload + def edges_where( + self, + conditions: Optional[Mapping[str, Any]], + data: bool, + **kwargs: Any, + ) -> Iterator[Union[Edge, tuple[Edge, EdgeAttributeView]]]: ... + + def edges_where( + self, + conditions: Optional[Mapping[str, Any]] = None, + data: bool = False, + **kwargs: Any, + ) -> Iterator[Any]: """Get edges for which a certain condition or set of conditions is true. Parameters ---------- - conditions : dict, optional + conditions A set of conditions in the form of key-value pairs. The keys should be attribute names. The values can be attribute values or ranges of attribute values in the form of min/max pairs. - data : bool, optional + data If True, yield the edge attributes in addition to the identifiers. - **kwargs : dict[str, Any], optional + **kwargs Additional conditions provided as named function arguments. Yields ------ - tuple[int, int] | tuple[tuple[int, int], dict[str, Any]] - If `data` is False, the next edge as a (u, v) tuple. - If `data` is True, the next edge as a (u, v, data) tuple. + tuple[int, int] + A matching edge identifier if `data` is `False`. + tuple[tuple[int, int], EdgeAttributeView] + A matching edge identifier and its attributes if `data` is `True`. See Also -------- - :meth:`edges_where_predicate` - :meth:`vertices_where`, :meth:`faces_where`, :meth:`cells_where` + edges_where_predicate + vertices_where, faces_where, cells_where """ - conditions = conditions or {} + conditions = dict(conditions or {}) conditions.update(kwargs) for key in self.edges(): @@ -2038,27 +2241,53 @@ def edges_where(self, conditions=None, data=False, **kwargs): else: yield key - def edges_where_predicate(self, predicate, data=False): + @overload + def edges_where_predicate( + self, + predicate: Callable[[Edge, EdgeAttributeView], bool], + data: Literal[False] = False, + ) -> Iterator[Edge]: ... + + @overload + def edges_where_predicate( + self, + predicate: Callable[[Edge, EdgeAttributeView], bool], + data: Literal[True], + ) -> Iterator[tuple[Edge, EdgeAttributeView]]: ... + + @overload + def edges_where_predicate( + self, + predicate: Callable[[Edge, EdgeAttributeView], bool], + data: bool, + ) -> Iterator[Union[Edge, tuple[Edge, EdgeAttributeView]]]: ... + + def edges_where_predicate( + self, + predicate: Callable[[Edge, EdgeAttributeView], bool], + data: bool = False, + ) -> Iterator[Any]: """Get edges for which a certain condition or set of conditions is true using a lambda function. Parameters ---------- - predicate : callable + predicate The condition you want to evaluate. The callable takes 2 parameters: the edge identifier and the edge attributes, and should return True or False. - data : bool, optional + data If True, yield the edge attributes in addition to the identifiers. Yields ------ - tuple[int, int] | tuple[tuple[int, int], dict[str, Any]] - If `data` is False, the next edge as a (u, v) tuple. - If `data` is True, the next edge as a (u, v, data) tuple. + tuple[int, int] + A matching edge identifier if `data` is `False`. + tuple[tuple[int, int], EdgeAttributeView] + A matching edge identifier and its attributes if `data` is `True`. See Also -------- - :meth:`edges_where` - :meth:`vertices_where_predicate`, :meth:`faces_where_predicate`, :meth:`cells_where_predicate` + edges_where + vertices_where_predicate, faces_where_predicate, cells_where_predicate """ for key, attr in self.edges(True): @@ -2072,14 +2301,18 @@ def edges_where_predicate(self, predicate, data=False): # Edge Attributes # -------------------------------------------------------------------------- - def update_default_edge_attributes(self, attr_dict=None, **kwattr): + def update_default_edge_attributes( + self, + attr_dict: Optional[Mapping[str, Any]] = None, + **kwattr: Any, + ) -> None: """Update the default edge attributes. Parameters ---------- - attr_dict : dict[str, Any], optional + attr_dict A dictionary of attributes with their default values. - **kwattr : dict[str, Any], optional + **kwattr A dictionary of additional attributes compiled of remaining named arguments. Returns @@ -2088,62 +2321,71 @@ def update_default_edge_attributes(self, attr_dict=None, **kwattr): See Also -------- - :meth:`update_default_vertex_attributes`, :meth:`update_default_face_attributes`, :meth:`update_default_cell_attributes` + update_default_vertex_attributes, update_default_face_attributes, update_default_cell_attributes Notes ----- Named arguments overwrite correpsonding key-value pairs in the attribute dictionary. """ - if not attr_dict: - attr_dict = {} + attr_dict = dict(attr_dict or {}) attr_dict.update(kwattr) self.default_edge_attributes.update(attr_dict) - def edge_attribute(self, edge, name, value=None): + @overload + def edge_attribute(self, edge: Edge, name: str) -> Any: ... + + @overload + def edge_attribute(self, edge: Edge, name: str, value: Any) -> None: ... + + def edge_attribute(self, edge: Edge, name: str, value: Any = _MISSING) -> Any: """Get or set an attribute of an edge. Parameters ---------- - edge : tuple[int, int] + edge The edge identifier. - name : str + name The name of the attribute. - value : object, optional + value The value of the attribute. Returns ------- - object | None - The value of the attribute, or None when the function is used as a "setter". + Any + The attribute value when `value` is not provided. + None + When `value` is provided. Raises ------ KeyError If the edge does not exist. + """ if not self.has_edge(edge): raise KeyError(edge) - attr = self._edge_data.get(tuple(sorted(edge)), {}) + key = _edge_data_key(edge) + attr = self._edge_data.get(key, {}) - if value is not None: + if value is not _MISSING: attr.update({name: value}) - self._edge_data[tuple(sorted(edge))] = attr + self._edge_data[key] = attr return if name in attr: return attr[name] if name in self.default_edge_attributes: return self.default_edge_attributes[name] - def unset_edge_attribute(self, edge, name): + def unset_edge_attribute(self, edge: Edge, name: str) -> None: """Unset the attribute of an edge. Parameters ---------- - edge : tuple[int, int] + edge The edge identifier. - name : str + name The name of the attribute. Raises @@ -2157,7 +2399,7 @@ def unset_edge_attribute(self, edge, name): See Also -------- - :meth:`edge_attribute` + edge_attribute Notes ----- @@ -2168,26 +2410,47 @@ def unset_edge_attribute(self, edge, name): if not self.has_edge(edge): raise KeyError(edge) - del self._edge_data[tuple(sorted(edge))][name] + attr = self._edge_data[_edge_data_key(edge)] + if name in attr: + del attr[name] + + @overload + def edge_attributes(self, edge: Edge, names: None = None, values: None = None) -> EdgeAttributeView: ... + + @overload + def edge_attributes(self, edge: Edge, names: None, values: Sequence[Any]) -> EdgeAttributeView: ... + + @overload + def edge_attributes(self, edge: Edge, names: Sequence[str], values: None = None) -> list[Any]: ... + + @overload + def edge_attributes(self, edge: Edge, names: Sequence[str], values: Sequence[Any]) -> None: ... - def edge_attributes(self, edge, names=None, values=None): + def edge_attributes( + self, + edge: Edge, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + ) -> Any: """Get or set multiple attributes of an edge. Parameters ---------- - edge : tuple[int, int] + edge The identifier of the edge. - names : list[str], optional + names A list of attribute names. - values : list[Any], optional + values A list of attribute values. Returns ------- - dict[str, Any] | list[Any] | None - If the parameter `names` is empty, a dictionary of all attribute name-value pairs of the edge. - If the parameter `names` is not empty, a list of the values corresponding to the provided names. - None if the function is used as a "setter". + EdgeAttributeView + All attributes when `names` is not provided. + list[Any] + The requested attribute values when `names` is provided and `values` is not provided. + None + When both `names` and `values` are provided. Raises ------ @@ -2196,43 +2459,65 @@ def edge_attributes(self, edge, names=None, values=None): See Also -------- - :meth:`edge_attribute`, :meth:`edges_attribute`, :meth:`edges_attributes` - :meth:`vertex_attributes`, :meth:`face_attributes`, :meth:`cell_attributes` + edge_attribute, edges_attribute, edges_attributes + vertex_attributes, face_attributes, cell_attributes """ if not self.has_edge(edge): raise KeyError(edge) - if names and values: + if names and values is not None: for name, value in zip(names, values): - self._edge_data[tuple(sorted(edge))][name] = value + self._edge_data[_edge_data_key(edge)][name] = value return if not names: - return EdgeAttributeView(self.default_edge_attributes, self._edge_data[tuple(sorted(edge))]) + return EdgeAttributeView(self.default_edge_attributes, self._edge_data[_edge_data_key(edge)]) values = [] for name in names: value = self.edge_attribute(edge, name) values.append(value) return values - def edges_attribute(self, name, value=None, edges=None): + @overload + def edges_attribute( + self, + name: str, + *, + edges: Optional[Iterable[Edge]] = None, + ) -> list[Any]: ... + + @overload + def edges_attribute( + self, + name: str, + value: Any, + edges: Optional[Iterable[Edge]] = None, + ) -> None: ... + + def edges_attribute( + self, + name: str, + value: Any = _MISSING, + edges: Optional[Iterable[Edge]] = None, + ) -> Optional[list[Any]]: """Get or set an attribute of multiple edges. Parameters ---------- - name : str + name The name of the attribute. - value : object, optional + value The value of the attribute. Default is None. - edges : list[tuple[int, int]], optional + edges A list of edge identifiers. Returns ------- - list[Any] | None - A list containing the value per edge of the requested attribute, - or None if the function is used as a "setter". + list[Any] + The attribute values when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -2241,37 +2526,74 @@ def edges_attribute(self, name, value=None, edges=None): See Also -------- - :meth:`edge_attribute`, :meth:`edge_attributes`, :meth:`edges_attributes` - :meth:`vertex_attribute`, :meth:`face_attribute`, :meth:`cell_attribute` + edge_attribute, edge_attributes, edges_attributes + vertex_attribute, face_attribute, cell_attribute """ - edges = edges or self.edges() - if value is not None: + edges = self.edges() if edges is None else edges + if value is not _MISSING: for edge in edges: self.edge_attribute(edge, name, value) return return [self.edge_attribute(edge, name) for edge in edges] - def edges_attributes(self, names=None, values=None, edges=None): + @overload + def edges_attributes( + self, + names: None = None, + values: None = None, + edges: Optional[Iterable[Edge]] = None, + ) -> list[EdgeAttributeView]: ... + + @overload + def edges_attributes( + self, + names: Sequence[str], + values: None = None, + edges: Optional[Iterable[Edge]] = None, + ) -> list[list[Any]]: ... + + @overload + def edges_attributes( + self, + names: Sequence[str], + values: Sequence[Any], + edges: Optional[Iterable[Edge]] = None, + ) -> None: ... + + @overload + def edges_attributes( + self, + names: None, + values: Sequence[Any], + edges: Optional[Iterable[Edge]] = None, + ) -> None: ... + + def edges_attributes( + self, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + edges: Optional[Iterable[Edge]] = None, + ) -> Optional[list[Any]]: """Get or set multiple attributes of multiple edges. Parameters ---------- - names : list[str], optional + names The names of the attribute. - values : list[Any], optional + values The values of the attributes. - edges : list[tuple[int, int]], optional + edges A list of edge identifiers. Returns ------- - list[dict[str, Any]] | list[list[Any]] | None - If the parameter `names` is empty, - a list containing per edge an attribute dict with all attributes (default + custom) of the edge. - If the parameter `names` is not empty, - a list containing per edge a list of attribute values corresponding to the requested names. - None if the function is used as a "setter". + list[EdgeAttributeView] + All attributes when `names` is not provided. + list[list[Any]] + The requested attribute values when `names` is provided and `values` is not provided. + None + When `values` is provided. Raises ------ @@ -2280,12 +2602,12 @@ def edges_attributes(self, names=None, values=None, edges=None): See Also -------- - :meth:`edge_attribute`, :meth:`edge_attributes`, :meth:`edges_attribute` - :meth:`vertex_attributes`, :meth:`face_attributes`, :meth:`cell_attributes` + edge_attribute, edge_attributes, edges_attribute + vertex_attributes, face_attributes, cell_attributes """ - edges = edges or self.edges() - if values: + edges = self.edges() if edges is None else edges + if values is not None: for edge in edges: self.edge_attributes(edge, names, values) return @@ -2295,15 +2617,15 @@ def edges_attributes(self, names=None, values=None, edges=None): # Edge Topology # -------------------------------------------------------------------------- - def has_edge(self, edge, directed=False): + def has_edge(self, edge: Edge, directed: bool = False) -> bool: """Verify that the cell network contains a directed edge (u, v). Parameters ---------- - edge : tuple[int, int] + edge The identifier of the edge. - directed : bool, optional - If ``True``, the direction of the edge should be taken into account. + directed + If `True`, the direction of the edge should be taken into account. Returns ------- @@ -2313,7 +2635,7 @@ def has_edge(self, edge, directed=False): See Also -------- - :meth:`has_vertex`, :meth:`has_face`, :meth:`has_cell` + has_vertex, has_face, has_cell """ u, v = edge @@ -2321,18 +2643,19 @@ def has_edge(self, edge, directed=False): return u in self._edge and v in self._edge[u] return (u in self._edge and v in self._edge[u]) or (v in self._edge and u in self._edge[v]) - def edge_faces(self, edge): + def edge_faces(self, edge: Edge) -> list[Face]: """Return the faces adjacent to an edge. Parameters ---------- - edge : tuple[int, int] + edge The edge identifier. Returns ------- list[int] The identifiers of the adjacent faces. + """ u, v = edge faces = set() @@ -2342,12 +2665,12 @@ def edge_faces(self, edge): faces.update(self._plane[v][u].keys()) return sorted(list(faces)) - def edge_cells(self, edge): + def edge_cells(self, edge: Edge) -> list[Cell]: """Ordered cells around edge (u, v). Parameters ---------- - edge : tuple[int, int] + edge The identifier of the edge. Returns @@ -2357,14 +2680,13 @@ def edge_cells(self, edge): See Also -------- - :meth:`edge_halffaces` + edge_halffaces """ - # @Roman: should v, u also be checked? u, v = edge cells = [] - for cell in self._plane[u][v].values(): - if cell is not None: + for cell in list(self._plane[u].get(v, {}).values()) + list(self._plane[v].get(u, {}).values()): + if cell is not None and cell not in cells: cells.append(cell) return cells @@ -2384,7 +2706,7 @@ def edge_cells(self, edge): # See Also # -------- - # :meth:`is_vertex_on_boundary`, :meth:`is_face_on_boundary`, :meth:`is_cell_on_boundary` + # is_vertex_on_boundary, is_face_on_boundary, is_cell_on_boundary # Notes # ----- @@ -2395,7 +2717,7 @@ def edge_cells(self, edge): # u, v = edge # return None in self._plane[u][v].values() - def edges_without_face(self): + def edges_without_face(self) -> list[Edge]: """Find the edges that are not part of a face. Returns @@ -2407,7 +2729,7 @@ def edges_without_face(self): edges = {edge for edge in self.edges() if not self.edge_faces(edge)} return list(edges) - def nonmanifold_edges(self): + def nonmanifold_edges(self) -> list[Edge]: """Returns the edges that belong to more than two faces. Returns @@ -2423,14 +2745,14 @@ def nonmanifold_edges(self): # Edge Geometry # -------------------------------------------------------------------------- - def edge_coordinates(self, edge, axes="xyz"): + def edge_coordinates(self, edge: Edge, axes: str = "xyz") -> tuple[list[float], list[float]]: """Return the coordinates of the start and end point of an edge. Parameters ---------- - edge : tuple[int, int] + edge The edge identifier. - axes : str, optional + axes The axes along which the coordinates should be included. Returns @@ -2438,81 +2760,84 @@ def edge_coordinates(self, edge, axes="xyz"): tuple[list[float], list[float]] The coordinates of the start point. The coordinates of the end point. + """ u, v = edge return self.vertex_coordinates(u, axes=axes), self.vertex_coordinates(v, axes=axes) - def edge_start(self, edge): + def edge_start(self, edge: Edge) -> Point: """Return the start point of an edge. Parameters ---------- - edge : tuple[int, int] + edge The edge identifier. Returns ------- - :class:`compas.geometry.Point` + Point The start point. + """ return self.vertex_point(edge[0]) - def edge_end(self, edge): + def edge_end(self, edge: Edge) -> Point: """Return the end point of an edge. Parameters ---------- - edge : tuple[int, int] + edge The edge identifier. Returns ------- - :class:`compas.geometry.Point` + Point The end point. + """ return self.vertex_point(edge[1]) - def edge_midpoint(self, edge): + def edge_midpoint(self, edge: Edge) -> Point: """Return the midpoint of an edge. Parameters ---------- - edge : tuple[int, int] + edge The edge identifier. Returns ------- - :class:`compas.geometry.Point` + Point The midpoint. See Also -------- - :meth:`edge_start`, :meth:`edge_end`, :meth:`edge_point` + edge_start, edge_end, edge_point """ a, b = self.edge_coordinates(edge) return Point(0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1]), 0.5 * (a[2] + b[2])) - def edge_point(self, edge, t=0.5): + def edge_point(self, edge: Edge, t: float = 0.5) -> Point: """Return the point at a parametric location along an edge. Parameters ---------- - edge : tuple[int, int] + edge The edge identifier. - t : float, optional + t The location of the point on the edge. If the value of `t` is outside the range 0-1, the point will lie in the direction of the edge, but not on the edge vector. Returns ------- - :class:`compas.geometry.Point` + Point The XYZ coordinates of the point. See Also -------- - :meth:`edge_start`, :meth:`edge_end`, :meth:`edge_midpoint` + edge_start, edge_end, edge_midpoint """ if t == 0: @@ -2526,64 +2851,68 @@ def edge_point(self, edge, t=0.5): ab = subtract_vectors(b, a) return Point(*add_vectors(a, scale_vector(ab, t))) - def edge_vector(self, edge): + def edge_vector(self, edge: Edge) -> Vector: """Return the vector of an edge. Parameters ---------- - edge : tuple[int, int] + edge The edge identifier. Returns ------- - :class:`compas.geometry.Vector` + Vector The vector from start to end. + """ a, b = self.edge_coordinates(edge) return Vector.from_start_end(a, b) - def edge_direction(self, edge): + def edge_direction(self, edge: Edge) -> Vector: """Return the direction vector of an edge. Parameters ---------- - edge : tuple[int, int] + edge The edge identifier. Returns ------- - :class:`compas.geometry.Vector` + Vector The direction vector of the edge. + """ return Vector(*normalize_vector(self.edge_vector(edge))) - def edge_line(self, edge): + def edge_line(self, edge: Edge) -> Line: """Return the line representation of an edge. Parameters ---------- - edge : tuple[int, int] + edge The edge identifier. Returns ------- - :class:`compas.geometry.Line` + Line The line. + """ return Line(*self.edge_coordinates(edge)) - def edge_length(self, edge): + def edge_length(self, edge: Edge) -> float: """Return the length of an edge. Parameters ---------- - edge : tuple[int, int] + edge The edge identifier. Returns ------- float The length of the edge. + """ a, b = self.edge_coordinates(edge) return distance_point_point(a, b) @@ -2592,31 +2921,33 @@ def edge_length(self, edge): # Face Accessors # -------------------------------------------------------------------------- - def faces(self, data=False): - """Iterate over the halffaces of the cell network and yield faces. + @overload + def faces(self, data: Literal[False] = False) -> Iterator[Face]: ... + + @overload + def faces(self, data: Literal[True]) -> Iterator[tuple[Face, FaceAttributeView]]: ... + + @overload + def faces(self, data: bool) -> Iterator[Union[Face, tuple[Face, FaceAttributeView]]]: ... + + def faces(self, data: bool = False) -> Iterator[Any]: + """Iterate over the faces of the cell network. Parameters ---------- - data : bool, optional + data If True, yield the face attributes in addition to the face identifiers. Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next face identifier. - If `data` is True, the next face as a (face, attr) tuple. + int + The face identifier if `data` is `False`. + tuple[int, FaceAttributeView] + The face identifier and its attributes if `data` is `True`. See Also -------- - :meth:`vertices`, :meth:`edges`, :meth:`cells` - - Notes - ----- - Volmesh faces have no topological meaning (analogous to an edge of a mesh). - They are typically used for geometric operations (i.e. planarisation). - Between the interface of two cells, there are two interior faces (one from each cell). - Only one of these two interior faces are returned as a "face". - The unique faces are found by comparing string versions of sorted vertex lists. + vertices, edges, cells """ for face in self._face: @@ -2625,33 +2956,71 @@ def faces(self, data=False): else: yield face, self.face_attributes(face) - def faces_where(self, conditions=None, data=False, **kwargs): + @overload + def faces_where( + self, + conditions: Optional[Mapping[str, Any]] = None, + data: Literal[False] = False, + **kwargs: Any, + ) -> Iterator[Face]: ... + + @overload + def faces_where( + self, + conditions: Optional[Mapping[str, Any]], + data: Literal[True], + **kwargs: Any, + ) -> Iterator[tuple[Face, FaceAttributeView]]: ... + + @overload + def faces_where( + self, + *, + data: Literal[True], + **kwargs: Any, + ) -> Iterator[tuple[Face, FaceAttributeView]]: ... + + @overload + def faces_where( + self, + conditions: Optional[Mapping[str, Any]], + data: bool, + **kwargs: Any, + ) -> Iterator[Union[Face, tuple[Face, FaceAttributeView]]]: ... + + def faces_where( + self, + conditions: Optional[Mapping[str, Any]] = None, + data: bool = False, + **kwargs: Any, + ) -> Iterator[Any]: """Get faces for which a certain condition or set of conditions is true. Parameters ---------- - conditions : dict, optional + conditions A set of conditions in the form of key-value pairs. The keys should be attribute names. The values can be attribute values or ranges of attribute values in the form of min/max pairs. - data : bool, optional + data If True, yield the face attributes in addition to the identifiers. - **kwargs : dict[str, Any], optional + **kwargs Additional conditions provided as named function arguments. Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next face that matches the condition. - If `data` is True, the next face and its attributes. + int + A matching face identifier if `data` is `False`. + tuple[int, FaceAttributeView] + A matching face identifier and its attributes if `data` is `True`. See Also -------- - :meth:`faces_where_predicate` - :meth:`vertices_where`, :meth:`edges_where`, :meth:`cells_where` + faces_where_predicate + vertices_where, edges_where, cells_where """ - conditions = conditions or {} + conditions = dict(conditions or {}) conditions.update(kwargs) for fkey in self.faces(): @@ -2690,27 +3059,53 @@ def faces_where(self, conditions=None, data=False, **kwargs): else: yield fkey - def faces_where_predicate(self, predicate, data=False): + @overload + def faces_where_predicate( + self, + predicate: Callable[[Face, FaceAttributeView], bool], + data: Literal[False] = False, + ) -> Iterator[Face]: ... + + @overload + def faces_where_predicate( + self, + predicate: Callable[[Face, FaceAttributeView], bool], + data: Literal[True], + ) -> Iterator[tuple[Face, FaceAttributeView]]: ... + + @overload + def faces_where_predicate( + self, + predicate: Callable[[Face, FaceAttributeView], bool], + data: bool, + ) -> Iterator[Union[Face, tuple[Face, FaceAttributeView]]]: ... + + def faces_where_predicate( + self, + predicate: Callable[[Face, FaceAttributeView], bool], + data: bool = False, + ) -> Iterator[Any]: """Get faces for which a certain condition or set of conditions is true using a lambda function. Parameters ---------- - predicate : callable + predicate The condition you want to evaluate. The callable takes 2 parameters: the face identifier and the the face attributes, and should return True or False. - data : bool, optional + data If True, yield the face attributes in addition to the identifiers. Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next face that matches the condition. - If `data` is True, the next face and its attributes. + int + A matching face identifier if `data` is `False`. + tuple[int, FaceAttributeView] + A matching face identifier and its attributes if `data` is `True`. See Also -------- - :meth:`faces_where` - :meth:`vertices_where_predicate`, :meth:`edges_where_predicate`, :meth:`cells_where_predicate` + faces_where + vertices_where_predicate, edges_where_predicate, cells_where_predicate """ for fkey, attr in self.faces(True): @@ -2724,14 +3119,18 @@ def faces_where_predicate(self, predicate, data=False): # Face Attributes # -------------------------------------------------------------------------- - def update_default_face_attributes(self, attr_dict=None, **kwattr): + def update_default_face_attributes( + self, + attr_dict: Optional[Mapping[str, Any]] = None, + **kwattr: Any, + ) -> None: """Update the default face attributes. Parameters ---------- - attr_dict : dict[str, Any], optional + attr_dict A dictionary of attributes with their default values. - **kwattr : dict[str, Any], optional + **kwattr A dictionary of additional attributes compiled of remaining named arguments. Returns @@ -2740,34 +3139,41 @@ def update_default_face_attributes(self, attr_dict=None, **kwattr): See Also -------- - :meth:`update_default_vertex_attributes`, :meth:`update_default_edge_attributes`, :meth:`update_default_cell_attributes` + update_default_vertex_attributes, update_default_edge_attributes, update_default_cell_attributes Notes ----- Named arguments overwrite correpsonding key-value pairs in the attribute dictionary. """ - if not attr_dict: - attr_dict = {} + attr_dict = dict(attr_dict or {}) attr_dict.update(kwattr) self.default_face_attributes.update(attr_dict) - def face_attribute(self, face, name, value=None): + @overload + def face_attribute(self, face: Face, name: str) -> Any: ... + + @overload + def face_attribute(self, face: Face, name: str, value: Any) -> None: ... + + def face_attribute(self, face: Face, name: str, value: Any = _MISSING) -> Any: """Get or set an attribute of a face. Parameters ---------- - face : int + face The face identifier. - name : str + name The name of the attribute. - value : object, optional + value The value of the attribute. Returns ------- - object | None - The value of the attribute, or None when the function is used as a "setter". + Any + The attribute value when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -2776,15 +3182,15 @@ def face_attribute(self, face, name, value=None): See Also -------- - :meth:`unset_face_attribute` - :meth:`face_attributes`, :meth:`faces_attribute`, :meth:`faces_attributes` - :meth:`vertex_attribute`, :meth:`edge_attribute`, :meth:`cell_attribute` + unset_face_attribute + face_attributes, faces_attribute, faces_attributes + vertex_attribute, edge_attribute, cell_attribute """ if face not in self._face: raise KeyError(face) - if value is not None: + if value is not _MISSING: if face not in self._face_data: self._face_data[face] = {} self._face_data[face][name] = value @@ -2794,14 +3200,14 @@ def face_attribute(self, face, name, value=None): if name in self.default_face_attributes: return self.default_face_attributes[name] - def unset_face_attribute(self, face, name): + def unset_face_attribute(self, face: Face, name: str) -> None: """Unset the attribute of a face. Parameters ---------- - face : int + face The face identifier. - name : str + name The name of the attribute. Raises @@ -2815,7 +3221,7 @@ def unset_face_attribute(self, face, name): See Also -------- - :meth:`face_attribute` + face_attribute Notes ----- @@ -2829,24 +3235,43 @@ def unset_face_attribute(self, face, name): if face in self._face_data and name in self._face_data[face]: del self._face_data[face][name] - def face_attributes(self, face, names=None, values=None): + @overload + def face_attributes(self, face: Face, names: None = None, values: None = None) -> FaceAttributeView: ... + + @overload + def face_attributes(self, face: Face, names: None, values: Sequence[Any]) -> FaceAttributeView: ... + + @overload + def face_attributes(self, face: Face, names: Sequence[str], values: None = None) -> list[Any]: ... + + @overload + def face_attributes(self, face: Face, names: Sequence[str], values: Sequence[Any]) -> None: ... + + def face_attributes( + self, + face: Face, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + ) -> Any: """Get or set multiple attributes of a face. Parameters ---------- - face : int + face The identifier of the face. - names : list[str], optional + names A list of attribute names. - values : list[Any], optional + values A list of attribute values. Returns ------- - dict[str, Any] | list[Any] | None - If the parameter `names` is empty, a dictionary of all attribute name-value pairs of the face. - If the parameter `names` is not empty, a list of the values corresponding to the provided names. - None if the function is used as a "setter". + FaceAttributeView + All attributes when `names` is not provided. + list[Any] + The requested attribute values when `names` is provided and `values` is not provided. + None + When both `names` and `values` are provided. Raises ------ @@ -2855,14 +3280,14 @@ def face_attributes(self, face, names=None, values=None): See Also -------- - :meth:`face_attribute`, :meth:`faces_attribute`, :meth:`faces_attributes` - :meth:`vertex_attributes`, :meth:`edge_attributes`, :meth:`cell_attributes` + face_attribute, faces_attribute, faces_attributes + vertex_attributes, edge_attributes, cell_attributes """ if face not in self._face: raise KeyError(face) - if names and values: + if names and values is not None: for name, value in zip(names, values): if face not in self._face_data: self._face_data[face] = {} @@ -2878,24 +3303,46 @@ def face_attributes(self, face, names=None, values=None): values.append(value) return values - def faces_attribute(self, name, value=None, faces=None): + @overload + def faces_attribute( + self, + name: str, + *, + faces: Optional[Iterable[Face]] = None, + ) -> list[Any]: ... + + @overload + def faces_attribute( + self, + name: str, + value: Any, + faces: Optional[Iterable[Face]] = None, + ) -> None: ... + + def faces_attribute( + self, + name: str, + value: Any = _MISSING, + faces: Optional[Iterable[Face]] = None, + ) -> Optional[list[Any]]: """Get or set an attribute of multiple faces. Parameters ---------- - name : str + name The name of the attribute. - value : object, optional + value The value of the attribute. Default is None. - faces : list[int], optional + faces A list of face identifiers. Returns ------- - list[Any] | None - A list containing the value per face of the requested attribute, - or None if the function is used as a "setter". + list[Any] + The attribute values when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -2904,39 +3351,76 @@ def faces_attribute(self, name, value=None, faces=None): See Also -------- - :meth:`face_attribute`, :meth:`face_attributes`, :meth:`faces_attributes` - :meth:`vertex_attribute`, :meth:`edge_attribute`, :meth:`cell_attribute` + face_attribute, face_attributes, faces_attributes + vertex_attribute, edge_attribute, cell_attribute """ - faces = faces or self.faces() - if value is not None: + faces = self.faces() if faces is None else faces + if value is not _MISSING: for face in faces: self.face_attribute(face, name, value) return return [self.face_attribute(face, name) for face in faces] - def faces_attributes(self, names=None, values=None, faces=None): + @overload + def faces_attributes( + self, + names: None = None, + values: None = None, + faces: Optional[Iterable[Face]] = None, + ) -> list[FaceAttributeView]: ... + + @overload + def faces_attributes( + self, + names: Sequence[str], + values: None = None, + faces: Optional[Iterable[Face]] = None, + ) -> list[list[Any]]: ... + + @overload + def faces_attributes( + self, + names: Sequence[str], + values: Sequence[Any], + faces: Optional[Iterable[Face]] = None, + ) -> None: ... + + @overload + def faces_attributes( + self, + names: None, + values: Sequence[Any], + faces: Optional[Iterable[Face]] = None, + ) -> None: ... + + def faces_attributes( + self, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + faces: Optional[Iterable[Face]] = None, + ) -> Optional[list[Any]]: """Get or set multiple attributes of multiple faces. Parameters ---------- - names : list[str], optional + names The names of the attribute. Default is None. - values : list[Any], optional + values The values of the attributes. Default is None. - faces : list[int], optional + faces A list of face identifiers. Returns ------- - list[dict[str, Any]] | list[list[Any]] | None - If the parameter `names` is empty, - a list containing per face an attribute dict with all attributes (default + custom) of the face. - If the parameter `names` is not empty, - a list containing per face a list of attribute values corresponding to the requested names. - None if the function is used as a "setter". + list[FaceAttributeView] + All attributes when `names` is not provided. + list[list[Any]] + The requested attribute values when `names` is provided and `values` is not provided. + None + When `values` is provided. Raises ------ @@ -2945,12 +3429,12 @@ def faces_attributes(self, names=None, values=None, faces=None): See Also -------- - :meth:`face_attribute`, :meth:`face_attributes`, :meth:`faces_attribute` - :meth:`vertex_attributes`, :meth:`edge_attributes`, :meth:`cell_attributes` + face_attribute, face_attributes, faces_attribute + vertex_attributes, edge_attributes, cell_attributes """ - faces = faces or self.faces() - if values: + faces = self.faces() if faces is None else faces + if values is not None: for face in faces: self.face_attributes(face, names, values) return @@ -2960,12 +3444,12 @@ def faces_attributes(self, names=None, values=None, faces=None): # Face Topology # -------------------------------------------------------------------------- - def has_face(self, face): + def has_face(self, face: Face) -> bool: """Verify that a face is part of the cell network. Parameters ---------- - face : int + face The identifier of the face. Returns @@ -2976,17 +3460,17 @@ def has_face(self, face): See Also -------- - :meth:`has_vertex`, :meth:`has_edge`, :meth:`has_cell` + has_vertex, has_edge, has_cell """ return face in self._face - def face_vertices(self, face): + def face_vertices(self, face: Face) -> list[Vertex]: """The vertices of a face. Parameters ---------- - face : int + face The identifier of the face. Returns @@ -2997,12 +3481,12 @@ def face_vertices(self, face): """ return self._face[face] - def face_edges(self, face): + def face_edges(self, face: Face) -> list[Edge]: """The edges of a face. Parameters ---------- - face : int + face The identifier of the face. Returns @@ -3019,12 +3503,12 @@ def face_edges(self, face): edges.append((u, v)) return edges - def face_cells(self, face): + def face_cells(self, face: Face) -> list[Cell]: """Return the cells connected to a face. Parameters ---------- - face : int + face The identifier of the face. Returns @@ -3037,14 +3521,14 @@ def face_cells(self, face): cells = [] if v in self._plane[u]: cell = self._plane[u][v][face] - if cell is not None: + if cell is not None and cell not in cells: cells.append(cell) cell = self._plane[v][u][face] - if cell is not None: + if cell is not None and cell not in cells: cells.append(cell) return cells - def faces_without_cell(self): + def faces_without_cell(self) -> list[Face]: """Find the faces that are not part of a cell. Returns @@ -3058,12 +3542,12 @@ def faces_without_cell(self): # @Romana: this logic only makes sense for a face belonging to a cell # # yep, if the face is not belonging to a cell, it returns False, which is correct - def is_face_on_boundary(self, face): + def is_face_on_boundary(self, face: Face) -> bool: """Verify that a face is on the boundary. Parameters ---------- - face : int + face The identifier of the face. Returns @@ -3078,7 +3562,7 @@ def is_face_on_boundary(self, face): cv = 1 if self._plane[v][u][face] is None else 0 return cu + cv == 1 - def faces_on_boundaries(self): + def faces_on_boundaries(self) -> list[Face]: """Find the faces that are on the boundary. Returns @@ -3093,14 +3577,14 @@ def faces_on_boundaries(self): # Face Geometry # -------------------------------------------------------------------------- - def face_coordinates(self, face, axes="xyz"): + def face_coordinates(self, face: Face, axes: str = "xyz") -> list[list[float]]: """Compute the coordinates of the vertices of a face. Parameters ---------- - face : int + face The identifier of the face. - axes : str, optional + axes The axes alon which to take the coordinates. Should be a combination of x, y, and z. @@ -3111,120 +3595,120 @@ def face_coordinates(self, face, axes="xyz"): See Also -------- - :meth:`face_points`, :meth:`face_polygon`, :meth:`face_normal`, :meth:`face_centroid`, :meth:`face_center` - :meth:`face_area`, :meth:`face_flatness`, :meth:`face_aspect_ratio` + face_points, face_polygon, face_normal, face_centroid, face_center + face_area, face_flatness, face_aspect_ratio """ return [self.vertex_coordinates(vertex, axes=axes) for vertex in self.face_vertices(face)] - def face_points(self, face): + def face_points(self, face: Face) -> list[Point]: """Compute the points of the vertices of a face. Parameters ---------- - face : int + face The identifier of the face. Returns ------- - list[:class:`compas.geometry.Point`] + list[Point] The points of the vertices of the face. See Also -------- - :meth:`face_polygon`, :meth:`face_normal`, :meth:`face_centroid`, :meth:`face_center` + face_polygon, face_normal, face_centroid, face_center """ return [self.vertex_point(vertex) for vertex in self.face_vertices(face)] - def face_polygon(self, face): + def face_polygon(self, face: Face) -> Polygon: """Compute the polygon of a face. Parameters ---------- - face : int + face The identifier of the face. Returns ------- - :class:`compas.geometry.Polygon` + Polygon The polygon of the face. See Also -------- - :meth:`face_points`, :meth:`face_normal`, :meth:`face_centroid`, :meth:`face_center` + face_points, face_normal, face_centroid, face_center """ return Polygon(self.face_points(face)) - def face_normal(self, face, unitized=True): + def face_normal(self, face: Face, unitized: bool = True) -> Vector: """Compute the oriented normal of a face. Parameters ---------- - face : int + face The identifier of the face. - unitized : bool, optional + unitized If True, unitize the normal vector. Returns ------- - :class:`compas.geometry.Vector` + Vector The normal vector. See Also -------- - :meth:`face_points`, :meth:`face_polygon`, :meth:`face_centroid`, :meth:`face_center` + face_points, face_polygon, face_centroid, face_center """ return Vector(*normal_polygon(self.face_coordinates(face), unitized=unitized)) - def face_centroid(self, face): + def face_centroid(self, face: Face) -> Point: """Compute the point at the centroid of a face. Parameters ---------- - face : int + face The identifier of the face. Returns ------- - :class:`compas.geometry.Point` + Point The coordinates of the centroid. See Also -------- - :meth:`face_points`, :meth:`face_polygon`, :meth:`face_normal`, :meth:`face_center` + face_points, face_polygon, face_normal, face_center """ return Point(*centroid_points(self.face_coordinates(face))) - def face_center(self, face): + def face_center(self, face: Face) -> Point: """Compute the point at the center of mass of a face. Parameters ---------- - face : int + face The identifier of the face. Returns ------- - :class:`compas.geometry.Point` + Point The coordinates of the center of mass. See Also -------- - :meth:`face_points`, :meth:`face_polygon`, :meth:`face_normal`, :meth:`face_centroid` + face_points, face_polygon, face_normal, face_centroid """ return Point(*centroid_polygon(self.face_coordinates(face))) - def face_area(self, face): + def face_area(self, face: Face) -> float: """Compute the oriented area of a face. Parameters ---------- - face : int + face The identifier of the face. Returns @@ -3234,37 +3718,37 @@ def face_area(self, face): See Also -------- - :meth:`face_flatness`, :meth:`face_aspect_ratio` + face_flatness, face_aspect_ratio """ return length_vector(self.face_normal(face, unitized=False)) - def face_plane(self, face): + def face_plane(self, face: Face) -> Plane: """Compute the plane of a face. Parameters ---------- - face : int + face The identifier of the face. Returns ------- - :class:`compas.geometry.Plane` + Plane The plane of the face. See Also -------- - :meth:`face_points`, :meth:`face_polygon`, :meth:`face_normal`, :meth:`face_centroid`, :meth:`face_center` + face_points, face_polygon, face_normal, face_centroid, face_center """ return Plane(self.face_centroid(face), self.face_normal(face)) - def face_flatness(self, face, maxdev=0.02): + def face_flatness(self, face: Face, maxdev: float = 0.02) -> float: """Compute the flatness of a face. Parameters ---------- - face : int + face The identifier of the face. Returns @@ -3274,7 +3758,7 @@ def face_flatness(self, face, maxdev=0.02): See Also -------- - :meth:`face_area`, :meth:`face_aspect_ratio` + face_area, face_aspect_ratio Notes ----- @@ -3293,12 +3777,12 @@ def face_flatness(self, face, maxdev=0.02): deviation = dev return deviation - def face_aspect_ratio(self, face): + def face_aspect_ratio(self, face: Face) -> float: """Face aspect ratio as the ratio between the lengths of the maximum and minimum face edges. Parameters ---------- - face : int + face The identifier of the face. Returns @@ -3308,12 +3792,11 @@ def face_aspect_ratio(self, face): See Also -------- - :meth:`face_area`, :meth:`face_flatness` + face_area, face_flatness References ---------- - .. [1] Wikipedia. *Types of mesh*. - Available at: https://en.wikipedia.org/wiki/Types_of_mesh. + * Wikipedia. *Types of mesh*. Available at: https://en.wikipedia.org/wiki/Types_of_mesh. """ lengths = [self.edge_length(edge) for edge in self.face_edges(face)] @@ -3323,23 +3806,33 @@ def face_aspect_ratio(self, face): # Cell Accessors # -------------------------------------------------------------------------- - def cells(self, data=False): - """Iterate over the cells of the volmesh. + @overload + def cells(self, data: Literal[False] = False) -> Iterator[Cell]: ... + + @overload + def cells(self, data: Literal[True]) -> Iterator[tuple[Cell, CellAttributeView]]: ... + + @overload + def cells(self, data: bool) -> Iterator[Union[Cell, tuple[Cell, CellAttributeView]]]: ... + + def cells(self, data: bool = False) -> Iterator[Any]: + """Iterate over the cells of the cell network. Parameters ---------- - data : bool, optional + data If True, yield the cell attributes in addition to the cell identifiers. Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next cell identifier. - If `data` is True, the next cell as a (cell, attr) tuple. + int + The cell identifier if `data` is `False`. + tuple[int, CellAttributeView] + The cell identifier and its attributes if `data` is `True`. See Also -------- - :meth:`vertices`, :meth:`edges`, :meth:`faces` + vertices, edges, faces """ for cell in self._cell: @@ -3348,33 +3841,71 @@ def cells(self, data=False): else: yield cell, self.cell_attributes(cell) - def cells_where(self, conditions=None, data=False, **kwargs): + @overload + def cells_where( + self, + conditions: Optional[Mapping[str, Any]] = None, + data: Literal[False] = False, + **kwargs: Any, + ) -> Iterator[Cell]: ... + + @overload + def cells_where( + self, + conditions: Optional[Mapping[str, Any]], + data: Literal[True], + **kwargs: Any, + ) -> Iterator[tuple[Cell, CellAttributeView]]: ... + + @overload + def cells_where( + self, + *, + data: Literal[True], + **kwargs: Any, + ) -> Iterator[tuple[Cell, CellAttributeView]]: ... + + @overload + def cells_where( + self, + conditions: Optional[Mapping[str, Any]], + data: bool, + **kwargs: Any, + ) -> Iterator[Union[Cell, tuple[Cell, CellAttributeView]]]: ... + + def cells_where( + self, + conditions: Optional[Mapping[str, Any]] = None, + data: bool = False, + **kwargs: Any, + ) -> Iterator[Any]: """Get cells for which a certain condition or set of conditions is true. Parameters ---------- - conditions : dict, optional + conditions A set of conditions in the form of key-value pairs. The keys should be attribute names. The values can be attribute values or ranges of attribute values in the form of min/max pairs. - data : bool, optional + data If True, yield the cell attributes in addition to the identifiers. - **kwargs : dict[str, Any], optional + **kwargs Additional conditions provided as named function arguments. Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next cell that matches the condition. - If `data` is True, the next cell and its attributes. + int + A matching cell identifier if `data` is `False`. + tuple[int, CellAttributeView] + A matching cell identifier and its attributes if `data` is `True`. See Also -------- - :meth:`cells_where_predicate` - :meth:`vertices_where`, :meth:`edges_where`, :meth:`faces_where` + cells_where_predicate + vertices_where, edges_where, faces_where """ - conditions = conditions or {} + conditions = dict(conditions or {}) conditions.update(kwargs) for ckey in self.cells(): @@ -3413,27 +3944,53 @@ def cells_where(self, conditions=None, data=False, **kwargs): else: yield ckey - def cells_where_predicate(self, predicate, data=False): + @overload + def cells_where_predicate( + self, + predicate: Callable[[Cell, CellAttributeView], bool], + data: Literal[False] = False, + ) -> Iterator[Cell]: ... + + @overload + def cells_where_predicate( + self, + predicate: Callable[[Cell, CellAttributeView], bool], + data: Literal[True], + ) -> Iterator[tuple[Cell, CellAttributeView]]: ... + + @overload + def cells_where_predicate( + self, + predicate: Callable[[Cell, CellAttributeView], bool], + data: bool, + ) -> Iterator[Union[Cell, tuple[Cell, CellAttributeView]]]: ... + + def cells_where_predicate( + self, + predicate: Callable[[Cell, CellAttributeView], bool], + data: bool = False, + ) -> Iterator[Any]: """Get cells for which a certain condition or set of conditions is true using a lambda function. Parameters ---------- - predicate : callable + predicate The condition you want to evaluate. The callable takes 2 parameters: the cell identifier and the cell attributes, and should return True or False. - data : bool, optional + data If True, yield the cell attributes in addition to the identifiers. Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next cell that matches the condition. - If `data` is True, the next cell and its attributes. + int + A matching cell identifier if `data` is `False`. + tuple[int, CellAttributeView] + A matching cell identifier and its attributes if `data` is `True`. See Also -------- - :meth:`cells_where` - :meth:`vertices_where_predicate`, :meth:`edges_where_predicate`, :meth:`faces_where_predicate` + cells_where + vertices_where_predicate, edges_where_predicate, faces_where_predicate """ for ckey, attr in self.cells(True): @@ -3447,14 +4004,18 @@ def cells_where_predicate(self, predicate, data=False): # Cell Attributes # -------------------------------------------------------------------------- - def update_default_cell_attributes(self, attr_dict=None, **kwattr): + def update_default_cell_attributes( + self, + attr_dict: Optional[Mapping[str, Any]] = None, + **kwattr: Any, + ) -> None: """Update the default cell attributes. Parameters ---------- - attr_dict : dict[str, Any], optional + attr_dict A dictionary of attributes with their default values. - **kwattr : dict[str, Any], optional + **kwattr A dictionary of additional attributes compiled of remaining named arguments. Returns @@ -3463,34 +4024,41 @@ def update_default_cell_attributes(self, attr_dict=None, **kwattr): See Also -------- - :meth:`update_default_vertex_attributes`, :meth:`update_default_edge_attributes`, :meth:`update_default_face_attributes` + update_default_vertex_attributes, update_default_edge_attributes, update_default_face_attributes Notes ----- Named arguments overwrite corresponding cell-value pairs in the attribute dictionary. """ - if not attr_dict: - attr_dict = {} + attr_dict = dict(attr_dict or {}) attr_dict.update(kwattr) self.default_cell_attributes.update(attr_dict) - def cell_attribute(self, cell, name, value=None): + @overload + def cell_attribute(self, cell: Cell, name: str) -> Any: ... + + @overload + def cell_attribute(self, cell: Cell, name: str, value: Any) -> None: ... + + def cell_attribute(self, cell: Cell, name: str, value: Any = _MISSING) -> Any: """Get or set an attribute of a cell. Parameters ---------- - cell : int + cell The cell identifier. - name : str + name The name of the attribute. - value : object, optional + value The value of the attribute. Returns ------- - object | None - The value of the attribute, or None when the function is used as a "setter". + Any + The attribute value when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -3499,14 +4067,14 @@ def cell_attribute(self, cell, name, value=None): See Also -------- - :meth:`unset_cell_attribute` - :meth:`cell_attributes`, :meth:`cells_attribute`, :meth:`cells_attributes` - :meth:`vertex_attribute`, :meth:`edge_attribute`, :meth:`face_attribute` + unset_cell_attribute + cell_attributes, cells_attribute, cells_attributes + vertex_attribute, edge_attribute, face_attribute """ if cell not in self._cell: raise KeyError(cell) - if value is not None: + if value is not _MISSING: if cell not in self._cell_data: self._cell_data[cell] = {} self._cell_data[cell][name] = value @@ -3516,14 +4084,14 @@ def cell_attribute(self, cell, name, value=None): if name in self.default_cell_attributes: return self.default_cell_attributes[name] - def unset_cell_attribute(self, cell, name): + def unset_cell_attribute(self, cell: Cell, name: str) -> None: """Unset the attribute of a cell. Parameters ---------- - cell : int + cell The cell identifier. - name : str + name The name of the attribute. Returns @@ -3537,7 +4105,7 @@ def unset_cell_attribute(self, cell, name): See Also -------- - :meth:`cell_attribute` + cell_attribute Notes ----- @@ -3551,24 +4119,43 @@ def unset_cell_attribute(self, cell, name): if name in self._cell_data[cell]: del self._cell_data[cell][name] - def cell_attributes(self, cell, names=None, values=None): + @overload + def cell_attributes(self, cell: Cell, names: None = None, values: None = None) -> CellAttributeView: ... + + @overload + def cell_attributes(self, cell: Cell, names: None, values: Sequence[Any]) -> CellAttributeView: ... + + @overload + def cell_attributes(self, cell: Cell, names: Sequence[str], values: None = None) -> list[Any]: ... + + @overload + def cell_attributes(self, cell: Cell, names: Sequence[str], values: Sequence[Any]) -> None: ... + + def cell_attributes( + self, + cell: Cell, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + ) -> Any: """Get or set multiple attributes of a cell. Parameters ---------- - cell : int + cell The identifier of the cell. - names : list[str], optional + names A list of attribute names. - values : list[Any], optional + values A list of attribute values. Returns ------- - dict[str, Any] | list[Any] | None - If the parameter `names` is empty, a dictionary of all attribute name-value pairs of the cell. - If the parameter `names` is not empty, a list of the values corresponding to the provided names. - None if the function is used as a "setter". + CellAttributeView + All attributes when `names` is not provided. + list[Any] + The requested attribute values when `names` is provided and `values` is not provided. + None + When both `names` and `values` are provided. Raises ------ @@ -3577,8 +4164,8 @@ def cell_attributes(self, cell, names=None, values=None): See Also -------- - :meth:`cell_attribute`, :meth:`cells_attribute`, :meth:`cells_attributes` - :meth:`vertex_attributes`, :meth:`edge_attributes`, :meth:`face_attributes` + cell_attribute, cells_attribute, cells_attributes + vertex_attributes, edge_attributes, face_attributes """ if cell not in self._cell: @@ -3597,23 +4184,45 @@ def cell_attributes(self, cell, names=None, values=None): values.append(value) return values - def cells_attribute(self, name, value=None, cells=None): + @overload + def cells_attribute( + self, + name: str, + *, + cells: Optional[Iterable[Cell]] = None, + ) -> list[Any]: ... + + @overload + def cells_attribute( + self, + name: str, + value: Any, + cells: Optional[Iterable[Cell]] = None, + ) -> None: ... + + def cells_attribute( + self, + name: str, + value: Any = _MISSING, + cells: Optional[Iterable[Cell]] = None, + ) -> Optional[list[Any]]: """Get or set an attribute of multiple cells. Parameters ---------- - name : str + name The name of the attribute. - value : object, optional + value The value of the attribute. - cells : list[int], optional + cells A list of cell identifiers. Returns ------- - list[Any] | None - A list containing the value per face of the requested attribute, - or None if the function is used as a "setter". + list[Any] + The attribute values when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -3622,40 +4231,76 @@ def cells_attribute(self, name, value=None, cells=None): See Also -------- - :meth:`cell_attribute`, :meth:`cell_attributes`, :meth:`cells_attributes` - :meth:`vertex_attribute`, :meth:`edge_attribute`, :meth:`face_attribute` + cell_attribute, cell_attributes, cells_attributes + vertex_attribute, edge_attribute, face_attribute """ - if not cells: - cells = self.cells() - if value is not None: + cells = self.cells() if cells is None else cells + if value is not _MISSING: for cell in cells: self.cell_attribute(cell, name, value) return return [self.cell_attribute(cell, name) for cell in cells] - def cells_attributes(self, names=None, values=None, cells=None): + @overload + def cells_attributes( + self, + names: None = None, + values: None = None, + cells: Optional[Iterable[Cell]] = None, + ) -> list[CellAttributeView]: ... + + @overload + def cells_attributes( + self, + names: Sequence[str], + values: None = None, + cells: Optional[Iterable[Cell]] = None, + ) -> list[list[Any]]: ... + + @overload + def cells_attributes( + self, + names: Sequence[str], + values: Sequence[Any], + cells: Optional[Iterable[Cell]] = None, + ) -> None: ... + + @overload + def cells_attributes( + self, + names: None, + values: Sequence[Any], + cells: Optional[Iterable[Cell]] = None, + ) -> None: ... + + def cells_attributes( + self, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + cells: Optional[Iterable[Cell]] = None, + ) -> Optional[list[Any]]: """Get or set multiple attributes of multiple cells. Parameters ---------- - names : list[str], optional + names The names of the attribute. Default is None. - values : list[Any], optional + values The values of the attributes. Default is None. - cells : list[int], optional + cells A list of cell identifiers. Returns ------- - list[dict[str, Any]] | list[list[Any]] | None - If the parameter `names` is empty, - a list containing per cell an attribute dict with all attributes (default + custom) of the cell. - If the parameter `names` is empty, - a list containing per cell a list of attribute values corresponding to the requested names. - None if the function is used as a "setter". + list[CellAttributeView] + All attributes when `names` is not provided. + list[list[Any]] + The requested attribute values when `names` is provided and `values` is not provided. + None + When `values` is provided. Raises ------ @@ -3664,12 +4309,11 @@ def cells_attributes(self, names=None, values=None, cells=None): See Also -------- - :meth:`cell_attribute`, :meth:`cell_attributes`, :meth:`cells_attribute` - :meth:`vertex_attributes`, :meth:`edge_attributes`, :meth:`face_attributes` + cell_attribute, cell_attributes, cells_attribute + vertex_attributes, edge_attributes, face_attributes """ - if not cells: - cells = self.cells() + cells = self.cells() if cells is None else cells if values is not None: for cell in cells: self.cell_attributes(cell, names, values) @@ -3680,12 +4324,32 @@ def cells_attributes(self, names=None, values=None, cells=None): # Cell Topology # -------------------------------------------------------------------------- - def cell_vertices(self, cell): + def has_cell(self, cell: Cell) -> bool: + """Verify that a cell is part of the cell network. + + Parameters + ---------- + cell + The identifier of the cell. + + Returns + ------- + bool + True if the cell exists, and False otherwise. + + See Also + -------- + has_vertex, has_edge, has_face + + """ + return cell in self._cell + + def cell_vertices(self, cell: Cell) -> list[Vertex]: """The vertices of a cell. Parameters ---------- - cell : int + cell Identifier of the cell. Returns @@ -3695,22 +4359,22 @@ def cell_vertices(self, cell): See Also -------- - :meth:`cell_edges`, :meth:`cell_faces`, :meth:`cell_halfedges` + cell_edges, cell_faces, cell_halfedges Notes ----- - This method is similar to :meth:`~compas.datastructures.HalfEdge.vertices`, + This method is similar to ~compas.datastructures.HalfEdge.vertices, but in the context of a cell of the `VolMesh`. """ return list(set([vertex for face in self.cell_faces(cell) for vertex in self.face_vertices(face)])) - def cell_halfedges(self, cell): + def cell_halfedges(self, cell: Cell) -> list[Edge]: """The halfedges of a cell. Parameters ---------- - cell : int + cell Identifier of the cell. Returns @@ -3720,11 +4384,11 @@ def cell_halfedges(self, cell): See Also -------- - :meth:`cell_edges`, :meth:`cell_faces`, :meth:`cell_vertices` + cell_edges, cell_faces, cell_vertices Notes ----- - This method is similar to :meth:`~compas.datastructures.HalfEdge.halfedges`, + This method is similar to ~compas.datastructures.HalfEdge.halfedges, but in the context of a cell of the `VolMesh`. """ @@ -3734,12 +4398,12 @@ def cell_halfedges(self, cell): halfedges.append((u, v)) return halfedges - def cell_edges(self, cell): + def cell_edges(self, cell: Cell) -> list[Edge]: """Return all edges of a cell. Parameters ---------- - cell : int + cell The cell identifier. Returns @@ -3749,22 +4413,30 @@ def cell_edges(self, cell): See Also -------- - :meth:`cell_halfedges`, :meth:`cell_faces`, :meth:`cell_vertices` + cell_halfedges, cell_faces, cell_vertices Notes ----- - This method is similar to :meth:`~compas.datastructures.HalfEdge.edges`, + This method is similar to ~compas.datastructures.HalfEdge.edges, but in the context of a cell of the `VolMesh`. """ - return self.cell_halfedges(cell) + seen = set() + edges = [] + for edge in self.cell_halfedges(cell): + key = _edge_data_key(edge) + if key in seen: + continue + seen.add(key) + edges.append(edge) + return edges - def cell_faces(self, cell): + def cell_faces(self, cell: Cell) -> list[Face]: """The faces of a cell. Parameters ---------- - cell : int + cell Identifier of the cell. Returns @@ -3774,11 +4446,11 @@ def cell_faces(self, cell): See Also -------- - :meth:`cell_halfedges`, :meth:`cell_edges`, :meth:`cell_vertices` + cell_halfedges, cell_edges, cell_vertices Notes ----- - This method is similar to :meth:`~compas.datastructures.HalfEdge.faces`, + This method is similar to ~compas.datastructures.HalfEdge.faces, but in the context of a cell of the `VolMesh`. """ @@ -3787,14 +4459,14 @@ def cell_faces(self, cell): faces.update(self._cell[cell][vertex].values()) return list(faces) - def cell_vertex_neighbors(self, cell, vertex): + def cell_vertex_neighbors(self, cell: Cell, vertex: Vertex) -> list[Vertex]: """Ordered vertex neighbors of a vertex of a cell. Parameters ---------- - cell : int + cell Identifier of the cell. - vertex : int + vertex Identifier of the vertex. Returns @@ -3804,13 +4476,13 @@ def cell_vertex_neighbors(self, cell, vertex): See Also -------- - :meth:`cell_vertex_faces` + cell_vertex_faces Notes ----- All of the returned vertices are part of the cell. - This method is similar to :meth:`~compas.datastructures.HalfEdge.vertex_neighbors`, + This method is similar to ~compas.datastructures.HalfEdge.vertex_neighbors, but in the context of a cell of the `VolMesh`. """ @@ -3818,7 +4490,7 @@ def cell_vertex_neighbors(self, cell, vertex): raise KeyError(vertex) nbrs = [] - for nbr in self._vertex[vertex]: + for nbr in self._edge[vertex]: if nbr in self._cell[cell]: nbrs.append(nbr) @@ -3833,14 +4505,14 @@ def cell_vertex_neighbors(self, cell, vertex): # ordered_vkeys.append(v) # return ordered_vkeys - def cell_vertex_faces(self, cell, vertex): + def cell_vertex_faces(self, cell: Cell, vertex: Vertex) -> list[Face]: """Ordered faces connected to a vertex of a cell. Parameters ---------- - cell : int + cell Identifier of the cell. - vertex : int + vertex Identifier of the vertex. Returns @@ -3850,13 +4522,13 @@ def cell_vertex_faces(self, cell, vertex): See Also -------- - :meth:`cell_vertex_neighbors` + cell_vertex_neighbors Notes ----- All of the returned faces should are part of the same cell. - This method is similar to :meth:`~compas.datastructures.HalfEdge.vertex_faces`, + This method is similar to ~compas.datastructures.HalfEdge.vertex_faces, but in the context of a cell of the `VolMesh`. """ @@ -3879,14 +4551,14 @@ def cell_vertex_faces(self, cell, vertex): return faces - def cell_face_vertices(self, cell, face): + def cell_face_vertices(self, cell: Cell, face: Face) -> list[Vertex]: """The vertices of a face of a cell. Parameters ---------- - cell : int + cell Identifier of the cell. - face : int + face Identifier of the face. Returns @@ -3894,15 +4566,22 @@ def cell_face_vertices(self, cell, face): list[int] The vertices of the face of the cell. + Raises + ------ + KeyError + If the face does not exist. + ValueError + If the face is not part of the cell. + See Also -------- - :meth:`cell_face_halfedges` + cell_face_halfedges Notes ----- All of the returned vertices are part of the cell. - This method is similar to :meth:`~compas.datastructures.HalfEdge.face_vertices`, + This method is similar to ~compas.datastructures.HalfEdge.face_vertices, but in the context of a cell of the `VolMesh`. """ @@ -3916,16 +4595,16 @@ def cell_face_vertices(self, cell, face): if u in self._cell[cell][v] and self._cell[cell][v][u] == face: return self.face_vertices(face)[::-1] - raise Exception("Face is not part of the cell") + raise ValueError("Face {} is not part of cell {}.".format(face, cell)) - def cell_face_halfedges(self, cell, face): + def cell_face_halfedges(self, cell: Cell, face: Face) -> list[Edge]: """The halfedges of a face of a cell. Parameters ---------- - cell : int + cell Identifier of the cell. - face : int + face Identifier of the face. Returns @@ -3935,27 +4614,27 @@ def cell_face_halfedges(self, cell, face): See Also -------- - :meth:`cell_face_vertices` + cell_face_vertices Notes ----- All of the returned halfedges are part of the cell. - This method is similar to :meth:`~compas.datastructures.HalfEdge.face_halfedges`, + This method is similar to ~compas.datastructures.HalfEdge.face_halfedges, but in the context of a cell of the `VolMesh`. """ vertices = self.cell_face_vertices(cell, face) return list(pairwise(vertices + vertices[:1])) - def cell_halfedge_face(self, cell, halfedge): + def cell_halfedge_face(self, cell: Cell, halfedge: Edge) -> Face: """Find the face corresponding to a specific halfedge of a cell. Parameters ---------- - cell : int + cell The identifier of the cell. - halfedge : tuple[int, int] + halfedge The identifier of the halfedge. Returns @@ -3965,11 +4644,11 @@ def cell_halfedge_face(self, cell, halfedge): See Also -------- - :meth:`cell_halfedge_opposite_face` + cell_halfedge_opposite_face Notes ----- - This method is similar to :meth:`~compas.datastructures.HalfEdge.halfedge_face`, + This method is similar to ~compas.datastructures.HalfEdge.halfedge_face, but in the context of a cell of the `VolMesh`. """ @@ -3995,20 +4674,20 @@ def cell_halfedge_face(self, cell, halfedge): # See Also # -------- - # :meth:`cell_halfedge_face` + # cell_halfedge_face # """ # u, v = halfedge # return self._cell[cell][v][u] - def cell_face_neighbors(self, cell, face): + def cell_face_neighbors(self, cell: Cell, face: Face) -> list[Face]: """Find the faces adjacent to a given face of a cell. Parameters ---------- - cell : int + cell The identifier of the cell. - face : int + face The identifier of the face. Returns @@ -4018,11 +4697,11 @@ def cell_face_neighbors(self, cell, face): See Also -------- - :meth:`cell_neighbors` + cell_neighbors Notes ----- - This method is similar to :meth:`~compas.datastructures.HalfEdge.face_neighbors`, + This method is similar to ~compas.datastructures.HalfEdge.face_neighbors, but in the context of a cell of the `VolMesh`. """ @@ -4043,12 +4722,12 @@ def cell_face_neighbors(self, cell, face): nbrs.append(nbr) return nbrs - def cell_neighbors(self, cell): + def cell_neighbors(self, cell: Cell) -> list[Cell]: """Find the neighbors of a given cell. Parameters ---------- - cell : int + cell The identifier of the cell. Returns @@ -4058,7 +4737,8 @@ def cell_neighbors(self, cell): See Also -------- - :meth:`cell_face_neighbors` + cell_face_neighbors + """ nbrs = [] for face in self.cell_faces(cell): @@ -4067,12 +4747,12 @@ def cell_neighbors(self, cell): nbrs.append(nbr) return list(set(nbrs)) - def is_cell_on_boundary(self, cell): + def is_cell_on_boundary(self, cell: Cell) -> bool: """Verify that a cell is on the boundary. Parameters ---------- - cell : int + cell Identifier of the cell. Returns @@ -4083,7 +4763,7 @@ def is_cell_on_boundary(self, cell): See Also -------- - :meth:`is_vertex_on_boundary`, :meth:`is_edge_on_boundary`, :meth:`is_face_on_boundary` + is_vertex_on_boundary, is_edge_on_boundary, is_face_on_boundary """ faces = self.cell_faces(cell) @@ -4092,7 +4772,7 @@ def is_cell_on_boundary(self, cell): return True return False - def cells_on_boundaries(self): + def cells_on_boundaries(self) -> list[Cell]: """Find the cells on the boundary. Returns @@ -4102,7 +4782,7 @@ def cells_on_boundaries(self): See Also -------- - :meth:`vertices_on_boundaries`, :meth:`faces_on_boundaries` + vertices_on_boundaries, faces_on_boundaries """ cells = [] @@ -4115,61 +4795,64 @@ def cells_on_boundaries(self): # Cell Geometry # -------------------------------------------------------------------------- - def cell_points(self, cell): + def cell_points(self, cell: Cell) -> list[Point]: """Compute the points of the vertices of a cell. Parameters ---------- - cell : int + cell The identifier of the cell. Returns ------- - list[:class:`compas.geometry.Point`] + list[Point] The points of the vertices of the cell. See Also -------- - :meth:`cell_polygon`, :meth:`cell_centroid`, :meth:`cell_center` + cell_polygon, cell_centroid, cell_center + """ return [self.vertex_point(vertex) for vertex in self.cell_vertices(cell)] - def cell_centroid(self, cell): + def cell_centroid(self, cell: Cell) -> Point: """Compute the point at the centroid of a cell. Parameters ---------- - cell : int + cell The identifier of the cell. Returns ------- - :class:`compas.geometry.Point` + Point The coordinates of the centroid. See Also -------- - :meth:`cell_center` + cell_center + """ vertices = self.cell_vertices(cell) return Point(*centroid_points([self.vertex_coordinates(vertex) for vertex in vertices])) - def cell_center(self, cell): + def cell_center(self, cell: Cell) -> Point: """Compute the point at the center of mass of a cell. Parameters ---------- - cell : int + cell The identifier of the cell. Returns ------- - :class:`compas.geometry.Point` + Point The coordinates of the center of mass. See Also -------- - :meth:`cell_centroid` + cell_centroid + """ vertices, faces = self.cell_to_vertices_and_faces(cell) return Point(*centroid_polyhedron((vertices, faces))) @@ -4187,7 +4870,7 @@ def cell_center(self, cell): # Returns # ------- - # :class:`compas.geometry.Vector` + # Vector # The components of the normal vector. # """ @@ -4195,29 +4878,29 @@ def cell_center(self, cell): # vectors = [self.face_normal(face) for face in self.vertex_halffaces(vertex) if face in cell_faces] # return Vector(*normalize_vector(centroid_points(vectors))) - def cell_polyhedron(self, cell): + def cell_polyhedron(self, cell: Cell) -> Polyhedron: """Construct a polyhedron from the vertices and faces of a cell. Parameters ---------- - cell : int + cell The identifier of the cell. Returns ------- - :class:`compas.geometry.Polyhedron` + Polyhedron The polyhedron. """ vertices, faces = self.cell_to_vertices_and_faces(cell) return Polyhedron(vertices, faces) - def cell_volume(self, cell): + def cell_volume(self, cell: Cell) -> float: """Compute the volume of a cell. Parameters ---------- - cell : int + cell The identifier of the cell. Returns @@ -4243,7 +4926,7 @@ def cell_volume(self, cell): # See Also # -------- - # :meth:`faces_on_boundaries`, :meth:`cells_on_boundaries` + # faces_on_boundaries, cells_on_boundaries # """ # vertices = set() @@ -4262,7 +4945,7 @@ def cell_volume(self, cell): # See Also # -------- - # :meth:`vertices_on_boundaries`, :meth:`cells_on_boundaries` + # vertices_on_boundaries, cells_on_boundaries # """ # faces = set() @@ -4281,7 +4964,7 @@ def cell_volume(self, cell): # See Also # -------- - # :meth:`vertices_on_boundaries`, :meth:`faces_on_boundaries` + # vertices_on_boundaries, faces_on_boundaries # """ # cells = set() @@ -4298,7 +4981,7 @@ def cell_volume(self, cell): # Parameters # ---------- - # T : :class:`Transformation` + # T : Transformation # The transformation used to transform the mesh. # Returns @@ -4309,7 +4992,7 @@ def cell_volume(self, cell): # Examples # -------- # >>> from compas.datastructures import Mesh - # >>> from compas.geometry import matrix_from_axis_and_angle + # >>> from compas.linalg import matrix_from_axis_and_angle # >>> mesh = Mesh.from_polyhedron(6) # >>> T = matrix_from_axis_and_angle([0, 0, 1], math.pi / 4) # >>> mesh.transform(T) diff --git a/src/compas/datastructures/cell_network/types.py b/src/compas/datastructures/cell_network/types.py new file mode 100644 index 000000000000..2bb193f5f985 --- /dev/null +++ b/src/compas/datastructures/cell_network/types.py @@ -0,0 +1,9 @@ +from typing import Any +from typing import Sequence + +Vertex = int +Face = int +Cell = int +Edge = tuple[Vertex, Vertex] +AttributeDict = dict[str, Any] +PointCoordinates = Sequence[float] diff --git a/src/compas/datastructures/datastructure.py b/src/compas/datastructures/datastructure.py index b144595e0727..39cf17575d46 100644 --- a/src/compas/datastructures/datastructure.py +++ b/src/compas/datastructures/datastructure.py @@ -1,28 +1,30 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import Any +from typing import Mapping +from typing import Optional +from typing import Sequence -try: - from typing import TypeVar # noqa: F401 -except ImportError: - pass -else: - G = TypeVar("G", bound="Datastructure") +from typing_extensions import Self from compas.data import Data +from compas.geometry import Box +from compas.geometry import Transformation class Datastructure(Data): """Base class for all data structures.""" - def __init__(self, attributes=None, name=None): - super(Datastructure, self).__init__(name=name) - self.attributes = attributes or {} - self._aabb = None - self._obb = None + def __init__( + self, + attributes: Optional[Mapping[str, Any]] = None, + name: Optional[str] = None, + ) -> None: + super().__init__(name=name) + self.attributes = dict(attributes or {}) + self._aabb: Optional[Box] = None + self._obb: Optional[Box] = None @property - def __inheritance__(self): + def __inheritance__(self) -> list[str]: """Get the inheritance chain of the datastructure. Until one level above the Datastructure class (eg. Mesh, Graph, ...). @@ -41,12 +43,12 @@ def __inheritance__(self): inheritance.append(cls.__clstype__()) return inheritance - def __jsondump__(self, minimal=False): + def __jsondump__(self, minimal: bool = False) -> dict[str, Any]: """Return the required information for serialization with the COMPAS JSON serializer. Parameters ---------- - minimal : bool, optional + minimal If True, exclude the GUID from the dump dict. Returns @@ -67,23 +69,39 @@ def __jsondump__(self, minimal=False): return state @property - def aabb(self): + def aabb(self) -> Box: if self._aabb is None: self._aabb = self.compute_aabb() return self._aabb @property - def obb(self): + def obb(self) -> Box: if self._obb is None: self._obb = self.compute_obb() return self._obb - def compute_aabb(self): + def to_points(self) -> list[list[float]]: + """Return the points of the datastructure. + + Returns + ------- + list[list[float]] + The point coordinates. + + Raises + ------ + NotImplementedError + If the subclass does not implement this method. + + """ + raise NotImplementedError + + def compute_aabb(self) -> Box: """Compute the axis-aligned bounding box of the datastructure. Returns ------- - :class:`compas.geometry.Box` + Box """ from compas.geometry import Box @@ -91,12 +109,12 @@ def compute_aabb(self): return Box.from_bounding_box(bounding_box(self.to_points())) - def compute_obb(self): + def compute_obb(self) -> Box: """Compute the oriented bounding box of the datastructure. Returns ------- - :class:`compas.geometry.Box` + Box """ from compas.geometry import Box @@ -104,27 +122,32 @@ def compute_obb(self): return Box.from_bounding_box(oriented_bounding_box_numpy(self.to_points())) - def transform(self, transformation): + def transform(self, transformation: Transformation) -> None: """Transforms the data structure. Parameters ---------- - transformation : :class:`Transformation` + transformation The transformation used to transform the data structure. Returns ------- None + Raises + ------ + NotImplementedError + If the subclass does not implement this method. + """ raise NotImplementedError - def transformed(self, transformation): + def transformed(self, transformation: Transformation) -> Self: """Returns a transformed copy of this data structure. Parameters ---------- - transformation : :class:`Transformation` + transformation The transformation used to transform the copy. Returns @@ -137,27 +160,32 @@ def transformed(self, transformation): datastructure.transform(transformation) return datastructure - def transform_numpy(self, transformation): + def transform_numpy(self, transformation: Any) -> None: """Transforms the data structure. Parameters ---------- - transformation : :class:`Transformation` + transformation The transformation used to transform the data structure. Returns ------- None + Raises + ------ + NotImplementedError + If the subclass does not implement this method. + """ raise NotImplementedError - def transformed_numpy(self, transformation): + def transformed_numpy(self, transformation: Any) -> Self: """Returns a transformed copy of this data structure. Parameters ---------- - transformation : :class:`Transformation` + transformation The transformation used to transform the copy. Returns @@ -170,19 +198,19 @@ def transformed_numpy(self, transformation): datastructure.transform_numpy(transformation) return datastructure - def scale(self, x, y=None, z=None): + def scale(self, x: float, y: Optional[float] = None, z: Optional[float] = None) -> None: """Scale the datastructure. Parameters ---------- - x : float + x The scaling factor in the x-direction. - y : float, optional + y The scaling factor in the y-direction. - Defaults to ``x``. - z : float, optional + Defaults to `x`. + z The scaling factor in the z-direction. - Defaults to ``x``. + Defaults to `x`. Returns ------- @@ -206,24 +234,24 @@ def scale(self, x, y=None, z=None): self.transform(Scale.from_factors([x, y, z])) - def scaled(self, x, y=None, z=None): # type: (...) -> G - """Returns a scaled copy of this geometry. + def scaled(self, x: float, y: Optional[float] = None, z: Optional[float] = None) -> Self: + """Returns a scaled copy of this datastructure. Parameters ---------- - x : float + x The scaling factor in the x-direction. - y : float, optional + y The scaling factor in the y-direction. - Defaults to ``x``. - z : float, optional + Defaults to `x`. + z The scaling factor in the z-direction. - Defaults to ``x``. + Defaults to `x`. Returns ------- - :class:`Geometry` - The scaled geometry. + Datastructure + The scaled datastructure. See Also -------- @@ -243,12 +271,12 @@ def scaled(self, x, y=None, z=None): # type: (...) -> G return self.transformed(Scale.from_factors([x, y, z])) - def translate(self, vector): + def translate(self, vector: Sequence[float]) -> None: """Translate the datastructure. Parameters ---------- - vector : :class:`compas.geometry.Vector` + vector The vector used to translate the datastructure. Returns @@ -267,18 +295,18 @@ def translate(self, vector): self.transform(Translation.from_vector(vector)) - def translated(self, vector): # type: (...) -> G - """Returns a translated copy of this geometry. + def translated(self, vector: Sequence[float]) -> Self: + """Returns a translated copy of this datastructure. Parameters ---------- - vector : :class:`compas.geometry.Vector` + vector The vector used to translate the datastructure. Returns ------- - :class:`Geometry` - The translated geometry. + Datastructure + The translated datastructure. See Also -------- @@ -292,17 +320,22 @@ def translated(self, vector): # type: (...) -> G return self.transformed(Translation.from_vector(vector)) - def rotate(self, angle, axis=None, point=None): + def rotate( + self, + angle: float, + axis: Optional[Sequence[float]] = None, + point: Optional[Sequence[float]] = None, + ) -> None: """Rotate the datastructure. Parameters ---------- - angle : float + angle The angle of rotation in radians. - axis : :class:`compas.geometry.Vector`, optional + axis The axis of rotation. Defaults to the z-axis. - point : :class:`compas.geometry.Point`, optional + point The base point of the rotation axis. Defaults to the origin. @@ -325,24 +358,29 @@ def rotate(self, angle, axis=None, point=None): self.transform(Rotation.from_axis_and_angle(axis, angle, point)) - def rotated(self, angle, axis=None, point=None): # type: (...) -> G - """Returns a rotated copy of this geometry. + def rotated( + self, + angle: float, + axis: Optional[Sequence[float]] = None, + point: Optional[Sequence[float]] = None, + ) -> Self: + """Returns a rotated copy of this datastructure. Parameters ---------- - angle : float + angle The angle of rotation in radians. - axis : :class:`compas.geometry.Vector`, optional + axis The axis of rotation. Defaults to the z-axis. - point : :class:`compas.geometry.Point`, optional + point The base point of the rotation axis. Defaults to the origin. Returns ------- - :class:`Geometry` - The rotated geometry. + Datastructure + The rotated datastructure. See Also -------- diff --git a/src/compas/datastructures/graph/duality.py b/src/compas/datastructures/graph/duality.py index 89482267878e..a8a4fea08bb5 100644 --- a/src/compas/datastructures/graph/duality.py +++ b/src/compas/datastructures/graph/duality.py @@ -1,26 +1,37 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import pi +from typing import TYPE_CHECKING +from typing import Iterable +from typing import Mapping +from typing import Optional +from typing import Sequence from compas.geometry import angle_vectors from compas.geometry import is_ccw_xy from compas.itertools import pairwise +from .types import Node + +if TYPE_CHECKING: + from compas.datastructures import Graph + PI2 = 2.0 * pi -def graph_find_cycles(graph, breakpoints=None): +def graph_find_cycles(graph: "Graph", breakpoints: Optional[Iterable[Node]] = None) -> list[list[Node]]: """Find the faces of a graph. Parameters ---------- - graph : :class:`compas.datastructures.Graph` + graph The graph object. - breakpoints : list, optional + breakpoints The vertices at which to break the found faces. + Returns + ------- + list[list[hashable]] + The cycles of the graph. + Notes ----- Breakpoints are primarily used to break up the outside face in between @@ -30,8 +41,8 @@ def graph_find_cycles(graph, breakpoints=None): Warnings -------- - This algorithms is essentially a wall follower (a type of maze-solving algorithm). - It relies on the geometry of the graph to be repesented as a planar, + This algorithm is essentially a wall follower (a type of maze-solving algorithm). + It relies on the geometry of the graph to be represented as a planar, straight-line embedding. It determines an ordering of the neighboring vertices around each vertex, and then follows the *walls* of the graph, always taking turns in the same direction. @@ -40,6 +51,9 @@ def graph_find_cycles(graph, breakpoints=None): if not breakpoints: breakpoints = [] + if not graph.number_of_edges(): + return [] + for u, v in graph.edges(): graph.adjacency[u][v] = None graph.adjacency[v][u] = None @@ -90,7 +104,22 @@ def graph_find_cycles(graph, breakpoints=None): return cycles -def graph_node_find_first_neighbor(graph, key): +def graph_node_find_first_neighbor(graph: "Graph", key: Node) -> Node: + """Find the first neighbor of a node in clockwise order from the negative XY diagonal. + + Parameters + ---------- + graph + The graph object. + key + The node identifier. + + Returns + ------- + hashable + The identifier of the first neighbor. + + """ nbrs = graph.neighbors(key) if len(nbrs) == 1: return nbrs[0] @@ -108,7 +137,22 @@ def graph_node_find_first_neighbor(graph, key): return nbrs[angles.index(min(angles))] -def graph_sort_neighbors(graph, ccw=True): +def graph_sort_neighbors(graph: "Graph", ccw: bool = True) -> dict[Node, list[Node]]: + """Sort the neighbors of every node around the node in the XY plane. + + Parameters + ---------- + graph + The graph object. + ccw + If True, sort the neighbors counterclockwise. + + Returns + ------- + dict[hashable, list[hashable]] + The sorted neighbors keyed by node identifier. + + """ sorted_neighbors = {} xyz = {key: graph.node_coordinates(key) for key in graph.nodes()} for key in graph.nodes(): @@ -119,10 +163,34 @@ def graph_sort_neighbors(graph, ccw=True): return sorted_neighbors -def node_sort_neighbors(key, nbrs, xyz, ccw=True): +def node_sort_neighbors( + key: Node, + nbrs: Sequence[Node], + xyz: Mapping[Node, Sequence[float]], + ccw: bool = True, +) -> list[Node]: + """Sort the neighbors of a node around the node in the XY plane. + + Parameters + ---------- + key + The node identifier. + nbrs + The identifiers of the neighboring nodes. + xyz + A mapping of node identifiers to point coordinates. + ccw + If True, sort the neighbors counterclockwise. + + Returns + ------- + list[hashable] + The sorted neighbor identifiers. + + """ if len(nbrs) == 1: - return nbrs - ordered = nbrs[0:1] + return list(nbrs) + ordered = list(nbrs[0:1]) a = xyz[key] for i, nbr in enumerate(nbrs[1:]): c = xyz[nbr] @@ -148,7 +216,22 @@ def node_sort_neighbors(key, nbrs, xyz, ccw=True): return ordered -def graph_find_edge_cycle(graph, edge): +def graph_find_edge_cycle(graph: "Graph", edge: tuple[Node, Node]) -> list[Node]: + """Find the cycle to the left of a directed edge. + + Parameters + ---------- + graph + The graph object. + edge + The directed edge from which to start. + + Returns + ------- + list[hashable] + The nodes of the cycle. + + """ u, v = edge cycle = [u] while True: @@ -161,7 +244,22 @@ def graph_find_edge_cycle(graph, edge): return cycle -def _break_cycles(cycles, breakpoints): +def _break_cycles(cycles: Mapping[int, list[Node]], breakpoints: Iterable[Node]) -> list[list[Node]]: + """Break cycles at specified nodes. + + Parameters + ---------- + cycles + A mapping of cycle identifiers to node cycles. + breakpoints + The nodes at which to break the cycles. + + Returns + ------- + list[list[hashable]] + The resulting cycles. + + """ breakpoints = set(breakpoints) broken = [] diff --git a/src/compas/datastructures/graph/graph.py b/src/compas/datastructures/graph/graph.py index 49b396fcf8e7..3d165daf0eb3 100644 --- a/src/compas/datastructures/graph/graph.py +++ b/src/compas/datastructures/graph/graph.py @@ -1,37 +1,39 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from ast import literal_eval +from collections.abc import Mapping from itertools import combinations from random import sample from random import shuffle - -import compas - -if compas.PY2: - from collections import Mapping -else: - from collections.abc import Mapping +from typing import Any +from typing import Callable +from typing import Iterable +from typing import Iterator +from typing import Literal +from typing import Optional +from typing import Sequence +from typing import Union +from typing import overload + +from typing_extensions import Self from compas.datastructures.attributes import EdgeAttributeView from compas.datastructures.attributes import NodeAttributeView from compas.datastructures.datastructure import Datastructure -from compas.files import OBJ +from compas.files import obj_data +from compas.files import read_obj from compas.geometry import Box from compas.geometry import Line from compas.geometry import Point from compas.geometry import Vector -from compas.geometry import add_vectors from compas.geometry import bounding_box from compas.geometry import centroid_points from compas.geometry import distance_point_point from compas.geometry import midpoint_line -from compas.geometry import normalize_vector from compas.geometry import oriented_bounding_box -from compas.geometry import scale_vector -from compas.geometry import subtract_vectors from compas.geometry import transform_points +from compas.linalg.vectors import add_vectors +from compas.linalg.vectors import normalize_vector +from compas.linalg.vectors import scale_vector +from compas.linalg.vectors import subtract_vectors from compas.tolerance import TOL from compas.topology import astar_shortest_path from compas.topology import breadth_first_traverse @@ -48,6 +50,11 @@ from .planarity import graph_is_planar_embedding from .planarity import graph_is_xy from .smoothing import graph_smooth_centroid +from .types import AttributeDict +from .types import Edge +from .types import Node + +_MISSING = object() class Graph(Datastructure): @@ -55,24 +62,24 @@ class Graph(Datastructure): Parameters ---------- - default_node_attributes : dict, optional + default_node_attributes Default values for node attributes. - default_edge_attributes : dict, optional + default_edge_attributes Default values for edge attributes. - name : str, optional + name The name of the graph. - **kwargs : dict, optional + **kwargs Additional keyword arguments, which are stored in the attributes dict. Attributes ---------- - default_node_attributes : dict[str, Any] + default_node_attributes dictionary containing default values for the attributes of nodes. - It is recommended to add a default to this dictionary using :meth:`update_default_node_attributes` + It is recommended to add a default to this dictionary using update_default_node_attributes for every node attribute used in the data structure. - default_edge_attributes : dict[str, Any] + default_edge_attributes dictionary containing default values for the attributes of edges. - It is recommended to add a default to this dictionary using :meth:`update_default_edge_attributes` + It is recommended to add a default to this dictionary using update_default_edge_attributes for every edge attribute used in the data structure. """ @@ -89,37 +96,8 @@ class Graph(Datastructure): embed_in_plane = graph_embed_in_plane find_cycles = graph_find_cycles - DATASCHEMA = { - "type": "object", - "properties": { - "attributes": {"type": "object"}, - "default_node_attributes": {"type": "object"}, - "default_edge_attributes": {"type": "object"}, - "node": { - "type": "object", - "additionalProperties": {"type": "object"}, - }, - "edge": { - "type": "object", - "additionalProperties": { - "type": "object", - "additionalProperties": {"type": "object"}, - }, - }, - "max_node": {"type": "integer", "minimum": -1}, - }, - "required": [ - "attributes", - "default_node_attributes", - "default_edge_attributes", - "node", - "edge", - "max_node", - ], - } - @property - def __data__(self): + def __data__(self) -> dict[str, Any]: return self.__before_json_dump__( { "attributes": self.attributes, @@ -131,12 +109,12 @@ def __data__(self): } ) - def __before_json_dump__(self, data): + def __before_json_dump__(self, data: dict[str, Any]) -> dict[str, Any]: data["node"] = {repr(key): attr for key, attr in data["node"].items()} data["edge"] = {repr(u): {repr(v): attr for v, attr in nbrs.items()} for u, nbrs in data["edge"].items()} return data - def __after_json_load__(self, data): + def __after_json_load__(self, data: dict[str, Any]) -> dict[str, Any]: l_e = literal_eval nodes = data["node"] or {} edges = data["edge"] or {} @@ -145,7 +123,7 @@ def __after_json_load__(self, data): return data @classmethod - def __from_data__(cls, data): + def __from_data__(cls, data: dict[str, Any]) -> Self: graph = cls( default_node_attributes=data.get("default_node_attributes"), default_edge_attributes=data.get("default_edge_attributes"), @@ -162,12 +140,12 @@ def __from_data__(cls, data): def __init__( self, - default_node_attributes=None, - default_edge_attributes=None, - name=None, - **kwargs - ): # fmt: skip - super(Graph, self).__init__(kwargs, name=name) + default_node_attributes: Optional[AttributeDict] = None, + default_edge_attributes: Optional[AttributeDict] = None, + name: Optional[str] = None, + **kwargs: Any, + ) -> None: + super().__init__(kwargs, name=name) self._max_node = -1 self.node = {} self.edge = {} @@ -179,7 +157,7 @@ def __init__( if default_edge_attributes: self.default_edge_attributes.update(default_edge_attributes) - def __str__(self): + def __str__(self) -> str: tpl = "" return tpl.format(self.number_of_nodes(), self.number_of_edges()) @@ -188,21 +166,21 @@ def __str__(self): # -------------------------------------------------------------------------- @classmethod - def from_edges(cls, edges): + def from_edges(cls, edges: Iterable[Edge]) -> Self: """Create a new graph instance from information about the edges. Parameters ---------- - edges : list[tuple[hashable, hashable]] + edges The edges of the graph as pairs of node identifiers. Returns ------- - :class:`compas.datastructures.Graph` + Graph See Also -------- - :meth:`from_networkx` + from_networkx """ graph = cls() @@ -215,22 +193,22 @@ def from_edges(cls, edges): return graph @classmethod - def from_networkx(cls, graph): + def from_networkx(cls, graph: Any) -> Self: """Create a new graph instance from a NetworkX DiGraph instance. Parameters ---------- - graph : networkx.DiGraph + graph NetworkX instance of a directed graph. Returns ------- - :class:`compas.datastructures.Graph` + Graph See Also -------- - :meth:`to_networkx` - :meth:`from_edges` + to_networkx + from_edges """ g = cls() @@ -245,60 +223,53 @@ def from_networkx(cls, graph): return g @classmethod - def from_obj(cls, filepath, precision=None): + def from_obj(cls, filepath: Any, precision: Optional[int] = None) -> Self: """Construct a graph from the data contained in an OBJ file. Parameters ---------- - filepath : path string | file-like object | URL string + filepath A path, a file-like object or a URL pointing to a file. - precision: str, optional + precision The precision of the geometric map that is used to connect the lines. Returns ------- - :class:`compas.datastructures.Graph` + Graph A graph object. See Also -------- - :meth:`to_obj` - :meth:`from_lines`, :meth:`from_nodes_and_edges`, :meth:`from_pointcloud` - :class:`compas.files.OBJ` + to_obj + from_lines, from_nodes_and_edges, from_pointcloud + OBJ """ - graph = cls() - obj = OBJ(filepath, precision) - obj.read() - nodes = obj.vertices - edges = obj.lines - for i, (x, y, z) in enumerate(nodes): # type: ignore - graph.add_node(i, x=x, y=y, z=z) - for edge in edges: # type: ignore - graph.add_edge(*edge) - return graph + data = obj_data(read_obj(filepath)) + lines = [(data.vertices[u], data.vertices[v]) for u, v in data.lines] + return cls.from_lines(lines, precision=precision) @classmethod - def from_lines(cls, lines, precision=None): + def from_lines(cls, lines: Iterable[Sequence[Any]], precision: Optional[int] = None) -> Self: """Construct a graph from a set of lines represented by their start and end point coordinates. Parameters ---------- - lines : list[tuple[list[float, list[float]]]] + lines A list of pairs of point coordinates. - precision : int, optional + precision Precision for converting numbers to strings. - Default is :attr:`TOL.precision`. + Default is TOL.precision. Returns ------- - :class:`compas.datastructures.Graph` + Graph A graph object. See Also -------- - :meth:`to_lines` - :meth:`from_obj`, :meth:`from_nodes_and_edges`, :meth:`from_pointcloud` + to_lines + from_obj, from_nodes_and_edges, from_pointcloud """ graph = cls() @@ -323,24 +294,28 @@ def from_lines(cls, lines, precision=None): return graph @classmethod - def from_nodes_and_edges(cls, nodes, edges): + def from_nodes_and_edges( + cls, + nodes: Union[Sequence[Sequence[float]], dict[Node, Sequence[float]]], + edges: Iterable[Edge], + ) -> Self: """Construct a graph from nodes and edges. Parameters ---------- - nodes : list[list[float]] | dict[hashable, list[float]] + nodes A list of node coordinates or a dictionary of keys pointing to node coordinates to specify keys. - edges : list[tuple[hashable, hshable]] + edges Returns ------- - :class:`compas.datastructures.Graph` + Graph A graph object. See Also -------- - :meth:`to_nodes_and_edges` - :meth:`from_obj`, :meth:`from_lines`, :meth:`from_pointcloud` + to_nodes_and_edges + from_obj, from_lines, from_pointcloud """ graph = cls() @@ -358,25 +333,25 @@ def from_nodes_and_edges(cls, nodes, edges): return graph @classmethod - def from_pointcloud(cls, cloud, degree=3): + def from_pointcloud(cls, cloud: Iterable[Sequence[float]], degree: int = 3) -> Self: """Construct a graph from random connections between the points of a pointcloud. Parameters ---------- - cloud : :class:`compas.geometry.Pointcloud` + cloud A pointcloud object. - degree : int, optional + degree The number of connections per node. Returns ------- - :class:`compas.datastructures.Graph` + Graph A graph object. See Also -------- - :meth:`to_points` - :meth:`from_obj`, :meth:`from_lines`, :meth:`from_nodes_and_edges` + to_points + from_obj, from_lines, from_nodes_and_edges """ graph = cls() @@ -399,12 +374,12 @@ def from_pointcloud(cls, cloud, degree=3): # Converters # -------------------------------------------------------------------------- - def to_obj(self): + def to_obj(self) -> None: """Write the graph to an OBJ file. Parameters ---------- - filepath : path string | file-like object + filepath A path or a file-like object pointing to a file. Returns @@ -413,13 +388,13 @@ def to_obj(self): See Also -------- - :meth:`from_obj` - :meth:`to_lines`, :meth:`to_nodes_and_edges`, :meth:`to_points` + from_obj + to_lines, to_nodes_and_edges, to_points """ raise NotImplementedError - def to_points(self): + def to_points(self) -> list[list[float]]: """Return the coordinates of the graph. Returns @@ -429,13 +404,13 @@ def to_points(self): See Also -------- - :meth:`from_pointcloud` - :meth:`to_lines`, :meth:`to_nodes_and_edges`, :meth:`to_obj` + from_pointcloud + to_lines, to_nodes_and_edges, to_obj """ return [self.node_coordinates(key) for key in self.nodes()] - def to_lines(self): + def to_lines(self) -> list[tuple[list[float], list[float]]]: """Return the lines of the graph as pairs of start and end point coordinates. Returns @@ -445,26 +420,25 @@ def to_lines(self): See Also -------- - :meth:`from_lines` - :meth:`to_nodes_and_edges`, :meth:`to_obj`, :meth:`to_points` + from_lines + to_nodes_and_edges, to_obj, to_points """ return [self.edge_coordinates(edge) for edge in self.edges()] - def to_nodes_and_edges(self): + def to_nodes_and_edges(self) -> tuple[list[list[float]], list[tuple[int, int]]]: """Return the nodes and edges of a graph. Returns ------- - list[list[float]] - A list of nodes, represented by their XYZ coordinates. - list[tuple[hashable, hashable]] - A list of edges, with each edge represented by a pair of indices in the node list. + tuple[list[list[float]], list[tuple[int, int]]] + A list of nodes, represented by their XYZ coordinates, and + a list of edges, with each edge represented by a pair of indices in the node list. See Also -------- - :meth:`from_nodes_and_edges` - :meth:`to_lines`, :meth:`to_obj`, :meth:`to_points` + from_nodes_and_edges + to_lines, to_obj, to_points """ key_index = dict((key, index) for index, key in enumerate(self.nodes())) @@ -472,7 +446,7 @@ def to_nodes_and_edges(self): edges = [(key_index[u], key_index[v]) for u, v in self.edges()] return nodes, edges - def to_networkx(self): + def to_networkx(self) -> Any: """Create a new NetworkX graph instance from a graph. Returns @@ -482,7 +456,7 @@ def to_networkx(self): See Also -------- - :meth:`from_networkx` + from_networkx """ import networkx as nx @@ -502,7 +476,7 @@ def to_networkx(self): # Helpers # -------------------------------------------------------------------------- - def clear(self): + def clear(self) -> None: """Clear all the graph data. Returns @@ -517,12 +491,12 @@ def clear(self): self.edge = {} self.adjacency = {} - def node_sample(self, size=1): + def node_sample(self, size: int = 1) -> list[Node]: """Get a list of identifiers of a random set of n nodes. Parameters ---------- - size : int, optional + size The size of the sample. Returns @@ -532,17 +506,17 @@ def node_sample(self, size=1): See Also -------- - :meth:`edge_sample` + edge_sample """ return sample(list(self.nodes()), size) - def edge_sample(self, size=1): + def edge_sample(self, size: int = 1) -> list[Edge]: """Get the identifiers of a set of random edges. Parameters ---------- - size : int, optional + size The size of the sample. Returns @@ -552,12 +526,12 @@ def edge_sample(self, size=1): See Also -------- - :meth:`node_sample` + node_sample """ return sample(list(self.edges()), size) - def node_index(self): + def node_index(self) -> dict[Node, int]: """Returns a dictionary that maps node identifiers to their corresponding index in a node list or array. Returns @@ -567,13 +541,13 @@ def node_index(self): See Also -------- - :meth:`index_node` - :meth:`edge_index` + index_node + edge_index """ return {key: index for index, key in enumerate(self.nodes())} - def index_node(self): + def index_node(self) -> dict[int, Node]: """Returns a dictionary that maps the indices of a node list to keys in a node dictionary. Returns @@ -583,13 +557,13 @@ def index_node(self): See Also -------- - :meth:`node_index` - :meth:`index_edge` + node_index + index_edge """ return dict(enumerate(self.nodes())) - def edge_index(self): + def edge_index(self) -> dict[Edge, int]: """Returns a dictionary that maps edge identifiers (i.e. pairs of vertex identifiers) to the corresponding edge index in a list or array of edges. @@ -600,13 +574,13 @@ def edge_index(self): See Also -------- - :meth:`index_edge` - :meth:`node_index` + index_edge + node_index """ return {(u, v): index for index, (u, v) in enumerate(self.edges())} - def index_edge(self): + def index_edge(self) -> dict[int, Edge]: """Returns a dictionary that maps edges in a list to the corresponding vertex identifier pairs. @@ -617,21 +591,21 @@ def index_edge(self): See Also -------- - :meth:`edge_index` - :meth:`index_node` + edge_index + index_node """ return dict(enumerate(self.edges())) - def node_gkey(self, precision=None): + def node_gkey(self, precision: Optional[int] = None) -> dict[Node, str]: """Returns a dictionary that maps node identifiers to the corresponding *geometric key* up to a certain precision. Parameters ---------- - precision : int, optional + precision Precision for converting numbers to strings. - Default is :attr:`TOL.precision`. + Default is TOL.precision. Returns ------- @@ -640,23 +614,23 @@ def node_gkey(self, precision=None): See Also -------- - :meth:`gkey_node` - :meth:`compas.Tolerance.geometric_key` + gkey_node + TOL.geometric_key """ gkey = TOL.geometric_key xyz = self.node_coordinates return {key: gkey(xyz(key), precision) for key in self.nodes()} - def gkey_node(self, precision=None): + def gkey_node(self, precision: Optional[int] = None) -> dict[str, Node]: """Returns a dictionary that maps *geometric keys* of a certain precision to the identifiers of the corresponding nodes. Parameters ---------- - precision : int, optional + precision Precision for converting numbers to strings. - Default is :attr:`TOL.precision`. + Default is TOL.precision. Returns ------- @@ -665,8 +639,8 @@ def gkey_node(self, precision=None): See Also -------- - :meth:`node_gkey` - :meth:`compas.Tolerance.geometric_key` + node_gkey + TOL.geometric_key """ gkey = TOL.geometric_key @@ -677,17 +651,22 @@ def gkey_node(self, precision=None): # Builders # -------------------------------------------------------------------------- - def add_node(self, key=None, attr_dict=None, **kwattr): + def add_node( + self, + key: Optional[Node] = None, + attr_dict: Optional[AttributeDict] = None, + **kwattr: Any, + ) -> Node: """Add a node and specify its attributes (optional). Parameters ---------- - key : hashable, optional + key An identifier for the node. Defaults to None, in which case an identifier of type int is automatically generated. - attr_dict : dict[str, Any], optional + attr_dict A dictionary of vertex attributes. - **kwattr : dict[str, Any], optional + **kwattr A dictionary of additional attributes compiled of remaining named arguments. Returns @@ -697,8 +676,8 @@ def add_node(self, key=None, attr_dict=None, **kwattr): See Also -------- - :meth:`add_edge` - :meth:`delete_node` + add_edge + delete_node Notes ----- @@ -716,11 +695,8 @@ def add_node(self, key=None, attr_dict=None, **kwattr): """ if key is None: key = self._max_node = self._max_node + 1 - try: - if key > self._max_node: - self._max_node = key - except (ValueError, TypeError): - pass + elif isinstance(key, int) and key > self._max_node: + self._max_node = key if key not in self.node: self.node[key] = {} @@ -731,18 +707,24 @@ def add_node(self, key=None, attr_dict=None, **kwattr): self.node[key].update(attr) return key - def add_edge(self, u, v, attr_dict=None, **kwattr): + def add_edge( + self, + u: Node, + v: Node, + attr_dict: Optional[AttributeDict] = None, + **kwattr: Any, + ) -> Edge: """Add an edge and specify its attributes. Parameters ---------- - u : hashable + u The identifier of the first node of the edge. - v : hashable + v The identifier of the second node of the edge. - attr_dict : dict[str, Any], optional + attr_dict A dictionary of edge attributes. - **kwattr : dict[str, Any], optional + **kwattr A dictionary of additional attributes compiled of remaining named arguments. Returns @@ -752,8 +734,8 @@ def add_edge(self, u, v, attr_dict=None, **kwattr): See Also -------- - :meth:`add_node` - :meth:`delete_edge` + add_node + delete_edge Examples -------- @@ -779,12 +761,12 @@ def add_edge(self, u, v, attr_dict=None, **kwattr): # Modifiers # -------------------------------------------------------------------------- - def delete_node(self, key): + def delete_node(self, key: Node) -> None: """Delete a node from the graph. Parameters ---------- - key : hashable + key The identifier of the node. Returns @@ -793,8 +775,8 @@ def delete_node(self, key): See Also -------- - :meth:`delete_edge` - :meth:`add_node` + delete_edge + add_node Examples -------- @@ -816,12 +798,12 @@ def delete_node(self, key): if v == key: del self.adjacency[u][v] - def delete_edge(self, edge): + def delete_edge(self, edge: Edge) -> None: """Delete an edge from the graph. Parameters ---------- - edge : tuple[hashable, hashable] + edge The identifier of the edge as a pair of node identifiers. Returns @@ -830,8 +812,8 @@ def delete_edge(self, edge): See Also -------- - :meth:`delete_node` - :meth:`add_edge` + delete_node + add_edge Examples -------- @@ -854,7 +836,7 @@ def delete_edge(self, edge): # Info # -------------------------------------------------------------------------- - def summary(self): + def summary(self) -> str: """Return a summary of the graph. Returns @@ -873,7 +855,7 @@ def summary(self): ) return tpl.format(self.name, self.number_of_nodes(), self.number_of_edges()) - def number_of_nodes(self): + def number_of_nodes(self) -> int: """Compute the number of nodes of the graph. Returns @@ -883,12 +865,12 @@ def number_of_nodes(self): See Also -------- - :meth:`number_of_edges` + number_of_edges """ return len(list(self.nodes())) - def number_of_edges(self): + def number_of_edges(self) -> int: """Compute the number of edges of the graph. Returns @@ -898,12 +880,12 @@ def number_of_edges(self): See Also -------- - :meth:`number_of_nodes` + number_of_nodes """ return len(list(self.edges())) - def is_connected(self): + def is_connected(self) -> bool: """Verify that the graph is connected. @@ -935,24 +917,34 @@ def is_connected(self): # Accessors # -------------------------------------------------------------------------- - def nodes(self, data=False): + @overload + def nodes(self, data: Literal[False] = False) -> Iterator[Node]: ... + + @overload + def nodes(self, data: Literal[True]) -> Iterator[tuple[Node, NodeAttributeView]]: ... + + @overload + def nodes(self, data: bool) -> Iterator[Union[Node, tuple[Node, NodeAttributeView]]]: ... + + def nodes(self, data: bool = False) -> Iterator[Any]: """Iterate over the nodes of the graph. Parameters ---------- - data : bool, optional + data If True, yield the node attributes in addition to the node identifiers. Yields ------ - hashable | tuple[hashable, dict[str, Any]] - If `data` is False, the next node identifier. - If `data` is True, the next node as a (key, attr) tuple. + hashable + The node identifier if `data` is `False`. + tuple[hashable, NodeAttributeView] + The node identifier and its attributes if `data` is `True`. See Also -------- - :meth:`nodes_where`, :meth:`nodes_where_predicate` - :meth:`edges`, :meth:`edges_where`, :meth:`edges_where_predicate` + nodes_where, nodes_where_predicate + edges, edges_where, edges_where_predicate """ for key in self.node: @@ -961,28 +953,58 @@ def nodes(self, data=False): else: yield key, self.node_attributes(key) - def nodes_where(self, conditions=None, data=False, **kwargs): + @overload + def nodes_where( + self, + conditions: Optional[AttributeDict] = None, + data: Literal[False] = False, + **kwargs: Any, + ) -> Iterator[Node]: ... + + @overload + def nodes_where( + self, + conditions: Optional[AttributeDict], + data: Literal[True], + **kwargs: Any, + ) -> Iterator[tuple[Node, NodeAttributeView]]: ... + + @overload + def nodes_where( + self, + conditions: Optional[AttributeDict] = None, + data: bool = False, + **kwargs: Any, + ) -> Iterator[Union[Node, tuple[Node, NodeAttributeView]]]: ... + + def nodes_where( + self, + conditions: Optional[AttributeDict] = None, + data: bool = False, + **kwargs: Any, + ) -> Iterator[Any]: """Get nodes for which a certain condition or set of conditions is true. Parameters ---------- - conditions : dict, optional + conditions A set of conditions in the form of key-value pairs. The keys should be attribute names. The values can be attribute values or ranges of attribute values in the form of min/max pairs. - data : bool, optional + data If True, yield the node attributes in addition to the node identifiers. Yields ------ - hashable | tuple[hashable, dict[str, Any]] - If `data` is False, the next node that matches the condition. - If `data` is True, the next node and its attributes. + hashable + A matching node identifier if `data` is `False`. + tuple[hashable, NodeAttributeView] + A matching node identifier and its attributes if `data` is `True`. See Also -------- - :meth:`nodes`, :meth:`nodes_where_predicate` - :meth:`edges`, :meth:`edges_where`, :meth:`edges_where_predicate` + nodes, nodes_where_predicate + edges, edges_where, edges_where_predicate """ conditions = conditions or {} @@ -1037,27 +1059,53 @@ def nodes_where(self, conditions=None, data=False, **kwargs): else: yield key - def nodes_where_predicate(self, predicate, data=False): + @overload + def nodes_where_predicate( + self, + predicate: Callable[[Node, NodeAttributeView], bool], + data: Literal[False] = False, + ) -> Iterator[Node]: ... + + @overload + def nodes_where_predicate( + self, + predicate: Callable[[Node, NodeAttributeView], bool], + data: Literal[True], + ) -> Iterator[tuple[Node, NodeAttributeView]]: ... + + @overload + def nodes_where_predicate( + self, + predicate: Callable[[Node, NodeAttributeView], bool], + data: bool, + ) -> Iterator[Union[Node, tuple[Node, NodeAttributeView]]]: ... + + def nodes_where_predicate( + self, + predicate: Callable[[Node, NodeAttributeView], bool], + data: bool = False, + ) -> Iterator[Any]: """Get nodes for which a certain condition or set of conditions is true using a lambda function. Parameters ---------- - predicate : callable + predicate The condition you want to evaluate. - The callable takes 2 parameters: the node identifier and the node attributes, and should return True or False. - data : bool, optional + The callable takes 2 parameters + data If True, yield the node attributes in addition to the node identifiers. Yields ------ - hashable | tuple[hashable, dict[str, Any]] - If `data` is False, the next node that matches the condition. - If `data` is True, the next node and its attributes. + hashable + A matching node identifier if `data` is `False`. + tuple[hashable, NodeAttributeView] + A matching node identifier and its attributes if `data` is `True`. See Also -------- - :meth:`nodes`, :meth:`nodes_where` - :meth:`edges`, :meth:`edges_where`, :meth:`edges_where_predicate` + nodes, nodes_where + edges, edges_where, edges_where_predicate Examples -------- @@ -1071,24 +1119,34 @@ def nodes_where_predicate(self, predicate, data=False): else: yield key - def edges(self, data=False): + @overload + def edges(self, data: Literal[False] = False) -> Iterator[Edge]: ... + + @overload + def edges(self, data: Literal[True]) -> Iterator[tuple[Edge, EdgeAttributeView]]: ... + + @overload + def edges(self, data: bool) -> Iterator[Union[Edge, tuple[Edge, EdgeAttributeView]]]: ... + + def edges(self, data: bool = False) -> Iterator[Any]: """Iterate over the edges of the graph. Parameters ---------- - data : bool, optional + data If True, yield the edge attributes in addition to the edge identifiers. Yields ------ - tuple[hashable, hashable] | tuple[tuple[hashable, hashable], dict[str, Any]] - If `data` is False, the next edge identifier (u, v). - If `data` is True, the next edge identifier and its attributes as a ((u, v), attr) tuple. + tuple[hashable, hashable] + The edge identifier if `data` is `False`. + tuple[tuple[hashable, hashable], EdgeAttributeView] + The edge identifier and its attributes if `data` is `True`. See Also -------- - :meth:`edges_where`, :meth:`edges_where_predicate` - :meth:`nodes`, :meth:`nodes_where`, :meth:`nodes_where_predicate` + edges_where, edges_where_predicate + nodes, nodes_where, nodes_where_predicate """ for u, nbrs in iter(self.edge.items()): @@ -1098,30 +1156,60 @@ def edges(self, data=False): else: yield u, v - def edges_where(self, conditions=None, data=False, **kwargs): + @overload + def edges_where( + self, + conditions: Optional[AttributeDict] = None, + data: Literal[False] = False, + **kwargs: Any, + ) -> Iterator[Edge]: ... + + @overload + def edges_where( + self, + conditions: Optional[AttributeDict], + data: Literal[True], + **kwargs: Any, + ) -> Iterator[tuple[Edge, EdgeAttributeView]]: ... + + @overload + def edges_where( + self, + conditions: Optional[AttributeDict] = None, + data: bool = False, + **kwargs: Any, + ) -> Iterator[Union[Edge, tuple[Edge, EdgeAttributeView]]]: ... + + def edges_where( + self, + conditions: Optional[AttributeDict] = None, + data: bool = False, + **kwargs: Any, + ) -> Iterator[Any]: """Get edges for which a certain condition or set of conditions is true. Parameters ---------- - conditions : dict, optional + conditions A set of conditions in the form of key-value pairs. The keys should be attribute names. The values can be attribute values or ranges of attribute values in the form of min/max pairs. - data : bool, optional + data If True, yield the edge attributes in addition to the edge identifiers. - **kwargs : dict[str, Any], optional + **kwargs Additional conditions provided as named function arguments. Yields ------ - tuple[hashable, hashable] | tuple[tuple[hashable, hashable], dict[str, Any]] - If `data` is False, the next edge identifier (u, v). - If `data` is True, the next edge identifier and its attributes as a ((u, v), attr) tuple. + tuple[hashable, hashable] + A matching edge identifier if `data` is `False`. + tuple[tuple[hashable, hashable], EdgeAttributeView] + A matching edge identifier and its attributes if `data` is `True`. See Also -------- - :meth:`edges`, :meth:`edges_where_predicate` - :meth:`nodes`, :meth:`nodes_where`, :meth:`nodes_where_predicate` + edges, edges_where_predicate + nodes, nodes_where, nodes_where_predicate """ conditions = conditions or {} @@ -1163,29 +1251,55 @@ def edges_where(self, conditions=None, data=False, **kwargs): else: yield key - def edges_where_predicate(self, predicate, data=False): + @overload + def edges_where_predicate( + self, + predicate: Callable[[Edge, EdgeAttributeView], bool], + data: Literal[False] = False, + ) -> Iterator[Edge]: ... + + @overload + def edges_where_predicate( + self, + predicate: Callable[[Edge, EdgeAttributeView], bool], + data: Literal[True], + ) -> Iterator[tuple[Edge, EdgeAttributeView]]: ... + + @overload + def edges_where_predicate( + self, + predicate: Callable[[Edge, EdgeAttributeView], bool], + data: bool, + ) -> Iterator[Union[Edge, tuple[Edge, EdgeAttributeView]]]: ... + + def edges_where_predicate( + self, + predicate: Callable[[Edge, EdgeAttributeView], bool], + data: bool = False, + ) -> Iterator[Any]: """Get edges for which a certain condition or set of conditions is true using a lambda function. Parameters ---------- - predicate : callable + predicate The condition you want to evaluate. The callable takes 2 parameters: an edge identifier (tuple of node identifiers) and edge attributes, and should return True or False. - data : bool, optional + data If True, yield the edge attributes in addition to the edge attributes. Yields ------ - tuple[hashable, hashable] | tuple[tuple[hashable, hashable], dict[str, Any]] - If `data` is False, the next edge identifier (u, v). - If `data` is True, the next edge identifier and its attributes as a ((u, v), attr) tuple. + tuple[hashable, hashable] + A matching edge identifier if `data` is `False`. + tuple[tuple[hashable, hashable], EdgeAttributeView] + A matching edge identifier and its attributes if `data` is `True`. See Also -------- - :meth:`edges`, :meth:`edges_where` - :meth:`nodes`, :meth:`nodes_where`, :meth:`nodes_where_predicate` + edges, edges_where + nodes, nodes_where, nodes_where_predicate Examples -------- @@ -1199,14 +1313,14 @@ def edges_where_predicate(self, predicate, data=False): else: yield key - def shortest_path(self, u, v): + def shortest_path(self, u: Node, v: Node) -> Optional[list[Node]]: """Find the shortest path between two nodes using the A* algorithm. Parameters ---------- - u : hashable + u The identifier of the start node. - v : hashable + v The identifier of the end node. Returns @@ -1216,7 +1330,7 @@ def shortest_path(self, u, v): See Also -------- - :meth:`compas.topology.astar_shortest_path` + astar_shortest_path """ return astar_shortest_path(self, u, v) @@ -1225,14 +1339,14 @@ def shortest_path(self, u, v): # Default attributes # -------------------------------------------------------------------------- - def update_default_node_attributes(self, attr_dict=None, **kwattr): + def update_default_node_attributes(self, attr_dict: Optional[AttributeDict] = None, **kwattr: Any) -> None: """Update the default node attributes. Parameters ---------- - attr_dict : dict[str, Any], optional + attr_dict A dictionary of attributes with their default values. - **kwattr : dict[str, Any], optional + **kwattr A dictionary of additional attributes compiled of remaining named arguments. Returns @@ -1241,7 +1355,7 @@ def update_default_node_attributes(self, attr_dict=None, **kwattr): See Also -------- - :meth:`update_default_edge_attributes` + update_default_edge_attributes """ if not attr_dict: @@ -1249,14 +1363,14 @@ def update_default_node_attributes(self, attr_dict=None, **kwattr): attr_dict.update(kwattr) self.default_node_attributes.update(attr_dict) - def update_default_edge_attributes(self, attr_dict=None, **kwattr): + def update_default_edge_attributes(self, attr_dict: Optional[AttributeDict] = None, **kwattr: Any) -> None: """Update the default edge attributes. Parameters ---------- - attr_dict : dict[str, Any], optional + attr_dict A dictionary of attributes with their default values. - **kwattr : dict[str, Any], optional + **kwattr A dictionary of additional attributes compiled of remaining named arguments. Returns @@ -1265,7 +1379,7 @@ def update_default_edge_attributes(self, attr_dict=None, **kwattr): See Also -------- - :meth:`update_default_node_attributes` + update_default_node_attributes """ if not attr_dict: @@ -1277,23 +1391,30 @@ def update_default_edge_attributes(self, attr_dict=None, **kwattr): # Node attributes # -------------------------------------------------------------------------- - def node_attribute(self, key, name, value=None): + @overload + def node_attribute(self, key: Node, name: str) -> Any: ... + + @overload + def node_attribute(self, key: Node, name: str, value: Any) -> None: ... + + def node_attribute(self, key: Node, name: str, value: Any = _MISSING) -> Any: """Get or set an attribute of a node. Parameters ---------- - key : hashable + key The node identifier. - name : str + name The name of the attribute - value : obj, optional - The value of the attribute. + value + The value of the attribute. If omitted, the current value is returned. Returns ------- - obj or None - The value of the attribute, - or None when the function is used as a "setter". + Any + The attribute value when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -1302,14 +1423,14 @@ def node_attribute(self, key, name, value=None): See Also -------- - :meth:`unset_node_attribute` - :meth:`node_attributes`, :meth:`nodes_attribute`, :meth:`nodes_attributes` - :meth:`edge_attribute`, :meth:`edge_attributes`, :meth:`edges_attribute`, :meth:`edges_attributes` + unset_node_attribute + node_attributes, nodes_attribute, nodes_attributes + edge_attribute, edge_attributes, edges_attribute, edges_attributes """ if key not in self.node: raise KeyError(key) - if value is not None: + if value is not _MISSING: self.node[key][name] = value return if name in self.node[key]: @@ -1318,14 +1439,14 @@ def node_attribute(self, key, name, value=None): if name in self.default_node_attributes: return self.default_node_attributes[name] - def unset_node_attribute(self, key, name): + def unset_node_attribute(self, key: Node, name: str) -> None: """Unset the attribute of a node. Parameters ---------- - key : int + key The node identifier. - name : str + name The name of the attribute. Raises @@ -1335,7 +1456,7 @@ def unset_node_attribute(self, key, name): See Also -------- - :meth:`node_attribute` + node_attribute Notes ----- @@ -1346,16 +1467,21 @@ def unset_node_attribute(self, key, name): if name in self.node[key]: del self.node[key][name] - def node_attributes(self, key, names=None, values=None): + def node_attributes( + self, + key: Node, + names: Optional[Sequence[str]] = None, + values: Optional[list[Any]] = None, + ) -> Any: """Get or set multiple attributes of a node. Parameters ---------- - key : hashable + key The identifier of the node. - names : list[str], optional + names A list of attribute names. - values : list[Any], optional + values A list of attribute values. Returns @@ -1374,8 +1500,8 @@ def node_attributes(self, key, names=None, values=None): See Also -------- - :meth:`node_attribute`, :meth:`nodes_attribute`, :meth:`nodes_attributes` - :meth:`edge_attribute`, :meth:`edge_attributes`, :meth:`edges_attribute`, :meth:`edges_attributes` + node_attribute, nodes_attribute, nodes_attributes + edge_attribute, edge_attributes, edges_attribute, edges_attributes """ if key not in self.node: @@ -1399,23 +1525,45 @@ def node_attributes(self, key, names=None, values=None): values.append(None) return values - def nodes_attribute(self, name, value=None, keys=None): + @overload + def nodes_attribute( + self, + name: str, + *, + keys: Optional[Iterable[Node]] = None, + ) -> list[Any]: ... + + @overload + def nodes_attribute( + self, + name: str, + value: Any, + keys: Optional[Iterable[Node]] = None, + ) -> None: ... + + def nodes_attribute( + self, + name: str, + value: Any = _MISSING, + keys: Optional[Iterable[Node]] = None, + ) -> Optional[list[Any]]: """Get or set an attribute of multiple nodes. Parameters ---------- - name : str + name The name of the attribute. - value : obj, optional - The value of the attribute. - keys : list[hashable], optional + value + The value of the attribute. If omitted, the current values are returned. + keys A list of node identifiers. Returns ------- - list[Any] | None - The value of the attribute for each node, - or None if the function is used as a "setter". + list[Any] + The attribute values when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -1424,28 +1572,33 @@ def nodes_attribute(self, name, value=None, keys=None): See Also -------- - :meth:`node_attribute`, :meth:`node_attributes`, :meth:`nodes_attributes` - :meth:`edge_attribute`, :meth:`edge_attributes`, :meth:`edges_attribute`, :meth:`edges_attributes` + node_attribute, node_attributes, nodes_attributes + edge_attribute, edge_attributes, edges_attribute, edges_attributes """ if not keys: keys = self.nodes() - if value is not None: + if value is not _MISSING: for key in keys: self.node_attribute(key, name, value) return return [self.node_attribute(key, name) for key in keys] - def nodes_attributes(self, names=None, values=None, keys=None): + def nodes_attributes( + self, + names: Optional[Sequence[str]] = None, + values: Optional[list[Any]] = None, + keys: Optional[Iterable[Node]] = None, + ) -> Any: """Get or set multiple attributes of multiple nodes. Parameters ---------- - names : list[str], optional + names The names of the attribute. - values : list[Any], optional + values The values of the attributes. - keys : list[hashable], optional + keys A list of node identifiers. Returns @@ -1464,8 +1617,8 @@ def nodes_attributes(self, names=None, values=None, keys=None): See Also -------- - :meth:`node_attribute`, :meth:`node_attributes`, :meth:`nodes_attribute` - :meth:`edge_attribute`, :meth:`edge_attributes`, :meth:`edges_attribute`, :meth:`edges_attributes` + node_attribute, node_attributes, nodes_attribute + edge_attribute, edge_attributes, edges_attribute, edges_attributes """ if not keys: @@ -1480,22 +1633,30 @@ def nodes_attributes(self, names=None, values=None, keys=None): # Edge attributes # -------------------------------------------------------------------------- - def edge_attribute(self, key, name, value=None): + @overload + def edge_attribute(self, key: Edge, name: str) -> Any: ... + + @overload + def edge_attribute(self, key: Edge, name: str, value: Any) -> None: ... + + def edge_attribute(self, key: Edge, name: str, value: Any = _MISSING) -> Any: """Get or set an attribute of an edge. Parameters ---------- - key : tuple[hashable, hashable] + key The identifier of the edge as a pair of node identifiers. - name : str + name The name of the attribute. - value : obj, optional - The value of the attribute. + value + The value of the attribute. If omitted, the current value is returned. Returns ------- - object | None - The value of the attribute, or None when the function is used as a "setter". + Any + The attribute value when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -1504,16 +1665,16 @@ def edge_attribute(self, key, name, value=None): See Also -------- - :meth:`unset_edge_attribute` - :meth:`edge_attributes`, :meth:`edges_attribute`, :meth:`edges_attributes` - :meth:`node_attribute`, :meth:`node_attributes`, :meth:`nodes_attribute`, :meth:`nodes_attributes` + unset_edge_attribute + edge_attributes, edges_attribute, edges_attributes + node_attribute, node_attributes, nodes_attribute, nodes_attributes """ u, v = key if u not in self.edge or v not in self.edge[u]: raise KeyError(key) attr = self.edge[u][v] - if value is not None: + if value is not _MISSING: attr[name] = value return if name in attr: @@ -1521,14 +1682,14 @@ def edge_attribute(self, key, name, value=None): if name in self.default_edge_attributes: return self.default_edge_attributes[name] - def unset_edge_attribute(self, key, name): + def unset_edge_attribute(self, key: Edge, name: str) -> None: """Unset the attribute of an edge. Parameters ---------- - key : tuple[hashable, hashable] + key The edge identifier. - name : str + name The name of the attribute. Returns @@ -1542,7 +1703,7 @@ def unset_edge_attribute(self, key, name): See Also -------- - :meth:`edge_attribute` + edge_attribute Notes ----- @@ -1557,16 +1718,21 @@ def unset_edge_attribute(self, key, name): if name in attr: del attr[name] - def edge_attributes(self, key, names=None, values=None): + def edge_attributes( + self, + key: Edge, + names: Optional[Sequence[str]] = None, + values: Optional[list[Any]] = None, + ) -> Any: """Get or set multiple attributes of an edge. Parameters ---------- - key : tuple[hashable, hashable] + key The identifier of the edge. - names : list[str], optional + names A list of attribute names. - values : list[Any], optional + values A list of attribute values. Returns @@ -1583,8 +1749,8 @@ def edge_attributes(self, key, names=None, values=None): See Also -------- - :meth:`edge_attribute`, :meth:`edges_attribute`, :meth:`edges_attributes` - :meth:`node_attribute`, :meth:`node_attributes`, :meth:`nodes_attribute`, :meth:`nodes_attributes` + edge_attribute, edges_attribute, edges_attributes + node_attribute, node_attributes, nodes_attribute, nodes_attributes """ u, v = key @@ -1606,23 +1772,45 @@ def edge_attributes(self, key, names=None, values=None): values.append(value) return values - def edges_attribute(self, name, value=None, keys=None): + @overload + def edges_attribute( + self, + name: str, + *, + keys: Optional[Iterable[Edge]] = None, + ) -> list[Any]: ... + + @overload + def edges_attribute( + self, + name: str, + value: Any, + keys: Optional[Iterable[Edge]] = None, + ) -> None: ... + + def edges_attribute( + self, + name: str, + value: Any = _MISSING, + keys: Optional[Iterable[Edge]] = None, + ) -> Optional[list[Any]]: """Get or set an attribute of multiple edges. Parameters ---------- - name : str + name The name of the attribute. - value : obj, optional - The value of the attribute. - keys : list[tuple[hashable, hashable]], optional + value + The value of the attribute. If omitted, the current values are returned. + keys A list of edge identifiers. Returns ------- - list[Any] | None - A list containing the value per edge of the requested attribute, - or None if the function is used as a "setter". + list[Any] + The attribute values when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -1631,28 +1819,33 @@ def edges_attribute(self, name, value=None, keys=None): See Also -------- - :meth:`edge_attribute`, :meth:`edge_attributes`, :meth:`edges_attributes` - :meth:`node_attribute`, :meth:`node_attributes`, :meth:`nodes_attribute`, :meth:`nodes_attributes` + edge_attribute, edge_attributes, edges_attributes + node_attribute, node_attributes, nodes_attribute, nodes_attributes """ if not keys: keys = self.edges() - if value is not None: + if value is not _MISSING: for key in keys: self.edge_attribute(key, name, value) return return [self.edge_attribute(key, name) for key in keys] - def edges_attributes(self, names=None, values=None, keys=None): + def edges_attributes( + self, + names: Optional[Sequence[str]] = None, + values: Optional[list[Any]] = None, + keys: Optional[Iterable[Edge]] = None, + ) -> Any: """Get or set multiple attributes of multiple edges. Parameters ---------- - names : list[str], optional + names The names of the attribute. - values : list[Any], optional + values The values of the attributes. - keys : list[tuple[hashable, hashable]], optional + keys A list of edge identifiers. Returns @@ -1671,8 +1864,8 @@ def edges_attributes(self, names=None, values=None, keys=None): See Also -------- - :meth:`edge_attribute`, :meth:`edge_attributes`, :meth:`edges_attribute` - :meth:`node_attribute`, :meth:`node_attributes`, :meth:`nodes_attribute`, :meth:`nodes_attributes` + edge_attribute, edge_attributes, edges_attribute + node_attribute, node_attributes, nodes_attribute, nodes_attributes """ if not keys: @@ -1687,12 +1880,12 @@ def edges_attributes(self, names=None, values=None, keys=None): # Node topology # -------------------------------------------------------------------------- - def has_node(self, key): + def has_node(self, key: Node) -> bool: """Verify if a specific node is present in the graph. Parameters ---------- - key : hashable + key The identifier of the node. Returns @@ -1702,17 +1895,17 @@ def has_node(self, key): See Also -------- - :meth:`has_edge` + has_edge """ return key in self.node - def is_leaf(self, key): + def is_leaf(self, key: Node) -> bool: """Verify if a node is a leaf. Parameters ---------- - key : hashable + key The identifier of the node. Returns @@ -1722,8 +1915,8 @@ def is_leaf(self, key): See Also -------- - :meth:`leaves` - :meth:`is_node_connected` + leaves + is_node_connected Notes ----- @@ -1732,7 +1925,7 @@ def is_leaf(self, key): """ return self.degree(key) == 1 - def leaves(self): + def leaves(self) -> list[Node]: """Return all leaves of the graph. Returns @@ -1743,12 +1936,12 @@ def leaves(self): """ return [key for key in self.nodes() if self.is_leaf(key)] - def is_node_connected(self, key): + def is_node_connected(self, key: Node) -> bool: """Verify if a specific node is connected. Parameters ---------- - key : hashable + key The identifier of the node. Returns @@ -1758,17 +1951,17 @@ def is_node_connected(self, key): See Also -------- - :meth:`is_leaf` + is_leaf """ return self.degree(key) > 0 - def neighbors(self, key): + def neighbors(self, key: Node) -> list[Node]: """Return the neighbors of a node. Parameters ---------- - key : hashable + key The identifier of the node. Returns @@ -1778,20 +1971,20 @@ def neighbors(self, key): See Also -------- - :meth:`neighbors_out`, :meth:`neighbors_in` - :meth:`neighborhood` + neighbors_out, neighbors_in + neighborhood """ return list(self.adjacency[key]) - def neighborhood(self, key, ring=1): + def neighborhood(self, key: Node, ring: int = 1) -> list[Node]: """Return the nodes in the neighborhood of a node. Parameters ---------- - key : hashable + key The identifier of the node. - ring : int, optional + ring The size of the neighborhood. Returns @@ -1801,7 +1994,7 @@ def neighborhood(self, key, ring=1): See Also -------- - :meth:`neighbors` + neighbors """ nbrs = set(self.neighbors(key)) @@ -1818,12 +2011,12 @@ def neighborhood(self, key, ring=1): nbrs.remove(key) return list(nbrs) - def neighbors_out(self, key): + def neighbors_out(self, key: Node) -> list[Node]: """Return the outgoing neighbors of a node. Parameters ---------- - key : hashable + key The identifier of the node. Returns @@ -1833,17 +2026,17 @@ def neighbors_out(self, key): See Also -------- - :meth:`neighbors`, :meth:`neighbors_in` + neighbors, neighbors_in """ return list(self.edge[key]) - def neighbors_in(self, key): + def neighbors_in(self, key: Node) -> list[Node]: """Return the incoming neighbors of a node. Parameters ---------- - key : hashable + key The identifier of the node. Returns @@ -1853,17 +2046,17 @@ def neighbors_in(self, key): See Also -------- - :meth:`neighbors`, :meth:`neighbors_out` + neighbors, neighbors_out """ return list(set(self.adjacency[key]) - set(self.edge[key])) - def degree(self, key): + def degree(self, key: Node) -> int: """Return the number of neighbors of a node. Parameters ---------- - key : hashable + key The identifier of the node. Returns @@ -1873,17 +2066,17 @@ def degree(self, key): See Also -------- - :meth:`degree_out`, :meth:`degree_in` + degree_out, degree_in """ return len(self.neighbors(key)) - def degree_out(self, key): + def degree_out(self, key: Node) -> int: """Return the number of outgoing neighbors of a node. Parameters ---------- - key : hashable + key The identifier of the node. Returns @@ -1893,17 +2086,17 @@ def degree_out(self, key): See Also -------- - :meth:`degree`, :meth:`degree_in` + degree, degree_in """ return len(self.neighbors_out(key)) - def degree_in(self, key): + def degree_in(self, key: Node) -> int: """Return the numer of incoming neighbors of a node. Parameters ---------- - key : hashable + key The identifier of the node. Returns @@ -1913,17 +2106,17 @@ def degree_in(self, key): See Also -------- - :meth:`degree`, :meth:`degree_out` + degree, degree_out """ return len(self.neighbors_in(key)) - def node_edges(self, key): + def node_edges(self, key: Node) -> list[Edge]: """Return the edges connected to a node. Parameters ---------- - key : hashable + key The identifier of the node. Returns @@ -1944,14 +2137,14 @@ def node_edges(self, key): # Edge topology # -------------------------------------------------------------------------- - def has_edge(self, edge, directed=True): + def has_edge(self, edge: Edge, directed: bool = True) -> bool: """Verify if the graph contains a specific edge. Parameters ---------- - edge : tuple[hashable, hashable] + edge The identifier of the edge as a pair of node identifiers. - directed : bool, optional + directed If True, the direction of the edge is taken into account. Returns @@ -1961,7 +2154,7 @@ def has_edge(self, edge, directed=True): See Also -------- - :meth:`has_node` + has_node """ u, v = edge @@ -1973,14 +2166,14 @@ def has_edge(self, edge, directed=True): # Node geometry # -------------------------------------------------------------------------- - def node_coordinates(self, key, axes="xyz"): + def node_coordinates(self, key: Node, axes: str = "xyz") -> list[float]: """Return the coordinates of a node. Parameters ---------- - key : hashable + key The identifier of the node. - axes : str, optional + axes The components of the node coordinates to return. Returns @@ -1990,69 +2183,69 @@ def node_coordinates(self, key, axes="xyz"): See Also -------- - :meth:`node_point`, :meth:`node_laplacian`, :meth:`node_neighborhood_centroid` + node_point, node_laplacian, node_neighborhood_centroid """ return [self.node[key][axis] for axis in axes] - def node_point(self, node): + def node_point(self, node: Node) -> Point: """Return the point of a node. Parameters ---------- - node : hashable + node The identifier of the node. Returns ------- - :class:`compas.geometry.Point` + Point The point of the node. See Also -------- - :meth:`node_coordinates`, :meth:`node_laplacian`, :meth:`node_neighborhood_centroid` + node_coordinates, node_laplacian, node_neighborhood_centroid """ return Point(*self.node_coordinates(node)) - def node_laplacian(self, key): + def node_laplacian(self, key: Node) -> Vector: """Return the vector from the node to the centroid of its 1-ring neighborhood. Parameters ---------- - key : hashable + key The identifier of the node. Returns ------- - :class:`compas.geometry.Vector` + Vector The laplacian vector. See Also -------- - :meth:`node_coordinates`, :meth:`node_point`, :meth:`node_neighborhood_centroid` + node_coordinates, node_point, node_neighborhood_centroid """ c = centroid_points([self.node_coordinates(nbr) for nbr in self.neighbors(key)]) p = self.node_coordinates(key) return Vector(*subtract_vectors(c, p)) - def node_neighborhood_centroid(self, key): + def node_neighborhood_centroid(self, key: Node) -> Point: """Return the computed centroid of the neighboring nodes. Parameters ---------- - key : hashable + key The identifier of the node. Returns ------- - :class:`compas.geometry.Point` + Point The point at the centroid. See Also -------- - :meth:`node_coordinates`, :meth:`node_point`, :meth:`node_laplacian` + node_coordinates, node_point, node_laplacian """ return Point(*centroid_points([self.node_coordinates(nbr) for nbr in self.neighbors(key)])) @@ -2061,14 +2254,14 @@ def node_neighborhood_centroid(self, key): # Edge geometry # -------------------------------------------------------------------------- - def edge_coordinates(self, edge, axes="xyz"): + def edge_coordinates(self, edge: Edge, axes: str = "xyz") -> tuple[list[float], list[float]]: """Return the coordinates of the start and end point of an edge. Parameters ---------- - edge : tuple[hashable, hashable] + edge The identifier of the edge. - axes : str, optional + axes The axes along which the coordinates should be included. Returns @@ -2079,72 +2272,72 @@ def edge_coordinates(self, edge, axes="xyz"): See Also -------- - :meth:`edge_point`, :meth:`edge_start`, :meth:`edge_end`, :meth:`edge_midpoint` + edge_point, edge_start, edge_end, edge_midpoint """ u, v = edge return self.node_coordinates(u, axes=axes), self.node_coordinates(v, axes=axes) - def edge_start(self, edge): + def edge_start(self, edge: Edge) -> Point: """Return the start point of an edge. Parameters ---------- - edge : tuple[hashable, hashable] + edge The identifier of the edge. Returns ------- - :class:`compas.geometry.Point` + Point The start point of the edge. See Also -------- - :meth:`edge_point`, :meth:`edge_end`, :meth:`edge_midpoint` + edge_point, edge_end, edge_midpoint """ return self.node_point(edge[0]) - def edge_end(self, edge): + def edge_end(self, edge: Edge) -> Point: """Return the end point of an edge. Parameters ---------- - edge : tuple[hashable, hashable] + edge The identifier of the edge. Returns ------- - :class:`compas.geometry.Point` + Point The end point of the edge. See Also -------- - :meth:`edge_point`, :meth:`edge_start`, :meth:`edge_midpoint` + edge_point, edge_start, edge_midpoint """ return self.node_point(edge[1]) - def edge_point(self, edge, t=0.5): + def edge_point(self, edge: Edge, t: float = 0.5) -> Point: """Return the point at a parametric location along an edge. Parameters ---------- - edge : tuple[hashable, hashable] + edge The identifier of the edge. - t : float, optional + t The location of the point on the edge. If the value of `t` is outside the range 0-1, the point will lie in the direction of the edge, but not on the edge vector. Returns ------- - :class:`compas.geometry.Point` + Point The point at the specified location. See Also -------- - :meth:`edge_start`, :meth:`edge_end`, :meth:`edge_midpoint` + edge_start, edge_end, edge_midpoint """ if t == 0.0: @@ -2158,94 +2351,94 @@ def edge_point(self, edge, t=0.5): ab = subtract_vectors(b, a) return Point(*add_vectors(a, scale_vector(ab, t))) - def edge_midpoint(self, edge): + def edge_midpoint(self, edge: Edge) -> Point: """Return the location of the midpoint of an edge. Parameters ---------- - edge : tuple[hashable, hashable] + edge The identifier of the edge. Returns ------- - :class:`compas.geometry.Point` + Point The midpoint of the edge. See Also -------- - :meth:`edge_start`, :meth:`edge_end`, :meth:`edge_point` + edge_start, edge_end, edge_point """ a, b = self.edge_coordinates(edge) return Point(*midpoint_line((a, b))) - def edge_vector(self, edge): + def edge_vector(self, edge: Edge) -> Vector: """Return the vector of an edge. Parameters ---------- - edge : tuple[hashable, hashable] + edge The identifier of the edge. Returns ------- - :class:`compas.geometry.Vector` + Vector The vector from start to end. See Also -------- - :meth:`edge_direction`, :meth:`edge_line`, :meth:`edge_length` + edge_direction, edge_line, edge_length """ a, b = self.edge_coordinates(edge) return Vector.from_start_end(a, b) - def edge_direction(self, edge): + def edge_direction(self, edge: Edge) -> Vector: """Return the direction vector of an edge. Parameters ---------- - edge : tuple[hashable, hashable] + edge The identifier of the edge. Returns ------- - :class:`compas.geometry.Vector` + Vector The direction vector of the edge. See Also -------- - :meth:`edge_vector`, :meth:`edge_line`, :meth:`edge_length` + edge_vector, edge_line, edge_length """ return Vector(*normalize_vector(self.edge_vector(edge))) - def edge_line(self, edge): + def edge_line(self, edge: Edge) -> Line: """Return the line of an edge. Parameters ---------- - edge : tuple[hashable, hashable] + edge The identifier of the edge. Returns ------- - :class:`compas.geometry.Line` + Line The line of the edge. See Also -------- - :meth:`edge_vector`, :meth:`edge_direction`, :meth:`edge_length` + edge_vector, edge_direction, edge_length """ return Line(*self.edge_coordinates(edge)) - def edge_length(self, edge): + def edge_length(self, edge: Edge) -> float: """Return the length of an edge. Parameters ---------- - edge : tuple[hashable, hashable] + edge The identifier of the edge. Returns @@ -2255,7 +2448,7 @@ def edge_length(self, edge): See Also -------- - :meth:`edge_vector`, :meth:`edge_direction`, :meth:`edge_line` + edge_vector, edge_direction, edge_line """ a, b = self.edge_coordinates(edge) @@ -2265,12 +2458,12 @@ def edge_length(self, edge): # Transformations and BBox # -------------------------------------------------------------------------- - def transform(self, transformation): + def transform(self, transformation: Any) -> None: """Transform all nodes of the graph. Parameters ---------- - transformation : :class:`Transformation` + transformation The transformation used to transform the nodes. Returns @@ -2283,23 +2476,23 @@ def transform(self, transformation): for point, node in zip(points, self.nodes()): self.node_attributes(node, "xyz", point) - def aabb(self): + def aabb(self) -> Box: # type: ignore[override] """Calculate the axis aligned bounding box of the graph. Returns ------- - :class:`compas.geometry.Box` + Box """ nodes = self.nodes_attributes("xyz") return Box.from_bounding_box(bounding_box(nodes)) - def obb(self): + def obb(self) -> Box: # type: ignore[override] """Calculate the oriented bounding box of the graph. Returns ------- - :class:`compas.geometry.Box` + Box """ nodes = self.nodes_attributes("xyz") @@ -2309,7 +2502,7 @@ def obb(self): # Other Methods # -------------------------------------------------------------------------- - def connected_nodes(self): + def connected_nodes(self) -> list[list[Node]]: """Get groups of connected nodes. Returns @@ -2318,12 +2511,12 @@ def connected_nodes(self): See Also -------- - :meth:`connected_edges` + connected_edges """ return connected_components(self.adjacency) - def connected_edges(self): + def connected_edges(self) -> list[list[Edge]]: """Get groups of connected edges. Returns @@ -2332,23 +2525,31 @@ def connected_edges(self): See Also -------- - :meth:`connected_nodes` + connected_nodes """ - return [[(u, v) for u in nodes for v in self.neighbors(u) if u < v] for nodes in self.connected_nodes()] + return self._group_edges_by_component(self.connected_nodes()) + + def _group_edges_by_component(self, components: Sequence[Sequence[Node]]) -> list[list[Edge]]: + node_component = {node: index for index, nodes in enumerate(components) for node in nodes} + edges_by_component: list[list[Edge]] = [[] for _ in components] + for edge in self.edges(): + edges_by_component[node_component[edge[0]]].append(edge) + return edges_by_component - def exploded(self): + def exploded(self) -> list[Self]: """Explode the graph into its connected components. Returns ------- - list[:class:`compas.datastructures.Graph`] + list[Graph] """ cls = type(self) graphs = [] - for nodes in self.connected_nodes(): - edges = [(u, v) for u in nodes for v in self.neighbors(u) if u < v] + components = self.connected_nodes() + edges_by_component = self._group_edges_by_component(components) + for nodes, edges in zip(components, edges_by_component): graph = cls( default_node_attributes=self.default_node_attributes, default_edge_attributes=self.default_edge_attributes, @@ -2360,7 +2561,7 @@ def exploded(self): graphs.append(graph) return graphs - def complement(self): + def complement(self) -> Self: """Generate the complement of a graph. The complement of a graph G is the graph H with the same vertices @@ -2368,7 +2569,7 @@ def complement(self): Returns ------- - :class:`compas.datastructures.Graph` + Graph The complement graph. References @@ -2405,12 +2606,12 @@ def complement(self): # Matrices # -------------------------------------------------------------------------- - def adjacency_matrix(self, rtype="array"): + def adjacency_matrix(self, rtype: str = "array") -> Any: """Creates a node adjacency matrix from a Graph datastructure. Parameters ---------- - rtype : Literal['array', 'csc', 'csr', 'coo', 'list'], optional + rtype Format of the result. Returns @@ -2419,18 +2620,18 @@ def adjacency_matrix(self, rtype="array"): Constructed adjacency matrix. """ - from compas.matrices import adjacency_matrix + from compas.linalg.operators import adjacency_matrix node_index = self.node_index() adjacency = [[node_index[nbr] for nbr in self.neighbors(key)] for key in self.nodes()] return adjacency_matrix(adjacency, rtype=rtype) - def connectivity_matrix(self, rtype="array"): + def connectivity_matrix(self, rtype: str = "array") -> Any: """Creates a connectivity matrix from a Graph datastructure. Parameters ---------- - rtype : Literal['array', 'csc', 'csr', 'coo', 'list'], optional + rtype Format of the result. Returns @@ -2439,18 +2640,18 @@ def connectivity_matrix(self, rtype="array"): Constructed connectivity matrix. """ - from compas.matrices import connectivity_matrix + from compas.linalg.operators import connectivity_matrix node_index = self.node_index() edges = [(node_index[u], node_index[v]) for u, v in self.edges()] return connectivity_matrix(edges, rtype=rtype) - def degree_matrix(self, rtype="array"): + def degree_matrix(self, rtype: str = "array") -> Any: """Creates a degree matrix from a Graph datastructure. Parameters ---------- - rtype : Literal['array', 'csc', 'csr', 'coo', 'list'], optional + rtype Format of the result. Returns @@ -2459,20 +2660,20 @@ def degree_matrix(self, rtype="array"): Constructed degree matrix. """ - from compas.matrices import degree_matrix + from compas.linalg.operators import degree_matrix node_index = self.node_index() adjacency = [[node_index[nbr] for nbr in self.neighbors(key)] for key in self.nodes()] return degree_matrix(adjacency, rtype=rtype) - def laplacian_matrix(self, normalize=False, rtype="array"): + def laplacian_matrix(self, normalize: bool = False, rtype: str = "array") -> Any: """Creates a Laplacian matrix from a Graph datastructure. Parameters ---------- - normalize : bool, optional + normalize If True, normalize the entries such that the value on the diagonal is 1. - rtype : Literal['array', 'csc', 'csr', 'coo', 'list'], optional + rtype Format of the result. Returns @@ -2487,7 +2688,7 @@ def laplacian_matrix(self, normalize=False, rtype="array"): vectors could be used in a more natural way ``c = xyz + d``. """ - from compas.matrices import laplacian_matrix + from compas.linalg.operators import laplacian_matrix node_index = self.node_index() edges = [(node_index[u], node_index[v]) for u, v in self.edges()] diff --git a/src/compas/datastructures/graph/operations/join.py b/src/compas/datastructures/graph/operations/join.py index 9fcceb2d4739..b649522151fc 100644 --- a/src/compas/datastructures/graph/operations/join.py +++ b/src/compas/datastructures/graph/operations/join.py @@ -1,19 +1,25 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import TYPE_CHECKING +from typing import Iterable +from typing import Optional +from typing import Sequence from compas.itertools import pairwise from compas.tolerance import TOL +from ..types import Node -def graph_join_edges(graph, key): +if TYPE_CHECKING: + from compas.datastructures import Graph + + +def graph_join_edges(graph: "Graph", key: Node) -> None: """Join the edges incidental on the given node, if there are exactly two incident edges. Parameters ---------- - graph : :class:`compas.geometry.Graph` + graph A graph data structure. - key : hashable + key The node identifier. Returns @@ -28,30 +34,19 @@ def graph_join_edges(graph, key): Therefore, the new edge has only default edge attributes. """ - nbrs = graph.vertex_neighbors(key) + nbrs = graph.neighbors(key) if len(nbrs) != 2: return a, b = nbrs - if a in graph.edge[key]: - del graph.edge[key][a] - else: - del graph.edge[a][key] - del graph.halfedge[key][a] - del graph.halfedge[a][key] - if b in graph.edge[key]: - del graph.edge[key][b] - else: - del graph.edge[b][key] - del graph.halfedge[key][b] - del graph.halfedge[b][key] - del graph.vertex[key] - del graph.halfedge[key] - del graph.edge[key] + graph.delete_node(key) # set attributes based on average of two joining edges? - graph.add_edge((a, b)) + graph.add_edge(a, b) -def graph_polylines(graph, splits=None): +def graph_polylines( + graph: "Graph", + splits: Optional[Iterable[Sequence[float]]] = None, +) -> list[list[list[float]]]: """Join graph edges into polylines. The polylines stop at points with a valency different from 2 in the graph of line. @@ -59,9 +54,9 @@ def graph_polylines(graph, splits=None): Parameters ---------- - graph : Graph + graph A graph. - splits : sequence[[float, float, float] | :class:`compas.geometry.Point`], optional + splits List of point coordinates for polyline splits. Returns @@ -92,13 +87,13 @@ def graph_polylines(graph, splits=None): # geometric keys of split points if splits is None: splits = [] - stop_geom_keys = set([TOL.geometric_key(xyz) for xyz in splits]) + stop_geom_keys = {TOL.geometric_key(xyz) for xyz in splits} polylines = [] edges_to_visit = set(graph.edges()) # initiate a polyline from an unvisited edge - while len(edges_to_visit) > 0: + while edges_to_visit: polyline = list(edges_to_visit.pop()) # get adjacent edges until the polyline is closed... @@ -112,7 +107,7 @@ def graph_polylines(graph, splits=None): # add next edge polyline.append([nbr for nbr in graph.neighbors(polyline[-1]) if nbr != polyline[-2]][0]) - # delete polyline edges from the list of univisted edges + # delete polyline edges from the list of unvisited edges for u, v in pairwise(polyline): if (u, v) in edges_to_visit: edges_to_visit.remove((u, v)) diff --git a/src/compas/datastructures/graph/operations/split.py b/src/compas/datastructures/graph/operations/split.py index 1ba03018f2ca..52a489e66cf9 100644 --- a/src/compas/datastructures/graph/operations/split.py +++ b/src/compas/datastructures/graph/operations/split.py @@ -1,33 +1,42 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import TYPE_CHECKING +from typing import Optional +from ..types import Edge +from ..types import Node -def graph_split_edge(graph, edge, t=0.5): - """Split and edge by inserting a node along its length. +if TYPE_CHECKING: + from compas.datastructures import Graph + + +def graph_split_edge( + graph: "Graph", + edge: Edge, + t: float = 0.5, +) -> Optional[Node]: + """Split an edge by inserting a node along its length. Parameters ---------- - edge : tuple[hashable, hashable] + graph + A graph data structure. + edge The identifier of the edge to split. - t : float, optional + t The position of the inserted node on the edge. Returns ------- - hashable - The key of the inserted node. + hashable | None + The key of the inserted node, or None if the edge does not exist. Raises ------ ValueError If `t` is not in the range 0-1. - Exception - If the edge is not part of the graph. """ u, v = edge - if not graph.has_edge(u, v): + if not graph.has_edge(edge): return if t <= 0.0: @@ -39,25 +48,9 @@ def graph_split_edge(graph, edge, t=0.5): x, y, z = graph.edge_point(edge, t) w = graph.add_node(x=x, y=y, z=z) - graph.add_edge((u, w)) - graph.add_edge((w, v)) - - if v in graph.edge[u]: - del graph.edge[u][v] - elif u in graph.edge[v]: - del graph.edge[v][u] - else: - raise Exception - - # split half-edge UV - graph.adjacency[u][w] = None - graph.adjacency[w][v] = None - del graph.adjacency[u][v] - - # split half-edge VU - graph.adjacency[v][w] = None - graph.adjacency[w][u] = None - del graph.adjacency[v][u] + graph.add_edge(u, w) + graph.add_edge(w, v) + graph.delete_edge(edge) # return the key of the split node return w diff --git a/src/compas/datastructures/graph/planarity.py b/src/compas/datastructures/graph/planarity.py index 1e3802b1fc46..cf686aa5f953 100644 --- a/src/compas/datastructures/graph/planarity.py +++ b/src/compas/datastructures/graph/planarity.py @@ -1,32 +1,33 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from itertools import product from math import cos from math import pi from math import sin +from typing import TYPE_CHECKING +from typing import Any +from typing import Iterable +from typing import Mapping +from typing import Optional +from typing import Sequence from compas.geometry import angle_vectors_xy from compas.geometry import is_ccw_xy -from compas.geometry import subtract_vectors_xy from compas.geometry._core.predicates_2 import is_intersection_segment_segment_xy +from compas.linalg.vectors import subtract_vectors_xy +from .types import Crossing +from .types import Edge +from .types import Node -def graph_embed_in_plane_proxy(data, fixed=None): +if TYPE_CHECKING: from compas.datastructures import Graph - graph = Graph.__from_data__(data) - graph_embed_in_plane(graph, fixed=fixed) - return graph.to_data() - -def graph_is_crossed(graph): +def graph_is_crossed(graph: "Graph") -> bool: """Verify if a graph has crossing edges. Parameters ---------- - graph : :class:`compas.datastructures.Graph` + graph A graph object. Returns @@ -52,7 +53,22 @@ def graph_is_crossed(graph): return False -def _are_edges_crossed(edges, vertices): +def _are_edges_crossed(edges: Iterable[Edge], vertices: Mapping[Node, Sequence[float]]) -> bool: + """Verify whether any two edges cross. + + Parameters + ---------- + edges + The edges to check. + vertices + A mapping of node identifiers to point coordinates. + + Returns + ------- + bool + True if at least one pair of edges crosses. + + """ for (u1, v1), (u2, v2) in product(edges, edges): if u1 == u2 or v1 == v2 or u1 == v2 or u2 == v1: continue @@ -65,12 +81,12 @@ def _are_edges_crossed(edges, vertices): return False -def graph_count_crossings(graph): +def graph_count_crossings(graph: "Graph") -> int: """Count the number of crossings (pairs of crossing edges) in the graph. Parameters ---------- - graph : :class:`compas.datastructures.Graph` + graph A graph object. Returns @@ -86,12 +102,12 @@ def graph_count_crossings(graph): return len(graph_find_crossings(graph)) -def graph_find_crossings(graph): +def graph_find_crossings(graph: "Graph") -> list[Crossing]: """Identify all pairs of crossing edges in a graph. Parameters ---------- - graph : :class:`compas.datastructures.Graph` + graph A graph object. Returns @@ -104,7 +120,7 @@ def graph_find_crossings(graph): This algorithm assumes that the graph lies in the XY plane. """ - crossings = set() + crossings: set[Crossing] = set() for (u1, v1), (u2, v2) in product(graph.edges(), graph.edges()): if u1 == u2 or v1 == v2 or u1 == v2 or u2 == v1: continue @@ -121,18 +137,18 @@ def graph_find_crossings(graph): return list(crossings) -def graph_is_xy(graph): - """Verify that a graph lies in the XY plane. +def graph_is_xy(graph: "Graph") -> bool: + """Verify that all nodes of a graph lie in a plane parallel to XY. Parameters ---------- - graph : :class:`compas.datastructures.Graph` + graph A graph object. Returns ------- bool - True if the Z coordinate of all vertices is zero. + True if all nodes have the same Z coordinate. False otherwise. """ @@ -141,17 +157,17 @@ def graph_is_xy(graph): if z is None: z = graph.node_attribute(key, "z") or 0.0 else: - if z != graph.node_attribute(key, "z") or 0.0: + if z != (graph.node_attribute(key, "z") or 0.0): return False return True -def graph_is_planar(graph): +def graph_is_planar(graph: "Graph") -> bool: """Check if the graph is planar. Parameters ---------- - graph : :class:`compas.datastructures.Graph` + graph A graph object. Returns @@ -173,41 +189,37 @@ def graph_is_planar(graph): exists. """ - try: - import networkx as nx - except ImportError: - print("NetworkX is not installed.") - raise + import networkx as nx return nx.is_planar(graph.to_networkx()) -def graph_is_planar_embedding(graph): +def graph_is_planar_embedding(graph: "Graph") -> bool: """Verify that a graph is embedded in the plane without crossing edges. Parameters ---------- - graph : :class:`compas.datastructures.Graph` + graph A graph object. Returns ------- bool True if the graph is embedded in the plane without crossing edges. - Fase otherwise. + False otherwise. """ return graph_is_planar(graph) and graph_is_xy(graph) and not graph_is_crossed(graph) -def graph_embed_in_plane(graph, fixed=None): +def graph_embed_in_plane(graph: "Graph", fixed: Optional[Sequence[Node]] = None) -> bool: """Embed the graph in the plane. Parameters ---------- - graph : :class:`compas.datastructures.Graph` + graph A graph object. - fixed : [hashable, hashable], optional + fixed Two fixed points. Returns @@ -222,14 +234,14 @@ def graph_embed_in_plane(graph, fixed=None): If NetworkX is not installed. """ - try: - import networkx as nx - except ImportError: - print("NetworkX is not installed. Get NetworkX at https://networkx.github.io/.") - raise - - x = graph.nodes_attribute("x") - y = graph.nodes_attribute("y") + import networkx as nx + + x = graph.nodes_attribute("x") or [] + y = graph.nodes_attribute("y") or [] + + if not x or not y: + return False + xmin, xmax = min(x), max(x) ymin, ymax = min(y), max(y) xspan = xmax - xmin @@ -238,7 +250,7 @@ def graph_embed_in_plane(graph, fixed=None): edges = [(u, v) for u, v in graph.edges() if not graph.is_leaf(u) and not graph.is_leaf(v)] is_embedded = False - pos = {} + pos: dict[Node, Any] = {} count = 100 while count: diff --git a/src/compas/datastructures/graph/smoothing.py b/src/compas/datastructures/graph/smoothing.py index 457048eb48a3..2e2d49a4bd93 100644 --- a/src/compas/datastructures/graph/smoothing.py +++ b/src/compas/datastructures/graph/smoothing.py @@ -1,27 +1,41 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import TYPE_CHECKING +from typing import Any +from typing import Callable +from typing import Iterable +from typing import Optional from compas.geometry import centroid_points +from .types import Node -def graph_smooth_centroid(graph, fixed=None, kmax=100, damping=0.5, callback=None, callback_args=None): +if TYPE_CHECKING: + from compas.datastructures import Graph + + +def graph_smooth_centroid( + graph: "Graph", + fixed: Optional[Iterable[Node]] = None, + kmax: int = 100, + damping: float = 0.5, + callback: Optional[Callable[[int, Any], None]] = None, + callback_args: Any = None, +) -> None: """Smooth a graph by moving every free node to the centroid of its neighbors. Parameters ---------- - graph : Mesh + graph A graph object. - fixed : list, optional + fixed The fixed nodes of the graph. - kmax : int, optional + kmax The maximum number of iterations. - damping : float, optional + damping The damping factor. - callback : callable, optional + callback A user-defined callback function to be executed after every iteration. - callback_args : list, optional - A list of arguments to be passed to the callback. + callback_args + Additional arguments to pass to the callback. Returns ------- @@ -29,31 +43,33 @@ def graph_smooth_centroid(graph, fixed=None, kmax=100, damping=0.5, callback=Non Raises ------ - Exception + TypeError If a callback is provided, but it is not callable. """ - if callback: - if not callable(callback): - raise Exception("Callback is not callable.") + if callback is not None and not callable(callback): + raise TypeError("Callback is not callable.") - fixed = fixed or [] - fixed = set(fixed) + fixed_nodes = set(fixed or []) for k in range(kmax): key_xyz = {key: graph.node_coordinates(key) for key in graph.nodes()} - for key, attr in graph.nodes(True): - if key in fixed: + for key, attr in graph.nodes(data=True): + if key in fixed_nodes: + continue + + neighbors = graph.neighbors(key) + if not neighbors: continue x, y, z = key_xyz[key] - cx, cy, cz = centroid_points([key_xyz[nbr] for nbr in graph.neighbors(key)]) + cx, cy, cz = centroid_points([key_xyz[nbr] for nbr in neighbors]) attr["x"] += damping * (cx - x) attr["y"] += damping * (cy - y) attr["z"] += damping * (cz - z) - if callback: + if callback is not None: callback(k, callback_args) diff --git a/src/compas/datastructures/graph/types.py b/src/compas/datastructures/graph/types.py new file mode 100644 index 000000000000..e5b068bc6a4d --- /dev/null +++ b/src/compas/datastructures/graph/types.py @@ -0,0 +1,7 @@ +from typing import Any +from typing import Hashable + +Node = Hashable +Edge = tuple[Node, Node] +Crossing = tuple[Edge, Edge] +AttributeDict = dict[str, Any] diff --git a/src/compas/datastructures/mesh/conway.py b/src/compas/datastructures/mesh/conway.py index 09b56a0f853b..c55bfa68bec1 100644 --- a/src/compas/datastructures/mesh/conway.py +++ b/src/compas/datastructures/mesh/conway.py @@ -1,29 +1,41 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +"""Conway operators for closed manifold meshes. +The topological count relationships documented by these operators assume a +closed manifold seed mesh. Boundary vertices and edges are omitted by some +operators, so the same relationships do not generally hold for open meshes. -def mesh_conway_dual(mesh): +""" + +from typing import TYPE_CHECKING +from typing import TypeVar + +if TYPE_CHECKING: + from .mesh import Mesh + +MeshType = TypeVar("MeshType", bound="Mesh") + + +def mesh_conway_dual(mesh: MeshType) -> MeshType: """Generates the dual mesh from a seed mesh. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A seed mesh Returns ------- - :class:`compas.datastructures.Mesh` + Mesh The dual mesh. References ---------- - Based on [1]_ and [2]_. + Based on the references below. - .. [1] Wikipedia. *Conway polyhedron notation*. - Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. - .. [2] Hart, George. *Conway Notation for Polyhedron*. - Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. + * Wikipedia. *Conway polyhedron notation*. + Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. + * Hart, George. *Conway Notation for Polyhedron*. + Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. Examples -------- @@ -49,27 +61,27 @@ def mesh_conway_dual(mesh): return cls.from_vertices_and_faces(vertices, faces) -def mesh_conway_join(mesh): +def mesh_conway_join(mesh: MeshType) -> MeshType: """Generates the join mesh from a seed mesh. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A seed mesh Returns ------- - :class:`compas.datastructures.Mesh` + Mesh The join mesh. References ---------- - Based on [1]_ and [2]_. + Based on the references below. - .. [1] Wikipedia. *Conway polyhedron notation*. - Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. - .. [2] Hart, George. *Conway Notation for Polyhedron*. - Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. + * Wikipedia. *Conway polyhedron notation*. + Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. + * Hart, George. *Conway Notation for Polyhedron*. + Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. Examples -------- @@ -90,43 +102,40 @@ def mesh_conway_join(mesh): v = mesh.number_of_vertices() vkey_index = {vkey: i for i, vkey in enumerate(mesh.vertices())} fkey_index = {fkey: i + v for i, fkey in enumerate(mesh.faces())} - faces = [ - [ - vkey_index[u], - fkey_index[mesh.halfedge[v][u]], - vkey_index[v], - fkey_index[mesh.halfedge[u][v]], - ] - for u, v in mesh.edges() - if not mesh.is_edge_on_boundary((u, v)) - ] + faces = [] + for u, v in mesh.edges(): + face_uv = mesh.halfedge[u][v] + face_vu = mesh.halfedge[v][u] + if face_uv is None or face_vu is None: + continue + faces.append([vkey_index[u], fkey_index[face_vu], vkey_index[v], fkey_index[face_uv]]) join_mesh = cls.from_vertices_and_faces(vertices, faces) # is this necessary? join_mesh.cull_vertices() return join_mesh -def mesh_conway_ambo(mesh): +def mesh_conway_ambo(mesh: MeshType) -> MeshType: """Generates the ambo mesh from a seed mesh. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A seed mesh Returns ------- - :class:`compas.datastructures.Mesh` + Mesh The ambo mesh. References ---------- - Based on [1]_ and [2]_. + Based on the references below. - .. [1] Wikipedia. *Conway polyhedron notation*. - Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. - .. [2] Hart, George. *Conway Notation for Polyhedron*. - Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. + * Wikipedia. *Conway polyhedron notation*. + Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. + * Hart, George. *Conway Notation for Polyhedron*. + Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. Examples -------- @@ -144,27 +153,27 @@ def mesh_conway_ambo(mesh): return mesh_conway_dual(mesh_conway_join(mesh)) -def mesh_conway_kis(mesh): +def mesh_conway_kis(mesh: MeshType) -> MeshType: """Generates the kis mesh from a seed mesh. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A seed mesh Returns ------- - :class:`compas.datastructures.Mesh` + Mesh The kis mesh. References ---------- - Based on [1]_ and [2]_. + Based on the references below. - .. [1] Wikipedia. *Conway polyhedron notation*. - Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. - .. [2] Hart, George. *Conway Notation for Polyhedron*. - Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. + * Wikipedia. *Conway polyhedron notation*. + Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. + * Hart, George. *Conway Notation for Polyhedron*. + Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. Examples -------- @@ -185,31 +194,31 @@ def mesh_conway_kis(mesh): v = mesh.number_of_vertices() vkey_index = {vkey: i for i, vkey in enumerate(mesh.vertices())} fkey_index = {fkey: i + v for i, fkey in enumerate(mesh.faces())} - faces = [[vkey_index[u], vkey_index[v], fkey_index[mesh.halfedge[u][v]]] for fkey in mesh.faces() for u, v in mesh.face_halfedges(fkey)] + faces = [[vkey_index[u], vkey_index[v], fkey_index[fkey]] for fkey in mesh.faces() for u, v in mesh.face_halfedges(fkey)] return cls.from_vertices_and_faces(vertices, faces) -def mesh_conway_needle(mesh): +def mesh_conway_needle(mesh: MeshType) -> MeshType: """Generates the needle mesh from a seed mesh. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A seed mesh Returns ------- - :class:`compas.datastructures.Mesh` + Mesh The needle mesh. References ---------- - Based on [1]_ and [2]_. + Based on the references below. - .. [1] Wikipedia. *Conway polyhedron notation*. - Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. - .. [2] Hart, George. *Conway Notation for Polyhedron*. - Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. + * Wikipedia. *Conway polyhedron notation*. + Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. + * Hart, George. *Conway Notation for Polyhedron*. + Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. Examples -------- @@ -227,27 +236,27 @@ def mesh_conway_needle(mesh): return mesh_conway_kis(mesh_conway_dual(mesh)) -def mesh_conway_zip(mesh): +def mesh_conway_zip(mesh: MeshType) -> MeshType: """Generates the zip mesh from a seed mesh. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A seed mesh Returns ------- - :class:`compas.datastructures.Mesh` + Mesh The zip mesh. References ---------- - Based on [1]_ and [2]_. + Based on the references below. - .. [1] Wikipedia. *Conway polyhedron notation*. - Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. - .. [2] Hart, George. *Conway Notation for Polyhedron*. - Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. + * Wikipedia. *Conway polyhedron notation*. + Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. + * Hart, George. *Conway Notation for Polyhedron*. + Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. Examples -------- @@ -265,27 +274,27 @@ def mesh_conway_zip(mesh): return mesh_conway_dual(mesh_conway_kis(mesh)) -def mesh_conway_truncate(mesh): +def mesh_conway_truncate(mesh: MeshType) -> MeshType: """Generates the truncate mesh from a seed mesh. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A seed mesh Returns ------- - :class:`compas.datastructures.Mesh` + Mesh The truncate mesh. References ---------- - Based on [1]_ and [2]_. + Based on the references below. - .. [1] Wikipedia. *Conway polyhedron notation*. - Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. - .. [2] Hart, George. *Conway Notation for Polyhedron*. - Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. + * Wikipedia. *Conway polyhedron notation*. + Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. + * Hart, George. *Conway Notation for Polyhedron*. + Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. Examples -------- @@ -304,27 +313,27 @@ def mesh_conway_truncate(mesh): return mesh_conway_dual(mesh_conway_kis(mesh_conway_dual(mesh))) -def mesh_conway_ortho(mesh): +def mesh_conway_ortho(mesh: MeshType) -> MeshType: """Generates the ortho mesh from a seed mesh. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A seed mesh Returns ------- - :class:`compas.datastructures.Mesh` + Mesh The ortho mesh. References ---------- - Based on [1]_ and [2]_. + Based on the references below. - .. [1] Wikipedia. *Conway polyhedron notation*. - Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. - .. [2] Hart, George. *Conway Notation for Polyhedron*. - Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. + * Wikipedia. *Conway polyhedron notation*. + Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. + * Hart, George. *Conway Notation for Polyhedron*. + Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. Examples -------- @@ -342,27 +351,27 @@ def mesh_conway_ortho(mesh): return mesh_conway_join(mesh_conway_join(mesh)) -def mesh_conway_expand(mesh): +def mesh_conway_expand(mesh: MeshType) -> MeshType: """Generates the expand mesh from a seed mesh. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A seed mesh Returns ------- - :class:`compas.datastructures.Mesh` + Mesh The expand mesh. References ---------- - Based on [1]_ and [2]_. + Based on the references below. - .. [1] Wikipedia. *Conway polyhedron notation*. - Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. - .. [2] Hart, George. *Conway Notation for Polyhedron*. - Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. + * Wikipedia. *Conway polyhedron notation*. + Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. + * Hart, George. *Conway Notation for Polyhedron*. + Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. Examples -------- @@ -380,27 +389,27 @@ def mesh_conway_expand(mesh): return mesh_conway_ambo(mesh_conway_ambo(mesh)) -def mesh_conway_gyro(mesh): +def mesh_conway_gyro(mesh: MeshType) -> MeshType: """Generates the gyro mesh from a seed mesh. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A seed mesh Returns ------- - :class:`compas.datastructures.Mesh` + Mesh The gyro mesh. References ---------- - Based on [1]_ and [2]_. + Based on the references below. - .. [1] Wikipedia. *Conway polyhedron notation*. - Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. - .. [2] Hart, George. *Conway Notation for Polyhedron*. - Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. + * Wikipedia. *Conway polyhedron notation*. + Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. + * Hart, George. *Conway Notation for Polyhedron*. + Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. Examples -------- @@ -433,33 +442,33 @@ def mesh_conway_gyro(mesh): ekey_index[v, u], vkey_index[v], ekey_index[v, mesh.face_vertex_descendant(fkey, v)], - fkey_index[mesh.halfedge[u][v]], + fkey_index[fkey], ] ) return cls.from_vertices_and_faces(vertices, faces) -def mesh_conway_snub(mesh): +def mesh_conway_snub(mesh: MeshType) -> MeshType: """Generates the snub mesh from a seed mesh. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A seed mesh Returns ------- - :class:`compas.datastructures.Mesh` + Mesh The gyro mesh. References ---------- - Based on [1]_ and [2]_. + Based on the references below. - .. [1] Wikipedia. *Conway polyhedron notation*. - Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. - .. [2] Hart, George. *Conway Notation for Polyhedron*. - Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. + * Wikipedia. *Conway polyhedron notation*. + Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. + * Hart, George. *Conway Notation for Polyhedron*. + Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. Examples -------- @@ -477,27 +486,27 @@ def mesh_conway_snub(mesh): return mesh_conway_dual(mesh_conway_gyro(mesh_conway_dual(mesh))) -def mesh_conway_meta(mesh): +def mesh_conway_meta(mesh: MeshType) -> MeshType: """Generates the meta mesh from a seed mesh. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A seed mesh Returns ------- - :class:`compas.datastructures.Mesh` + Mesh The meta mesh. References ---------- - Based on [1]_ and [2]_. + Based on the references below. - .. [1] Wikipedia. *Conway polyhedron notation*. - Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. - .. [2] Hart, George. *Conway Notation for Polyhedron*. - Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. + * Wikipedia. *Conway polyhedron notation*. + Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. + * Hart, George. *Conway Notation for Polyhedron*. + Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. Examples -------- @@ -515,27 +524,27 @@ def mesh_conway_meta(mesh): return mesh_conway_kis(mesh_conway_join(mesh)) -def mesh_conway_bevel(mesh): +def mesh_conway_bevel(mesh: MeshType) -> MeshType: """Generates the bevel mesh from a seed mesh. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A seed mesh Returns ------- - :class:`compas.datastructures.Mesh` + Mesh The bevel mesh. References ---------- - Based on [1]_ and [2]_. + Based on the references below. - .. [1] Wikipedia. *Conway polyhedron notation*. - Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. - .. [2] Hart, George. *Conway Notation for Polyhedron*. - Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. + * Wikipedia. *Conway polyhedron notation*. + Available at: https://en.wikipedia.org/wiki/Conway_polyhedron_notation. + * Hart, George. *Conway Notation for Polyhedron*. + Available at: http://www.georgehart.com/virtual-polyhedra/conway_notation.html. Examples -------- diff --git a/src/compas/datastructures/mesh/duality.py b/src/compas/datastructures/mesh/duality.py index d1b17f85d271..eecc3534c33f 100644 --- a/src/compas/datastructures/mesh/duality.py +++ b/src/compas/datastructures/mesh/duality.py @@ -1,33 +1,48 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import TYPE_CHECKING +from typing import Optional +from typing import Type +from typing import TypeVar +from typing import overload -from math import pi +if TYPE_CHECKING: + from .mesh import Mesh -from compas.itertools import flatten +MeshType = TypeVar("MeshType", bound="Mesh") +DualMeshType = TypeVar("DualMeshType", bound="Mesh") -PI2 = 2.0 * pi +@overload +def mesh_dual(mesh: MeshType, cls: None = None, include_boundary: bool = False) -> MeshType: ... -def mesh_dual(mesh, cls=None, include_boundary=False): + +@overload +def mesh_dual(mesh: "Mesh", cls: Type[DualMeshType], include_boundary: bool = False) -> DualMeshType: ... + + +def mesh_dual(mesh: "Mesh", cls: Optional[Type["Mesh"]] = None, include_boundary: bool = False) -> "Mesh": """Construct the dual of a mesh. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A mesh object. - cls : Type[:class:`compas.datastructures.Mesh`], optional + cls The type of the dual mesh. Defaults to the type of the provided mesh object. - include_boundary: bool, optional - Whether to include boundary faces for the dual mesh + include_boundary + Whether to include boundary faces for the dual mesh. If True, create faces on boundaries including all original mesh boundary vertices. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh The dual mesh object. + Raises + ------ + RuntimeError + If the boundary vertices are not ordered consistently. + Examples -------- >>> import compas @@ -41,14 +56,15 @@ def mesh_dual(mesh, cls=None, include_boundary=False): >>> dual = mesh.dual(include_boundary=True) """ - if not cls: + if cls is None: cls = type(mesh) dual = cls() face_centroid = {face: mesh.face_centroid(face) for face in mesh.faces()} - outer = set(flatten(mesh.vertices_on_boundaries())) - inner = list(set(mesh.vertices()) - outer) + boundaries = mesh.vertices_on_boundaries() + outer = {vertex for boundary in boundaries for vertex in boundary} + inner = set(mesh.vertices()) - outer vertex_xyz = {} face_vertices = {} @@ -82,14 +98,14 @@ def mesh_dual(mesh, cls=None, include_boundary=False): edge_vertex[u, v] = edge_vertex[v, u] = dual.add_vertex(x=x, y=y, z=z) vertex_vertex = {} - for boundary in mesh.vertices_on_boundaries(): + for boundary in boundaries: if boundary[0] == boundary[-1]: boundary = boundary[:-1] for vertex in boundary: x, y, z = mesh.vertex_coordinates(vertex) vertex_vertex[vertex] = dual.add_vertex(x=x, y=y, z=z) - for boundary in mesh.vertices_on_boundaries(): + for boundary in boundaries: if boundary[0] == boundary[-1]: boundary = boundary[:-1] for vertex in boundary: @@ -97,7 +113,10 @@ def mesh_dual(mesh, cls=None, include_boundary=False): nbrs = mesh.vertex_neighbors(vertex, ordered=True)[::-1] vertices.append(edge_vertex[vertex, nbrs[0]]) for nbr in nbrs[:-1]: - vertices.append(mesh.halfedge_face((vertex, nbr))) + face = mesh.halfedge_face((vertex, nbr)) + if face is None: + raise RuntimeError("The boundary vertices are not ordered consistently.") + vertices.append(face) vertices.append(edge_vertex[vertex, nbrs[-1]]) dual.add_face(vertices[::-1]) diff --git a/src/compas/datastructures/mesh/mesh.py b/src/compas/datastructures/mesh/mesh.py index 81e371780d70..da533501ac60 100644 --- a/src/compas/datastructures/mesh/mesh.py +++ b/src/compas/datastructures/mesh/mesh.py @@ -1,26 +1,53 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - +"""Halfedge-based mesh data structure. + +Notes +----- +Several collection-based attribute methods currently select their default +elements with expressions such as ``keys or self.vertices()``. As a result, an +explicitly empty collection selects all elements rather than no elements. This +legacy behaviour should be reviewed separately because changing it may affect +existing callers. + +Edge data keys are currently constructed as direction-independent strings in +multiple locations. A dedicated helper should centralize this normalization in +the short term. Longer term, ``edgedata`` should use canonical ``Edge`` tuples +internally, with conversion to a JSON-compatible representation confined to +``__data__`` and ``__from_data__``. At that point, an ``edge_key`` helper should +return the canonical tuple rather than a serialized string. + +""" + +from collections.abc import Mapping from itertools import product from math import pi from random import sample - -import compas - -if compas.PY2: - from collections import Mapping -else: - from collections.abc import Mapping +from typing import Any +from typing import Callable +from typing import Iterable +from typing import Iterator +from typing import Literal +from typing import Optional +from typing import Sequence +from typing import Union +from typing import overload + +from typing_extensions import Self from compas.datastructures.attributes import EdgeAttributeView from compas.datastructures.attributes import FaceAttributeView from compas.datastructures.attributes import VertexAttributeView from compas.datastructures.datastructure import Datastructure -from compas.files import OBJ -from compas.files import OFF -from compas.files import PLY -from compas.files import STL +from compas.files import obj_data +from compas.files import ply_data +from compas.files import read_obj +from compas.files import read_off +from compas.files import read_ply +from compas.files import read_stl +from compas.files import weld_stl_data +from compas.files import write_obj +from compas.files import write_off +from compas.files import write_ply +from compas.files import write_stl from compas.geometry import Box from compas.geometry import Circle from compas.geometry import Frame @@ -30,32 +57,33 @@ from compas.geometry import Polygon from compas.geometry import Polyhedron from compas.geometry import Shape # noqa: F401 +from compas.geometry import Transformation from compas.geometry import Vector -from compas.geometry import add_vectors from compas.geometry import angle_points from compas.geometry import area_polygon from compas.geometry import bestfit_plane from compas.geometry import bounding_box from compas.geometry import centroid_points from compas.geometry import centroid_polygon -from compas.geometry import cross_vectors from compas.geometry import distance_line_line from compas.geometry import distance_point_plane from compas.geometry import distance_point_point -from compas.geometry import dot_vectors -from compas.geometry import length_vector from compas.geometry import midpoint_line from compas.geometry import normal_polygon -from compas.geometry import normalize_vector from compas.geometry import oriented_bounding_box -from compas.geometry import scale_vector -from compas.geometry import subtract_vectors -from compas.geometry import sum_vectors from compas.geometry import transform_points -from compas.geometry import vector_average from compas.itertools import linspace from compas.itertools import pairwise from compas.itertools import window +from compas.linalg.vectors import add_vectors +from compas.linalg.vectors import cross_vectors +from compas.linalg.vectors import dot_vectors +from compas.linalg.vectors import length_vector +from compas.linalg.vectors import normalize_vector +from compas.linalg.vectors import scale_vector +from compas.linalg.vectors import subtract_vectors +from compas.linalg.vectors import sum_vectors +from compas.linalg.vectors import vector_average from compas.tolerance import TOL from compas.topology import breadth_first_traverse from compas.topology import connected_components @@ -73,6 +101,13 @@ from .smoothing import mesh_smooth_area from .smoothing import mesh_smooth_centroid from .subdivision import mesh_subdivide +from .types import AttributeDict +from .types import Edge +from .types import Face +from .types import PointCoordinates +from .types import Vertex + +_MISSING = object() class Mesh(Datastructure): @@ -80,30 +115,30 @@ class Mesh(Datastructure): Parameters ---------- - default_vertex_attributes : dict[str, Any], optional + default_vertex_attributes Default values for vertex attributes. - default_edge_attributes : dict[str, Any], optional + default_edge_attributes Default values for edge attributes. - default_face_attributes : dict[str, Any], optional + default_face_attributes Default values for face attributes. - name : str, optional + name Then name of the mesh. - **kwargs : dict, optional + **kwargs Additional keyword arguments, which are stored in the attributes dict. Attributes ---------- - default_vertex_attributes : dict[str, Any] + default_vertex_attributes Dictionary containing default values for the attributes of vertices. - It is recommended to add a default to this dictionary using :meth:`update_default_vertex_attributes` + It is recommended to add a default to this dictionary using for every vertex attribute used in the data structure. - default_edge_attributes : dict[str, Any] + default_edge_attributes Dictionary containing default values for the attributes of edges. - It is recommended to add a default to this dictionary using :meth:`update_default_edge_attributes` + It is recommended to add a default to this dictionary using for every edge attribute used in the data structure. - default_face_attributes : dict[str, Any] - Dictionary contnaining default values for the attributes of faces. - It is recommended to add a default to this dictionary using :meth:`update_default_face_attributes` + default_face_attributes + Dictionary containing default values for the attributes of faces. + It is recommended to add a default to this dictionary using for every face attribute used in the data structure. Examples @@ -131,58 +166,8 @@ class Mesh(Datastructure): smooth_centroid = mesh_smooth_centroid smooth_area = mesh_smooth_area - DATASCHEMA = { - "type": "object", - "properties": { - "attributes": {"type": "object"}, - "default_vertex_attributes": {"type": "object"}, - "default_edge_attributes": {"type": "object"}, - "default_face_attributes": {"type": "object"}, - "vertex": { - "type": "object", - "patternProperties": {"^[0-9]+$": {"type": "object"}}, - "additionalProperties": False, - }, - "face": { - "type": "object", - "patternProperties": { - "^[0-9]+$": { - "type": "array", - "items": {"type": "integer", "minimum": 0}, - "minItems": 3, - } - }, - "additionalProperties": False, - }, - "facedata": { - "type": "object", - "patternProperties": {"^[0-9]+$": {"type": "object"}}, - "additionalProperties": False, - }, - "edgedata": { - "type": "object", - "patternProperties": {"^\\([0-9]+, [0-9]+\\)$": {"type": "object"}}, - "additionalProperties": False, - }, - "max_vertex": {"type": "integer", "minimum": -1}, - "max_face": {"type": "integer", "minimum": -1}, - }, - "required": [ - "attributes", - "default_vertex_attributes", - "default_edge_attributes", - "default_face_attributes", - "vertex", - "face", - "facedata", - "edgedata", - "max_vertex", - "max_face", - ], - } - @property - def __data__(self): + def __data__(self) -> dict[str, Any]: return self.__before_json_dump__( { "attributes": self.attributes, @@ -198,14 +183,14 @@ def __data__(self): } ) - def __before_json_dump__(self, data): + def __before_json_dump__(self, data: dict[str, Any]) -> dict[str, Any]: data["vertex"] = {str(vertex): attr for vertex, attr in data["vertex"].items()} data["face"] = {str(face): vertices for face, vertices in data["face"].items()} data["facedata"] = {str(face): attr for face, attr in data["facedata"].items()} return data @classmethod - def __from_data__(cls, data): + def __from_data__(cls, data: dict[str, Any]) -> Self: mesh = cls( default_vertex_attributes=data.get("default_vertex_attributes"), default_face_attributes=data.get("default_face_attributes"), @@ -231,21 +216,21 @@ def __from_data__(cls, data): return mesh def __init__( - self, - default_vertex_attributes=None, - default_edge_attributes=None, - default_face_attributes=None, - name=None, - **kwargs - ): # fmt: skip - super(Mesh, self).__init__(kwargs, name=name) + self, + default_vertex_attributes: Optional[AttributeDict] = None, + default_edge_attributes: Optional[AttributeDict] = None, + default_face_attributes: Optional[AttributeDict] = None, + name: Optional[str] = None, + **kwargs: Any, + ) -> None: + super().__init__(kwargs, name=name) self._max_vertex = -1 self._max_face = -1 - self.vertex = {} - self.halfedge = {} - self.face = {} - self.facedata = {} - self.edgedata = {} + self.vertex: dict[Vertex, AttributeDict] = {} + self.halfedge: dict[Vertex, dict[Vertex, Optional[Face]]] = {} + self.face: dict[Face, list[Vertex]] = {} + self.facedata: dict[Face, AttributeDict] = {} + self.edgedata: dict[str, AttributeDict] = {} self.default_vertex_attributes = {"x": 0.0, "y": 0.0, "z": 0.0} self.default_edge_attributes = {} self.default_face_attributes = {} @@ -256,7 +241,7 @@ def __init__( if default_face_attributes: self.default_face_attributes.update(default_face_attributes) - def __str__(self): + def __str__(self) -> str: tpl = "" return tpl.format(self.number_of_vertices(), self.number_of_faces(), self.number_of_edges()) @@ -265,7 +250,7 @@ def __str__(self): # -------------------------------------------------------------------------- @property - def adjacency(self): + def adjacency(self) -> dict[Vertex, dict[Vertex, Optional[Face]]]: return self.halfedge # -------------------------------------------------------------------------- @@ -273,19 +258,19 @@ def adjacency(self): # -------------------------------------------------------------------------- @classmethod - def from_obj(cls, filepath, precision=None): # type: (...) -> Mesh + def from_obj(cls, filepath: Any, precision: Optional[int] = None) -> Self: """Construct a mesh object from the data described in an OBJ file. Parameters ---------- - filepath : str + filepath The path to the file. - precision: str, optional + precision The precision of the geometric map that is used to connect the lines. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A mesh object. Notes @@ -300,104 +285,99 @@ def from_obj(cls, filepath, precision=None): # type: (...) -> Mesh * quadmesh.obj """ - obj = OBJ(filepath, precision) - obj.read() - vertices = obj.vertices - faces = obj.faces - edges = obj.lines + data = obj_data(read_obj(filepath)) + vertices = data.vertices + faces = data.faces + edges = data.lines if not vertices: return cls() if faces: return cls.from_vertices_and_faces(vertices, faces) if edges: - lines = [(vertices[u], vertices[v], 0) for u, v in edges] - return cls.from_lines(lines) + lines = [(vertices[u], vertices[v]) for u, v in edges] + return cls.from_lines(lines, precision=precision) return cls() @classmethod - def from_ply(cls, filepath, precision=None): # type: (...) -> Mesh + def from_ply(cls, filepath: Any, precision: Optional[int] = None) -> Self: """Construct a mesh object from the data described in a PLY file. Parameters ---------- - filepath : str + filepath The path to the file. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A mesh object. """ - ply = PLY(filepath) - vertices = ply.parser.vertices # type: ignore - faces = ply.parser.faces # type: ignore - mesh = cls.from_vertices_and_faces(vertices, faces) - return mesh + data = ply_data(read_ply(filepath)) + return cls.from_vertices_and_faces(data.vertices, data.faces) @classmethod - def from_stl(cls, filepath, precision=None): # type: (...) -> Mesh + def from_stl(cls, filepath: Any, precision: Optional[int] = None) -> Self: """Construct a mesh object from the data described in a STL file. Parameters ---------- - filepath : str + filepath The path to the file. - precision: str, optional + precision The precision of the geometric map that is used to connect the lines. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A mesh object. """ - stl = STL(filepath, precision) - vertices = stl.parser.vertices # type: ignore - faces = stl.parser.faces # type: ignore - mesh = cls.from_vertices_and_faces(vertices, faces) - return mesh + data = weld_stl_data(read_stl(filepath), precision) + return cls.from_vertices_and_faces(data.vertices, data.faces) @classmethod - def from_off(cls, filepath): # type: (...) -> Mesh + def from_off(cls, filepath: Any) -> Self: """Construct a mesh object from the data described in a OFF file. Parameters ---------- - filepath : str + filepath The path to the file. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A mesh object. """ - off = OFF(filepath) - vertices = off.reader.vertices # type: ignore - faces = off.reader.faces # type: ignore - mesh = cls.from_vertices_and_faces(vertices, faces) - return mesh + document = read_off(filepath) + return cls.from_vertices_and_faces(document.vertices, document.faces) @classmethod - def from_lines(cls, lines, delete_boundary_face=False, precision=None): # type: (...) -> Mesh + def from_lines( + cls, + lines: Iterable[Sequence[PointCoordinates]], + delete_boundary_face: bool = False, + precision: Optional[int] = None, + ) -> Self: """Construct a mesh object from a list of lines described by start and end point coordinates. Parameters ---------- - lines : list[tuple[list[float], list[float]]] + lines A list of pairs of point coordinates. - delete_boundary_face : bool, optional + delete_boundary_face The algorithm that finds the faces formed by the connected lines first finds the face *on the outside*. In most cases this face is not expected to be there. Therefore, there is the option to have it automatically deleted. - precision: str, optional + precision The precision of the geometric map that is used to connect the lines. - Defaults to the value of :attr:`compas.PRECISION`. + Defaults to the value of Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A mesh object. """ @@ -405,7 +385,8 @@ def from_lines(cls, lines, delete_boundary_face=False, precision=None): # type: graph = Graph.from_lines(lines, precision=precision) vertices = graph.to_points() - faces = graph.find_cycles() + vertex_index = {vertex: index for index, vertex in enumerate(graph.nodes())} + faces = [[vertex_index[vertex] for vertex in face] for face in graph.find_cycles()] mesh = cls.from_vertices_and_faces(vertices, faces) if delete_boundary_face: mesh.delete_face(0) @@ -413,7 +394,11 @@ def from_lines(cls, lines, delete_boundary_face=False, precision=None): # type: return mesh @classmethod - def from_polylines(cls, boundary_polylines, other_polylines): # type: (...) -> Mesh + def from_polylines( + cls, + boundary_polylines: list[list[PointCoordinates]], + other_polylines: list[list[PointCoordinates]], + ) -> Self: """Construct mesh from polylines. Based on construction from_lines, @@ -425,14 +410,14 @@ def from_polylines(cls, boundary_polylines, other_polylines): # type: (...) -> Parameters ---------- - boundary_polylines : list[list[float]] + boundary_polylines List of polylines representing boundaries as lists of vertex coordinates. - other_polylines : list[list[float]] + other_polylines List of the other polylines as lists of vertex coordinates. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A mesh object. """ @@ -483,21 +468,25 @@ def from_polylines(cls, boundary_polylines, other_polylines): # type: (...) -> return cls.from_vertices_and_faces(vertices, faces) @classmethod - def from_vertices_and_faces(cls, vertices, faces): # type: (...) -> Mesh + def from_vertices_and_faces( + cls, + vertices: Union[Sequence[Iterable[float]], Mapping[Vertex, Iterable[float]]], + faces: Union[Sequence[Sequence[Vertex]], Mapping[Face, Sequence[Vertex]]], + ) -> Self: """Construct a mesh object from a list of vertices and faces. Parameters ---------- - vertices : list[list[float]] | dict[int, list[float]] + vertices A list of vertices, represented by their XYZ coordinates, or a dictionary of vertex keys pointing to their XYZ coordinates. - faces : list[list[int]] | dict[int, list[int]] + faces A list of faces, represented by a list of indices referencing the list of vertex coordinates, or a dictionary of face keys pointing to a list of indices referencing the list of vertex coordinates. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A mesh object. """ @@ -511,8 +500,8 @@ def from_vertices_and_faces(cls, vertices, faces): # type: (...) -> Mesh mesh.add_vertex(x=x, y=y, z=z) if isinstance(faces, Mapping): - for fkey, vertices in faces.items(): - mesh.add_face(vertices, fkey) + for fkey, face_vertices in faces.items(): + mesh.add_face(face_vertices, fkey) else: for face in iter(faces): mesh.add_face(face) @@ -520,17 +509,17 @@ def from_vertices_and_faces(cls, vertices, faces): # type: (...) -> Mesh return mesh @classmethod - def from_polyhedron(cls, f): # type: (...) -> Mesh + def from_polyhedron(cls, f: int) -> Self: """Construct a mesh from a platonic solid. Parameters ---------- - f : {4, 6, 8, 12, 20} + f The number of faces. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A mesh object. """ @@ -538,19 +527,19 @@ def from_polyhedron(cls, f): # type: (...) -> Mesh return cls.from_vertices_and_faces(p.vertices, p.faces) @classmethod - def from_shape(cls, shape, **kwargs): # type: (Shape, dict) -> Mesh + def from_shape(cls, shape: Shape, **kwargs: Any) -> Self: """Construct a mesh from a primitive shape. Parameters ---------- - shape : :class:`compas.geometry.Shape` + shape The input shape to generate a mesh from. - **kwargs : dict[str, Any], optional - Optional keyword arguments to be passed on to :meth:`compas.geometry.Shape.to_vertices_and_faces`. + **kwargs + Optional keyword arguments to be passed on to Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A mesh object. """ @@ -560,42 +549,46 @@ def from_shape(cls, shape, **kwargs): # type: (Shape, dict) -> Mesh return mesh @classmethod - def from_points(cls, points): # type: (...) -> Mesh + def from_points(cls, points: Sequence[PointCoordinates]) -> Self: """Construct a mesh from a delaunay triangulation of a set of points. Parameters ---------- - points : list[list[float]] + points XYZ coordinates of the points. Z coordinates should be zero. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A mesh object. """ from compas.geometry import delaunay_triangulation faces = delaunay_triangulation(points) - return cls.from_vertices_and_faces(points, faces) + return cls.from_vertices_and_faces(points, faces) # type: ignore @classmethod - def from_polygons(cls, polygons, precision=None): # type: (...) -> Mesh + def from_polygons( + cls, + polygons: Iterable[Sequence[PointCoordinates]], + precision: Optional[int] = None, + ) -> Self: """Construct a mesh from a series of polygons. Parameters ---------- - polygons : list[list[float]] + polygons A list of polygons, with each polygon defined as an ordered list of XYZ coordinates of its corners. - precision : int, optional + precision Precision for converting numbers to strings. - Default is :attr:`TOL.precision`. + Default is Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A mesh object. """ @@ -609,30 +602,36 @@ def from_polygons(cls, polygons, precision=None): # type: (...) -> Mesh face.append(gkey) faces.append(face) gkey_index = {gkey: index for index, gkey in enumerate(gkey_xyz)} - vertices = gkey_xyz.values() + vertices = list(gkey_xyz.values()) faces[:] = [[gkey_index[gkey] for gkey in face] for face in faces] return cls.from_vertices_and_faces(vertices, faces) @classmethod - def from_meshgrid(cls, dx, nx, dy=None, ny=None): # type: (...) -> Mesh + def from_meshgrid( + cls, + dx: float, + nx: int, + dy: Optional[float] = None, + ny: Optional[int] = None, + ) -> Self: """Construct a mesh from faces and vertices on a regular grid. Parameters ---------- - dx : float + dx The size of the grid in the X direction. - nx : int + nx The number of faces in the X direction. - dy : float, optional + dy The size of the grid in the Y direction. Defaults to the value of `dx`. - ny : int, optional + ny The number of faces in the Y direction. Defaults to the value of `nx`. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A mesh object. """ @@ -657,7 +656,7 @@ def from_meshgrid(cls, dx, nx, dy=None, ny=None): # type: (...) -> Mesh # Conversions # -------------------------------------------------------------------------- - def to_lines(self): + def to_lines(self) -> list[tuple[list[float], list[float]]]: """Return the lines of the mesh as pairs of start and end point coordinates. Returns @@ -668,7 +667,7 @@ def to_lines(self): """ return [self.edge_coordinates(edge) for edge in self.edges()] - def to_polylines(self): + def to_polylines(self) -> list[list[list[float]]]: """Convert the mesh to a collection of polylines. Returns @@ -679,12 +678,12 @@ def to_polylines(self): """ raise NotImplementedError - def to_vertices_and_faces(self, triangulated=False): + def to_vertices_and_faces(self, triangulated: bool = False) -> tuple[list[list[float]], list[list[int]]]: """Return the vertices and faces of a mesh. Parameters ---------- - triangulated: bool, optional + triangulated If True, triangulate the faces. Returns @@ -724,7 +723,7 @@ def to_vertices_and_faces(self, triangulated=False): return vertices, faces - def to_points(self): + def to_points(self) -> list[list[float]]: """Convert the mesh to a collection of points. Returns @@ -735,7 +734,7 @@ def to_points(self): """ return [self.vertex_coordinates(vertex) for vertex in self.vertices()] - def to_polygons(self): + def to_polygons(self) -> list[list[list[float]]]: """Convert the mesh to a collection of polygons. Returns @@ -746,16 +745,22 @@ def to_polygons(self): """ return [self.face_coordinates(fkey) for fkey in self.faces()] - def to_obj(self, filepath, precision=None, unweld=False, **kwargs): + def to_obj( + self, + filepath: Any, + precision: Optional[int] = None, + unweld: bool = False, + **kwargs: Any, + ) -> None: """Write the mesh to an OBJ file. Parameters ---------- - filepath : str + filepath Full path of the file. - precision: str, optional + precision The precision of the geometric map that is used to connect the lines. - unweld : bool, optional + unweld If True, all faces have their own unique vertices. If False (default), vertices are shared between faces if this is also the case in the mesh. @@ -769,15 +774,14 @@ def to_obj(self, filepath, precision=None, unweld=False, **kwargs): the faces to the file. """ - obj = OBJ(filepath, precision=precision) - obj.write(self, unweld=unweld, **kwargs) + write_obj(filepath, self, precision=precision, unweld=unweld, **kwargs) - def to_ply(self, filepath, **kwargs): + def to_ply(self, filepath: Any, **kwargs: Any) -> None: """Write a mesh object to a PLY file. Parameters ---------- - filepath : str + filepath The path to the file. Returns @@ -785,20 +789,25 @@ def to_ply(self, filepath, **kwargs): None """ - ply = PLY(filepath) - ply.write(self, **kwargs) + write_ply(filepath, self, **kwargs) - def to_stl(self, filepath, precision=None, binary=False, **kwargs): + def to_stl( + self, + filepath: Any, + precision: Optional[int] = None, + binary: bool = False, + **kwargs: Any, + ) -> None: """Write a mesh to an STL file. Parameters ---------- - filepath : str + filepath The path to the file. - precision : str, optional + precision Rounding precision for the vertex coordinates. - Defaults to the value of :attr:`compas.PRECISION`. - binary : bool, optional + Defaults to the value of `compas.PRECISION`. + binary If True, the file will be written in binary format. ASCII otherwise. @@ -810,18 +819,17 @@ def to_stl(self, filepath, precision=None, binary=False, **kwargs): ----- STL files only support triangle faces. It is the user's responsibility to convert all faces of a mesh to triangles. - For example, with :meth:`compas.datastructures.Mesh.quads_to_triangles`. + For example, with `compas.datastructures.Mesh.quads_to_triangles`. """ - stl = STL(filepath, precision) - stl.write(self, binary=binary, **kwargs) + write_stl(filepath, self, precision=precision, binary=binary, **kwargs) - def to_off(self, filepath, **kwargs): + def to_off(self, filepath: Any, **kwargs: Any) -> None: """Write a mesh object to an OFF file. Parameters ---------- - filepath : str + filepath The path to the file. Returns @@ -829,14 +837,13 @@ def to_off(self, filepath, **kwargs): None """ - off = OFF(filepath) - off.write(self, **kwargs) + write_off(filepath, self, **kwargs) # -------------------------------------------------------------------------- # Helpers # -------------------------------------------------------------------------- - def clear(self): + def clear(self) -> None: """Clear all the mesh data. Returns @@ -857,12 +864,12 @@ def clear(self): self._max_vertex = -1 self._max_face = -1 - def vertex_sample(self, size=1): + def vertex_sample(self, size: int = 1) -> list[Vertex]: """A random sample of the vertices. Parameters ---------- - size : int, optional + size The number of vertices in the random sample. Returns @@ -872,17 +879,17 @@ def vertex_sample(self, size=1): See Also -------- - :meth:`edge_sample`, :meth:`face_sample` + edge_sample, face_sample """ return sample(list(self.vertices()), size) - def edge_sample(self, size=1): + def edge_sample(self, size: int = 1) -> list[Edge]: """A random sample of the edges. Parameters ---------- - size : int, optional + size The number of edges in the random sample. Returns @@ -892,17 +899,17 @@ def edge_sample(self, size=1): See Also -------- - :meth:`vertex_sample`, :meth:`face_sample` + vertex_sample, face_sample """ return sample(list(self.edges()), size) - def face_sample(self, size=1): + def face_sample(self, size: int = 1) -> list[Face]: """A random sample of the faces. Parameters ---------- - size : int, optional + size The number of faces in the random sample. Returns @@ -912,12 +919,12 @@ def face_sample(self, size=1): See Also -------- - :meth:`vertex_sample`, :meth:`edge_sample` + vertex_sample, edge_sample """ return sample(list(self.faces()), size) - def vertex_index(self): + def vertex_index(self) -> dict[Vertex, int]: """Returns a dictionary that maps vertex identifiers to the corresponding index in a vertex list or array. @@ -928,12 +935,12 @@ def vertex_index(self): See Also -------- - :meth:`index_vertex` + index_vertex """ return {key: index for index, key in enumerate(self.vertices())} - def index_vertex(self): + def index_vertex(self) -> dict[int, Vertex]: """Returns a dictionary that maps the indices of a vertex list to the corresponding vertex identifiers. @@ -944,20 +951,20 @@ def index_vertex(self): See Also -------- - :meth:`vertex_index` + vertex_index """ return dict(enumerate(self.vertices())) - def vertex_gkey(self, precision=None): + def vertex_gkey(self, precision: Optional[int] = None) -> dict[Vertex, str]: """Returns a dictionary that maps vertex dictionary keys to the corresponding *geometric key* up to a certain precision. Parameters ---------- - precision : int, optional + precision Precision for converting numbers to strings. - Default is :attr:`TOL.precision`. + Default is Returns ------- @@ -969,15 +976,15 @@ def vertex_gkey(self, precision=None): xyz = self.vertex_coordinates return {key: gkey(xyz(key), precision) for key in self.vertices()} - def gkey_vertex(self, precision=None): + def gkey_vertex(self, precision: Optional[int] = None) -> dict[str, Vertex]: """Returns a dictionary that maps *geometric keys* of a certain precision to the keys of the corresponding vertices. Parameters ---------- - precision : int, optional + precision Precision for converting numbers to strings. - Default is :attr:`TOL.precision`. + Default is Returns ------- @@ -993,16 +1000,21 @@ def gkey_vertex(self, precision=None): # Builders & Modifiers # -------------------------------------------------------------------------- - def add_vertex(self, key=None, attr_dict=None, **kwattr): + def add_vertex( + self, + key: Optional[Vertex] = None, + attr_dict: Optional[Mapping[str, Any]] = None, + **kwattr: Any, + ) -> Vertex: """Add a vertex to the mesh object. Parameters ---------- - key : int, optional + key The vertex identifier. - attr_dict : dict[str, Any], optional + attr_dict A dictionary of vertex attributes. - **kwattr : dict[str, Any], optional + **kwattr A dictionary of additional attributes compiled of remaining named arguments. Returns @@ -1012,8 +1024,8 @@ def add_vertex(self, key=None, attr_dict=None, **kwattr): See Also -------- - :meth:`add_face` - :meth:`delete_vertex`, :meth:`delete_face` + add_face + delete_vertex, delete_face Notes ----- @@ -1046,27 +1058,36 @@ def add_vertex(self, key=None, attr_dict=None, **kwattr): if key not in self.vertex: self.vertex[key] = {} self.halfedge[key] = {} - attr = attr_dict or {} + # NOTE: This preserves the permissive legacy behaviour: any falsy value + # is treated as an empty mapping. Revisit separately whether attr_dict + # should be validated as a Mapping at runtime. + attr = dict(attr_dict or {}) attr.update(kwattr) self.vertex[key].update(attr) return key - def add_face(self, vertices, fkey=None, attr_dict=None, **kwattr): + def add_face( + self, + vertices: Sequence[Vertex], + fkey: Optional[Face] = None, + attr_dict: Optional[Mapping[str, Any]] = None, + **kwattr: Any, + ) -> Optional[Face]: """Add a face to the mesh object. Parameters ---------- - vertices : list[int] + vertices A list of vertex keys. - attr_dict : dict[str, Any], optional + attr_dict A dictionary of face attributes. - **kwattr : dict[str, Any], optional + **kwattr A dictionary of additional attributes compiled of remaining named arguments. See Also -------- - :meth:`add_vertex` - :meth:`delete_face`, :meth:`delete_vertex` + add_vertex + delete_face, delete_vertex Returns ------- @@ -1099,7 +1120,10 @@ def add_face(self, vertices, fkey=None, attr_dict=None, **kwattr): fkey = int(fkey) if fkey > self._max_face: self._max_face = fkey - attr = attr_dict or {} + # NOTE: This preserves the permissive legacy behaviour: any falsy value + # is treated as an empty mapping. Revisit separately whether attr_dict + # should be validated as a Mapping at runtime. + attr = dict(attr_dict or {}) attr.update(kwattr) self.face[fkey] = vertices self.facedata.setdefault(fkey, attr) @@ -1111,19 +1135,19 @@ def add_face(self, vertices, fkey=None, attr_dict=None, **kwattr): # rename this to "add" # and add an alias - def join(self, other, weld=False, precision=None): + def join(self, other: "Mesh", weld: bool = False, precision: Optional[int] = None) -> None: """Add the vertices and faces of another mesh to the current mesh. Parameters ---------- - other : :class:`compas.datastructures.Mesh` + other The other mesh. - weld : bool, optional + weld If True, weld close vertices after joining. Default is False. - precision : int, optional + precision The precision used for welding. - Default is :attr:`TOL.precision`. + Default is to use the global precision value. Returns ------- @@ -1173,12 +1197,12 @@ def join(self, other, weld=False, precision=None): if weld: self.weld(precision=precision) - def delete_vertex(self, key): + def delete_vertex(self, key: Vertex) -> None: """Delete a vertex from the mesh and everything that is attached to it. Parameters ---------- - key : int + key The identifier of the vertex. Returns @@ -1187,14 +1211,14 @@ def delete_vertex(self, key): See Also -------- - :meth:`delete_face` - :meth:`add_vertex`, :meth:`add_face` + delete_face + add_vertex, add_face Notes ----- In some cases, disconnected vertices can remain after application of this method. To remove these vertices as well, combine this method with vertex - culling (:meth:`cull_vertices`). + culling (cull_vertices). """ nbrs = self.vertex_neighbors(key) @@ -1223,12 +1247,12 @@ def delete_vertex(self, key): del self.halfedge[key] del self.vertex[key] - def delete_face(self, fkey): + def delete_face(self, fkey: Face) -> None: """Delete a face from the mesh object. Parameters ---------- - fkey : int + fkey The identifier of the face. Returns @@ -1237,14 +1261,14 @@ def delete_face(self, fkey): See Also -------- - :meth:`delete_vertex` - :meth:`add_vertex`, :meth:`add_face` + delete_vertex + add_vertex, add_face Notes ----- In some cases, disconnected vertices can remain after application of this method. To remove these vertices as well, combine this method with vertex - culling (:meth:`cull_vertices`). + culling (cull_vertices). """ for u, v in self.face_halfedges(fkey): @@ -1263,7 +1287,7 @@ def delete_face(self, fkey): if fkey in self.facedata: del self.facedata[fkey] - def remove_unused_vertices(self): + def remove_unused_vertices(self) -> None: """Remove all unused vertices from the mesh object. Returns @@ -1272,7 +1296,7 @@ def remove_unused_vertices(self): See Also -------- - :meth:`delete_vertex` + delete_vertex """ for u in list(self.vertices()): @@ -1285,7 +1309,7 @@ def remove_unused_vertices(self): cull_vertices = remove_unused_vertices - def flip_cycles(self): + def flip_cycles(self) -> None: """Flip the cycle directions of all faces. Returns @@ -1307,18 +1331,24 @@ def flip_cycles(self): if u not in self.halfedge[v]: self.halfedge[v][u] = None - def insert_vertex(self, fkey, key=None, xyz=None, return_fkeys=False): + def insert_vertex( + self, + fkey: Face, + key: Optional[Vertex] = None, + xyz: Optional[PointCoordinates] = None, + return_fkeys: bool = False, + ) -> Union[Vertex, tuple[Vertex, list[Face]]]: """Insert a vertex in the specified face. Parameters ---------- - fkey : int + fkey The key of the face in which the vertex should be inserted. - key : int, optional + key The key to be used to identify the inserted vertex. - xyz : list[float], optional + xyz Specific XYZ coordinates for the inserted vertex. - return_fkeys : bool, optional + return_fkeys If True, return the identifiers of the newly created faces in addition to the identifier of the inserted vertex. Returns @@ -1345,24 +1375,34 @@ def insert_vertex(self, fkey, key=None, xyz=None, return_fkeys=False): # Accessors # -------------------------------------------------------------------------- - def vertices(self, data=False): + @overload + def vertices(self, data: Literal[False] = False) -> Iterator[Vertex]: ... + + @overload + def vertices(self, data: Literal[True]) -> Iterator[tuple[Vertex, VertexAttributeView]]: ... + + @overload + def vertices(self, data: bool) -> Iterator[Union[Vertex, tuple[Vertex, VertexAttributeView]]]: ... + + def vertices(self, data: bool = False) -> Iterator[Any]: """Iterate over the vertices of the mesh. Parameters ---------- - data : bool, optional + data If True, yield the vertex attributes in addition to the vertex identifiers. Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next vertex identifier. - If `data` is True, the next vertex as a (key, attr) tuple. + int + The vertex identifier if `data` is `False`. + tuple[int, VertexAttributeView] + The vertex identifier and its attributes if `data` is `True`. See Also -------- - :meth:`faces`, :meth:`edges` - :meth:`vertices_where`, :meth:`edges_where`, :meth:`faces_where` + faces, edges + vertices_where, edges_where, faces_where """ for key in self.vertex: @@ -1371,24 +1411,34 @@ def vertices(self, data=False): else: yield key, self.vertex_attributes(key) - def faces(self, data=False): + @overload + def faces(self, data: Literal[False] = False) -> Iterator[Face]: ... + + @overload + def faces(self, data: Literal[True]) -> Iterator[tuple[Face, FaceAttributeView]]: ... + + @overload + def faces(self, data: bool) -> Iterator[Union[Face, tuple[Face, FaceAttributeView]]]: ... + + def faces(self, data: bool = False) -> Iterator[Any]: """Iterate over the faces of the mesh. Parameters ---------- - data : bool, optional + data If True, yield the face attributes in addition to the face identifiers. Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next face identifier. - If `data` is True, the next face as a (fkey, attr) tuple. + int + The face identifier if `data` is `False`. + tuple[int, FaceAttributeView] + The face identifier and its attributes if `data` is `True`. See Also -------- - :meth:`vertices`, :meth:`edges` - :meth:`vertices_where`, :meth:`edges_where`, :meth:`faces_where` + vertices, edges + vertices_where, edges_where, faces_where """ for key in self.face: @@ -1397,24 +1447,34 @@ def faces(self, data=False): else: yield key, self.face_attributes(key) - def edges(self, data=False): + @overload + def edges(self, data: Literal[False] = False) -> Iterator[Edge]: ... + + @overload + def edges(self, data: Literal[True]) -> Iterator[tuple[Edge, EdgeAttributeView]]: ... + + @overload + def edges(self, data: bool) -> Iterator[Union[Edge, tuple[Edge, EdgeAttributeView]]]: ... + + def edges(self, data: bool = False) -> Iterator[Any]: """Iterate over the edges of the mesh. Parameters ---------- - data : bool, optional + data If True, yield the edge attributes in addition to the edge identifiers. Yields ------ - tuple[int, int] | tuple[tuple[int, int], dict[str, Any]] - If `data` is False, the next edge as a (u, v) tuple. - If `data` is True, the next edge as a ((u, v), data) tuple. + tuple[int, int] + The edge identifier if `data` is `False`. + tuple[tuple[int, int], EdgeAttributeView] + The edge identifier and its attributes if `data` is `True`. See Also -------- - :meth:`vertices`, :meth:`faces` - :meth:`vertices_where`, :meth:`edges_where`, :meth:`faces_where` + vertices, faces + vertices_where, edges_where, faces_where Notes ----- @@ -1424,7 +1484,7 @@ def edges(self, data=False): they are accessed using this method. This method yields the directed edges of the mesh. - Unless edges were added explicitly using :meth:`add_edge` the order of + Unless edges were added explicitly using add_edge the order of edges is *as they come out*. However, as long as the toplogy remains unchanged, the order is consistent. @@ -1443,30 +1503,68 @@ def edges(self, data=False): else: yield key, self.edge_attributes(key) - def vertices_where(self, conditions=None, data=False, **kwargs): + @overload + def vertices_where( + self, + conditions: Optional[AttributeDict] = None, + data: Literal[False] = False, + **kwargs: Any, + ) -> Iterator[Vertex]: ... + + @overload + def vertices_where( + self, + conditions: Optional[AttributeDict], + data: Literal[True], + **kwargs: Any, + ) -> Iterator[tuple[Vertex, VertexAttributeView]]: ... + + @overload + def vertices_where( + self, + *, + data: Literal[True], + **kwargs: Any, + ) -> Iterator[tuple[Vertex, VertexAttributeView]]: ... + + @overload + def vertices_where( + self, + conditions: Optional[AttributeDict], + data: bool, + **kwargs: Any, + ) -> Iterator[Union[Vertex, tuple[Vertex, VertexAttributeView]]]: ... + + def vertices_where( + self, + conditions: Optional[AttributeDict] = None, + data: bool = False, + **kwargs: Any, + ) -> Iterator[Any]: """Get vertices for which a certain condition or set of conditions is true. Parameters ---------- - conditions : dict, optional + conditions A set of conditions in the form of key-value pairs. The keys should be attribute names. The values can be attribute values or ranges of attribute values in the form of min/max pairs. - data : bool, optional + data If True, yield the vertex attributes in addition to the vertex identifiers. - **kwargs : dict[str, Any], optional + **kwargs Additional conditions provided as named function arguments. Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next vertex that matches the condition. - If `data` is True, the next vertex and its attributes. + int + A matching vertex identifier if `data` is `False`. + tuple[int, VertexAttributeView] + A matching vertex identifier and its attributes if `data` is `True`. See Also -------- - :meth:`faces_where`, :meth:`edges_where` - :meth:`vertices_where_predicate`, :meth:`edges_where_predicate`, :meth:`faces_where_predicate` + faces_where, edges_where + vertices_where_predicate, edges_where_predicate, faces_where_predicate """ conditions = conditions or {} @@ -1525,28 +1623,54 @@ def vertices_where(self, conditions=None, data=False, **kwargs): else: yield key - def vertices_where_predicate(self, predicate, data=False): + @overload + def vertices_where_predicate( + self, + predicate: Callable[[Vertex, VertexAttributeView], bool], + data: Literal[False] = False, + ) -> Iterator[Vertex]: ... + + @overload + def vertices_where_predicate( + self, + predicate: Callable[[Vertex, VertexAttributeView], bool], + data: Literal[True], + ) -> Iterator[tuple[Vertex, VertexAttributeView]]: ... + + @overload + def vertices_where_predicate( + self, + predicate: Callable[[Vertex, VertexAttributeView], bool], + data: bool, + ) -> Iterator[Union[Vertex, tuple[Vertex, VertexAttributeView]]]: ... + + def vertices_where_predicate( + self, + predicate: Callable[[Vertex, VertexAttributeView], bool], + data: bool = False, + ) -> Iterator[Any]: """Get vertices for which a certain condition or set of conditions is true using a lambda function. Parameters ---------- - predicate : callable + predicate The condition you want to evaluate. - The callable takes 2 parameters: the vertex identifier and the vertex attributes, + The callable takes 2 parameters and should return True or False. - data : bool, optional + data If True, yield the vertex attributes in addition to the vertex identifiers. Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next vertex that matches the condition. - If `data` is True, the next vertex and its attributes. + int + A matching vertex identifier if `data` is `False`. + tuple[int, VertexAttributeView] + A matching vertex identifier and its attributes if `data` is `True`. See Also -------- - :meth:`faces_where_predicate`, :meth:`edges_where_predicate` - :meth:`vertices_where`, :meth:`edges_where`, :meth:`faces_where` + faces_where_predicate, edges_where_predicate + vertices_where, edges_where, faces_where """ for key, attr in self.vertices(True): @@ -1556,30 +1680,68 @@ def vertices_where_predicate(self, predicate, data=False): else: yield key - def edges_where(self, conditions=None, data=False, **kwargs): + @overload + def edges_where( + self, + conditions: Optional[AttributeDict] = None, + data: Literal[False] = False, + **kwargs: Any, + ) -> Iterator[Edge]: ... + + @overload + def edges_where( + self, + conditions: Optional[AttributeDict], + data: Literal[True], + **kwargs: Any, + ) -> Iterator[tuple[Edge, EdgeAttributeView]]: ... + + @overload + def edges_where( + self, + *, + data: Literal[True], + **kwargs: Any, + ) -> Iterator[tuple[Edge, EdgeAttributeView]]: ... + + @overload + def edges_where( + self, + conditions: Optional[AttributeDict], + data: bool, + **kwargs: Any, + ) -> Iterator[Union[Edge, tuple[Edge, EdgeAttributeView]]]: ... + + def edges_where( + self, + conditions: Optional[AttributeDict] = None, + data: bool = False, + **kwargs: Any, + ) -> Iterator[Any]: """Get edges for which a certain condition or set of conditions is true. Parameters ---------- - conditions : dict, optional + conditions A set of conditions in the form of key-value pairs. The keys should be attribute names. The values can be attribute values or ranges of attribute values in the form of min/max pairs. - data : bool, optional + data If True, yield the edge attributes in addition to the edge identifiers. - **kwargs : dict[str, Any], optional + **kwargs Additional conditions provided as named function arguments. Yields ------ - tuple[int, int] | tuple[tuple[int, int], dict[str, Any]] - If `data` is False, the next edge as a (u, v) tuple. - If `data` is True, the next edge as a (u, v, data) tuple. + tuple[int, int] + A matching edge identifier if `data` is `False`. + tuple[tuple[int, int], EdgeAttributeView] + A matching edge identifier and its attributes if `data` is `True`. See Also -------- - :meth:`vertices_where`, :meth:`faces_where` - :meth:`vertices_where_predicate`, :meth:`edges_where_predicate`, :meth:`faces_where_predicate` + vertices_where, faces_where + vertices_where_predicate, edges_where_predicate, faces_where_predicate """ conditions = conditions or {} @@ -1621,29 +1783,55 @@ def edges_where(self, conditions=None, data=False, **kwargs): else: yield key - def edges_where_predicate(self, predicate, data=False): + @overload + def edges_where_predicate( + self, + predicate: Callable[[Edge, EdgeAttributeView], bool], + data: Literal[False] = False, + ) -> Iterator[Edge]: ... + + @overload + def edges_where_predicate( + self, + predicate: Callable[[Edge, EdgeAttributeView], bool], + data: Literal[True], + ) -> Iterator[tuple[Edge, EdgeAttributeView]]: ... + + @overload + def edges_where_predicate( + self, + predicate: Callable[[Edge, EdgeAttributeView], bool], + data: bool, + ) -> Iterator[Union[Edge, tuple[Edge, EdgeAttributeView]]]: ... + + def edges_where_predicate( + self, + predicate: Callable[[Edge, EdgeAttributeView], bool], + data: bool = False, + ) -> Iterator[Any]: """Get edges for which a certain condition or set of conditions is true using a lambda function. Parameters ---------- - predicate : callable + predicate The condition you want to evaluate. The callable takes 3 parameters: the identifier of the first vertex, the identifier of the second vertex, and the edge attributes, and should return True or False. - data : bool, optional + data If True, yield the vertex attributes in addition ot the vertex identifiers. Yields ------ - tuple[int, int] | tuple[tuple[int, int], dict[str, Any]] - If `data` is False, the next edge as a (u, v) tuple. - If `data` is True, the next edge as a (u, v, data) tuple. + tuple[int, int] + A matching edge identifier if `data` is `False`. + tuple[tuple[int, int], EdgeAttributeView] + A matching edge identifier and its attributes if `data` is `True`. See Also -------- - :meth:`faces_where_predicate`, :meth:`vertices_where_predicate` - :meth:`vertices_where`, :meth:`edges_where`, :meth:`faces_where` + faces_where_predicate, vertices_where_predicate + vertices_where, edges_where, faces_where """ for key, attr in self.edges(True): @@ -1653,30 +1841,68 @@ def edges_where_predicate(self, predicate, data=False): else: yield key - def faces_where(self, conditions=None, data=False, **kwargs): + @overload + def faces_where( + self, + conditions: Optional[AttributeDict] = None, + data: Literal[False] = False, + **kwargs: Any, + ) -> Iterator[Face]: ... + + @overload + def faces_where( + self, + conditions: Optional[AttributeDict], + data: Literal[True], + **kwargs: Any, + ) -> Iterator[tuple[Face, FaceAttributeView]]: ... + + @overload + def faces_where( + self, + *, + data: Literal[True], + **kwargs: Any, + ) -> Iterator[tuple[Face, FaceAttributeView]]: ... + + @overload + def faces_where( + self, + conditions: Optional[AttributeDict], + data: bool, + **kwargs: Any, + ) -> Iterator[Union[Face, tuple[Face, FaceAttributeView]]]: ... + + def faces_where( + self, + conditions: Optional[AttributeDict] = None, + data: bool = False, + **kwargs: Any, + ) -> Iterator[Any]: """Get faces for which a certain condition or set of conditions is true. Parameters ---------- - conditions : dict, optional + conditions A set of conditions in the form of key-value pairs. The keys should be attribute names. The values can be attribute values or ranges of attribute values in the form of min/max pairs. - data : bool, optional + data If True, yield the face attributes in addition to face identifiers. - **kwargs : dict[str, Any], optional + **kwargs Additional conditions provided as named function arguments. Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next face that matches the condition. - If `data` is True, the next face and its attributes. + int + A matching face identifier if `data` is `False`. + tuple[int, FaceAttributeView] + A matching face identifier and its attributes if `data` is `True`. See Also -------- - :meth:`vertices_where`, :meth:`edges_where` - :meth:`vertices_where_predicate`, :meth:`edges_where_predicate`, :meth:`faces_where_predicate` + vertices_where, edges_where + vertices_where_predicate, edges_where_predicate, faces_where_predicate """ conditions = conditions or {} @@ -1718,28 +1944,54 @@ def faces_where(self, conditions=None, data=False, **kwargs): else: yield fkey - def faces_where_predicate(self, predicate, data=False): + @overload + def faces_where_predicate( + self, + predicate: Callable[[Face, FaceAttributeView], bool], + data: Literal[False] = False, + ) -> Iterator[Face]: ... + + @overload + def faces_where_predicate( + self, + predicate: Callable[[Face, FaceAttributeView], bool], + data: Literal[True], + ) -> Iterator[tuple[Face, FaceAttributeView]]: ... + + @overload + def faces_where_predicate( + self, + predicate: Callable[[Face, FaceAttributeView], bool], + data: bool, + ) -> Iterator[Union[Face, tuple[Face, FaceAttributeView]]]: ... + + def faces_where_predicate( + self, + predicate: Callable[[Face, FaceAttributeView], bool], + data: bool = False, + ) -> Iterator[Any]: """Get faces for which a certain condition or set of conditions is true using a lambda function. Parameters ---------- - predicate : callable + predicate The condition you want to evaluate. - The callable takes 2 parameters: the face identifier and the face attributes, + The callable takes 2 parameters and should return True or False. - data : bool, optional + data If True, yield the face attributes in addition to the face identifiers. Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next face that matches the condition. - If `data` is True, the next face and its attributes. + int + A matching face identifier if `data` is `False`. + tuple[int, FaceAttributeView] + A matching face identifier and its attributes if `data` is `True`. See Also -------- - :meth:`edges_where_predicate`, :meth:`vertices_where_predicate` - :meth:`vertices_where`, :meth:`edges_where`, :meth:`faces_where` + edges_where_predicate, vertices_where_predicate + vertices_where, edges_where, faces_where """ for fkey, attr in self.faces(True): @@ -1753,14 +2005,18 @@ def faces_where_predicate(self, predicate, data=False): # Attributes # -------------------------------------------------------------------------- - def update_default_vertex_attributes(self, attr_dict=None, **kwattr): + def update_default_vertex_attributes( + self, + attr_dict: Optional[AttributeDict] = None, + **kwattr: Any, + ) -> None: """Update the default vertex attributes. Parameters ---------- - attr_dict : dict[str, Any], optional + attr_dict A dictionary of attributes with their default values. - **kwattr : dict[str, Any], optional + **kwattr A dictionary compiled of remaining named arguments. Returns @@ -1769,8 +2025,8 @@ def update_default_vertex_attributes(self, attr_dict=None, **kwattr): See Also -------- - :meth:`update_default_edge_attributes` - :meth:`update_default_face_attributes` + update_default_edge_attributes + update_default_face_attributes Notes ----- @@ -1782,23 +2038,30 @@ def update_default_vertex_attributes(self, attr_dict=None, **kwattr): attr_dict.update(kwattr) self.default_vertex_attributes.update(attr_dict) - def vertex_attribute(self, key, name, value=None): + @overload + def vertex_attribute(self, key: Vertex, name: str) -> Any: ... + + @overload + def vertex_attribute(self, key: Vertex, name: str, value: Any) -> None: ... + + def vertex_attribute(self, key: Vertex, name: str, value: Any = _MISSING) -> Any: """Get or set an attribute of a vertex. Parameters ---------- - key : int + key The vertex identifier. - name : str + name The name of the attribute - value : object, optional + value The value of the attribute. Returns ------- - object | None - The value of the attribute, - or None when the function is used as a "setter". + Any + The attribute value when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -1807,15 +2070,15 @@ def vertex_attribute(self, key, name, value=None): See Also -------- - :meth:`vertex_attributes`, :meth:`vertices_attribute`, :meth:`vertices_attributes` - :meth:`unset_vertex_attribute` - :meth:`edge_attribute` - :meth:`face_attribute` + vertex_attributes, vertices_attribute, vertices_attributes + unset_vertex_attribute + edge_attribute + face_attribute """ if key not in self.vertex: raise KeyError(key) - if value is not None: + if value is not _MISSING: self.vertex[key][name] = value return None if name in self.vertex[key]: @@ -1824,14 +2087,14 @@ def vertex_attribute(self, key, name, value=None): if name in self.default_vertex_attributes: return self.default_vertex_attributes[name] - def unset_vertex_attribute(self, key, name): + def unset_vertex_attribute(self, key: Vertex, name: str) -> None: """Unset the attribute of a vertex. Parameters ---------- - key : int + key The vertex identifier. - name : str + name The name of the attribute. Returns @@ -1845,9 +2108,9 @@ def unset_vertex_attribute(self, key, name): See Also -------- - :meth:`vertex_attribute`, :meth:`vertex_attributes`, :meth:`vertices_attribute`, :meth:`vertices_attributes` - :meth:`unset_edge_attribute` - :meth:`unset_face_attribute` + vertex_attribute, vertex_attributes, vertices_attribute, vertices_attributes + unset_edge_attribute + unset_face_attribute Notes ----- @@ -1858,16 +2121,21 @@ def unset_vertex_attribute(self, key, name): if name in self.vertex[key]: del self.vertex[key][name] - def vertex_attributes(self, key, names=None, values=None): + def vertex_attributes( + self, + key: Vertex, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + ) -> Any: """Get or set multiple attributes of a vertex. Parameters ---------- - key : int + key The identifier of the vertex. - names : list[str], optional + names A list of attribute names. - values : list[Any], optional + values A list of attribute values. Returns @@ -1886,9 +2154,9 @@ def vertex_attributes(self, key, names=None, values=None): See Also -------- - :meth:`vertex_attribute`, :meth:`vertices_attribute`, :meth:`vertices_attributes` - :meth:`edge_attributes` - :meth:`face_attributes` + vertex_attribute, vertices_attribute, vertices_attributes + edge_attributes + face_attributes """ if key not in self.vertex: @@ -1912,24 +2180,46 @@ def vertex_attributes(self, key, names=None, values=None): values.append(None) return values - def vertices_attribute(self, name, value=None, keys=None): + @overload + def vertices_attribute( + self, + name: str, + *, + keys: Optional[Iterable[Vertex]] = None, + ) -> list[Any]: ... + + @overload + def vertices_attribute( + self, + name: str, + value: Any, + keys: Optional[Iterable[Vertex]] = None, + ) -> None: ... + + def vertices_attribute( + self, + name: str, + value: Any = _MISSING, + keys: Optional[Iterable[Vertex]] = None, + ) -> Optional[list[Any]]: """Get or set an attribute of multiple vertices. Parameters ---------- - name : str + name The name of the attribute. - value : object, optional + value The value of the attribute. Default is None. - keys : list[int], optional + keys A list of vertex identifiers. Returns ------- - list[Any] | None - The value of the attribute for each vertex, - or None if the function is used as a "setter". + list[Any] + The attribute values when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -1938,29 +2228,34 @@ def vertices_attribute(self, name, value=None, keys=None): See Also -------- - :meth:`vertex_attribute`, :meth:`vertex_attributes`, :meth:`vertices_attributes` - :meth:`edges_attribute` - :meth:`faces_attribute` + vertex_attribute, vertex_attributes, vertices_attributes + edges_attribute + faces_attribute """ if not keys: keys = self.vertices() - if value is not None: + if value is not _MISSING: for key in keys: self.vertex_attribute(key, name, value) return return [self.vertex_attribute(key, name) for key in keys] - def vertices_attributes(self, names=None, values=None, keys=None): + def vertices_attributes( + self, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + keys: Optional[Iterable[Vertex]] = None, + ) -> Any: """Get or set multiple attributes of multiple vertices. Parameters ---------- - names : list[str], optional + names The names of the attribute. - values : list[Any], optional + values The values of the attributes. - keys : list[int], optional + keys A list of vertex identifiers. Returns @@ -1979,9 +2274,9 @@ def vertices_attributes(self, names=None, values=None, keys=None): See Also -------- - :meth:`vertex_attribute`, :meth:`vertex_attributes`, :meth:`vertices_attribute` - :meth:`edges_attributes` - :meth:`faces_attributes` + vertex_attribute, vertex_attributes, vertices_attribute + edges_attributes + faces_attributes """ if not keys: @@ -1992,14 +2287,18 @@ def vertices_attributes(self, names=None, values=None, keys=None): return return [self.vertex_attributes(key, names) for key in keys] - def update_default_face_attributes(self, attr_dict=None, **kwattr): + def update_default_face_attributes( + self, + attr_dict: Optional[AttributeDict] = None, + **kwattr: Any, + ) -> None: """Update the default face attributes. Parameters ---------- - attr_dict : dict[str, Any], optional + attr_dict A dictionary of attributes with their default values. - **kwattr : dict[str, Any], optional + **kwattr A dictionary compiled of remaining named arguments. Returns @@ -2008,8 +2307,8 @@ def update_default_face_attributes(self, attr_dict=None, **kwattr): See Also -------- - :meth:`update_default_vertex_attributes` - :meth:`update_default_edge_attributes` + update_default_vertex_attributes + update_default_edge_attributes Notes ----- @@ -2021,22 +2320,30 @@ def update_default_face_attributes(self, attr_dict=None, **kwattr): attr_dict.update(kwattr) self.default_face_attributes.update(attr_dict) - def face_attribute(self, key, name, value=None): + @overload + def face_attribute(self, key: Face, name: str) -> Any: ... + + @overload + def face_attribute(self, key: Face, name: str, value: Any) -> None: ... + + def face_attribute(self, key: Face, name: str, value: Any = _MISSING) -> Any: """Get or set an attribute of a face. Parameters ---------- - key : int + key The face identifier. - name : str + name The name of the attribute. - value : object, optional + value The value of the attribute. Returns ------- - object | None - The value of the attribute, or None when the function is used as a "setter". + Any + The attribute value when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -2045,15 +2352,15 @@ def face_attribute(self, key, name, value=None): See Also -------- - :meth:`face_attributes`, :meth:`faces_attribute`, :meth:`faces_attributes` - :meth:`unset_face_attribute` - :meth:`edge_attribute` - :meth:`vertex_attribute` + face_attributes, faces_attribute, faces_attributes + unset_face_attribute + edge_attribute + vertex_attribute """ if key not in self.face: raise KeyError(key) - if value is not None: + if value is not _MISSING: if key not in self.facedata: self.facedata[key] = {} self.facedata[key][name] = value @@ -2063,14 +2370,14 @@ def face_attribute(self, key, name, value=None): if name in self.default_face_attributes: return self.default_face_attributes[name] - def unset_face_attribute(self, key, name): + def unset_face_attribute(self, key: Face, name: str) -> None: """Unset the attribute of a face. Parameters ---------- - key : int + key The face identifier. - name : str + name The name of the attribute. Returns @@ -2084,9 +2391,9 @@ def unset_face_attribute(self, key, name): See Also -------- - :meth:`face_attribute`, :meth:`face_attributes`, :meth:`faces_attribute`, :meth:`faces_attributes` - :meth:`unset_edge_attribute` - :meth:`unset_vertex_attribute` + face_attribute, face_attributes, faces_attribute, faces_attributes + unset_edge_attribute + unset_vertex_attribute Notes ----- @@ -2100,16 +2407,21 @@ def unset_face_attribute(self, key, name): if name in self.facedata[key]: del self.facedata[key][name] - def face_attributes(self, key, names=None, values=None): + def face_attributes( + self, + key: Face, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + ) -> Any: """Get or set multiple attributes of a face. Parameters ---------- - key : int + key The identifier of the face. - names : list[str], optional + names A list of attribute names. - values : list[Any], optional + values A list of attribute values. Returns @@ -2128,9 +2440,9 @@ def face_attributes(self, key, names=None, values=None): See Also -------- - :meth:`face_attribute`, :meth:`faces_attribute`, :meth:`faces_attributes` - :meth:`edge_attributes` - :meth:`vertex_attributes` + face_attribute, faces_attribute, faces_attributes + edge_attributes + vertex_attributes """ if key not in self.face: @@ -2151,24 +2463,46 @@ def face_attributes(self, key, names=None, values=None): values.append(value) return values - def faces_attribute(self, name, value=None, keys=None): + @overload + def faces_attribute( + self, + name: str, + *, + keys: Optional[Iterable[Face]] = None, + ) -> list[Any]: ... + + @overload + def faces_attribute( + self, + name: str, + value: Any, + keys: Optional[Iterable[Face]] = None, + ) -> None: ... + + def faces_attribute( + self, + name: str, + value: Any = _MISSING, + keys: Optional[Iterable[Face]] = None, + ) -> Optional[list[Any]]: """Get or set an attribute of multiple faces. Parameters ---------- - name : str + name The name of the attribute. - value : object, optional + value The value of the attribute. Default is None. - keys : list[int], optional + keys A list of face identifiers. Returns ------- - list[Any] | None - A list containing the value per face of the requested attribute, - or None if the function is used as a "setter". + list[Any] + The attribute values when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -2177,31 +2511,36 @@ def faces_attribute(self, name, value=None, keys=None): See Also -------- - :meth:`face_attribute`, :meth:`face_attributes`, :meth:`faces_attributes` - :meth:`edges_attribute` - :meth:`vertices_attribute` + face_attribute, face_attributes, faces_attributes + edges_attribute + vertices_attribute """ if not keys: keys = self.faces() - if value is not None: + if value is not _MISSING: for key in keys: self.face_attribute(key, name, value) return return [self.face_attribute(key, name) for key in keys] - def faces_attributes(self, names=None, values=None, keys=None): + def faces_attributes( + self, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + keys: Optional[Iterable[Face]] = None, + ) -> Any: """Get or set multiple attributes of multiple faces. Parameters ---------- - names : list[str], optional + names The names of the attribute. Default is None. - values : list[Any], optional + values The values of the attributes. Default is None. - keys : list[int], optional + keys A list of face identifiers. Returns @@ -2220,9 +2559,9 @@ def faces_attributes(self, names=None, values=None, keys=None): See Also -------- - :meth:`face_attribute`, :meth:`face_attributes`, :meth:`faces_attribute` - :meth:`edges_attributes` - :meth:`vertices_attributes` + face_attribute, face_attributes, faces_attribute + edges_attributes + vertices_attributes """ if not keys: @@ -2233,14 +2572,18 @@ def faces_attributes(self, names=None, values=None, keys=None): return return [self.face_attributes(key, names) for key in keys] - def update_default_edge_attributes(self, attr_dict=None, **kwattr): + def update_default_edge_attributes( + self, + attr_dict: Optional[AttributeDict] = None, + **kwattr: Any, + ) -> None: """Update the default edge attributes. Parameters ---------- - attr_dict : dict[str, Any], optional + attr_dict A dictionary of attributes with their default values. - **kwattr : dict[str, Any], optional + **kwattr A dictionary compiled of remaining named arguments. Returns @@ -2249,8 +2592,8 @@ def update_default_edge_attributes(self, attr_dict=None, **kwattr): See Also -------- - :meth:`update_default_vertex_attributes` - :meth:`update_default_face_attributes` + update_default_vertex_attributes + update_default_face_attributes Notes ----- @@ -2262,23 +2605,31 @@ def update_default_edge_attributes(self, attr_dict=None, **kwattr): attr_dict.update(kwattr) self.default_edge_attributes.update(attr_dict) - def edge_attribute(self, edge, name, value=None): + @overload + def edge_attribute(self, edge: Edge, name: str) -> Any: ... + + @overload + def edge_attribute(self, edge: Edge, name: str, value: Any) -> None: ... + + def edge_attribute(self, edge: Edge, name: str, value: Any = _MISSING) -> Any: """Get or set an attribute of an edge. Parameters ---------- - edge : tuple[int, int] + edge The identifier of the edge as a pair of vertex identifiers. - name : str + name The name of the attribute. - value : object, optional + value The value of the attribute. Default is None. Returns ------- - object | None - The value of the attribute, or None when the function is used as a "setter". + Any + The attribute value when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -2287,17 +2638,17 @@ def edge_attribute(self, edge, name, value=None): See Also -------- - :meth:`edge_attributes`, :meth:`edges_attribute`, :meth:`edges_attributes` - :meth:`unset_edge_attribute` - :meth:`vertex_attribute` - :meth:`face_attribute` + edge_attributes, edges_attribute, edges_attributes + unset_edge_attribute + vertex_attribute + face_attribute """ u, v = edge if u not in self.halfedge or v not in self.halfedge[u]: raise KeyError(edge) key = str(tuple(sorted(edge))) - if value is not None: + if value is not _MISSING: if key not in self.edgedata: self.edgedata[key] = {} self.edgedata[key][name] = value @@ -2307,14 +2658,14 @@ def edge_attribute(self, edge, name, value=None): if name in self.default_edge_attributes: return self.default_edge_attributes[name] - def unset_edge_attribute(self, edge, name): + def unset_edge_attribute(self, edge: Edge, name: str) -> None: """Unset the attribute of an edge. Parameters ---------- - edge : tuple[int, int] + edge The edge identifier. - name : str + name The name of the attribute. Returns @@ -2328,9 +2679,9 @@ def unset_edge_attribute(self, edge, name): See Also -------- - :meth:`edge_attribute`, :meth:`edge_attributes`, :meth:`edges_attribute`, :meth:`edges_attributes` - :meth:`unset_vertex_attribute` - :meth:`unset_face_attribute` + edge_attribute, edge_attributes, edges_attribute, edges_attributes + unset_vertex_attribute + unset_face_attribute Notes ----- @@ -2345,16 +2696,21 @@ def unset_edge_attribute(self, edge, name): if key in self.edgedata and name in self.edgedata[key]: del self.edgedata[key][name] - def edge_attributes(self, edge, names=None, values=None): + def edge_attributes( + self, + edge: Edge, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + ) -> Any: """Get or set multiple attributes of an edge. Parameters ---------- - edge : tuple[int, int] + edge The identifier of the edge. - names : list[str], optional + names A list of attribute names. - values : list[Any], optional + values A list of attribute values. Returns @@ -2373,9 +2729,9 @@ def edge_attributes(self, edge, names=None, values=None): See Also -------- - :meth:`edge_attribute`, :meth:`edges_attribute`, :meth:`edges_attributes` - :meth:`vertex_attributes` - :meth:`face_attributes` + edge_attribute, edges_attribute, edges_attributes + vertex_attributes + face_attributes """ u, v = edge @@ -2398,24 +2754,46 @@ def edge_attributes(self, edge, names=None, values=None): values.append(value) return values - def edges_attribute(self, name, value=None, keys=None): + @overload + def edges_attribute( + self, + name: str, + *, + keys: Optional[Iterable[Edge]] = None, + ) -> list[Any]: ... + + @overload + def edges_attribute( + self, + name: str, + value: Any, + keys: Optional[Iterable[Edge]] = None, + ) -> None: ... + + def edges_attribute( + self, + name: str, + value: Any = _MISSING, + keys: Optional[Iterable[Edge]] = None, + ) -> Optional[list[Any]]: """Get or set an attribute of multiple edges. Parameters ---------- - name : str + name The name of the attribute. - value : object, optional + value The value of the attribute. Default is None. - keys : list[tuple[int, int]], optional + keys A list of edge identifiers. Returns ------- - list[Any] | None - A list containing the value per edge of the requested attribute, - or None if the function is used as a "setter". + list[Any] + The attribute values when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -2424,30 +2802,35 @@ def edges_attribute(self, name, value=None, keys=None): See Also -------- - :meth:`edge_attribute`, :meth:`edge_attributes`, :meth:`edges_attributes` - :meth:`vertex_attributes` - :meth:`face_attributes` + edge_attribute, edge_attributes, edges_attributes + vertex_attributes + face_attributes """ edges = keys or self.edges() - if value is not None: + if value is not _MISSING: for edge in edges: self.edge_attribute(edge, name, value) return return [self.edge_attribute(edge, name) for edge in edges] - def edges_attributes(self, names=None, values=None, keys=None): + def edges_attributes( + self, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + keys: Optional[Iterable[Edge]] = None, + ) -> Any: """Get or set multiple attributes of multiple edges. Parameters ---------- - names : list[str], optional + names The names of the attribute. Default is None. - values : list[Any], optional + values The values of the attributes. Default is None. - keys : list[tuple[int, int]], optional + keys A list of edge identifiers. Returns @@ -2466,9 +2849,9 @@ def edges_attributes(self, names=None, values=None, keys=None): See Also -------- - :meth:`edge_attribute`, :meth:`edge_attributes`, :meth:`edges_attribute` - :meth:`vertex_attributes` - :meth:`face_attributes` + edge_attribute, edge_attributes, edges_attribute + vertex_attributes + face_attributes """ edges = keys or self.edges() @@ -2482,8 +2865,8 @@ def edges_attributes(self, names=None, values=None, keys=None): # Info # -------------------------------------------------------------------------- - def summary(self): - """Print a summary of the mesh. + def summary(self) -> str: + """Generate a summary of the mesh. Returns ------- @@ -2507,7 +2890,7 @@ def summary(self): self.number_of_faces(), ) - def number_of_vertices(self): + def number_of_vertices(self) -> int: """Count the number of vertices in the mesh. Returns @@ -2516,13 +2899,13 @@ def number_of_vertices(self): See Also -------- - :meth:`number_of_edges` - :meth:`number_of_faces` + number_of_edges + number_of_faces """ return len(list(self.vertices())) - def number_of_edges(self): + def number_of_edges(self) -> int: """Count the number of edges in the mesh. Returns @@ -2531,13 +2914,13 @@ def number_of_edges(self): See Also -------- - :meth:`number_of_vertices` - :meth:`number_of_faces` + number_of_vertices + number_of_faces """ return len(list(self.edges())) - def number_of_faces(self): + def number_of_faces(self) -> int: """Count the number of faces in the mesh. Returns @@ -2546,13 +2929,13 @@ def number_of_faces(self): See Also -------- - :meth:`number_of_vertices` - :meth:`number_of_edges` + number_of_vertices + number_of_edges """ return len(list(self.faces())) - def is_valid(self): + def is_valid(self) -> bool: """Verify that the mesh is valid. A mesh is valid if the following conditions are fulfilled: @@ -2570,7 +2953,7 @@ def is_valid(self): See Also -------- - :meth:`is_regular`, :meth:`is_manifold`, :meth:`is_orientable`, :meth:`is_empty`, :meth:`is_closed`, :meth:`is_trimesh`, :meth:`is_quadmesh` + is_regular, is_manifold, is_orientable, is_empty, is_closed, is_trimesh, is_quadmesh """ for key in self.vertices(): @@ -2604,7 +2987,7 @@ def is_valid(self): return False return True - def is_regular(self): + def is_regular(self) -> bool: """Verify that the mesh is regular. A mesh is regular if the following conditions are fulfilled: @@ -2620,7 +3003,7 @@ def is_regular(self): See Also -------- - :meth:`is_valid`, :meth:`is_manifold`, :meth:`is_orientable`, :meth:`is_empty`, :meth:`is_closed`, :meth:`is_trimesh`, :meth:`is_quadmesh` + is_valid, is_manifold, is_orientable, is_empty, is_closed, is_trimesh, is_quadmesh """ if not self.vertex or not self.face: @@ -2643,7 +3026,7 @@ def is_regular(self): return True - def is_manifold(self): + def is_manifold(self) -> bool: """Verify that the mesh is manifold. A mesh is manifold if the following conditions are fulfilled: @@ -2659,7 +3042,7 @@ def is_manifold(self): See Also -------- - :meth:`is_valid`, :meth:`is_regular`, :meth:`is_orientable`, :meth:`is_empty`, :meth:`is_closed`, :meth:`is_trimesh`, :meth:`is_quadmesh` + is_valid, is_regular, is_orientable, is_empty, is_closed, is_trimesh, is_quadmesh """ if not self.vertex: @@ -2688,7 +3071,7 @@ def is_manifold(self): return True - def is_orientable(self): + def is_orientable(self) -> bool: """Verify that the mesh is orientable. A manifold mesh is orientable if any two adjacent faces have compatible orientation, @@ -2702,12 +3085,12 @@ def is_orientable(self): See Also -------- - :meth:`is_valid`, :meth:`is_regular`, :meth:`is_manifold`, :meth:`is_empty`, :meth:`is_closed`, :meth:`is_trimesh`, :meth:`is_quadmesh` + is_valid, is_regular, is_manifold, is_empty, is_closed, is_trimesh, is_quadmesh """ raise NotImplementedError - def is_trimesh(self): + def is_trimesh(self) -> bool: """Verify that the mesh consists of only triangles. Returns @@ -2718,14 +3101,14 @@ def is_trimesh(self): See Also -------- - :meth:`is_valid`, :meth:`is_regular`, :meth:`is_manifold`, :meth:`is_orientable`, :meth:`is_empty`, :meth:`is_closed`, :meth:`is_quadmesh` + is_valid, is_regular, is_manifold, is_orientable, is_empty, is_closed, is_quadmesh """ if not self.face: return False return not any(3 != len(self.face_vertices(fkey)) for fkey in self.faces()) - def is_quadmesh(self): + def is_quadmesh(self) -> bool: """Verify that the mesh consists of only quads. Returns @@ -2736,14 +3119,14 @@ def is_quadmesh(self): See Also -------- - :meth:`is_valid`, :meth:`is_regular`, :meth:`is_manifold`, :meth:`is_orientable`, :meth:`is_empty`, :meth:`is_closed`, :meth:`is_trimesh` + is_valid, is_regular, is_manifold, is_orientable, is_empty, is_closed, is_trimesh """ if not self.face: return False return not any(4 != len(self.face_vertices(fkey)) for fkey in self.faces()) - def is_empty(self): + def is_empty(self) -> bool: """Verify that the mesh is empty. Returns @@ -2754,14 +3137,14 @@ def is_empty(self): See Also -------- - :meth:`is_valid`, :meth:`is_regular`, :meth:`is_manifold`, :meth:`is_orientable`, :meth:`is_closed`, :meth:`is_trimesh`, :meth:`is_quadmesh` + is_valid, is_regular, is_manifold, is_orientable, is_closed, is_trimesh, is_quadmesh """ if self.number_of_vertices() == 0: return True return False - def is_closed(self): + def is_closed(self) -> bool: """Verify that the mesh is closed. Returns @@ -2772,7 +3155,7 @@ def is_closed(self): See Also -------- - :meth:`is_valid`, :meth:`is_regular`, :meth:`is_manifold`, :meth:`is_orientable`, :meth:`is_empty`, :meth:`is_trimesh`, :meth:`is_quadmesh` + is_valid, is_regular, is_manifold, is_orientable, is_empty, is_trimesh, is_quadmesh """ if self.is_empty(): @@ -2782,18 +3165,18 @@ def is_closed(self): return False return True - def is_connected(self): + def is_connected(self) -> bool: """Verify that the mesh is connected. Returns ------- bool - True if the mesh is not empty and has no naked edges. + True if the mesh is not empty and all vertices belong to one connected component. False otherwise. See Also -------- - :meth:`is_valid`, :meth:`is_regular`, :meth:`is_manifold`, :meth:`is_orientable`, :meth:`is_empty`, :meth:`is_trimesh`, :meth:`is_quadmesh` + is_valid, is_regular, is_manifold, is_orientable, is_empty, is_trimesh, is_quadmesh """ if not self.vertex: @@ -2801,7 +3184,7 @@ def is_connected(self): nodes = breadth_first_traverse(self.adjacency, self.vertex_sample(size=1)[0]) return len(nodes) == self.number_of_vertices() - def euler(self): + def euler(self) -> int: """Calculate the Euler characteristic. Returns @@ -2811,7 +3194,7 @@ def euler(self): See Also -------- - :meth:`genus` + genus """ V = len([vkey for vkey in self.vertices() if len(self.vertex_neighbors(vkey)) != 0]) @@ -2823,14 +3206,14 @@ def euler(self): # Cleanup # -------------------------------------------------------------------------- - def weld(self, precision=None): + def weld(self, precision: Optional[int] = None) -> None: """Weld vertices that are closer than a given precision. Parameters ---------- - precision : int, optional + precision The precision of the geometric map that is used to connect the lines. - Defaults to the value of :attr:`compas.PRECISION`. + Defaults to the value of `compas.PRECISION`. Returns ------- @@ -2840,14 +3223,14 @@ def weld(self, precision=None): """ self.remove_duplicate_vertices(precision=precision) - def remove_duplicate_vertices(self, precision=None): + def remove_duplicate_vertices(self, precision: Optional[int] = None) -> None: """Remove all duplicate vertices and clean up any affected faces. Parameters ---------- - precision : int, optional + precision Precision for converting numbers to strings. - Default is :attr:`TOL.precision`. + Default is `TOL.precision`. Returns ------- @@ -2911,15 +3294,9 @@ def remove_duplicate_vertices(self, precision=None): if u not in self.halfedge[v]: self.halfedge[v][u] = None - # only reason this is here is because of the potential angles check - def quads_to_triangles(self, check_angles=False): + def quads_to_triangles(self) -> None: """Convert all quadrilateral faces to triangles by adding a diagonal edge. - Parameters - ---------- - check_angles : bool, optional - Flag indicating that the angles of the quads should be checked to choose the best diagonal. - Returns ------- None @@ -2940,16 +3317,21 @@ def quads_to_triangles(self, check_angles=False): del self.facedata[face] # only reason this is here and not on the halfedge is because of the spatial tree - def unify_cycles(self, root=None, nmax=None, max_distance=None): + def unify_cycles( + self, + root: Optional[Face] = None, + nmax: Optional[int] = None, + max_distance: Optional[float] = None, + ) -> None: """Unify the cycles of the mesh. Parameters ---------- - root : int, optional + root The key of the root face. - nmax : int, optional + nmax The maximum number of neighboring faces to consider. If neither nmax nor max_distance is specified, all faces will be considered. - max_distance : float, optional + max_distance The max_distance of the search sphere for neighboring faces. If neither nmax nor max_distance is specified, all faces will be considered. Returns @@ -2984,7 +3366,7 @@ def unify_cycles(self, root=None, nmax=None, max_distance=None): # Components # -------------------------------------------------------------------------- - def connected_vertices(self): + def connected_vertices(self) -> list[list[Vertex]]: """Find groups of connected vertices. Returns @@ -2995,7 +3377,7 @@ def connected_vertices(self): """ return connected_components(self.adjacency) - def connected_faces(self): + def connected_faces(self) -> list[set[Face]]: """Find groups of connected faces. Returns @@ -3004,7 +3386,6 @@ def connected_faces(self): Groups of connected faces. """ - # return connected_components(self.face_adjacency) parts = self.connected_vertices() return [set([face for vertex in part for face in self.vertex_faces(vertex)]) for part in parts] @@ -3012,12 +3393,12 @@ def connected_faces(self): # Vertex topology # -------------------------------------------------------------------------- - def has_vertex(self, key): + def has_vertex(self, key: Vertex) -> bool: """Verify that a vertex is in the mesh. Parameters ---------- - key : int + key The identifier of the vertex. Returns @@ -3029,12 +3410,12 @@ def has_vertex(self, key): """ return key in self.vertex - def is_vertex_connected(self, key): + def is_vertex_connected(self, key: Vertex) -> bool: """Verify that a vertex is connected. Parameters ---------- - key : int + key The identifier of the vertex. Returns @@ -3046,12 +3427,12 @@ def is_vertex_connected(self, key): """ return self.vertex_degree(key) > 0 - def is_vertex_on_boundary(self, key): + def is_vertex_on_boundary(self, key: Vertex) -> bool: """Verify that a vertex is on a boundary. Parameters ---------- - key : int + key The identifier of the vertex. Returns @@ -3066,14 +3447,14 @@ def is_vertex_on_boundary(self, key): return True return False - def vertex_neighbors(self, key, ordered=False): + def vertex_neighbors(self, key: Vertex, ordered: bool = False) -> list[Vertex]: """Return the neighbors of a vertex. Parameters ---------- - key : int + key The identifier of the vertex. - ordered : bool, optional + ordered If True, return the neighbors in the cycling order of the faces. Returns @@ -3112,30 +3493,28 @@ def vertex_neighbors(self, key, ordered=False): fkey = self.halfedge[start][key] nbrs = [start] count = 1000 - while count: + while count and fkey is not None: count -= 1 nbr = self.face_vertex_descendant(fkey, key) fkey = self.halfedge[nbr][key] if nbr == start: break nbrs.append(nbr) - if fkey is None: - break return nbrs - def vertex_neighborhood(self, key, ring=1): + def vertex_neighborhood(self, key: Vertex, ring: int = 1) -> set[Vertex]: """Return the vertices in the neighborhood of a vertex. Parameters ---------- - key : int + key The identifier of the vertex. - ring : int, optional + ring The number of neighborhood rings to include. Returns ------- - list[int] + set[int] The vertices in the neighborhood. Notes @@ -3155,12 +3534,12 @@ def vertex_neighborhood(self, key, ring=1): i += 1 return nbrs - def vertex_degree(self, key): + def vertex_degree(self, key: Vertex) -> int: """Count the neighbors of a vertex. Parameters ---------- - key : int + key The identifier of the vertex. Returns @@ -3171,7 +3550,7 @@ def vertex_degree(self, key): """ return len(self.vertex_neighbors(key)) - def vertex_min_degree(self): + def vertex_min_degree(self) -> int: """Compute the minimum degree of all vertices. Returns @@ -3184,7 +3563,7 @@ def vertex_min_degree(self): return 0 return min(self.vertex_degree(key) for key in self.vertices()) - def vertex_max_degree(self): + def vertex_max_degree(self) -> int: """Compute the maximum degree of all vertices. Returns @@ -3197,22 +3576,33 @@ def vertex_max_degree(self): return 0 return max(self.vertex_degree(key) for key in self.vertices()) - def vertex_faces(self, key, ordered=False, include_none=False): + @overload + def vertex_faces(self, key: Vertex, ordered: bool = False, include_none: Literal[False] = False) -> list[Face]: ... + + @overload + def vertex_faces(self, key: Vertex, ordered: bool, include_none: Literal[True]) -> list[Optional[Face]]: ... + + @overload + def vertex_faces(self, key: Vertex, *, include_none: Literal[True]) -> list[Optional[Face]]: ... + + def vertex_faces(self, key: Vertex, ordered: bool = False, include_none: bool = False) -> Union[list[Face], list[Optional[Face]]]: """The faces connected to a vertex. Parameters ---------- - key : int + key The identifier of the vertex. - ordered : bool, optional + ordered If True, return the faces in cycling order. - include_none : bool, optional + include_none If True, include *outside* faces in the list. Returns ------- list[int] - The faces connected to a vertex. + The incident faces if `include_none` is `False`. + list[int | None] + The incident faces, including outside faces, if `include_none` is `True`. """ if not ordered: @@ -3228,7 +3618,7 @@ def vertex_faces(self, key, ordered=False, include_none=False): # Edge topology # -------------------------------------------------------------------------- - def has_edge(self, key): + def has_edge(self, key: Edge) -> bool: """Verify that the mesh contains a specific edge. Warnings @@ -3237,7 +3627,7 @@ def has_edge(self, key): Parameters ---------- - key : tuple[int, int] + key The identifier of the edge. Returns @@ -3249,12 +3639,12 @@ def has_edge(self, key): """ return key in set(self.edges()) - def has_halfedge(self, key): + def has_halfedge(self, key: Edge) -> bool: """Verify that a halfedge is part of the mesh. Parameters ---------- - key : tuple[int, int] + key The identifier of the halfedge. Returns @@ -3267,17 +3657,17 @@ def has_halfedge(self, key): u, v = key return u in self.halfedge and v in self.halfedge[u] - def edge_faces(self, edge): + def edge_faces(self, edge: Edge) -> tuple[Optional[Face], Optional[Face]]: """Find the two faces adjacent to an edge. Parameters ---------- - edge : tuple[int, int] + edge The identifier of the edge. Returns ------- - tuple[int, int] + tuple[int | None, int | None] The identifiers of the adjacent faces. If the edge is on the boundary, one of the identifiers is None. @@ -3285,12 +3675,12 @@ def edge_faces(self, edge): u, v = edge return self.halfedge[u][v], self.halfedge[v][u] - def halfedge_face(self, edge): + def halfedge_face(self, edge: Edge) -> Optional[Face]: """Find the face corresponding to a halfedge. Parameters ---------- - edge : tuple[int, int] + edge The identifier of the halfedge. Returns @@ -3308,12 +3698,12 @@ def halfedge_face(self, edge): u, v = edge return self.halfedge[u][v] - def is_edge_on_boundary(self, edge): + def is_edge_on_boundary(self, edge: Edge) -> bool: """Verify that an edge is on the boundary. Parameters ---------- - edge : tuple[int, int] + edge The identifier of the edge. Returns @@ -3330,12 +3720,12 @@ def is_edge_on_boundary(self, edge): # Polyedge topology # -------------------------------------------------------------------------- - def edge_loop(self, edge): + def edge_loop(self, edge: Edge) -> list[Edge]: """Find all edges on the same loop as a given edge. Parameters ---------- - edge : tuple[int, int] + edge The identifier of the starting edge. Returns @@ -3352,12 +3742,12 @@ def edge_loop(self, edge): vu_loop[:] = [(u, v) for v, u in vu_loop[::-1]] return vu_loop + uv_loop[1:] - def halfedge_loop(self, edge): + def halfedge_loop(self, edge: Edge) -> list[Edge]: """Find all edges on the same loop as the halfedge, in the direction of the halfedge. Parameters ---------- - edge : tuple[int, int] + edge The identifier of the starting edge. Returns @@ -3382,12 +3772,12 @@ def halfedge_loop(self, edge): break return edges - def _halfedge_loop_on_boundary(self, edge): + def _halfedge_loop_on_boundary(self, edge: Edge) -> list[Edge]: """Find all edges on the same loop as the halfedge, in the direction of the halfedge, if the halfedge is on the boundary. Parameters ---------- - edge : tuple[int, int] + edge The identifier of the starting edge. Returns @@ -3417,21 +3807,28 @@ def _halfedge_loop_on_boundary(self, edge): break return edges - def edge_strip(self, edge, return_faces=False): + @overload + def edge_strip(self, edge: Edge, return_faces: Literal[False] = False) -> list[Edge]: ... + + @overload + def edge_strip(self, edge: Edge, return_faces: Literal[True]) -> tuple[list[Edge], list[Optional[Face]]]: ... + + def edge_strip(self, edge: Edge, return_faces: bool = False) -> Union[list[Edge], tuple[list[Edge], list[Optional[Face]]]]: """Find all edges on the same strip as a given edge. Parameters ---------- - edge : tuple[int, int] + edge The identifier of the starting edge. - return_faces : bool, optional + return_faces Return the faces on the strip in addition to the edges. Returns ------- - list[tuple[int, int]] | tuple[list[tuple[int, int]], list[int]] - If `return_faces` is False, the edges on the same strip as the given edge. - If `return_faces` is False, the edges on the same strip and the corresponding faces. + list[tuple[int, int]] + The strip edges if `return_faces` is `False`. + tuple[list[tuple[int, int]], list[int | None]] + The strip edges and corresponding faces if `return_faces` is `True`. """ u, v = edge @@ -3453,12 +3850,12 @@ def edge_strip(self, edge, return_faces=False): faces = [self.halfedge_face(edge) for edge in strip[:-1]] return strip, faces - def halfedge_strip(self, edge): + def halfedge_strip(self, edge: Edge) -> list[Edge]: """Find all edges on the same strip as a given halfedge. Parameters ---------- - edge : tuple[int, int] + edge The identifier of the starting edge. Returns @@ -3488,12 +3885,12 @@ def halfedge_strip(self, edge): # Face topology # -------------------------------------------------------------------------- - def has_face(self, fkey): + def has_face(self, fkey: Face) -> bool: """Verify that a face is part of the mesh. Parameters ---------- - fkey : int + fkey The identifier of the face. Returns @@ -3505,12 +3902,12 @@ def has_face(self, fkey): """ return fkey in self.face - def face_vertices(self, fkey): + def face_vertices(self, fkey: Face) -> list[Vertex]: """The vertices of a face. Parameters ---------- - fkey : int + fkey Identifier of the face. Returns @@ -3521,12 +3918,12 @@ def face_vertices(self, fkey): """ return self.face[fkey] - def face_halfedges(self, fkey): + def face_halfedges(self, fkey: Face) -> list[Edge]: """The halfedges of a face. Parameters ---------- - fkey : int + fkey Identifier of the face. Returns @@ -3538,29 +3935,29 @@ def face_halfedges(self, fkey): vertices = self.face_vertices(fkey) return list(pairwise(vertices + vertices[0:1])) - def face_corners(self, fkey): + def face_corners(self, fkey: Face) -> list[tuple[Vertex, Vertex, Vertex]]: """Return triplets of face vertices forming the corners of the face. Parameters ---------- - fkey : int + fkey Identifier of the face. Returns ------- - list[int] + list[tuple[int, int, int]] The corners of the face in the form of a list of vertex triplets. """ vertices = self.face_vertices(fkey) return list(window(vertices + vertices[0:2], 3)) - def face_neighbors(self, fkey): + def face_neighbors(self, fkey: Face) -> list[Face]: """Return the neighbors of a face across its edges. Parameters ---------- - fkey : int + fkey Identifier of the face. Returns @@ -3576,14 +3973,14 @@ def face_neighbors(self, fkey): nbrs.append(nbr) return nbrs - def face_neighborhood(self, key, ring=1): + def face_neighborhood(self, key: Face, ring: int = 1) -> list[Face]: """Return the faces in the neighborhood of a face. Parameters ---------- - key : int + key The identifier of the face. - ring : int, optional + ring The size of the neighborhood. Returns @@ -3604,12 +4001,12 @@ def face_neighborhood(self, key, ring=1): i += 1 return list(nbrs) - def face_degree(self, fkey): + def face_degree(self, fkey: Face) -> int: """Count the neighbors of a face. Parameters ---------- - fkey : int + fkey Identifier of the face. Returns @@ -3620,7 +4017,7 @@ def face_degree(self, fkey): """ return len(self.face_neighbors(fkey)) - def face_min_degree(self): + def face_min_degree(self) -> int: """Compute the minimum degree of all faces. Returns @@ -3633,7 +4030,7 @@ def face_min_degree(self): return 0 return min(self.face_degree(fkey) for fkey in self.faces()) - def face_max_degree(self): + def face_max_degree(self) -> int: """Compute the maximum degree of all faces. Returns @@ -3646,16 +4043,16 @@ def face_max_degree(self): return 0 return max(self.face_degree(fkey) for fkey in self.faces()) - def face_vertex_ancestor(self, fkey, key, n=1): + def face_vertex_ancestor(self, fkey: Face, key: Vertex, n: int = 1) -> Vertex: """Return the n-th vertex before the specified vertex in a specific face. Parameters ---------- - fkey : int + fkey Identifier of the face. - key : int + key The identifier of the vertex. - n : int, optional + n The index of the vertex ancestor. Default is 1, meaning the previous vertex. @@ -3673,16 +4070,16 @@ def face_vertex_ancestor(self, fkey, key, n=1): i = self.face[fkey].index(key) return self.face[fkey][(i - n) % len(self.face[fkey])] - def face_vertex_descendant(self, fkey, key, n=1): + def face_vertex_descendant(self, fkey: Face, key: Vertex, n: int = 1) -> Vertex: """Return the n-th vertex after the specified vertex in a specific face. Parameters ---------- - fkey : int + fkey Identifier of the face. - key : int + key The identifier of the vertex. - n : int, optional + n The index of the vertex descendant. Default is 1, meaning the next vertex. @@ -3700,14 +4097,14 @@ def face_vertex_descendant(self, fkey, key, n=1): i = self.face[fkey].index(key) return self.face[fkey][(i + n) % len(self.face[fkey])] - def face_adjacency_halfedge(self, f1, f2): + def face_adjacency_halfedge(self, f1: Face, f2: Face) -> Optional[Edge]: """Find one half-edge over which two faces are adjacent. Parameters ---------- - f1 : int + f1 The identifier of the first face. - f2 : int + f2 The identifier of the second face. Returns @@ -3726,31 +4123,30 @@ def face_adjacency_halfedge(self, f1, f2): if self.halfedge[v][u] == f2: return u, v - def face_adjacency_vertices(self, f1, f2): + def face_adjacency_vertices(self, f1: Face, f2: Face) -> list[Vertex]: """Find all vertices over which two faces are adjacent. Parameters ---------- - f1 : int + f1 The identifier of the first face. - f2 : int + f2 The identifier of the second face. Returns ------- - list[int] | None - The vertices separating face 1 from face 2, - or None, if the faces are not adjacent. + list[int] + The vertices shared by face 1 and face 2. """ return [vkey for vkey in self.face_vertices(f1) if vkey in self.face_vertices(f2)] - def is_face_on_boundary(self, key): + def is_face_on_boundary(self, key: Face) -> bool: """Verify that a face is on a boundary. Parameters ---------- - key : int + key The identifier of the face. Returns @@ -3769,12 +4165,12 @@ def is_face_on_boundary(self, key): face_vertex_after = face_vertex_descendant face_vertex_before = face_vertex_ancestor - def halfedge_after(self, edge): + def halfedge_after(self, edge: Edge) -> Edge: """Find the halfedge after the given halfedge in the same face. Parameters ---------- - edge : tuple[int, int] + edge The identifier of the starting halfedge. Returns @@ -3792,12 +4188,12 @@ def halfedge_after(self, edge): w = nbrs[0] return v, w - def halfedge_before(self, edge): + def halfedge_before(self, edge: Edge) -> Edge: """Find the halfedge before the given halfedge in the same face. Parameters ---------- - edge : tuple[int, int] + edge The identifier of the starting halfedge. Returns @@ -3815,12 +4211,12 @@ def halfedge_before(self, edge): t = nbrs[-1] return t, u - def vertex_edges(self, vertex): + def vertex_edges(self, vertex: Vertex) -> list[Edge]: """Find all edges connected to a given vertex. Parameters ---------- - vertex : int + vertex Returns ------- @@ -3835,12 +4231,12 @@ def vertex_edges(self, vertex): edges.append((nbr, vertex)) return edges - def halfedge_loop_vertices(self, edge): + def halfedge_loop_vertices(self, edge: Edge) -> list[Vertex]: """Find all vertices on the same loop as a given halfedge. Parameters ---------- - edge : tuple[int, int] + edge The identifier of the starting halfedge. Returns ------- @@ -3851,17 +4247,17 @@ def halfedge_loop_vertices(self, edge): loop = self.halfedge_loop(edge) return [loop[0][0]] + [edge[1] for edge in loop] - def halfedge_strip_faces(self, edge): + def halfedge_strip_faces(self, edge: Edge) -> list[Optional[Face]]: """Find all faces on the same strip as a given halfedge. Parameters ---------- - edge : tuple[int, int] + edge The identifier of the starting halfedge. Returns ------- - list[int] + list[int | None] The faces on the same strip as the given halfedge. """ @@ -3872,7 +4268,7 @@ def halfedge_strip_faces(self, edge): # Mesh geometry # -------------------------------------------------------------------------- - def area(self): + def area(self) -> float: """Calculate the total mesh area. Returns @@ -3883,15 +4279,15 @@ def area(self): """ return sum(self.face_area(fkey) for fkey in self.faces()) - def volume(self, copy=True, unify_cycles=True): + def volume(self, copy: bool = True, unify_cycles: bool = True) -> Optional[float]: """Calculate the volume of the mesh. Parameters ---------- - copy : bool, optional + copy If True, a copy of the mesh is made before computation to avoid modifying the original. Default is True. - unify_cycles : bool, optional + unify_cycles If True, face cycles are unified to ensure consistent orientation. Default is True. @@ -3952,7 +4348,7 @@ def volume(self, copy=True, unify_cycles=True): return abs(volume) - def centroid(self): + def centroid(self) -> list[float]: """Calculate the mesh centroid. Returns @@ -3966,7 +4362,7 @@ def centroid(self): 1.0 / self.area(), ) - def normal(self): + def normal(self) -> list[float]: """Calculate the average mesh normal. Returns @@ -3980,23 +4376,25 @@ def normal(self): 1.0 / self.area(), ) - def aabb(self): + @property + def aabb(self) -> Box: """Calculate the axis aligned bounding box of the mesh. Returns ------- - :class:`compas.geometry.Box` + Box """ xyz = self.vertices_attributes("xyz") return Box.from_bounding_box(bounding_box(xyz)) - def obb(self): + @property + def obb(self) -> Box: """Calculate the oriented bounding box of the mesh. Returns ------- - :class:`compas.geometry.Box` + Box """ xyz = self.vertices_attributes("xyz") @@ -4006,14 +4404,14 @@ def obb(self): # Vertex geometry # -------------------------------------------------------------------------- - def vertex_coordinates(self, key, axes="xyz"): + def vertex_coordinates(self, key: Vertex, axes: str = "xyz") -> list[float]: """Return the coordinates of a vertex. Parameters ---------- - key : int + key The identifier of the vertex. - axes : str, optional + axes The axes along which to take the coordinates. Should be a combination of x, y, and z. @@ -4025,44 +4423,46 @@ def vertex_coordinates(self, key, axes="xyz"): """ return self.vertex_attributes(key, axes) - def vertex_point(self, key): + def vertex_point(self, key: Vertex) -> Point: """Return the point of a vertex. Parameters ---------- - key : int + key The identifier of the vertex. Returns ------- - :class:`compas.geometry.Point` + Point The point of the vertex. + """ - return Point(*self.vertex_coordinates(key)) # type: ignore + return Point(*self.vertex_coordinates(key)) - def vertices_points(self, vertices): + def vertices_points(self, vertices: Iterable[Vertex]) -> list[Point]: """Return the points of multiple vertices. Parameters ---------- - vertices : list[int] + vertices The identifiers of the vertices. Returns ------- - list[:class:`compas.geometry.Point`] + list[Point] The points of the vertices. + """ return [self.vertex_point(vertex) for vertex in vertices] - def set_vertex_point(self, vertex, point): + def set_vertex_point(self, vertex: Vertex, point: PointCoordinates) -> None: """Set the point of a vertex. Parameters ---------- - vertex : int + vertex The identifier of the vertex. - point : :class:`compas.geometry.Point` + point The point to set. Returns @@ -4072,12 +4472,12 @@ def set_vertex_point(self, vertex, point): """ self.vertex_attributes(vertex, "xyz", point) - def vertex_area(self, key): + def vertex_area(self, key: Vertex) -> float: """Compute the tributary area of a vertex. Parameters ---------- - key : int + key The identifier of the vertex. Returns @@ -4108,17 +4508,17 @@ def vertex_area(self, key): return 0.25 * area - def vertex_laplacian(self, key): + def vertex_laplacian(self, key: Vertex) -> Vector: """Compute the vector from a vertex to the centroid of its neighbors. Parameters ---------- - key : int + key The identifier of the vertex. Returns ------- - :class:`compas.geometry.Vector` + Vector The Laplacian vector. """ @@ -4126,47 +4526,47 @@ def vertex_laplacian(self, key): p = self.vertex_coordinates(key) return Vector(*subtract_vectors(c, p)) - def vertex_neighborhood_centroid(self, key): + def vertex_neighborhood_centroid(self, key: Vertex) -> Point: """Compute the centroid of the neighbors of a vertex. Parameters ---------- - key : int + key The identifier of the vertex. Returns ------- - :class:`compas.geometry.Point` + Point The centroid of the vertex neighbors. """ return Point(*centroid_points([self.vertex_coordinates(nbr) for nbr in self.vertex_neighbors(key)])) - def vertex_normal(self, key): + def vertex_normal(self, key: Vertex) -> Vector: """Return the normal vector at the vertex as the weighted average of the normals of the neighboring faces. Parameters ---------- - key : int + key The identifier of the vertex. Returns ------- - :class:`compas.geometry.Vector` + Vector The normal vector. """ vectors = [self.face_normal(fkey, False) for fkey in self.vertex_faces(key) if fkey is not None] return Vector(*normalize_vector(centroid_points(vectors))) - def vertex_curvature(self, vkey): + def vertex_curvature(self, vkey: Vertex) -> float: """Dimensionless vertex curvature. Parameters ---------- - fkey : int - The face key. + vkey + The vertex key. Returns ------- @@ -4175,9 +4575,7 @@ def vertex_curvature(self, vkey): References ---------- - Based on [#]_. - - .. [#] Botsch, Mario, et al. *Polygon mesh processing.* AK Peters/CRC Press, 2010. + Botsch, Mario, et al. *Polygon mesh processing.* AK Peters/CRC Press, 2010. """ C = 0 @@ -4193,14 +4591,14 @@ def vertex_curvature(self, vkey): # Edge geometry # -------------------------------------------------------------------------- - def edge_coordinates(self, edge, axes="xyz"): + def edge_coordinates(self, edge: Edge, axes: str = "xyz") -> tuple[list[float], list[float]]: """Return the coordinates of the start and end point of an edge. Parameters ---------- - edge : tuple(int, int) + edge The identifier of the edge. - axes : str, optional + axes The axes along which the coordinates should be included. Returns @@ -4212,44 +4610,44 @@ def edge_coordinates(self, edge, axes="xyz"): """ return self.vertex_coordinates(edge[0], axes=axes), self.vertex_coordinates(edge[1], axes=axes) - def edge_start(self, edge): + def edge_start(self, edge: Edge) -> Point: """Return the point at the start of an edge. Parameters ---------- - edge : tuple(int, int) + edge The identifier of the edge. Returns ------- - :class:`compas.geometry.Point` + Point The point at the start. """ return self.vertex_point(edge[0]) - def edge_end(self, edge): + def edge_end(self, edge: Edge) -> Point: """Return the point at the end of an edge. Parameters ---------- - edge : tuple(int, int) + edge The identifier of the edge. Returns ------- - :class:`compas.geometry.Point` + Point The point at the end. """ return self.vertex_point(edge[1]) - def edge_length(self, edge): + def edge_length(self, edge: Edge) -> float: """Return the length of an edge. Parameters ---------- - edge : tuple(int, int) + edge The identifier of the edge. Returns @@ -4261,38 +4659,38 @@ def edge_length(self, edge): a, b = self.edge_coordinates(edge) return distance_point_point(a, b) - def edge_vector(self, edge): + def edge_vector(self, edge: Edge) -> Vector: """Return the vector of an edge. Parameters ---------- - edge : tuple(int, int) + edge The identifier of the edge. Returns ------- - :class:`compas.geometry.Vector` + Vector """ a, b = self.edge_coordinates(edge) ab = subtract_vectors(b, a) return Vector(*ab) - def edge_point(self, edge, t=0.5): + def edge_point(self, edge: Edge, t: float = 0.5) -> Point: """Return a point along an edge. Parameters ---------- - edge : tuple(int, int) + edge The identifier of the edge. - t : float, optional + t The location of the point on the edge. If the value of `t` is outside the range 0-1, the point will lie in the direction of the edge, but not on the edge vector. Returns ------- - :class:`compas.geometry.Point` + Point The point at parameter ``t``. """ @@ -4300,34 +4698,34 @@ def edge_point(self, edge, t=0.5): ab = subtract_vectors(b, a) return Point(*add_vectors(a, scale_vector(ab, t))) - def edge_midpoint(self, edge): + def edge_midpoint(self, edge: Edge) -> Point: """Return the midpoint of an edge. Parameters ---------- - edge : tuple(int, int) + edge The identifier of the edge. Returns ------- - list[float] - The XYZ coordinates of the midpoint. + Point + The midpoint of the edge. """ a, b = self.edge_coordinates(edge) return Point(*midpoint_line((a, b))) - def edge_direction(self, edge): + def edge_direction(self, edge: Edge) -> Vector: """Return the direction vector of an edge. Parameters ---------- - edge : tuple(int, int) + edge The identifier of the edge. Returns ------- - :class:`compas.geometry.Vector` + Vector The direction vector of the edge. """ @@ -4335,17 +4733,17 @@ def edge_direction(self, edge): vector.unitize() return vector - def edge_line(self, edge): + def edge_line(self, edge: Edge) -> Line: """Return the line of an edge. Parameters ---------- - edge : tuple(int, int) + edge The identifier of the edge. Returns ------- - :class:`compas.geometry.Line` + Line The line of the edge. """ @@ -4355,14 +4753,14 @@ def edge_line(self, edge): # Face geometry # -------------------------------------------------------------------------- - def face_coordinates(self, fkey, axes="xyz"): + def face_coordinates(self, fkey: Face, axes: str = "xyz") -> list[list[float]]: """Compute the coordinates of the vertices of a face. Parameters ---------- - fkey : int + fkey The identifier of the face. - axes : str, optional + axes The axes along which to take the coordinates. Should be a combination of x, y, and z. @@ -4374,77 +4772,77 @@ def face_coordinates(self, fkey, axes="xyz"): """ return [self.vertex_coordinates(key, axes=axes) for key in self.face_vertices(fkey)] - def face_points(self, fkey): + def face_points(self, fkey: Face) -> list[Point]: """Compute the points of the vertices of a face. Parameters ---------- - fkey : int + fkey The identifier of the face. Returns ------- - list[:class:`compas.geometry.Point`] + list[Point] The points of the vertices of the face. """ return [self.vertex_point(key) for key in self.face_vertices(fkey)] - def face_normal(self, fkey, unitized=True): + def face_normal(self, fkey: Face, unitized: bool = True) -> Vector: """Compute the normal of a face. Parameters ---------- - fkey : int + fkey The identifier of the face. - unitized : bool, optional + unitized If True, the vector is unitized. Returns ------- - :class:`compas.geometry.Vector` + Vector """ return Vector(*normal_polygon(self.face_coordinates(fkey), unitized=unitized)) - def face_centroid(self, fkey): + def face_centroid(self, fkey: Face) -> Point: """Compute the point at the centroid of a face. Parameters ---------- - fkey : int + fkey The identifier of the face. Returns ------- - :class:`compas.geometry.Point` + Point The point at the centroid. """ return Point(*centroid_points(self.face_coordinates(fkey))) - def face_center(self, fkey): + def face_center(self, fkey: Face) -> Point: """Compute the point at the center of mass of a face. Parameters ---------- - fkey : int + fkey The identifier of the face. Returns ------- - :class:`compas.geometry.Point` + Point The point at the center of mass. """ return Point(*centroid_polygon(self.face_coordinates(fkey))) # type: ignore - def face_area(self, fkey): + def face_area(self, fkey: Face) -> float: """Compute the area of a face. Parameters ---------- - fkey : int + fkey The identifier of the face. Returns @@ -4455,14 +4853,14 @@ def face_area(self, fkey): """ return area_polygon(self.face_coordinates(fkey)) - def face_flatness(self, fkey, maxdev=0.02): + def face_flatness(self, fkey: Face, maxdev: float = 0.02) -> float: """Compute the flatness of the mesh face. Parameters ---------- - fkey : int + fkey The identifier of the face. - maxdev : float, optional + maxdev A maximum value for the allowed deviation from flatness. Returns @@ -4500,12 +4898,12 @@ def face_flatness(self, fkey, maxdev=0.02): d = distance_line_line((points[0], points[2]), (points[1], points[3])) return (d / length) / maxdev - def face_aspect_ratio(self, fkey): + def face_aspect_ratio(self, fkey: Face) -> float: """Face aspect ratio as the ratio between the lengths of the maximum and minimum face edges. Parameters ---------- - fkey : int + fkey The face key. Returns @@ -4521,12 +4919,12 @@ def face_aspect_ratio(self, fkey): face_edge_lengths = [self.edge_length(edge) for edge in self.face_halfedges(fkey)] return max(face_edge_lengths) / min(face_edge_lengths) - def face_skewness(self, fkey): - """Face skewness as the maximum absolute angular deviation from the idefault_edge_attributesl polygon angle. + def face_skewness(self, fkey: Face) -> float: + """Face skewness as the maximum absolute angular deviation from the ideal polygon angle. Parameters ---------- - fkey : int + fkey The face key. Returns @@ -4539,7 +4937,7 @@ def face_skewness(self, fkey): * Wikipedia. *Types of mesh*. Available at: https://en.wikipedia.org/wiki/Types_of_mesh. """ - idefault_edge_attributesl_angle = 180 * (1 - 2 / float(len(self.face_vertices(fkey)))) + ideal_angle = 180 * (1 - 2 / float(len(self.face_vertices(fkey)))) angles = [] vertices = self.face_vertices(fkey) for u, v, w in window(vertices + vertices[:2], n=3): @@ -4549,11 +4947,11 @@ def face_skewness(self, fkey): angle = angle_points(o, a, b, deg=True) angles.append(angle) return max( - (max(angles) - idefault_edge_attributesl_angle) / (180 - idefault_edge_attributesl_angle), # type: ignore - (idefault_edge_attributesl_angle - min(angles)) / idefault_edge_attributesl_angle, # type: ignore + (max(angles) - ideal_angle) / (180 - ideal_angle), + (ideal_angle - min(angles)) / ideal_angle, ) - def face_curvature(self, fkey): + def face_curvature(self, fkey: Face) -> float: """Dimensionless face curvature. Face curvature is defined as the maximum face vertex deviation from @@ -4562,7 +4960,7 @@ def face_curvature(self, fkey): Parameters ---------- - fkey : int + fkey The face key. Returns @@ -4579,49 +4977,49 @@ def face_curvature(self, fkey): average_distances = vector_average([distance_point_point(point, centroid) for point in points]) return max_deviation / average_distances - def face_plane(self, face): + def face_plane(self, face: Face) -> Plane: """A plane defined by the centroid and the normal of the face. Parameters ---------- - face : int + face The face identifier. Returns ------- - :class:`compas.geometry.Plane` + Plane The plane of the face. """ return Plane(self.face_centroid(face), self.face_normal(face)) - def face_polygon(self, face): + def face_polygon(self, face: Face) -> Polygon: """The polygon of a face. Parameters ---------- - face : int + face The face identifier. Returns ------- - :class:`compas.geometry.Polygon` + Polygon The polygon of the face. """ return Polygon(self.face_coordinates(face)) - def face_circle(self, face): + def face_circle(self, face: Face) -> Circle: """The circle of a face. Parameters ---------- - face : int + face The face identifier. Returns ------- - :class:`compas.geometry.Circle` + Circle The circle of the face. """ @@ -4630,17 +5028,17 @@ def face_circle(self, face): point, normal, radius = bestfit_circle_numpy(self.face_coordinates(face)) return Circle.from_plane_and_radius(Plane(point, normal), radius) - def face_frame(self, face): + def face_frame(self, face: Face) -> Frame: """The frame of a face. Parameters ---------- - face : int + face The face identifier. Returns ------- - :class:`compas.geometry.Frame` + Frame The frame of the face. """ @@ -4653,7 +5051,7 @@ def face_frame(self, face): # Boundaries # -------------------------------------------------------------------------- - def vertices_on_boundary(self): + def vertices_on_boundary(self) -> list[Vertex]: """Find the vertices on the longest boundary. Returns @@ -4665,7 +5063,7 @@ def vertices_on_boundary(self): boundaries = self.vertices_on_boundaries() return boundaries[0] if boundaries else [] - def edges_on_boundary(self): + def edges_on_boundary(self) -> list[Edge]: """Find the edges on the longest boundary. Returns @@ -4677,7 +5075,7 @@ def edges_on_boundary(self): boundaries = self.edges_on_boundaries() return boundaries[0] if boundaries else [] - def faces_on_boundary(self): + def faces_on_boundary(self) -> list[Face]: """Find the faces on the longest boundary. Returns @@ -4689,7 +5087,7 @@ def faces_on_boundary(self): boundaries = self.faces_on_boundaries() return boundaries[0] if boundaries else [] - def vertices_on_boundaries(self): + def vertices_on_boundaries(self) -> list[list[Vertex]]: """Find the vertices on all boundaries of the mesh. Returns @@ -4790,12 +5188,12 @@ def vertices_on_boundaries(self): if vertices_all: key = vertices_all[0] - def length(boundary): + def length(boundary: list[Vertex]) -> float: return sum(self.edge_length(edge) for edge in pairwise(boundary + boundary[:1])) # type: ignore return sorted(boundaries, key=length, reverse=True) - def edges_on_boundaries(self): + def edges_on_boundaries(self) -> list[list[Edge]]: """Find the edges on all boundaries of the mesh. Returns @@ -4810,7 +5208,7 @@ def edges_on_boundaries(self): edgegroups.append(list(pairwise(vertices))) return edgegroups - def faces_on_boundaries(self): + def faces_on_boundaries(self) -> list[list[Face]]: """Find the faces on all boundaries of the mesh. Returns @@ -4837,12 +5235,12 @@ def faces_on_boundaries(self): # Transformations # -------------------------------------------------------------------------- - def transform(self, T): + def transform(self, T: Union[Transformation, Sequence[Sequence[float]]]) -> None: # type: ignore[override] """Transform the mesh. Parameters ---------- - T : :class:`Transformation` + T The transformation used to transform the mesh. Returns @@ -4853,7 +5251,7 @@ def transform(self, T): Examples -------- >>> from compas.datastructures import Mesh - >>> from compas.geometry import matrix_from_axis_and_angle + >>> from compas.linalg import matrix_from_axis_and_angle >>> mesh = Mesh.from_polyhedron(6) >>> T = matrix_from_axis_and_angle([0, 0, 1], math.pi / 4) >>> mesh.transform(T) @@ -4863,12 +5261,12 @@ def transform(self, T): for vertex, point in zip(self.vertices(), points): self.vertex_attributes(vertex, "xyz", point) - def transform_numpy(self, T): + def transform_numpy(self, T: Any) -> None: # type: ignore[override] """Transform the mesh. Parameters ---------- - T : :class:`numpy.ndarray` + T The transformation used to transform the mesh. Returns @@ -4879,7 +5277,7 @@ def transform_numpy(self, T): Examples -------- >>> from compas.datastructures import Mesh - >>> from compas.geometry import matrix_from_axis_and_angle + >>> from compas.linalg import matrix_from_axis_and_angle >>> mesh = Mesh.from_polyhedron(6) >>> T = matrix_from_axis_and_angle([0, 0, 1], math.pi / 4) >>> mesh.transform_numpy(T) @@ -4895,12 +5293,12 @@ def transform_numpy(self, T): # Matrices # -------------------------------------------------------------------------- - def adjacency_matrix(self, rtype="array"): + def adjacency_matrix(self, rtype: Literal["array", "csc", "csr", "coo", "list"] = "array") -> Any: """Compute the adjacency matrix of the mesh. Parameters ---------- - rtype : Literal['array', 'csc', 'csr', 'coo', 'list'], optional + rtype Format of the result. Returns @@ -4909,18 +5307,18 @@ def adjacency_matrix(self, rtype="array"): The adjacency matrix. """ - from compas.matrices import adjacency_matrix + from compas.linalg.operators import adjacency_matrix vertex_index = self.vertex_index() adjacency = [[vertex_index[nbr] for nbr in self.vertex_neighbors(vertex)] for vertex in self.vertices()] return adjacency_matrix(adjacency, rtype=rtype) - def connectivity_matrix(self, rtype="array"): + def connectivity_matrix(self, rtype: Literal["array", "csc", "csr", "coo", "list"] = "array") -> Any: """Compute the connectivity matrix of the mesh. Parameters ---------- - rtype : Literal['array', 'csc', 'csr', 'coo', 'list'], optional + rtype Format of the result. Returns @@ -4929,18 +5327,18 @@ def connectivity_matrix(self, rtype="array"): The connectivity matrix. """ - from compas.matrices import connectivity_matrix + from compas.linalg.operators import connectivity_matrix vertex_index = self.vertex_index() - adjacency = [[vertex_index[nbr] for nbr in self.vertex_neighbors(vertex)] for vertex in self.vertices()] - return connectivity_matrix(adjacency, rtype=rtype) + edges = [(vertex_index[u], vertex_index[v]) for u, v in self.edges()] + return connectivity_matrix(edges, rtype=rtype) - def degree_matrix(self, rtype="array"): + def degree_matrix(self, rtype: Literal["array", "csc", "csr", "coo", "list"] = "array") -> Any: """Compute the degree matrix of the mesh. Parameters ---------- - rtype : Literal['array', 'csc', 'csr', 'coo', 'list'], optional + rtype Format of the result. Returns @@ -4949,18 +5347,18 @@ def degree_matrix(self, rtype="array"): The degree matrix. """ - from compas.matrices import degree_matrix + from compas.linalg.operators import degree_matrix vertex_index = self.vertex_index() adjacency = [[vertex_index[nbr] for nbr in self.vertex_neighbors(vertex)] for vertex in self.vertices()] return degree_matrix(adjacency, rtype=rtype) - def face_matrix(self, rtype="array"): + def face_matrix(self, rtype: Literal["array", "csc", "csr", "coo", "list"] = "array") -> Any: r"""Compute the face matrix of the mesh. Parameters ---------- - rtype : Literal['array', 'csc', 'csr', 'coo', 'list'], optional + rtype Format of the result. Returns @@ -5003,18 +5401,18 @@ def face_matrix(self, rtype="array"): True """ - from compas.matrices import face_matrix + from compas.linalg.operators import face_matrix vertex_index = self.vertex_index() faces = [[vertex_index[vertex] for vertex in self.face_vertices(face)] for face in self.faces()] return face_matrix(faces, rtype=rtype) - def laplacian_matrix(self, rtype="array"): + def laplacian_matrix(self, rtype: Literal["array", "csc", "csr", "coo", "list"] = "array") -> Any: r"""Compute the Laplacian matrix of the mesh. Parameters ---------- - rtype : Literal['array', 'csc', 'csr', 'coo', 'list'], optional + rtype Format of the result. Returns @@ -5026,7 +5424,7 @@ def laplacian_matrix(self, rtype="array"): ----- The :math:`n \times n` uniform Laplacian matrix :math:`\mathbf{L}` of a mesh with vertices :math:`\mathbf{V}` and edges :math:`\mathbf{E}` is defined as - follows [1]_ + follows. .. math:: @@ -5044,8 +5442,8 @@ def laplacian_matrix(self, rtype="array"): References ---------- - .. [1] Nealen A., Igarashi T., Sorkine O. and Alexa M. - `Laplacian Mesh Optimization `_. + Nealen A., Igarashi T., Sorkine O. and Alexa M. + [Laplacian Mesh Optimization](https://igl.ethz.ch/projects/Laplacian-mesh-processing/Laplacian-mesh-optimization/lmo.pdf). Examples -------- @@ -5061,27 +5459,27 @@ def laplacian_matrix(self, rtype="array"): >>> d = L.dot(xyz) """ - from compas.matrices import laplacian_matrix + from compas.linalg.operators import laplacian_matrix vertex_index = self.vertex_index() - adjacency = [[vertex_index[nbr] for nbr in self.vertex_neighbors(vertex)] for vertex in self.vertices()] - return laplacian_matrix(adjacency, rtype=rtype) + edges = [(vertex_index[u], vertex_index[v]) for u, v in self.edges()] + return laplacian_matrix(edges, rtype=rtype) # -------------------------------------------------------------------------- # Other methods # -------------------------------------------------------------------------- - def offset(self, distance=1.0): + def offset(self, distance: float = 1.0) -> Self: """Generate an offset mesh. Parameters ---------- - distance : float, optional + distance The offset distance. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh The offset mesh. Notes @@ -5110,21 +5508,21 @@ def offset(self, distance=1.0): return offset - def thickened(self, thickness=1.0, both=True): + def thickened(self, thickness: float = 1.0, both: bool = True) -> Self: """Generate a thicknened mesh. Parameters ---------- - thickness : float, optional + thickness The mesh thickness. This should be a positive value. - both : bool, optional + both If true, the mesh is thickened on both sides of the original. Otherwise, the mesh is thickened on the side of the positive normal. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh The thickened mesh. Raises @@ -5144,8 +5542,8 @@ def thickened(self, thickness=1.0, both=True): raise ValueError("Thickness should be a positive number.") if both: - mesh_top = self.offset(+0.5 * thickness) # type: Mesh - mesh_bottom = self.offset(-0.5 * thickness) # type: Mesh + mesh_top = self.offset(+0.5 * thickness) + mesh_bottom = self.offset(-0.5 * thickness) else: mesh_top = self.offset(thickness) mesh_bottom = self.copy() @@ -5154,11 +5552,11 @@ def thickened(self, thickness=1.0, both=True): mesh_bottom.flip_cycles() # join parts - thickened_mesh = mesh_top.copy() # type: Mesh + thickened_mesh = mesh_top.copy() thickened_mesh.join(mesh_bottom) # close boundaries - n = thickened_mesh.number_of_vertices() / 2 + n = thickened_mesh.number_of_vertices() // 2 edges_on_boundary = [edge for boundary in list(thickened_mesh.edges_on_boundaries()) for edge in boundary] @@ -5173,12 +5571,12 @@ def thickened(self, thickness=1.0, both=True): # perhaps there should be # * from_vertices_and_faces # * from_points_and_faces - def exploded(self): + def exploded(self) -> list[Self]: """Explode the mesh into its connected components. Returns ------- - list[:class:`compas.datastructures.Mesh`] + list[Mesh] The list of the meshes from the exploded mesh parts. """ diff --git a/src/compas/datastructures/mesh/operations/collapse.py b/src/compas/datastructures/mesh/operations/collapse.py index 8f5c688cb81a..6d9db8243ccc 100644 --- a/src/compas/datastructures/mesh/operations/collapse.py +++ b/src/compas/datastructures/mesh/operations/collapse.py @@ -1,18 +1,25 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import TYPE_CHECKING +from typing import Collection +from typing import Literal +from typing import Optional +from ..types import Edge +from ..types import Vertex -def is_collapse_legal(mesh, edge, allow_boundary=False): +if TYPE_CHECKING: + from ..mesh import Mesh + + +def is_collapse_legal(mesh: "Mesh", edge: Edge, allow_boundary: bool = False) -> bool: """Verify if the requested collapse is legal for a triangle mesh. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh The mesh. - edge : tuple[int, int] + edge The identifier of the edge. - allow_boundary : bool, optional + allow_boundary If True, collapse is allowed even if `u` and/or `v` is on the boundary. Returns @@ -24,6 +31,9 @@ def is_collapse_legal(mesh, edge, allow_boundary=False): """ u, v = edge + if not mesh.has_halfedge((u, v)) or not mesh.has_halfedge((v, u)): + return False + u_on = mesh.is_vertex_on_boundary(u) v_on = mesh.is_vertex_on_boundary(v) @@ -71,28 +81,36 @@ def is_collapse_legal(mesh, edge, allow_boundary=False): return True -def mesh_collapse_edge(mesh, edge, t=0.5, allow_boundary=False, fixed=None): +def mesh_collapse_edge( + mesh: "Mesh", + edge: Edge, + t: float = 0.5, + allow_boundary: bool = False, + fixed: Optional[Collection[Vertex]] = None, +) -> Optional[Literal[False]]: """Collapse an edge to its first or second vertex, or to an intermediate point. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh Instance of a mesh. - edge : tuple[int, int] + edge The identifier of the edge. - t : float, optional + t Determines where to collapse to. If ``t == 0.0`` collapse to start of the edge. If ``t == 1.0`` collapse to end of the edge. If ``0.0 < t < 1.0``, collapse to a point between start and end of the edge. - allow_boundary : bool, optional + allow_boundary If True, allow collapses involving boundary vertices. - fixed : list[int], optional + fixed A list of identifiers of vertices that should stay fixed. Returns ------- - None + False | None + False if the collapse is not legal or involves a fixed vertex. + None if the collapse succeeds. Raises ------ @@ -102,6 +120,9 @@ def mesh_collapse_edge(mesh, edge, t=0.5, allow_boundary=False, fixed=None): """ u, v = edge + if not mesh.has_halfedge((u, v)) or not mesh.has_halfedge((v, u)): + raise ValueError("The edge is not part of the mesh.") + if t < 0.0: raise ValueError("Parameter t should be greater than or equal to 0.") if t > 1.0: @@ -217,28 +238,35 @@ def mesh_collapse_edge(mesh, edge, t=0.5, allow_boundary=False, fixed=None): # - u and v on boundary -def trimesh_collapse_edge(mesh, edge, t=0.5, allow_boundary=False, fixed=None): +def trimesh_collapse_edge( + mesh: "Mesh", + edge: Edge, + t: float = 0.5, + allow_boundary: bool = False, + fixed: Optional[Collection[Vertex]] = None, +) -> bool: """Collapse an edge to its first or second vertex, or to an intermediate point. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh Instance of a mesh. - edge : tuple[int, int] + edge The identifier of the edge. - t : float, optional + t Determines where to collapse to. If ``t == 0.0`` collapse to the start of the edge. If ``t == 1.0`` collapse to the end of the edge. If ``0.0 < t < 1.0``, collapse to a point between start and end. - allow_boundary : bool, optional + allow_boundary If True, allow collapses involving vertices on the boundary. - fixed : list, optional + fixed Identifiers of the vertices that should stay fixed. Returns ------- - None + bool + True if the collapse succeeds, False otherwise. Raises ------ @@ -248,6 +276,9 @@ def trimesh_collapse_edge(mesh, edge, t=0.5, allow_boundary=False, fixed=None): """ u, v = edge + if not mesh.has_halfedge((u, v)) or not mesh.has_halfedge((v, u)): + raise ValueError("The edge is not part of the mesh.") + if t < 0.0: raise ValueError("Parameter t should be greater than or equal to 0.") if t > 1.0: diff --git a/src/compas/datastructures/mesh/operations/extrude.py b/src/compas/datastructures/mesh/operations/extrude.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/src/compas/datastructures/mesh/operations/insert.py b/src/compas/datastructures/mesh/operations/insert.py index 0d68bf9dce0c..7175e1ecdb15 100644 --- a/src/compas/datastructures/mesh/operations/insert.py +++ b/src/compas/datastructures/mesh/operations/insert.py @@ -1,20 +1,26 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import TYPE_CHECKING +from typing import Optional +from ..types import Edge +from ..types import Face +from ..types import Vertex -def mesh_add_vertex_to_face_edge(mesh, key, fkey, v): +if TYPE_CHECKING: + from ..mesh import Mesh + + +def mesh_add_vertex_to_face_edge(mesh: "Mesh", key: Vertex, fkey: Face, v: Vertex) -> None: """Add an existing vertex of the mesh to an existing face. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh The mesh data structure. - key : int + key The identifier of the vertex. - fkey : int + fkey The identifier of the face. - v : int + v The identifier of the vertex before which the new vertex should be added. Returns @@ -51,7 +57,7 @@ def mesh_add_vertex_to_face_edge(mesh, key, fkey, v): vertices = mesh.face_vertices(fkey) i = vertices.index(v) u = vertices[i - 1] - vertices.insert(key, i - 1) + vertices.insert(i, key) mesh.halfedge[u][key] = fkey mesh.halfedge[key][v] = fkey if u not in mesh.halfedge[key]: @@ -61,13 +67,12 @@ def mesh_add_vertex_to_face_edge(mesh, key, fkey, v): del mesh.halfedge[u][v] if u in mesh.halfedge[v]: del mesh.halfedge[v][u] - if (u, v) in mesh.edgedata: - del mesh.edgedata[u, v] - if (v, u) in mesh.edgedata: - del mesh.edgedata[v, u] + edge_data_key = str(tuple(sorted((u, v)))) + if edge_data_key in mesh.edgedata: + del mesh.edgedata[edge_data_key] -def mesh_insert_vertex_on_edge(mesh, edge, vkey=None): +def mesh_insert_vertex_on_edge(mesh: "Mesh", edge: Edge, vkey: Optional[Vertex] = None) -> Vertex: """Insert a vertex in the faces adjacent to an edge, between the two edge vertices. If no vertex key is specified or if the key does not exist yet, a vertex is added and located at the edge midpoint. @@ -75,9 +80,11 @@ def mesh_insert_vertex_on_edge(mesh, edge, vkey=None): Parameters ---------- - edge : tuple[int, int] + mesh + The mesh data structure. + edge The edge identifier. - vkey: int, optional + vkey The vertex key to insert. Default is to auto-generate a new vertex identifier. @@ -103,7 +110,7 @@ def mesh_insert_vertex_on_edge(mesh, edge, vkey=None): # add new vertex if there is none or if vkey not in vertices if vkey is None: vkey = mesh.add_vertex(attr_dict={attr: xyz for attr, xyz in zip(["x", "y", "z"], mesh.edge_midpoint(edge))}) - elif vkey not in list(mesh.vertices()): + elif not mesh.has_vertex(vkey): vkey = mesh.add_vertex( key=vkey, attr_dict={attr: xyz for attr, xyz in zip(["x", "y", "z"], mesh.edge_midpoint(edge))}, diff --git a/src/compas/datastructures/mesh/operations/merge.py b/src/compas/datastructures/mesh/operations/merge.py index d5cf5c77e30c..67e2b7f2eb58 100644 --- a/src/compas/datastructures/mesh/operations/merge.py +++ b/src/compas/datastructures/mesh/operations/merge.py @@ -1,21 +1,27 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import TYPE_CHECKING +from typing import Optional +from typing import Sequence +from ..types import Face -def mesh_merge_faces(mesh, faces): +if TYPE_CHECKING: + from ..mesh import Mesh + + +def mesh_merge_faces(mesh: "Mesh", faces: Sequence[Face]) -> Optional[Face]: """Merge two faces of a mesh over their shared edge. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A mesh data structure. - faces : list[int] + faces Face identifiers. Returns ------- - int + int | None + The merged face, or None if the faces are not adjacent. Examples -------- @@ -32,6 +38,9 @@ def mesh_merge_faces(mesh, faces): [3, 5, 0, 4, 1, 6, 2, 7] """ + if len(faces) != 2: + raise ValueError("Exactly two faces are required.") + u, v = None, None for i, j in mesh.face_halfedges(faces[0]): if faces[1] == mesh.halfedge[j][i]: @@ -59,6 +68,8 @@ def mesh_merge_faces(mesh, faces): mesh.delete_face(faces[0]) mesh.delete_face(faces[1]) key = mesh.add_face(vertices) + if key is None: + raise RuntimeError("Merging the faces produced an invalid face.") # remove internal edges remove = [] for edge in mesh.face_halfedges(key): diff --git a/src/compas/datastructures/mesh/operations/split.py b/src/compas/datastructures/mesh/operations/split.py index 08e5e376b053..a9b719e9ab7a 100644 --- a/src/compas/datastructures/mesh/operations/split.py +++ b/src/compas/datastructures/mesh/operations/split.py @@ -1,23 +1,29 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import TYPE_CHECKING +from typing import Optional from compas.itertools import pairwise +from ..types import Edge +from ..types import Face +from ..types import Vertex -def mesh_split_edge(mesh, edge, t=0.5, allow_boundary=False): +if TYPE_CHECKING: + from ..mesh import Mesh + + +def mesh_split_edge(mesh: "Mesh", edge: Edge, t: float = 0.5, allow_boundary: bool = False) -> Optional[Vertex]: """Split and edge by inserting a vertex along its length. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh Instance of a mesh. - edge : tuple[int, int] + edge The identifier of the edge to split. - t : float, optional + t The position of the inserted vertex. The value should be between 0.0 and 1.0 - allow_boundary : bool, optional + allow_boundary If True, also split edges on the boundary. Returns @@ -33,6 +39,9 @@ def mesh_split_edge(mesh, edge, t=0.5, allow_boundary=False): """ u, v = edge + if not mesh.has_halfedge((u, v)) or not mesh.has_halfedge((v, u)): + raise ValueError("The edge is not part of the mesh.") + if t < 0.0: raise ValueError("t should be greater than or equal to 0.0.") if t > 1.0: @@ -81,19 +90,19 @@ def mesh_split_edge(mesh, edge, t=0.5, allow_boundary=False): return w -def trimesh_split_edge(mesh, edge, t=0.5, allow_boundary=False): +def trimesh_split_edge(mesh: "Mesh", edge: Edge, t: float = 0.5, allow_boundary: bool = False) -> Optional[Vertex]: """Split an edge of a triangle mesh. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh Instance of a mesh. - edge : tuple[int, int] + edge The identifier of the edge to split. - t : float, optional + t The location of the split point along the original edge. The value should be between 0.0 and 1.0 - allow_boundary : bool, optional + allow_boundary If True, allow splits on boundary edges. Returns @@ -108,6 +117,9 @@ def trimesh_split_edge(mesh, edge, t=0.5, allow_boundary=False): """ u, v = edge + if not mesh.has_halfedge((u, v)) or not mesh.has_halfedge((v, u)): + raise ValueError("The edge is not part of the mesh.") + if t <= 0.0: raise ValueError("t should be greater than 0.0.") if t >= 1.0: @@ -158,18 +170,18 @@ def trimesh_split_edge(mesh, edge, t=0.5, allow_boundary=False): return w -def mesh_split_face(mesh, fkey, u, v): +def mesh_split_face(mesh: "Mesh", fkey: Face, u: Vertex, v: Vertex) -> tuple[Face, Face]: """Split a face by inserting an edge between two specified vertices. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh Instance of a mesh - fkey : int + fkey The face key. - u : int + u The key of the first split vertex. - v : int + v The key of the second split vertex. Returns @@ -179,7 +191,7 @@ def mesh_split_face(mesh, fkey, u, v): Raises ------ - :exc:`ValueError` + ValueError If the split vertices does not belong to the split face or if the split vertices are neighbors. @@ -208,7 +220,7 @@ def mesh_split_face(mesh, fkey, u, v): i = face.index(u) j = face.index(v) - if i + 1 == j: + if abs(i - j) in (1, len(face) - 1): raise ValueError("The split vertices are neighbors.") if j > i: @@ -221,19 +233,22 @@ def mesh_split_face(mesh, fkey, u, v): f = mesh.add_face(f) g = mesh.add_face(g) + if f is None or g is None: + raise RuntimeError("Splitting the face produced an invalid face.") + del mesh.face[fkey] return f, g -def mesh_split_strip(mesh, edge): - """Split the srip of faces corresponding to a given edge. +def mesh_split_strip(mesh: "Mesh", edge: Edge) -> list[Vertex]: + """Split the strip of faces corresponding to a given edge. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh The input mesh. - edge : tuple[int, int] + edge The edge identifying the strip. Returns @@ -248,14 +263,21 @@ def mesh_split_strip(mesh, edge): ngons = [] splits = [] for edge in strip[:-1]: - ngons.append(mesh.halfedge_face(edge)) - splits.append(mesh.split_edge(edge, t=0.5, allow_boundary=True)) + ngon = mesh.halfedge_face(edge) + split = mesh.split_edge(edge, t=0.5, allow_boundary=True) + if ngon is None or split is None: + raise RuntimeError("Splitting the strip failed.") + ngons.append(ngon) + splits.append(split) if is_closed: splits.append(splits[0]) else: edge = strip[-1] - splits.append(mesh.split_edge(edge, t=0.5, allow_boundary=True)) + split = mesh.split_edge(edge, t=0.5, allow_boundary=True) + if split is None: + raise RuntimeError("Splitting the strip failed.") + splits.append(split) for (u, v), ngon in zip(pairwise(splits), ngons): mesh.split_face(ngon, u, v) diff --git a/src/compas/datastructures/mesh/operations/substitute.py b/src/compas/datastructures/mesh/operations/substitute.py index 571618645f0a..d1fee34ac7cc 100644 --- a/src/compas/datastructures/mesh/operations/substitute.py +++ b/src/compas/datastructures/mesh/operations/substitute.py @@ -1,21 +1,34 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import TYPE_CHECKING +from typing import Iterable +from typing import Optional +from ..types import Face +from ..types import Vertex -def mesh_substitute_vertex_in_faces(mesh, old_vkey, new_vkey, fkeys=None): +if TYPE_CHECKING: + from ..mesh import Mesh + + +def mesh_substitute_vertex_in_faces( + mesh: "Mesh", + old_vkey: Vertex, + new_vkey: Vertex, + fkeys: Optional[Iterable[Face]] = None, +) -> list[Face]: """Substitute in a mesh a vertex by another one. In all faces by default or in a given set of faces. Parameters ---------- - old_vkey : int + mesh + The mesh data structure. + old_vkey The old vertex key. - new_vkey : int + new_vkey The new vertex key. - fkeys : list[int], optional - List of face keys where to subsitute the old vertex by the new one. - Default is to subsitute in all faces. + fkeys + Face keys in which to substitute the old vertex with the new one. + Default is to substitute it in all faces. Returns ------- @@ -25,13 +38,12 @@ def mesh_substitute_vertex_in_faces(mesh, old_vkey, new_vkey, fkeys=None): """ # apply to all faces if there is none chosen - if fkeys is None: - fkeys = list(mesh.faces()) + faces = list(mesh.faces()) if fkeys is None else list(fkeys) # substitute vertices - for fkey in fkeys: + for fkey in faces: face_vertices = [new_vkey if key == old_vkey else key for key in mesh.face_vertices(fkey)] mesh.delete_face(fkey) mesh.add_face(face_vertices, fkey) - return fkeys + return faces diff --git a/src/compas/datastructures/mesh/operations/swap.py b/src/compas/datastructures/mesh/operations/swap.py index f36ef35ba5f8..84d72670d1bf 100644 --- a/src/compas/datastructures/mesh/operations/swap.py +++ b/src/compas/datastructures/mesh/operations/swap.py @@ -1,31 +1,48 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import TYPE_CHECKING +from typing import Literal +from typing import Union +from ..types import Edge +from ..types import Face -def trimesh_swap_edge(mesh, edge, allow_boundary=True): +if TYPE_CHECKING: + from ..mesh import Mesh + + +def trimesh_swap_edge( + mesh: "Mesh", edge: Edge, allow_boundary: bool = True +) -> Union[tuple[Face, Face], Literal[False]]: """Replace an edge of the mesh by an edge connecting the opposite vertices of the adjacent faces. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh Instance of mesh. - edge : tuple[int, int] + edge The identifier of the edge to swap. + allow_boundary + If False, reject edges incident to boundary vertices. Returns ------- - None + tuple[int, int] | False + The new face identifiers if the swap succeeds, False otherwise. """ u, v = edge + if not mesh.has_halfedge((u, v)) or not mesh.has_halfedge((v, u)): + raise ValueError("The edge is not part of the mesh.") + # check legality of the swap # swapping on the boundary is not allowed fkey_uv = mesh.halfedge[u][v] fkey_vu = mesh.halfedge[v][u] + if fkey_uv is None or fkey_vu is None: + return False + u_on = mesh.is_vertex_on_boundary(u) v_on = mesh.is_vertex_on_boundary(v) @@ -59,4 +76,7 @@ def trimesh_swap_edge(mesh, edge, allow_boundary=True): a = mesh.add_face([o_uv, o_vu, v]) b = mesh.add_face([o_vu, o_uv, u]) + if a is None or b is None: + raise RuntimeError("Swapping the edge produced an invalid face.") + return a, b diff --git a/src/compas/datastructures/mesh/operations/weld.py b/src/compas/datastructures/mesh/operations/weld.py index 015a3c1cfab4..9c75a97237aa 100644 --- a/src/compas/datastructures/mesh/operations/weld.py +++ b/src/compas/datastructures/mesh/operations/weld.py @@ -1,24 +1,32 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import TYPE_CHECKING +from typing import Collection +from typing import Iterable +from typing import Optional -from compas.itertools import pairwise from compas.topology import connected_components from compas.topology import vertex_adjacency_from_edges +from ..types import Edge +from ..types import Face +from ..types import Vertex from .substitute import mesh_substitute_vertex_in_faces +if TYPE_CHECKING: + from ..mesh import Mesh -def mesh_unweld_vertices(mesh, fkey, where=None): + +def mesh_unweld_vertices( + mesh: "Mesh", fkey: Face, where: Optional[Collection[Vertex]] = None +) -> list[Vertex]: """Unweld a face of the mesh. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A mesh object. - fkey : int + fkey The identifier of a face. - where : list[int], optional + where A list of vertices to unweld. Default is to unweld all vertices of the face. @@ -28,33 +36,33 @@ def mesh_unweld_vertices(mesh, fkey, where=None): The vertices of the unwelded face. """ - face = [] vertices = mesh.face_vertices(fkey) + face_attributes = dict(mesh.face_attributes(fkey)) if not where: where = vertices + selected = set(where) - for u, v in pairwise(vertices + vertices[0:1]): - if u in where: - x, y, z = mesh.vertex_coordinates(u) - u = mesh.add_vertex(x=x, y=y, z=z) - if u in where or v in where: - mesh.halfedge[v][u] = None - face.append(u) + face = [] + for vertex in vertices: + if vertex in selected: + vertex = mesh.add_vertex(attr_dict=dict(mesh.vertex_attributes(vertex))) + face.append(vertex) - mesh.add_face(face, fkey=fkey) + mesh.delete_face(fkey) + mesh.add_face(face, fkey=fkey, attr_dict=face_attributes) return face -def mesh_unweld_edges(mesh, edges): +def mesh_unweld_edges(mesh: "Mesh", edges: Iterable[Edge]) -> None: """Unwelds a mesh along edges. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A mesh. - edges: list[tuple[int, int]] + edges List of edges as tuples of vertex keys. Returns @@ -62,6 +70,8 @@ def mesh_unweld_edges(mesh, edges): None """ + edges = set(edges) + # set of vertices in edges to unweld vertices = set([i for edge in edges for i in edge]) @@ -78,10 +88,14 @@ def mesh_unweld_edges(mesh, edges): network_edges = [] for nbr in mesh.vertex_neighbors(vkey): if not mesh.is_edge_on_boundary((vkey, nbr)) and (vkey, nbr) not in edges and (nbr, vkey) not in edges: + face_vkey_nbr = mesh.halfedge[vkey][nbr] + face_nbr_vkey = mesh.halfedge[nbr][vkey] + if face_vkey_nbr is None or face_nbr_vkey is None: + continue network_edges.append( ( - old_to_new[mesh.halfedge[vkey][nbr]], - old_to_new[mesh.halfedge[nbr][vkey]], + old_to_new[face_vkey_nbr], + old_to_new[face_nbr_vkey], ) ) diff --git a/src/compas/datastructures/mesh/remesh.py b/src/compas/datastructures/mesh/remesh.py index e47bf46b2699..e0c47bc6c35c 100644 --- a/src/compas/datastructures/mesh/remesh.py +++ b/src/compas/datastructures/mesh/remesh.py @@ -1,64 +1,77 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import TYPE_CHECKING +from typing import Any +from typing import Callable +from typing import Collection +from typing import Optional from .operations.collapse import trimesh_collapse_edge from .operations.split import trimesh_split_edge from .operations.swap import trimesh_swap_edge from .smoothing import mesh_smooth_area +from .types import Vertex + +if TYPE_CHECKING: + from .mesh import Mesh + +RemeshCallback = Callable[["Mesh", int, Any], None] def trimesh_remesh( - mesh, - target, - kmax=100, - tol=0.1, - divergence=0.01, - verbose=False, - allow_boundary_split=False, - allow_boundary_swap=False, - allow_boundary_collapse=False, - smooth=True, - fixed=None, - callback=None, - callback_args=None, -): + mesh: "Mesh", + target: float, + kmax: int = 100, + tol: float = 0.1, + divergence: float = 0.01, + verbose: bool = False, + allow_boundary_split: bool = False, + allow_boundary_swap: bool = False, + allow_boundary_collapse: bool = False, + smooth: bool = True, + fixed: Optional[Collection[Vertex]] = None, + callback: Optional[RemeshCallback] = None, + callback_args: Any = None, +) -> None: """Remesh until all edges have a specified target length. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A triangle mesh. - target : float + target The target length for the mesh edges. - kmax : int, optional + kmax The number of iterations. - tol : float, optional + tol Length deviation tolerance. - divergence : float, optional - ?? - verbose : bool, optional + divergence + The relative change in vertex count below which remeshing stops. + verbose Print feedback messages. - allow_boundary_split : bool, optional + allow_boundary_split Allow boundary edges to be split. - allow_boundary_swap : bool, optional + allow_boundary_swap Allow boundary edges or edges connected to the boundary to be swapped. - allow_boundary_collapse : bool, optional + allow_boundary_collapse Allow boundary edges or edges connected to the boundary to be collapsed. - smooth : bool, optional + smooth Apply smoothing at every iteration. - fixed : list[int], optional + fixed A list of vertices that have to stay fixed. - callback : callable, optional + callback A user-defined function that is called after every iteration. - callback_args : list[Any], optional - A list of additional parameters to be passed to the callback function. + callback_args + Additional parameters to pass to the callback function. Returns ------- None The mesh is modified in place. + Raises + ------ + ValueError + If the target length is not positive. + Notes ----- This algorithm not only changes the geometry of the mesh, but also its @@ -73,15 +86,16 @@ def trimesh_remesh( The minimum and maximum lengths are calculated based on a desired target length. - For more info, see [1]_. - References ---------- - .. [1] Botsch, M. & Kobbelt, L., 2004. *A remeshing approach to multiresolution modeling*. - Proceedings of the 2004 Eurographics/ACM SIGGRAPH symposium on Geometry processing - SGP '04, p.185. - Available at: http://portal.acm.org/citation.cfm?doid=1057432.1057457. + * Botsch, M. & Kobbelt, L., 2004. *A remeshing approach to multiresolution modeling*. + Proceedings of the 2004 Eurographics/ACM SIGGRAPH symposium on Geometry processing - SGP '04, p.185. + Available at: http://portal.acm.org/citation.cfm?doid=1057432.1057457. """ + if target <= 0: + raise ValueError("The target length should be greater than zero.") + if verbose: print(target) @@ -133,7 +147,7 @@ def trimesh_remesh( continue if verbose: - print("split edge: {0} - {1}".format(u, v)) + print(f"split edge: {u} - {v}") trimesh_split_edge(mesh, (u, v), allow_boundary=allow_boundary_split) @@ -153,7 +167,7 @@ def trimesh_remesh( if mesh.edge_length((u, v)) >= lmin - dlmin: continue if verbose: - print("collapse edge: {0} - {1}".format(u, v)) + print(f"collapse edge: {u} - {v}") trimesh_collapse_edge(mesh, (u, v), allow_boundary=allow_boundary_collapse, fixed=fixed) @@ -206,7 +220,7 @@ def trimesh_remesh( continue if verbose: - print("swap edge: {0} - {1}".format(u, v)) + print(f"swap edge: {u} - {v}") trimesh_swap_edge(mesh, (u, v), allow_boundary=allow_boundary_swap) diff --git a/src/compas/datastructures/mesh/slice.py b/src/compas/datastructures/mesh/slice.py index 1059c5fdaa43..fc8aada9ed98 100644 --- a/src/compas/datastructures/mesh/slice.py +++ b/src/compas/datastructures/mesh/slice.py @@ -1,27 +1,51 @@ -from compas.geometry import dot_vectors +from typing import TYPE_CHECKING +from typing import Generic +from typing import Optional +from typing import Type +from typing import TypeVar + +from compas.geometry import Plane from compas.geometry import intersection_segment_plane -from compas.geometry import length_vector -from compas.geometry import subtract_vectors +from compas.linalg.vectors import dot_vectors +from compas.linalg.vectors import length_vector +from compas.linalg.vectors import subtract_vectors + +from .types import Vertex + +if TYPE_CHECKING: + from .mesh import Mesh +MeshType = TypeVar("MeshType", bound="Mesh") -def mesh_slice_plane(mesh, plane): + +def mesh_slice_plane(mesh: MeshType, plane: Plane) -> Optional[tuple[MeshType, MeshType]]: """Slice a mesh with a plane and construct the resulting submeshes. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh The original mesh. - plane : :class:`compas.geometry.Plane` + plane The cutting plane. Returns ------- - tuple[:class:`compas.datastructures.Mesh`, :class:`compas.datastructures.Mesh`] | None + tuple[Mesh, Mesh] | None The "positive" and "negative" submeshes. If the mesh and plane do not intersect, or if the intersection is degenerate (point or line), the function returns None. + Raises + ------ + RuntimeError + If an intersected mesh edge cannot be split. + + Notes + ----- + The current implementation assumes that the input mesh represents a closed + volume. This condition is not checked. + Examples -------- >>> from compas.geometry import Plane @@ -41,43 +65,43 @@ def mesh_slice_plane(mesh, plane): return intersection.split() -class IntersectionMeshPlane(object): - def __init__(self, mesh, plane): +class IntersectionMeshPlane(Generic[MeshType]): + def __init__(self, mesh: MeshType, plane: Plane): self.mesh = mesh self.plane = plane - self._intersections = [] + self._intersections: list[Vertex] = [] self.intersect() @property - def meshtype(self): + def meshtype(self) -> Type[MeshType]: return type(self.mesh) @property - def intersections(self): + def intersections(self) -> list[Vertex]: return self._intersections @property - def is_none(self): + def is_none(self) -> bool: return len(self.intersections) == 0 @property - def is_point(self): + def is_point(self) -> bool: return len(self.intersections) == 1 @property - def is_line(self): + def is_line(self) -> bool: return len(self.intersections) == 2 @property - def is_polygon(self): + def is_polygon(self) -> bool: return len(self.intersections) >= 3 @property - def is_mesh_closed(self): + def is_mesh_closed(self) -> bool: return self.mesh.is_closed() @property - def positive(self): + def positive(self) -> Optional[MeshType]: if self.is_none: return vertices = [] @@ -86,8 +110,8 @@ def positive(self): vertices.append(key) faces = [] for key in vertices: - faces += self.mesh.vertex_faces(key) - faces = list(set(faces)) + faces.extend(self.mesh.vertex_faces(key)) + faces = set(faces) vdict = {key: self.mesh.vertex_coordinates(key) for key in vertices + self.intersections} fdict = [self.mesh.face_vertices(fkey) for fkey in faces] mesh = self.meshtype.from_vertices_and_faces(vdict, fdict) @@ -95,7 +119,7 @@ def positive(self): mesh.add_face(mesh.vertices_on_boundary()) return mesh - def is_positive(self, key): + def is_positive(self, key: Vertex) -> bool: o = self.plane.point n = self.plane.normal if key not in self.intersections: @@ -107,7 +131,7 @@ def is_positive(self, key): return False @property - def negative(self): + def negative(self) -> Optional[MeshType]: if self.is_none: return vertices = [] @@ -116,8 +140,8 @@ def negative(self): vertices.append(key) faces = [] for key in vertices: - faces += self.mesh.vertex_faces(key) - faces = list(set(faces)) + faces.extend(self.mesh.vertex_faces(key)) + faces = set(faces) vdict = {key: self.mesh.vertex_coordinates(key) for key in vertices + self.intersections} fdict = [self.mesh.face_vertices(fkey) for fkey in faces] mesh = self.meshtype.from_vertices_and_faces(vdict, fdict) @@ -125,7 +149,7 @@ def negative(self): mesh.add_face(mesh.vertices_on_boundary()) return mesh - def is_negative(self, key): + def is_negative(self, key: Vertex) -> bool: o = self.plane.point n = self.plane.normal if key in self.intersections: @@ -135,9 +159,9 @@ def is_negative(self, key): similarity = dot_vectors(n, oa) return similarity < 0.0 - def intersect(self): - intersections = [] - vertex_intersections = [] + def intersect(self) -> None: + intersections: list[Vertex] = [] + vertex_intersections: list[Vertex] = [] for u, v in list(self.mesh.edges()): a = self.mesh.vertex_attributes(u, "xyz") b = self.mesh.vertex_attributes(v, "xyz") @@ -149,6 +173,8 @@ def intersect(self): L_ab = length_vector(subtract_vectors(b, a)) t = L_ax / L_ab key = self.mesh.split_edge((u, v), t=t, allow_boundary=True) + if key is None: + raise RuntimeError("Splitting an intersected edge failed.") intersections.append(key) else: if u in vertex_intersections: @@ -158,13 +184,17 @@ def intersect(self): vertex_intersections.append(v) self._intersections = intersections - def split(self): + def split(self) -> tuple[MeshType, MeshType]: 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: u, v = split try: self.mesh.split_face(fkey, u, v) - except Exception: + except ValueError: continue - return self.positive, self.negative + positive = self.positive + negative = self.negative + if positive is None or negative is None: + raise RuntimeError("Splitting the mesh did not produce two submeshes.") + return positive, negative diff --git a/src/compas/datastructures/mesh/smoothing.py b/src/compas/datastructures/mesh/smoothing.py index ffe1f3151cbd..fc361d798023 100644 --- a/src/compas/datastructures/mesh/smoothing.py +++ b/src/compas/datastructures/mesh/smoothing.py @@ -1,28 +1,44 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import TYPE_CHECKING +from typing import Any +from typing import Callable +from typing import Collection +from typing import Optional from compas.geometry import centroid_points from compas.geometry import centroid_polygon +from .types import Vertex -def mesh_smooth_centroid(mesh, fixed=None, kmax=100, damping=0.5, callback=None, callback_args=None): +if TYPE_CHECKING: + from .mesh import Mesh + +SmoothingCallback = Callable[[int, Any], None] + + +def mesh_smooth_centroid( + mesh: "Mesh", + fixed: Optional[Collection[Vertex]] = None, + kmax: int = 100, + damping: float = 0.5, + callback: Optional[SmoothingCallback] = None, + callback_args: Any = None, +) -> None: """Smooth a mesh by moving every free vertex to the centroid of its neighbors. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A mesh object. - fixed : list[int], optional + fixed The fixed vertices of the mesh. - kmax : int, optional + kmax The maximum number of iterations. - damping : float, optional + damping The damping factor. - callback : callable, optional + callback A user-defined callback function to be executed after every iteration. - callback_args : list[Any], optional - A list of arguments to be passed to the callback. + callback_args + Additional arguments to pass to the callback. Returns ------- @@ -30,13 +46,12 @@ def mesh_smooth_centroid(mesh, fixed=None, kmax=100, damping=0.5, callback=None, Raises ------ - Exception + TypeError If a callback is provided, but it is not callable. """ - if callback: - if not callable(callback): - raise Exception("Callback is not callable.") + if callback is not None and not callable(callback): + raise TypeError("Callback is not callable.") fixed = fixed or [] fixed = set(fixed) @@ -56,27 +71,34 @@ def mesh_smooth_centroid(mesh, fixed=None, kmax=100, damping=0.5, callback=None, attr["y"] += damping * (cy - y) attr["z"] += damping * (cz - z) - if callback: + if callback is not None: callback(k, callback_args) -def mesh_smooth_centerofmass(mesh, fixed=None, kmax=100, damping=0.5, callback=None, callback_args=None): +def mesh_smooth_centerofmass( + mesh: "Mesh", + fixed: Optional[Collection[Vertex]] = None, + kmax: int = 100, + damping: float = 0.5, + callback: Optional[SmoothingCallback] = None, + callback_args: Any = None, +) -> None: """Smooth a mesh by moving every free vertex to the center of mass of the polygon formed by the neighboring vertices. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A mesh object. - fixed : list[int], optional + fixed The fixed vertices of the mesh. - kmax : int, optional + kmax The maximum number of iterations. - damping : float, optional + damping The damping factor. - callback : callable, optional + callback A user-defined callback function to be executed after every iteration. - callback_args : list[Any], optional - A list of arguments to be passed to the callback. + callback_args + Additional arguments to pass to the callback. Returns ------- @@ -84,13 +106,12 @@ def mesh_smooth_centerofmass(mesh, fixed=None, kmax=100, damping=0.5, callback=N Raises ------ - Exception + TypeError If a callback is provided, but it is not callable. """ - if callback: - if not callable(callback): - raise Exception("Callback is not callable.") + if callback is not None and not callable(callback): + raise TypeError("Callback is not callable.") fixed = fixed or [] fixed = set(fixed) @@ -110,27 +131,34 @@ def mesh_smooth_centerofmass(mesh, fixed=None, kmax=100, damping=0.5, callback=N attr["y"] += damping * (cy - y) attr["z"] += damping * (cz - z) - if callback: + if callback is not None: callback(k, callback_args) -def mesh_smooth_area(mesh, fixed=None, kmax=100, damping=0.5, callback=None, callback_args=None): +def mesh_smooth_area( + mesh: "Mesh", + fixed: Optional[Collection[Vertex]] = None, + kmax: int = 100, + damping: float = 0.5, + callback: Optional[SmoothingCallback] = None, + callback_args: Any = None, +) -> None: """Smooth a mesh by moving each vertex to the barycenter of the centroids of the surrounding faces, weighted by area. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A mesh object. - fixed : list[int], optional + fixed The fixed vertices of the mesh. - kmax : int, optional + kmax The maximum number of iterations. - damping : float, optional + damping The damping factor. - callback : callable, optional + callback A user-defined callback function to be executed after every iteration. - callback_args : list[Any], optional - A list of arguments to be passed to the callback. + callback_args + Additional arguments to pass to the callback. Returns ------- @@ -138,13 +166,12 @@ def mesh_smooth_area(mesh, fixed=None, kmax=100, damping=0.5, callback=None, cal Raises ------ - Exception + TypeError If a callback is provided, but it is not callable. """ - if callback: - if not callable(callback): - raise Exception("Callback is not callable.") + if callback is not None and not callable(callback): + raise TypeError("Callback is not callable.") fixed = fixed or [] fixed = set(fixed) @@ -183,5 +210,5 @@ def mesh_smooth_area(mesh, fixed=None, kmax=100, damping=0.5, callback=None, cal attr["y"] += damping * (ay - y) attr["z"] += damping * (az - z) - if callback: + if callback is not None: callback(k, callback_args) diff --git a/src/compas/datastructures/mesh/subdivision.py b/src/compas/datastructures/mesh/subdivision.py index 375c48eff78a..5d50cec62bf3 100644 --- a/src/compas/datastructures/mesh/subdivision.py +++ b/src/compas/datastructures/mesh/subdivision.py @@ -1,18 +1,32 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from copy import deepcopy from math import cos from math import pi +from typing import TYPE_CHECKING +from typing import Any +from typing import Collection +from typing import Literal +from typing import Mapping +from typing import Optional +from typing import TypeVar +from typing import Union +from typing import cast from compas.geometry import centroid_points from compas.geometry import offset_polygon from compas.itertools import iterable_like from compas.itertools import pairwise +from .types import Face +from .types import Vertex + +if TYPE_CHECKING: + from .mesh import Mesh + +MeshType = TypeVar("MeshType", bound="Mesh") +SubdivisionScheme = Literal["tri", "quad", "corner", "catmullclark", "doosabin", "frames", "loop"] -def subd_factory(cls): + +def subd_factory(cls: Any) -> Any: class SubdMesh(cls): _add_vertex = cls.add_vertex _add_face = cls.add_face @@ -49,7 +63,7 @@ def insert_vertex(self, fkey): return SubdMesh -def mesh_fast_copy(other): +def mesh_fast_copy(other: MeshType) -> MeshType: SubdMesh = subd_factory(type(other)) subd = SubdMesh() subd.vertex = deepcopy(other.vertex) @@ -58,7 +72,7 @@ def mesh_fast_copy(other): subd.halfedge = deepcopy(other.halfedge) subd._max_vertex = other._max_vertex subd._max_face = other._max_face - return subd + return cast(MeshType, subd) # distinguish between subd of meshes with and without boundary @@ -71,21 +85,21 @@ def mesh_fast_copy(other): # any subd algorithm should return a new subd mesh, leaving the control mesh intact -def mesh_subdivide(mesh, scheme="catmullclark", **options): +def mesh_subdivide(mesh: MeshType, scheme: SubdivisionScheme = "catmullclark", **options: Any) -> MeshType: """Subdivide the input mesh. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh A mesh object. - scheme : Literal['tri', 'quad', 'corner', 'catmullclark', 'doosabin', 'frames', 'loop'], optional + scheme The scheme according to which the mesh should be subdivided. - **options : dict[str, Any], optional + **options Optional additional keyword arguments. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh The subdivided mesh. Raises @@ -109,22 +123,22 @@ def mesh_subdivide(mesh, scheme="catmullclark", **options): if scheme == "loop": return trimesh_subdivide_loop(mesh, **options) - raise ValueError("Scheme is not supported") + raise ValueError(f"Subdivision scheme is not supported: {scheme}") -def mesh_subdivide_tri(mesh, k=1): +def mesh_subdivide_tri(mesh: MeshType, k: int = 1) -> MeshType: """Subdivide a mesh using simple insertion of vertices. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh The mesh object that will be subdivided. - k : int, optional + k The number of levels of subdivision. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A new subdivided mesh. Examples @@ -152,21 +166,26 @@ def mesh_subdivide_tri(mesh, k=1): return cls.__from_data__(subd.__data__) -def mesh_subdivide_quad(mesh, k=1): +def mesh_subdivide_quad(mesh: MeshType, k: int = 1) -> MeshType: """Subdivide a mesh such that all faces are quads. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh The mesh object that will be subdivided. - k : int, optional + k The number of levels of subdivision. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A new subdivided mesh. + Raises + ------ + RuntimeError + If subdivision produces an invalid face. + Examples -------- >>> from compas.geometry import Box @@ -185,8 +204,9 @@ def mesh_subdivide_quad(mesh, k=1): """ cls = type(mesh) subd = mesh_fast_copy(mesh) - for face in subd.faces(): - subd.facedata[face]["path"] = [face] + if k > 0: + for face in subd.faces(): + subd.facedata[face]["path"] = [face] for _ in range(k): faces = {face: subd.face_vertices(face)[:] for face in subd.faces()} face_centroid = {face: subd.face_centroid(face) for face in subd.faces()} @@ -201,6 +221,8 @@ def mesh_subdivide_quad(mesh, k=1): a = ancestor[vertex] d = descendant[vertex] newface = subd.add_face([a, vertex, d, c]) + if newface is None: + raise RuntimeError("Quad subdivision produced an invalid face.") subd.facedata[newface]["path"] = subd.facedata[face]["path"] + [i] del subd.face[face] del subd.facedata[face] @@ -208,19 +230,19 @@ def mesh_subdivide_quad(mesh, k=1): return subd2 -def mesh_subdivide_corner(mesh, k=1): +def mesh_subdivide_corner(mesh: MeshType, k: int = 1) -> MeshType: """Subdivide a mesh by cutting corners. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh The mesh object that will be subdivided. - k : int, optional + k The number of levels of subdivision. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A new subdivided mesh. Notes @@ -251,23 +273,32 @@ def mesh_subdivide_corner(mesh, k=1): return subd2 -def mesh_subdivide_catmullclark(mesh, k=1, fixed=None): +def mesh_subdivide_catmullclark( + mesh: MeshType, + k: int = 1, + fixed: Optional[Collection[Vertex]] = None, +) -> MeshType: """Subdivide a mesh using the Catmull-Clark algorithm. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh The mesh object that will be subdivided. - k : int, optional + k The number of levels of subdivision. - fixed : list[int], optional + fixed A list of fixed vertices. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A new subdivided mesh. + Raises + ------ + RuntimeError + If an edge cannot be split during subdivision. + Notes ----- Note that *Catmull-Clark* subdivision is like *Quad* subdivision, but with @@ -318,9 +349,7 @@ def mesh_subdivide_catmullclark(mesh, k=1, fixed=None): """ cls = type(mesh) - if not fixed: - fixed = [] - fixed = set(fixed) + fixed = set(fixed or []) for _ in range(k): subd = mesh_fast_copy(mesh) @@ -339,6 +368,8 @@ def mesh_subdivide_catmullclark(mesh, k=1, fixed=None): for u, v in mesh.edges(): w = subd.split_edge((u, v), allow_boundary=True) + if w is None: + raise RuntimeError("Splitting an edge during Catmull-Clark subdivision failed.") crease = mesh.edge_attribute((u, v), "crease") or 0 if crease: @@ -411,7 +442,7 @@ def mesh_subdivide_catmullclark(mesh, k=1, fixed=None): elif C == 2: V = key_xyz[key] - E = [0, 0, 0] + E = [0.0, 0.0, 0.0] for nbr, crease in zip(nbrs, creases): if crease: x, y, z = key_xyz[nbr] @@ -433,23 +464,32 @@ def mesh_subdivide_catmullclark(mesh, k=1, fixed=None): return subd2 -def mesh_subdivide_doosabin(mesh, k=1, fixed=None): +def mesh_subdivide_doosabin( + mesh: MeshType, + k: int = 1, + fixed: Optional[Collection[Vertex]] = None, +) -> MeshType: """Subdivide a mesh following the doo-sabin scheme. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh The mesh object that will be subdivided. - k : int, optional + k The number of levels of subdivision. - fixed : list[int], optional + fixed A list of fixed vertices. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A new subdivided mesh. + Notes + ----- + The ``fixed`` parameter is currently retained for API compatibility but has + no effect on Doo-Sabin subdivision. + Examples -------- >>> from compas.geometry import Box @@ -464,11 +504,6 @@ def mesh_subdivide_doosabin(mesh, k=1, fixed=None): True """ - if not fixed: - fixed = [] - - fixed = set(fixed) - cls = type(mesh) SubdMesh = subd_factory(cls) @@ -548,23 +583,27 @@ def mesh_subdivide_doosabin(mesh, k=1, fixed=None): return subd2 -def mesh_subdivide_frames(mesh, offset, add_windows=False): +def mesh_subdivide_frames( + mesh: MeshType, + offset: Union[float, Mapping[Face, float]], + add_windows: bool = False, +) -> MeshType: """Subdivide a mesh by creating offset frames and windows on its faces. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh The mesh object to be subdivided. - offset : float | dict[int, float] + offset The offset distance to create the frames. A single value will result in a constant offset everywhere. A dictionary mapping faces to offset values will be processed accordingly. - add_windows : bool, optional + add_windows If True, add a window face in the frame opening. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A new subdivided mesh. """ @@ -574,9 +613,11 @@ def mesh_subdivide_frames(mesh, offset, add_windows=False): subd = SubdMesh() # 0. pre-compute offset distances - if not isinstance(offset, dict): + if not isinstance(offset, Mapping): distances = iterable_like(mesh.faces(), [offset], offset) - offset = {fkey: od for fkey, od in zip(mesh.faces(), distances)} + face_offsets = {fkey: od for fkey, od in zip(mesh.faces(), distances)} + else: + face_offsets = offset # 1. add vertices newkeys = {} @@ -586,7 +627,7 @@ def mesh_subdivide_frames(mesh, offset, add_windows=False): # 2. add faces for fkey in mesh.faces(): face = [newkeys[vkey] for vkey in mesh.face_vertices(fkey)] - d = offset.get(fkey) + d = face_offsets.get(fkey) # 2a. add face and break if no offset is found if d is None: @@ -615,23 +656,32 @@ def mesh_subdivide_frames(mesh, offset, add_windows=False): return cls.__from_data__(subd.__data__) -def trimesh_subdivide_loop(mesh, k=1, fixed=None): +def trimesh_subdivide_loop( + mesh: MeshType, + k: int = 1, + fixed: Optional[Collection[Vertex]] = None, +) -> MeshType: """Subdivide a triangle mesh using the Loop algorithm. Parameters ---------- - mesh : :class:`compas.datastructures.Mesh` + mesh The mesh object that will be subdivided. - k : int, optional + k The number of levels of subdivision. - fixed : list[int], optional + fixed A list of fixed vertices. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A new subdivided mesh. + Raises + ------ + RuntimeError + If an edge cannot be split during subdivision. + Examples -------- Make a low poly mesh from a box shape. @@ -660,10 +710,7 @@ def trimesh_subdivide_loop(mesh, k=1, fixed=None): """ cls = type(mesh) - if not fixed: - fixed = [] - - fixed = set(fixed) + fixed = set(fixed or []) subd = mesh_fast_copy(mesh) @@ -717,6 +764,8 @@ def trimesh_subdivide_loop(mesh, k=1, fixed=None): # odd vertices for u, v in list(subd.edges()): w = subd.split_edge((u, v), allow_boundary=True) + if w is None: + raise RuntimeError("Splitting an edge during Loop subdivision failed.") edgepoints[(u, v)] = w edgepoints[(v, u)] = w diff --git a/src/compas/datastructures/mesh/types.py b/src/compas/datastructures/mesh/types.py new file mode 100644 index 000000000000..d698e1fb8180 --- /dev/null +++ b/src/compas/datastructures/mesh/types.py @@ -0,0 +1,8 @@ +from typing import Any +from typing import Sequence + +Vertex = int +Face = int +Edge = tuple[Vertex, Vertex] +AttributeDict = dict[str, Any] +PointCoordinates = Sequence[float] diff --git a/src/compas/datastructures/tree/hashtree.py b/src/compas/datastructures/tree/hashtree.py index cff4002c1a93..755c6fff4da1 100644 --- a/src/compas/datastructures/tree/hashtree.py +++ b/src/compas/datastructures/tree/hashtree.py @@ -1,260 +1,433 @@ -# -*- coding: utf-8 -*- +"""Immutable hash trees for comparing hierarchical data.""" + import hashlib +from collections import deque +from copy import deepcopy +from typing import TYPE_CHECKING +from typing import Any +from typing import Callable +from typing import Hashable +from typing import Iterator +from typing import Optional +from typing import Sequence + +from typing_extensions import Self from compas.data import Data from compas.data import json_dumps -from compas.datastructures import Tree -from compas.datastructures import TreeNode + +from .tree import TraversalOrder +from .tree import TraversalStrategy + +if TYPE_CHECKING: + from compas.datastructures import Graph + + +_MISSING = object() -class HashNode(TreeNode): - """A node in a HashTree. This class is used internally by the HashTree class. +class HashNode(Data): + """An immutable node in a HashTree. Parameters ---------- - path : str + path The relative path of the node. - value : str, int, float, list, bool, None - The value of the node. Only leaf nodes can have a value. + value + The value of a leaf node. If omitted, the node is a branch node. + children + The child nodes. + **kwargs + User-defined node attributes. Attributes ---------- - path : str + path The relative path of the node. - value : str, int, float, list, bool, None - The value of the node. Only leaf nodes can have a value. - absolute_path : str + value + The value of the node. + children + The child nodes as an immutable tuple. + absolute_path The absolute path of the node. - is_value : bool - True if the node is a leaf node and has a value. - signature : str + is_value + True if a value was provided, including an explicit value of None. + signature The SHA256 signature of the node. - children_dict : dict - A dictionary of the children of the node. The keys are the relative paths - children_paths : list[str] - A list of the relative paths of the children of the node. """ - def __init__(self, path, value=None, **kwargs): - super(HashNode, self).__init__(**kwargs) - self.path = path - self.value = value - self._signature = None + @property + def __data__(self) -> dict[str, Any]: + data = { + "path": self.path, + "is_value": self.is_value, + "attributes": self.attributes, + "children": [child.__data__ for child in self.children], + } + if self.is_value: + data["value"] = self.value + return data - def __repr__(self): + @classmethod + def __from_data__(cls, data: dict[str, Any]) -> Self: + children = [cls.__from_data__(child) for child in data.get("children", [])] + value = data.get("value") if data.get("is_value") else _MISSING + return cls( + data["path"], + value=value, + children=children, + **data.get("attributes", {}), + ) + + def __init__( + self, + path: str, + value: Any = _MISSING, + children: Sequence["HashNode"] = (), + **kwargs: Any, + ) -> None: + super().__init__() + self.attributes = kwargs + self._path = path + self._is_value = value is not _MISSING + self._value = None if value is _MISSING else deepcopy(value) + self._children = tuple(children) + self._parent: Optional[HashNode] = None + self._signature: Optional[str] = None + + if self.is_value and self.children: + raise ValueError("A value node cannot have children.") + + child_paths = [child.path for child in self.children] + if len(child_paths) != len(set(child_paths)): + raise ValueError("Sibling HashNodes must have unique paths.") + + for child in self.children: + if not isinstance(child, HashNode): + raise TypeError("The children of a HashNode must be HashNode objects.") + if child._parent is not None: + raise ValueError("A HashNode cannot belong to more than one parent.") + child._parent = self + + def __repr__(self) -> str: path = self.path or "ROOT" - if self.value is not None: + if self.is_value: return "{}:{} @ {}".format(path, self.value, self.signature[:5]) - else: - return "{} @ {}".format(path, self.signature[:5]) + return "{} @ {}".format(path, self.signature[:5]) @property - def absolute_path(self): + def parent(self) -> Optional["HashNode"]: + return self._parent + + @property + def path(self) -> str: + return self._path + + @property + def value(self) -> Any: + return deepcopy(self._value) + + @property + def children(self) -> tuple["HashNode", ...]: + return self._children + + @property + def absolute_path(self) -> str: if self.parent is None: return self.path return self.parent.absolute_path + self.path @property - def is_value(self): - return self.value is not None + def is_value(self) -> bool: + return self._is_value + + @property + def is_root(self) -> bool: + return self.parent is None @property - def signature(self): + def is_leaf(self) -> bool: + return not self.children + + @property + def signature(self) -> str: + if self._signature is None: + content = { + "path": self.path, + "is_value": self.is_value, + "value": self._value, + "children": [child.signature for child in self.children], + } + self._signature = hashlib.sha256(json_dumps(content).encode()).hexdigest() return self._signature @property - def children_dict(self): + def children_dict(self) -> dict[str, "HashNode"]: return {child.path: child for child in self.children} @property - def children_paths(self): - return [child.path for child in self.children] + def children_paths(self) -> list[str]: + return list(self.children_dict) + + @property + def ancestors(self) -> Iterator["HashNode"]: + node = self.parent + while node is not None: + yield node + node = node.parent + + @property + def descendants(self) -> Iterator["HashNode"]: + for child in self.children: + yield child + yield from child.descendants + + def traverse( + self, + strategy: TraversalStrategy = "depthfirst", + order: TraversalOrder = "preorder", + ) -> Iterator["HashNode"]: + """Traverse the hierarchy from this node. + + Parameters + ---------- + strategy + The traversal strategy. + order + The traversal order used for depth-first traversal. + + Yields + ------ + HashNode + The next node in the traversal. + + """ + if strategy == "depthfirst": + if order == "preorder": + yield self + for child in self.children: + yield from child.traverse(strategy, order) + elif order == "postorder": + for child in self.children: + yield from child.traverse(strategy, order) + yield self + else: + raise ValueError("Unknown traversal order: {}".format(order)) + elif strategy == "breadthfirst": + queue: deque[HashNode] = deque([self]) + while queue: + node = queue.popleft() + yield node + queue.extend(node.children) + else: + raise ValueError("Unknown traversal strategy: {}".format(strategy)) @classmethod - def from_dict(cls, data_dict, path=""): + def from_dict(cls, data: dict[str, Any], path: str = "") -> Self: """Construct a HashNode from a dictionary. Parameters ---------- - data_dict : dict - A dictionary to construct the HashNode from. - path : str + data + The dictionary representation of the hierarchy. + path The relative path of the node. Returns ------- - :class:`compas.datastructures.HashNode` - A HashNode constructed from the dictionary. + HashNode + The constructed node. """ - node = cls(path) - for key in data_dict: - path = ".{}".format(key) - if isinstance(data_dict[key], dict): - child = cls.from_dict(data_dict[key], path=path) - node.add(child) + children = [] + for key in sorted(data): + if not isinstance(key, str): + raise TypeError("HashTree dictionary keys must be strings.") + child_path = ".{}".format(key) + if isinstance(data[key], dict): + children.append(cls.from_dict(data[key], path=child_path)) else: - node.add(cls(path, value=data_dict[key])) - - return node + children.append(cls(child_path, value=data[key])) + return cls(path, children=children) -class HashTree(Tree): - """HashTree data structure to compare differences in hierarchical data. +class HashTree(Data): + """An immutable hash tree for comparing hierarchical data. - A Hash tree (or Merkle tree) is a tree in which every leaf node is labelled with the cryptographic hash - of a data block and every non-leaf node is labelled with the hash of the labels of its child nodes. - Hash trees allow efficient and secure verification of the contents of large data structures. - They can also be used to compare different versions(states) of the same data structure for changes. - - Attributes + Parameters ---------- - signatures : dict[str, str] - The SHA256 signatures of the nodes in the tree. The keys are the absolute paths of the nodes, the values are the signatures. - - Examples - -------- - >>> tree1 = HashTree.from_dict({"a": {"b": 1, "c": 3}, "d": [1, 2, 3], "e": 2}) - >>> tree2 = HashTree.from_dict({"a": {"b": 1, "c": 2}, "d": [1, 2, 3], "f": 2}) - >>> print(tree1) - +-- ROOT @ 4cd56 - +-- .a @ c16fd - | +-- .b:1 @ c9b55 - | +-- .c:3 @ 518d4 - +-- .d:[1, 2, 3] @ 9be3a - +-- .e:2 @ 68355 - >>> print(tree2) - +-- ROOT @ fbe39 - +-- .a @ c2022 - | +-- .b:1 @ c9b55 - | +-- .c:2 @ e3365 - +-- .d:[1, 2, 3] @ 9be3a - +-- .f:2 @ 93861 - >>> tree2.print_diff(tree1) - Added: - {'path': '.f', 'value': 2} - Removed: - {'path': '.e', 'value': 2} - Modified: - {'path': '.a.c', 'old': 3, 'new': 2} + root + The root node of the tree. + name + The name of the tree. + **kwargs + User-defined tree attributes. """ - def __init__(self, **kwargs): - super(HashTree, self).__init__(**kwargs) - self.signatures = {} + @property + def __data__(self) -> dict[str, Any]: + return { + "attributes": self.attributes, + "root": self.root.__data__ if self.root else None, + } + + @classmethod + def __from_data__(cls, data: dict[str, Any]) -> Self: + root = HashNode.__from_data__(data["root"]) if data.get("root") else None + return cls(root=root, **data.get("attributes", {})) + + def __init__( + self, + root: Optional[HashNode] = None, + name: Optional[str] = None, + **kwargs: Any, + ) -> None: + super().__init__(name=name) + if root is not None and not isinstance(root, HashNode): + raise TypeError("The root of a HashTree must be a HashNode.") + if root is not None and root.parent is not None: + raise ValueError("The root HashNode already belongs to another node.") + self.attributes = kwargs + self._root = root + + def __str__(self) -> str: + return self.get_hierarchy_string() + + @property + def root(self) -> Optional[HashNode]: + return self._root + + @property + def nodes(self) -> Iterator[HashNode]: + if self.root: + yield from self.root.traverse() + + @property + def leaves(self) -> Iterator[HashNode]: + for node in self.nodes: + if node.is_leaf: + yield node + + @property + def signatures(self) -> dict[str, str]: + return {node.absolute_path: node.signature for node in self.nodes} + + def traverse( + self, + strategy: TraversalStrategy = "depthfirst", + order: TraversalOrder = "preorder", + ) -> Iterator[HashNode]: + """Traverse the hash tree from its root. + + Parameters + ---------- + strategy + The traversal strategy. + order + The traversal order used for depth-first traversal. + + Yields + ------ + HashNode + The next node in the traversal. + + """ + if self.root: + yield from self.root.traverse(strategy, order) @classmethod - def from_dict(cls, data_dict): + def from_dict(cls, data: dict[str, Any]) -> Self: """Construct a HashTree from a dictionary. Parameters ---------- - data_dict : dict - A dictionary to construct the HashTree from. + data + The dictionary representation of the hierarchy. Returns ------- - :class:`compas.datastructures.HashTree` - A HashTree constructed from the dictionary. + HashTree + The constructed tree. """ - tree = cls() - root = HashNode.from_dict(data_dict) - tree.add(root) - tree.node_signature(tree.root) - return tree + return cls(root=HashNode.from_dict(data)) @classmethod - def from_object(cls, obj): + def from_object(cls, obj: Data) -> Self: """Construct a HashTree from a COMPAS data object.""" if not isinstance(obj, Data): raise TypeError("The object must be a COMPAS data object.") return cls.from_dict(obj.__data__) - def node_signature(self, node, parent_path=""): - """Compute the SHA256 signature of a node. The computed nodes are cached in `self.signatures` dictionary. + def node_signature(self, node: HashNode) -> str: + """Return the SHA256 signature of a node. Parameters ---------- - node : :class:`compas.datastructures.HashNode` - The node to compute the signature of. - parent_path : str - The absolute path of the parent node. + node + A node belonging to this tree. Returns ------- str - The SHA256 signature of the node. + The node signature. """ - absolute_path = parent_path + node.path - if absolute_path in self.signatures: - return self.signatures[absolute_path] - - content = { - "path": node.path, - "value": node.value, - "children": [self.node_signature(child, absolute_path) for child in node.children], - } + if node not in self.nodes: + raise ValueError("The HashNode does not belong to this HashTree.") + return node.signature - signature = hashlib.sha256(json_dumps(content).encode()).hexdigest() - - self.signatures[absolute_path] = signature - node._signature = signature - - return signature - - def diff(self, other): + def diff(self, other: "HashTree") -> dict[str, list[dict[str, Any]]]: """Compute the difference between two HashTrees. Parameters ---------- - other : :class:`compas.datastructures.HashTree` + other The HashTree to compare with. Returns ------- - dict - A dictionary containing the differences between the two HashTrees. The keys are `added`, `removed` and `modified`. - The values are lists of dictionaries containing the paths and values of the nodes that were added, removed or modified. - """ - added = [] - removed = [] - modified = [] - - def _diff(node1, node2): - if node1.signature == node2.signature: - return - else: - if node1.is_value or node2.is_value: - modified.append({"path": node1.absolute_path, "old": node2.value, "new": node1.value}) - - for path in node1.children_paths: - if path in node2.children_dict: - _diff(node1.children_dict[path], node2.children_dict[path]) - else: - added.append({"path": node1.children_dict[path].absolute_path, "value": node1.children_dict[path].value}) + dict[str, list[dict[str, Any]]] + The added, removed, and modified values. - for path in node2.children_paths: - if path not in node1.children_dict: - removed.append({"path": node2.children_dict[path].absolute_path, "value": node2.children_dict[path].value}) + """ + if self.root is None or other.root is None: + raise ValueError("Both HashTrees must have a root.") - _diff(self.root, other.root) + added: list[dict[str, Any]] = [] + removed: list[dict[str, Any]] = [] + modified: list[dict[str, Any]] = [] + def compare(node: HashNode, old_node: HashNode) -> None: + if node.signature == old_node.signature: + return + if node.is_value or old_node.is_value: + modified.append( + { + "path": node.absolute_path, + "old": old_node.value, + "new": node.value, + } + ) + + children = node.children_dict + old_children = old_node.children_dict + for path, child in children.items(): + if path in old_children: + compare(child, old_children[path]) + else: + added.append({"path": child.absolute_path, "value": child.value}) + for path, child in old_children.items(): + if path not in children: + removed.append({"path": child.absolute_path, "value": child.value}) + + compare(self.root, other.root) return {"added": added, "removed": removed, "modified": modified} - def print_diff(self, other): - """Print the difference between two HashTrees. - - Parameters - ---------- - other : :class:`compas.datastructures.HashTree` - The HashTree to compare with. - - """ - + def print_diff(self, other: "HashTree") -> None: + """Print the difference between two HashTrees.""" diff = self.diff(other) print("Added:") for item in diff["added"]: @@ -265,3 +438,49 @@ def print_diff(self, other): print("Modified:") for item in diff["modified"]: print(item) + + def get_hierarchy_string(self, max_depth: Optional[int] = None) -> str: + """Return a string representation of the hash tree hierarchy.""" + hierarchy = [] + + def visit( + node: HashNode, + prefix: str = "", + last: bool = True, + depth: int = 0, + ) -> None: + if max_depth is not None and depth > max_depth: + return + connector = "└── " if last else "├── " + hierarchy.append("{}{}{}".format(prefix, connector, node)) + child_prefix = prefix + (" " if last else "│ ") + for index, child in enumerate(node.children): + visit(child, child_prefix, index == len(node.children) - 1, depth + 1) + + if self.root: + visit(self.root) + return "\n".join(hierarchy) + + def to_graph( + self, + key_mapper: Optional[Callable[[HashNode], Hashable]] = None, + ) -> "Graph": + """Convert the hash tree to a graph.""" + from compas.datastructures import Graph + + graph = Graph(**self.attributes) + nodes = list(self.nodes) + if key_mapper is None: + node_key = {node: index for index, node in enumerate(nodes)} + key_mapper = node_key.__getitem__ + + keys = [key_mapper(node) for node in nodes] + if len(keys) != len(set(keys)): + raise ValueError("The key mapper produced duplicate graph keys.") + + for node, key in zip(nodes, keys): + graph.add_node(key=key, attr_dict=node.attributes, name=node.path) + for node in nodes: + if node.parent: + graph.add_edge(key_mapper(node.parent), key_mapper(node)) + return graph diff --git a/src/compas/datastructures/tree/tree.py b/src/compas/datastructures/tree/tree.py index f8bb56a3e574..65d96d38ee05 100644 --- a/src/compas/datastructures/tree/tree.py +++ b/src/compas/datastructures/tree/tree.py @@ -1,55 +1,82 @@ -# -*- coding: utf-8 -*- - -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +"""Tree data structures. + +Notes +----- +The following design considerations should be addressed in a future revision: + +* Enforce tree ownership consistently. A root node already assigned to another + tree currently has no parent and can therefore be added to a second tree. +* Prevent cycles by rejecting attempts to add a node to itself or to one of its + descendants. +* Prevent implicit reparenting. Adding a child to a second parent currently leaves + it in the first parent's children while changing its parent reference. +* Avoid exposing the mutable internal children list, because direct mutations + bypass parent and tree bookkeeping. +* Consider using one authoritative mutation API instead of overlapping operations + on both ``Tree`` and ``TreeNode``. +* Preserve specialized ``TreeNode`` subclasses during deserialization instead of + always reconstructing plain ``TreeNode`` instances. +* Detect duplicate keys produced by custom key mappers in ``Tree.to_graph`` rather + than silently merging nodes. +* Clarify and test the intended behavior of detached subtrees. Descendants remain + connected to the detached branch, but the subtree no longer belongs to a tree. + +""" + +from collections import deque +from typing import TYPE_CHECKING +from typing import Any +from typing import Callable +from typing import Hashable +from typing import Iterator +from typing import Literal +from typing import Optional + +from typing_extensions import Self from compas.data import Data from compas.datastructures import Datastructure +if TYPE_CHECKING: + from compas.datastructures import Graph + + +TraversalStrategy = Literal["depthfirst", "breadthfirst"] +TraversalOrder = Literal["preorder", "postorder"] + class TreeNode(Data): """A node of a tree data structure. Parameters ---------- - **kwargs : dict[str, Any], optional + **kwargs User-defined attributes of the tree node. Attributes ---------- - parent : :class:`compas.datastructures.TreeNode` + parent The parent node of the tree node. - children : list[:class:`compas.datastructures.TreeNode`] + children The children of the tree node. - tree : :class:`compas.datastructures.Tree` + tree The tree to which the node belongs. - is_root : bool + is_root True if the node is the root node of the tree. - is_leaf : bool + is_leaf True if the node is a leaf node of the tree. - is_branch : bool + is_branch True if the node is a branch node of the tree. - acestors : generator[:class:`compas.datastructures.TreeNode`] - A generator of the acestors of the tree node. - descendants : generator[:class:`compas.datastructures.TreeNode`] + ancestors + An iterator over the ancestors of the tree node. + descendants A generator of the descendants of the tree node, using a depth-first preorder traversal. """ - DATASCHEMA = { - "type": "object", - "$recursiveAnchor": True, - "properties": { - "name": {"type": "string"}, - "attributes": {"type": "object"}, - "children": {"type": "array", "items": {"$recursiveRef": "#"}}, - }, - } - @property - def __data__(self): - data = {} + def __data__(self) -> dict[str, Any]: + data: dict[str, Any] = {} if self.name is not None: data["name"] = self.name if self.attributes: @@ -59,7 +86,7 @@ def __data__(self): return data @classmethod - def __from_data__(cls, data): + def __from_data__(cls, data: dict[str, Any]) -> Self: name = data.get("name", None) attributes = data.get("attributes", {}) children = data.get("children", []) @@ -69,52 +96,52 @@ def __from_data__(cls, data): node.add(cls.__from_data__(child)) return node - def __init__(self, name=None, **kwargs): - super(TreeNode, self).__init__(name=name) + def __init__(self, name: Optional[str] = None, **kwargs: Any) -> None: + super().__init__(name=name) self.attributes = kwargs - self._parent = None - self._children = [] - self._tree = None + self._parent: Optional[TreeNode] = None + self._children: list[TreeNode] = [] + self._tree: Optional[Tree] = None - def __repr__(self): + def __repr__(self) -> str: if self._name: return "".format(self._name) return "" @property - def is_root(self): + def is_root(self) -> bool: return self._parent is None @property - def is_leaf(self): + def is_leaf(self) -> bool: return not self._children @property - def is_branch(self): + def is_branch(self) -> bool: return not self.is_root and not self.is_leaf @property - def parent(self): + def parent(self) -> Optional["TreeNode"]: return self._parent @property - def children(self): + def children(self) -> list["TreeNode"]: return self._children @property - def tree(self): + def tree(self) -> Optional["Tree"]: if self.is_root: return self._tree - else: - return self.parent.tree # type: ignore + if self.parent: + return self.parent.tree + return None - def add(self, node): - """ - Add a child node to this node. + def add(self, node: "TreeNode") -> None: + """Add a child node to this node. Parameters ---------- - node : :class:`compas.datastructures.TreeNode` + node The node to add. Returns @@ -124,7 +151,7 @@ def add(self, node): Raises ------ TypeError - If the node is not a :class:`compas.datastructures.TreeNode` object. + If the node is not a TreeNode object. """ if not isinstance(node, TreeNode): @@ -133,13 +160,12 @@ def add(self, node): self._children.append(node) node._parent = self - def remove(self, node): - """ - Remove a child node from this node. + def remove(self, node: "TreeNode") -> None: + """Remove a child node from this node. Parameters ---------- - node : :class:`compas.datastructures.TreeNode` + node The node to remove. Returns @@ -151,33 +177,36 @@ def remove(self, node): node._parent = None @property - def ancestors(self): + def ancestors(self) -> Iterator["TreeNode"]: this = self while this.parent: yield this.parent this = this.parent @property - def descendants(self): + def descendants(self) -> Iterator["TreeNode"]: for child in self.children: yield child for descendant in child.descendants: yield descendant - def traverse(self, strategy="depthfirst", order="preorder"): - """ - Traverse the tree from this node. + def traverse( + self, + strategy: TraversalStrategy = "depthfirst", + order: TraversalOrder = "preorder", + ) -> Iterator["TreeNode"]: + """Traverse the tree from this node. Parameters ---------- - strategy : {"depthfirst", "breadthfirst"}, optional + strategy The traversal strategy. - order : {"preorder", "postorder"}, optional + order The traversal order. This parameter is only used for depth-first traversal. Yields ------ - :class:`compas.datastructures.TreeNode` + TreeNode The next node in the traversal. Raises @@ -201,9 +230,9 @@ def traverse(self, strategy="depthfirst", order="preorder"): else: raise ValueError("Unknown traversal order: {}".format(order)) elif strategy == "breadthfirst": - queue = [self] + queue: deque[TreeNode] = deque([self]) while queue: - node = queue.pop(0) + node = queue.popleft() yield node queue.extend(node.children) else: @@ -216,18 +245,18 @@ class Tree(Datastructure): Parameters ---------- - name : str, optional + name The name of the tree. - **kwargs : dict, optional + **kwargs Additional keyword arguments, which are stored in the attributes dict. Attributes ---------- - root : :class:`compas.datastructures.TreeNode` + root The root node of the tree. - nodes : generator[:class:`compas.datastructures.TreeNode`] + nodes The nodes of the tree. - leaves : generator[:class:`compas.datastructures.TreeNode`] + leaves A generator of the leaves of the tree. Examples @@ -251,50 +280,41 @@ class Tree(Datastructure): """ - DATASCHEMA = { - "type": "object", - "properties": { - "root": TreeNode.DATASCHEMA, - "attributes": {"type": "object"}, - }, - "required": ["root", "attributes"], - } - @property - def __data__(self): + def __data__(self) -> dict[str, Any]: return { "attributes": self.attributes, - "root": self.root.__data__, # type: ignore + "root": None if not self.root else self.root.__data__, } @classmethod - def __from_data__(cls, data): + def __from_data__(cls, data: dict[str, Any]) -> Self: tree = cls() tree.attributes.update(data["attributes"] or {}) - root = TreeNode.__from_data__(data["root"]) - tree.add(root) + if data["root"] is not None: + root = TreeNode.__from_data__(data["root"]) + tree.add(root) return tree - def __init__(self, name=None, **kwargs): - super(Tree, self).__init__(kwargs, name=name) - self._root = None + def __init__(self, name: Optional[str] = None, **kwargs: Any) -> None: + super().__init__(kwargs, name=name) + self._root: Optional[TreeNode] = None - def __str__(self): + def __str__(self) -> str: return "\n{}".format(len(list(self.nodes)), self.get_hierarchy_string(max_depth=3)) @property - def root(self): + def root(self) -> Optional[TreeNode]: return self._root - def add(self, node, parent=None): - """ - Add a node to the tree. + def add(self, node: TreeNode, parent: Optional[TreeNode] = None) -> None: + """Add a node to the tree. Parameters ---------- - node : :class:`compas.datastructures.TreeNode` + node The node to add. - parent : :class:`compas.datastructures.TreeNode`, optional + parent The parent node of the node to add. Default is ``None``, in which case the node is added as a root node. @@ -305,8 +325,8 @@ def add(self, node, parent=None): Raises ------ TypeError - If the node is not a :class:`compas.datastructures.TreeNode` object. - If the supplied parent node is not a :class:`compas.datastructures.TreeNode` object. + If the node is not a TreeNode object. + If the supplied parent node is not a TreeNode object. ValueError If the node is already part of another tree. If the supplied parent node is not part of this tree. @@ -325,7 +345,7 @@ def add(self, node, parent=None): raise ValueError("The tree already has a root node, remove it first.") self._root = node - node._tree = self # type: ignore + node._tree = self else: # add the node as a child of the parent node @@ -338,18 +358,17 @@ def add(self, node, parent=None): parent.add(node) @property - def nodes(self): + def nodes(self) -> Iterator[TreeNode]: if self.root: for node in self.root.traverse(): yield node - def remove(self, node): - """ - Remove a node from the tree. + def remove(self, node: TreeNode) -> None: + """Remove a node from the tree. Parameters ---------- - node : :class:`compas.datastructures.TreeNode` + node The node to remove. Returns @@ -360,29 +379,32 @@ def remove(self, node): if node == self.root: self._root = None node._tree = None - else: + elif node.parent: node.parent.remove(node) @property - def leaves(self): + def leaves(self) -> Iterator[TreeNode]: for node in self.nodes: if node.is_leaf: yield node - def traverse(self, strategy="depthfirst", order="preorder"): - """ - Traverse the tree from the root node. + def traverse( + self, + strategy: TraversalStrategy = "depthfirst", + order: TraversalOrder = "preorder", + ) -> Iterator[TreeNode]: + """Traverse the tree from the root node. Parameters ---------- - strategy : {"depthfirst", "breadthfirst"}, optional + strategy The traversal strategy. - order : {"preorder", "postorder"}, optional + order The traversal order. This parameter is only used for depth-first traversal. Yields ------ - :class:`compas.datastructures.TreeNode` + TreeNode The next node in the traversal. Raises @@ -396,37 +418,35 @@ def traverse(self, strategy="depthfirst", order="preorder"): for node in self.root.traverse(strategy=strategy, order=order): yield node - def get_node_by_name(self, name): - """ - Get a node by its name. + def get_node_by_name(self, name: str) -> Optional[TreeNode]: + """Get a node by its name. Parameters ---------- - name : str + name The name of the node. Returns ------- - :class:`compas.datastructures.TreeNode` - The node. + TreeNode | None + The node, or None if no matching node exists. """ for node in self.nodes: if node.name == name: return node - def get_nodes_by_name(self, name): - """ - Get all nodes by their name. + def get_nodes_by_name(self, name: str) -> list[TreeNode]: + """Get all nodes by their name. Parameters ---------- - name : str + name The name of the node. Returns ------- - list[:class:`compas.datastructures.TreeNode`] + list[TreeNode] The nodes. """ @@ -436,13 +456,12 @@ def get_nodes_by_name(self, name): nodes.append(node) return nodes - def get_hierarchy_string(self, max_depth=None): - """ - Return string representation for the spatial hierarchy of the tree. + def get_hierarchy_string(self, max_depth: Optional[int] = None) -> str: + """Return a string representation of the tree hierarchy. Parameters ---------- - max_depth : int, optional + max_depth The maximum depth of the hierarchy to print. Default is ``None``, in which case the entire hierarchy is printed. @@ -455,7 +474,13 @@ def get_hierarchy_string(self, max_depth=None): hierarchy = [] - def traverse(node, hierarchy, prefix="", last=True, depth=0): + def traverse( + node: TreeNode, + hierarchy: list[str], + prefix: str = "", + last: bool = True, + depth: int = 0, + ) -> None: if max_depth is not None and depth > max_depth: return @@ -470,18 +495,18 @@ def traverse(node, hierarchy, prefix="", last=True, depth=0): return "\n".join(hierarchy) - def to_graph(self, key_mapper=None): + def to_graph(self, key_mapper: Optional[Callable[[TreeNode], Hashable]] = None) -> "Graph": """Convert the tree to a graph. Parameters ---------- - key_mapper : callable, optional + key_mapper A callable to map the tree node to a key in the graph. Default is ``None``, in which case the index of the node is used. Returns ------- - :class:`compas.datastructures.Graph` + Graph The graph. """ @@ -491,7 +516,8 @@ def to_graph(self, key_mapper=None): nodes = list(self.nodes) if key_mapper is None: - key_mapper = lambda node: nodes.index(node) # noqa: E731 + node_key = {node: index for index, node in enumerate(nodes)} + key_mapper = node_key.__getitem__ for node in nodes: graph.add_node(key=key_mapper(node), attr_dict=node.attributes, name=node._name) diff --git a/src/compas/datastructures/volmesh/types.py b/src/compas/datastructures/volmesh/types.py new file mode 100644 index 000000000000..66bba7e0a040 --- /dev/null +++ b/src/compas/datastructures/volmesh/types.py @@ -0,0 +1,12 @@ +from typing import Any +from typing import Sequence + +Vertex = int +Halfface = int +Face = int +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 247ea7ae8118..ebcb7e25668e 100644 --- a/src/compas/datastructures/volmesh/volmesh.py +++ b/src/compas/datastructures/volmesh/volmesh.py @@ -1,16 +1,28 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +"""Half-face data structure for volumetric meshes. -from itertools import product -from random import sample +Notes +----- +Edge and face attribute data use direction-independent serialized keys through +module-local helpers. These should be consolidated into a dedicated data-key +generation API used consistently for storage, lookup, deletion, and +serialization. -import compas +""" -if compas.PY2: - from collections import Mapping # type: ignore -else: - from collections.abc import Mapping +from collections.abc import Mapping +from itertools import product +from random import sample +from typing import Any +from typing import Callable +from typing import Iterable +from typing import Iterator +from typing import Literal +from typing import Optional +from typing import Sequence +from typing import Union +from typing import overload + +from typing_extensions import Self from compas.datastructures import Mesh from compas.datastructures.attributes import CellAttributeView @@ -18,141 +30,102 @@ from compas.datastructures.attributes import FaceAttributeView from compas.datastructures.attributes import VertexAttributeView from compas.datastructures.datastructure import Datastructure -from compas.files import OBJ +from compas.files import read_obj +from compas.files import weld_obj_data +from compas.files import write_obj from compas.geometry import Box from compas.geometry import Line from compas.geometry import Point from compas.geometry import Polygon from compas.geometry import Polyhedron +from compas.geometry import Transformation from compas.geometry import Vector -from compas.geometry import add_vectors from compas.geometry import bestfit_plane from compas.geometry import bounding_box from compas.geometry import centroid_points from compas.geometry import centroid_polygon from compas.geometry import centroid_polyhedron from compas.geometry import distance_point_point -from compas.geometry import length_vector from compas.geometry import normal_polygon -from compas.geometry import normalize_vector from compas.geometry import oriented_bounding_box from compas.geometry import project_point_plane -from compas.geometry import scale_vector -from compas.geometry import subtract_vectors from compas.geometry import transform_points from compas.itertools import linspace from compas.itertools import pairwise +from compas.linalg.vectors import add_vectors +from compas.linalg.vectors import length_vector +from compas.linalg.vectors import normalize_vector +from compas.linalg.vectors import scale_vector +from compas.linalg.vectors import subtract_vectors from compas.tolerance import TOL +from .types import AttributeDict +from .types import Cell +from .types import CellFaces +from .types import Edge +from .types import Face +from .types import FaceVertices +from .types import Halfface +from .types import PointCoordinates +from .types import Vertex -def uv_from_vertices(vertices): +_MISSING = object() + + +def uv_from_vertices(vertices: Sequence[Vertex]) -> Iterator[Edge]: for i in range(-1, len(vertices) - 1): yield vertices[i], vertices[i + 1] -def uvw_from_vertices(vertices): +def uvw_from_vertices(vertices: Sequence[Vertex]) -> Iterator[tuple[Vertex, Vertex, Vertex]]: for i in range(-2, len(vertices) - 2): yield vertices[i], vertices[i + 1], vertices[i + 2] +def edge_data_key(edge: Edge) -> str: + """Construct a direction-independent key for edge attribute storage.""" + return str(tuple(sorted(edge))) + + +def face_data_key(vertices: Iterable[Vertex]) -> str: + """Construct a direction-independent key for face attribute storage.""" + return str(tuple(sorted(vertices))) + + class VolMesh(Datastructure): """Geometric implementation of a face data structure for volumetric meshes. Parameters ---------- - default_vertex_attributes : dict, optional + default_vertex_attributes Default values for vertex attributes. - default_edge_attributes : dict, optional + default_edge_attributes Default values for edge attributes. - default_face_attributes : dict, optional + default_face_attributes Default values for face attributes. - default_cell_attributes : dict, optional + default_cell_attributes Default values for cell attributes. - name : str, optional + name The name of the volmesh. - **kwargs : dict, optional + **kwargs Additional keyword arguments, which are stored in the attributes dict. Attributes ---------- - default_vertex_attributes : dict[str, Any] + default_vertex_attributes Default attributes of the vertices. - default_edge_attributes : dict[str, Any] + default_edge_attributes Default values for edge attributes. - default_face_attributes : dict[str, Any] + default_face_attributes Default values for face attributes. - default_cell_attributes : dict[str, Any] + default_cell_attributes Default values for cell attributes. """ - DATASCHEMA = { - "type": "object", - "properties": { - "attributes": {"type": "object"}, - "default_vertex_attributes": {"type": "object"}, - "default_edge_attributes": {"type": "object"}, - "default_face_attributes": {"type": "object"}, - "default_cell_attributes": {"type": "object"}, - "vertex": { - "type": "object", - "patternProperties": {"^[0-9]+$": {"type": "object"}}, - "additionalProperties": False, - }, - "cell": { - "type": "object", - "patternProperties": { - "^[0-9]+$": { - "type": "array", - "minItems": 4, - "items": { - "type": "array", - "minItems": 3, - "items": {"type": "integer", "minimum": 0}, - }, - } - }, - "additionalProperties": False, - }, - "edge_data": { - "type": "object", - "patternProperties": {"^\\([0-9]+, [0-9]+\\)$": {"type": "object"}}, - "additionalProperties": False, - }, - "face_data": { - "type": "object", - "patternProperties": {"^\\([0-9]+(, [0-9]+){3, }\\)$": {"type": "object"}}, - "additionalProperties": False, - }, - "cell_data": { - "type": "object", - "patternProperties": {"^[0-9]+$": {"type": "object"}}, - "additionalProperties": False, - }, - "max_vertex": {"type": "number", "minimum": -1}, - "max_face": {"type": "number", "minimum": -1}, - "max_cell": {"type": "number", "minimum": -1}, - }, - "required": [ - "attributes", - "default_vertex_attributes", - "default_edge_attributes", - "default_face_attributes", - "vertex", - "cell", - "edge_data", - "face_data", - "cell_data", - "max_vertex", - "max_face", - "max_cell", - ], - } - @property - def __data__(self): - # type: () -> dict - _cell = {} + def __data__(self) -> dict[str, Any]: + _cell: dict[Cell, list[list[Vertex]]] = {} for c in self._cell: faces = [] for u in sorted(self._cell[c]): @@ -170,15 +143,14 @@ def __data__(self): "cell": {str(cell): faces for cell, faces in _cell.items()}, "edge_data": self._edge_data, "face_data": self._face_data, - "cell_data": {str(cell): attr for cell, attr in self._cell_data}, + "cell_data": {str(cell): attr for cell, attr in self._cell_data.items()}, "max_vertex": self._max_vertex, "max_face": self._max_face, "max_cell": self._max_cell, } @classmethod - def __from_data__(cls, data): - # type: (dict) -> VolMesh + def __from_data__(cls, data: dict[str, Any]) -> Self: volmesh = cls( default_vertex_attributes=data.get("default_vertex_attributes"), default_edge_attributes=data.get("default_edge_attributes"), @@ -211,19 +183,26 @@ def __from_data__(cls, data): return volmesh - def __init__(self, default_vertex_attributes=None, default_edge_attributes=None, default_face_attributes=None, default_cell_attributes=None, name=None, **kwargs): # fmt: skip - # type: (dict | None, dict | None, dict | None, dict | None, str | None, dict) -> None - super(VolMesh, self).__init__(kwargs, name=name) + def __init__( + self, + default_vertex_attributes: Optional[AttributeDict] = None, + default_edge_attributes: Optional[AttributeDict] = None, + default_face_attributes: Optional[AttributeDict] = None, + default_cell_attributes: Optional[AttributeDict] = None, + name: Optional[str] = None, + **kwargs: Any, + ) -> None: + super().__init__(kwargs, name=name) self._max_vertex = -1 self._max_face = -1 self._max_cell = -1 - self._vertex = {} - self._halfface = {} - self._cell = {} - self._plane = {} - self._edge_data = {} - self._face_data = {} - self._cell_data = {} + self._vertex: dict[Vertex, AttributeDict] = {} + self._halfface: dict[Halfface, list[Vertex]] = {} + self._cell: dict[Cell, dict[Vertex, dict[Vertex, Halfface]]] = {} + self._plane: dict[Vertex, dict[Vertex, dict[Vertex, Optional[Cell]]]] = {} + self._edge_data: dict[str, AttributeDict] = {} + self._face_data: dict[str, AttributeDict] = {} + self._cell_data: dict[Cell, AttributeDict] = {} self.default_vertex_attributes = {"x": 0.0, "y": 0.0, "z": 0.0} self.default_edge_attributes = {} self.default_face_attributes = {} @@ -237,8 +216,7 @@ def __init__(self, default_vertex_attributes=None, default_edge_attributes=None, if default_cell_attributes: self.default_cell_attributes.update(default_cell_attributes) - def __str__(self): - # type: () -> str + def __str__(self) -> str: tpl = "" return tpl.format( self.number_of_vertices(), @@ -260,42 +238,50 @@ def __str__(self): # -------------------------------------------------------------------------- @classmethod - def from_meshgrid(cls, dx=10, dy=None, dz=None, nx=10, ny=None, nz=None): - # type: (float, float | None, float | None, int, int | None, int | None) -> VolMesh + def from_meshgrid( + cls, + dx: float = 10, + dy: Optional[float] = None, + dz: Optional[float] = None, + nx: int = 10, + ny: Optional[int] = None, + nz: Optional[int] = None, + ) -> Self: """Construct a volmesh from a 3D meshgrid. Parameters ---------- - dx : float, optional + dx The size of the grid in the x direction. - dy : float, optional + dy The size of the grid in the y direction. Defaults to the value of `dx`. - dz : float, optional + dz The size of the grid in the z direction. Defaults to the value of `dx`. - nx : int, optional + nx The number of elements in the x direction. - ny : int, optional + ny The number of elements in the y direction. Defaults to the value of `nx`. - nz : int, optional + nz The number of elements in the z direction. Defaults to the value of `nx`. Returns ------- - :class:`compas.datastructures.VolMesh` + VolMesh + The constructed volumetric mesh. See Also -------- - :meth:`from_obj`, :meth:`from_vertices_and_cells` + from_obj, from_vertices_and_cells """ - dy = dy or dx - dz = dz or dx - ny = ny or nx - nz = nz or nx + dy = dx if dy is None else dy + dz = dx if dz is None else dz + ny = nx if ny is None else ny + nz = nx if nz is None else nz vertices = [ [x, y, z] @@ -326,8 +312,7 @@ def from_meshgrid(cls, dx=10, dy=None, dz=None, nx=10, ny=None, nz=None): return cls.from_vertices_and_cells(vertices, cells) @classmethod - def from_obj(cls, filepath, precision=None): - # type: (str, int | None) -> VolMesh + def from_obj(cls, filepath: Any, precision: Optional[int] = None) -> Self: """Construct a volmesh object from the data described in an OBJ file. Parameters @@ -344,16 +329,16 @@ def from_obj(cls, filepath, precision=None): See Also -------- - :meth:`to_obj` - :meth:`from_meshgrid`, :meth:`from_vertices_and_cells` - :class:`compas.files.OBJ` + to_obj + from_meshgrid, from_vertices_and_cells + read_obj """ - obj = OBJ(filepath, precision) - vertices = obj.parser.vertices or [] # type: ignore - faces = obj.parser.faces or [] # type: ignore - groups = obj.parser.groups or {} # type: ignore - objects = obj.parser.objects or {} # type: ignore + data = weld_obj_data(read_obj(filepath), precision) + vertices = data.vertices + faces = data.faces + groups = data.groups + objects = data.objects if groups: cells = [] @@ -382,8 +367,11 @@ def from_obj(cls, filepath, precision=None): return cls.from_vertices_and_cells(vertices, cell) @classmethod - def from_vertices_and_cells(cls, vertices, cells): - # type: (list[list[float]] | dict[int, list[float]], list[list[list[int]]]) -> VolMesh + def from_vertices_and_cells( + cls, + vertices: Union[Sequence[PointCoordinates], Mapping[Vertex, PointCoordinates]], + cells: Sequence[CellFaces], + ) -> Self: """Construct a volmesh object from vertices and cells. Parameters @@ -400,26 +388,25 @@ def from_vertices_and_cells(cls, vertices, cells): See Also -------- - :meth:`to_vertices_and_cells` - :meth:`from_obj`, :meth:`from_meshgrid` + to_vertices_and_cells + from_obj, from_meshgrid """ volmesh = cls() if isinstance(vertices, Mapping): - for key, xyz in vertices.items(): # type: ignore + for key, xyz in vertices.items(): volmesh.add_vertex(key=key, attr_dict=dict(zip(("x", "y", "z"), xyz))) else: - for x, y, z in iter(vertices): # type: ignore - volmesh.add_vertex(x=x, y=y, z=z) # type: ignore + for x, y, z in vertices: + volmesh.add_vertex(x=x, y=y, z=z) for cell in cells: volmesh.add_cell(cell) return volmesh @classmethod - def from_meshes(cls, meshes): - # type: (list[Mesh]) -> VolMesh + def from_meshes(cls, meshes: Iterable[Mesh]) -> Self: """Construct a volmesh from a list of faces. Parameters @@ -462,8 +449,7 @@ def from_meshes(cls, meshes): return cls.from_vertices_and_cells(vertices, cells) @classmethod - def from_polyhedrons(cls, polyhedrons): - # type: (list[Polyhedron]) -> VolMesh + def from_polyhedrons(cls, polyhedrons: Iterable[Polyhedron]) -> Self: """Construct a VolMesh from a list of polyhedrons. Parameters @@ -502,8 +488,7 @@ def from_polyhedrons(cls, polyhedrons): # Conversions # -------------------------------------------------------------------------- - def to_obj(self, filepath, precision=None, **kwargs): - # type: (str, int | None, dict) -> None + def to_obj(self, filepath: Any, precision: Optional[int] = None, **kwargs: Any) -> None: """Write the volmesh to an OBJ file. Parameters @@ -523,7 +508,7 @@ def to_obj(self, filepath, precision=None, **kwargs): See Also -------- - :meth:`from_obj` + from_obj Warnings -------- @@ -531,12 +516,10 @@ def to_obj(self, filepath, precision=None, **kwargs): the faces to the file. """ - meshes = [self.cell_to_mesh(cell) for cell in self.cells()] # type: ignore - obj = OBJ(filepath, precision=precision) - obj.write(meshes, **kwargs) # type: ignore + meshes = [self.cell_to_mesh(cell) for cell in self.cells()] + write_obj(filepath, meshes, precision=precision, **kwargs) - def to_vertices_and_cells(self): - # type: () -> tuple[list[list[float]], list[list[list[int]]]] + def to_vertices_and_cells(self) -> tuple[list[list[float]], list[list[list[Vertex]]]]: """Return the vertices and cells of a volmesh. Returns @@ -548,7 +531,7 @@ def to_vertices_and_cells(self): See Also -------- - :meth:`from_vertices_and_cells` + from_vertices_and_cells """ vertex_index = self.vertex_index() @@ -559,7 +542,7 @@ def to_vertices_and_cells(self): cells.append(faces) return vertices, cells - def to_points(self): + def to_points(self) -> list[list[float]]: """Convert the volmesh to a collection of points. Returns @@ -570,8 +553,7 @@ def to_points(self): """ return [self.vertex_coordinates(vertex) for vertex in self.vertices()] - def cell_to_mesh(self, cell): - # type: (int) -> Mesh + def cell_to_mesh(self, cell: Cell) -> Mesh: """Construct a mesh object from from a cell of a volmesh. Parameters @@ -586,14 +568,13 @@ def cell_to_mesh(self, cell): See Also -------- - :meth:`cell_to_vertices_and_faces` + cell_to_vertices_and_faces """ vertices, faces = self.cell_to_vertices_and_faces(cell) return Mesh.from_vertices_and_faces(vertices, faces) - def cell_to_vertices_and_faces(self, cell): - # type: (int) -> tuple[list[list[float]], list[list[int]]] + def cell_to_vertices_and_faces(self, cell: Cell) -> tuple[list[list[float]], list[list[Vertex]]]: """Return the vertices and faces of a cell. Parameters @@ -610,7 +591,7 @@ def cell_to_vertices_and_faces(self, cell): See Also -------- - :meth:`cell_to_mesh` + cell_to_mesh """ vertices = self.cell_vertices(cell) @@ -624,8 +605,7 @@ def cell_to_vertices_and_faces(self, cell): # Helpers # -------------------------------------------------------------------------- - def clear(self): - # type: () -> None + def clear(self) -> None: """Clear all the volmesh data. Returns @@ -651,8 +631,7 @@ def clear(self): self._max_face = -1 self._max_cell = -1 - def vertex_sample(self, size=1): - # type: (int) -> list[int] + def vertex_sample(self, size: int = 1) -> list[Vertex]: """Get the identifiers of a set of random vertices. Parameters @@ -667,13 +646,12 @@ def vertex_sample(self, size=1): See Also -------- - :meth:`edge_sample`, :meth:`face_sample`, :meth:`cell_sample` + edge_sample, face_sample, cell_sample """ - return sample(list(self.vertices()), size) # type: ignore + return sample(list(self.vertices()), size) - def edge_sample(self, size=1): - # type: (int) -> list[tuple[int, int]] + def edge_sample(self, size: int = 1) -> list[Edge]: """Get the identifiers of a set of random edges. Parameters @@ -688,13 +666,12 @@ def edge_sample(self, size=1): See Also -------- - :meth:`vertex_sample`, :meth:`face_sample`, :meth:`cell_sample` + vertex_sample, face_sample, cell_sample """ - return sample(list(self.edges()), size) # type: ignore + return sample(list(self.edges()), size) - def face_sample(self, size=1): - # type: (int) -> list[int] + def face_sample(self, size: int = 1) -> list[Face]: """Get the identifiers of a set of random faces. Parameters @@ -709,13 +686,12 @@ def face_sample(self, size=1): See Also -------- - :meth:`vertex_sample`, :meth:`edge_sample`, :meth:`cell_sample` + vertex_sample, edge_sample, cell_sample """ - return sample(list(self.faces()), size) # type: ignore + return sample(list(self.faces()), size) - def cell_sample(self, size=1): - # type: (int) -> list[int] + def cell_sample(self, size: int = 1) -> list[Cell]: """Get the identifiers of a set of random cells. Parameters @@ -730,13 +706,12 @@ def cell_sample(self, size=1): See Also -------- - :meth:`vertex_sample`, :meth:`edge_sample`, :meth:`face_sample` + vertex_sample, edge_sample, face_sample """ - return sample(list(self.cells()), size) # type: ignore + return sample(list(self.cells()), size) - def vertex_index(self): - # type: () -> dict[int, int] + def vertex_index(self) -> dict[Vertex, int]: """Returns a dictionary that maps vertex dictionary keys to the corresponding index in a vertex list or array. @@ -747,13 +722,12 @@ def vertex_index(self): See Also -------- - :meth:`index_vertex` + index_vertex """ - return {key: index for index, key in enumerate(self.vertices())} # type: ignore + return {key: index for index, key in enumerate(self.vertices())} - def index_vertex(self): - # type: () -> dict[int, int] + def index_vertex(self) -> dict[int, Vertex]: """Returns a dictionary that maps the indices of a vertex list to keys in the vertex dictionary. @@ -764,13 +738,12 @@ def index_vertex(self): See Also -------- - :meth:`vertex_index` + vertex_index """ - return dict(enumerate(self.vertices())) # type: ignore + return dict(enumerate(self.vertices())) - def vertex_gkey(self, precision=None): - # type: (int | None) -> dict[int, str] + def vertex_gkey(self, precision: Optional[int] = None) -> dict[Vertex, str]: """Returns a dictionary that maps vertex dictionary keys to the corresponding *geometric key* up to a certain precision. @@ -787,15 +760,14 @@ def vertex_gkey(self, precision=None): See Also -------- - :meth:`gkey_vertex` + gkey_vertex """ gkey = TOL.geometric_key xyz = self.vertex_coordinates - return {vertex: gkey(xyz(vertex), precision) for vertex in self.vertices()} # type: ignore + return {vertex: gkey(xyz(vertex), precision) for vertex in self.vertices()} - def gkey_vertex(self, precision=None): - # type: (int | None) -> dict[str, int] + def gkey_vertex(self, precision: Optional[int] = None) -> dict[str, Vertex]: """Returns a dictionary that maps *geometric keys* of a certain precision to the keys of the corresponding vertices. @@ -812,28 +784,32 @@ def gkey_vertex(self, precision=None): See Also -------- - :meth:`vertex_gkey` + vertex_gkey """ gkey = TOL.geometric_key xyz = self.vertex_coordinates - return {gkey(xyz(vertex), precision): vertex for vertex in self.vertices()} # type: ignore + return {gkey(xyz(vertex), precision): vertex for vertex in self.vertices()} # -------------------------------------------------------------------------- # Builders & Modifiers # -------------------------------------------------------------------------- - def add_vertex(self, key=None, attr_dict=None, **kwattr): - # type: (int | None, dict | None, dict) -> int + def add_vertex( + self, + key: Optional[Vertex] = None, + attr_dict: Optional[Mapping[str, Any]] = None, + **kwattr: Any, + ) -> Vertex: """Add a vertex to the volmesh object. Parameters ---------- - key : int, optional + key The vertex identifier. - attr_dict : dict[str, Any], optional + attr_dict dictionary of vertex attributes. - **kwattr : dict[str, Any], optional + **kwattr A dictionary of additional attributes compiled of remaining named arguments. Returns @@ -843,7 +819,7 @@ def add_vertex(self, key=None, attr_dict=None, **kwattr): See Also -------- - :meth:`add_halfface`, :meth:`add_cell` + add_halfface, add_cell Notes ----- @@ -863,25 +839,29 @@ def add_vertex(self, key=None, attr_dict=None, **kwattr): if key not in self._vertex: self._vertex[key] = {} self._plane[key] = {} - attr = attr_dict or {} + attr = dict(attr_dict or {}) attr.update(kwattr) self._vertex[key].update(attr) return key - def add_halfface(self, vertices, fkey=None, attr_dict=None, **kwattr): - # type: (list[int], int | None, dict | None, dict) -> int + def add_halfface( + self, + vertices: FaceVertices, + fkey: Optional[Halfface] = None, + attr_dict: Optional[Mapping[str, Any]] = None, + **kwattr: Any, + ) -> Halfface: """Add a face to the volmesh object. Parameters ---------- - vertices : list[int] + vertices A list of ordered vertex keys representing the face. - For every vertex that does not yet exist, a new vertex is created. - fkey : int, optional + fkey The face identifier. - attr_dict : dict[str, Any], optional + attr_dict dictionary of halfface attributes. - **kwattr : dict[str, Any], optional + **kwattr A dictionary of additional attributes compiled of remaining named arguments. Returns @@ -892,11 +872,12 @@ def add_halfface(self, vertices, fkey=None, attr_dict=None, **kwattr): Raises ------ ValueError - If the number of vertices is less than 3. + If the number of vertices is less than 3, or if a vertex is not part + of the volmesh. See Also -------- - :meth:`add_vertex`, :meth:`add_cell` + add_vertex, add_cell Notes ----- @@ -915,13 +896,17 @@ def add_halfface(self, vertices, fkey=None, attr_dict=None, **kwattr): vertices = vertices[:-1] vertices = [int(key) for key in vertices] + missing = [vertex for vertex in vertices if vertex not in self._vertex] + if missing: + raise ValueError(f"The following vertices are not part of the volmesh: {missing}") + if fkey is None: fkey = self._max_face = self._max_face + 1 fkey = int(fkey) if fkey > self._max_face: self._max_face = fkey - attr = attr_dict or {} + attr = dict(attr_dict or {}) attr.update(kwattr) self._halfface[fkey] = vertices @@ -939,18 +924,24 @@ def add_halfface(self, vertices, fkey=None, attr_dict=None, **kwattr): return fkey - def add_cell(self, faces, ckey=None, attr_dict=None, **kwattr): + def add_cell( + self, + faces: CellFaces, + ckey: Optional[Cell] = None, + attr_dict: Optional[Mapping[str, Any]] = None, + **kwattr: Any, + ) -> Cell: """Add a cell to the volmesh object. Parameters ---------- - faces : list[list[int]] + faces The faces of the cell defined as lists of vertices. - ckey : int, optional + ckey The cell identifier. - attr_dict : dict[str, Any], optional + attr_dict A dictionary of cell attributes. - **kwattr : dict[str, Any], optional + **kwattr A dictionary of additional attributes compiled of remaining named arguments. Returns @@ -983,7 +974,7 @@ def add_cell(self, faces, ckey=None, attr_dict=None, **kwattr): if ckey > self._max_cell: self._max_cell = ckey - attr = attr_dict or {} + attr = dict(attr_dict or {}) attr.update(kwattr) self._cell[ckey] = {} @@ -1002,7 +993,7 @@ def add_cell(self, faces, ckey=None, attr_dict=None, **kwattr): return ckey - def delete_vertex(self, vertex): + def delete_vertex(self, vertex: Vertex) -> None: """Delete a vertex from the volmesh and everything that is attached to it. Parameters @@ -1016,13 +1007,15 @@ def delete_vertex(self, vertex): See Also -------- - :meth:`delete_halfface`, :meth:`delete_cell` + delete_halfface, delete_cell """ for cell in self.vertex_cells(vertex): self.delete_cell(cell) + del self._vertex[vertex] + del self._plane[vertex] - def delete_cell(self, cell): + def delete_cell(self, cell: Cell) -> None: """Delete a cell from the volmesh. Parameters @@ -1041,7 +1034,7 @@ def delete_cell(self, cell): See Also -------- - :meth:`delete_vertex`, :meth:`delete_halfface` + delete_vertex, delete_halfface Notes ----- @@ -1054,17 +1047,17 @@ def delete_cell(self, cell): # remove edge data for face in cell_faces: for edge in self.halfface_halfedges(face): - # this should also use a key map - u, v = edge - if (u, v) in self._edge_data: - del self._edge_data[u, v] - if (v, u) in self._edge_data: - del self._edge_data[v, u] + if any(incident != cell for incident in self.edge_cells(edge)): + continue + key = edge_data_key(edge) + if key in self._edge_data: + del self._edge_data[key] # remove face data for face in cell_faces: - vertices = self.halfface_vertices(face) - key = "-".join(map(str, sorted(vertices))) + if self.halfface_opposite_cell(face) is not None: + continue + key = face_data_key(self.halfface_vertices(face)) if key in self._face_data: del self._face_data[key] @@ -1090,7 +1083,7 @@ def delete_cell(self, cell): # remove cell del self._cell[cell] - def remove_unused_vertices(self): + def remove_unused_vertices(self) -> None: """Remove all unused vertices from the volmesh object. Returns @@ -1110,7 +1103,7 @@ def remove_unused_vertices(self): # Volmesh Geometry # -------------------------------------------------------------------------- - def centroid(self): + def centroid(self) -> Point: """Compute the centroid of the volmesh. Returns @@ -1121,7 +1114,7 @@ def centroid(self): """ return Point(*centroid_points([self.vertex_coordinates(vertex) for vertex in self.vertices()])) - def aabb(self): + def aabb(self) -> Box: """Calculate the axis aligned bounding box of the mesh. Returns @@ -1132,7 +1125,7 @@ def aabb(self): xyz = self.vertices_attributes("xyz") return Box.from_bounding_box(bounding_box(xyz)) - def obb(self): + def obb(self) -> Box: """Calculate the oriented bounding box of the datastructure. Returns @@ -1147,7 +1140,7 @@ def obb(self): # VolMesh Topology # -------------------------------------------------------------------------- - def number_of_vertices(self): + def number_of_vertices(self) -> int: """Count the number of vertices in the volmesh. Returns @@ -1157,12 +1150,12 @@ def number_of_vertices(self): See Also -------- - :meth:`number_of_edges`, :meth:`number_of_faces`, :meth:`number_of_cells` + number_of_edges, number_of_faces, number_of_cells """ return len(list(self.vertices())) - def number_of_edges(self): + def number_of_edges(self) -> int: """Count the number of edges in the volmesh. Returns @@ -1172,12 +1165,12 @@ def number_of_edges(self): See Also -------- - :meth:`number_of_vertices`, :meth:`number_of_faces`, :meth:`number_of_cells` + number_of_vertices, number_of_faces, number_of_cells """ return len(list(self.edges())) - def number_of_faces(self): + def number_of_faces(self) -> int: """Count the number of faces in the volmesh. Returns @@ -1187,12 +1180,12 @@ def number_of_faces(self): See Also -------- - :meth:`number_of_vertices`, :meth:`number_of_edges`, :meth:`number_of_cells` + number_of_vertices, number_of_edges, number_of_cells """ return len(list(self.faces())) - def number_of_cells(self): + def number_of_cells(self) -> int: """Count the number of faces in the volmesh. Returns @@ -1202,12 +1195,12 @@ def number_of_cells(self): See Also -------- - :meth:`number_of_vertices`, :meth:`number_of_edges`, :meth:`number_of_faces` + number_of_vertices, number_of_edges, number_of_faces """ return len(list(self.cells())) - def is_valid(self): + def is_valid(self) -> bool: """Verify that the volmesh is valid. Returns @@ -1216,6 +1209,11 @@ def is_valid(self): True if the volmesh is valid. False otherwise. + Raises + ------ + NotImplementedError + This validation method is not implemented yet. + """ raise NotImplementedError @@ -1223,23 +1221,33 @@ def is_valid(self): # Vertex Accessors # -------------------------------------------------------------------------- - def vertices(self, data=False): + @overload + def vertices(self, data: Literal[False] = False) -> Iterator[Vertex]: ... + + @overload + def vertices(self, data: Literal[True]) -> Iterator[tuple[Vertex, VertexAttributeView]]: ... + + @overload + def vertices(self, data: bool) -> Iterator[Union[Vertex, tuple[Vertex, VertexAttributeView]]]: ... + + def vertices(self, data: bool = False) -> Iterator[Any]: """Iterate over the vertices of the volmesh. Parameters ---------- - data : bool, optional + data If True, yield the vertex attributes in addition to the vertex identifiers. Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next vertex identifier. - If `data` is True, the next vertex as a (vertex, attr) a tuple. + int + The vertex identifier if `data` is `False`. + tuple[int, VertexAttributeView] + The vertex identifier and its attributes if `data` is `True`. See Also -------- - :meth:`edges`, :meth:`faces`, :meth:`cells` + edges, faces, cells """ for vertex in self._vertex: @@ -1248,7 +1256,12 @@ def vertices(self, data=False): else: yield vertex, self.vertex_attributes(vertex) - def vertices_where(self, conditions=None, data=False, **kwargs): + def vertices_where( + self, + conditions: Optional[Mapping[str, Any]] = None, + data: bool = False, + **kwargs: Any, + ) -> Iterator[Any]: """Get vertices for which a certain condition or set of conditions is true. Parameters @@ -1270,11 +1283,11 @@ def vertices_where(self, conditions=None, data=False, **kwargs): See Also -------- - :meth:`vertices_where_predicate` - :meth:`edges_where`, :meth:`faces_where`, :meth:`cells_where` + vertices_where_predicate + edges_where, faces_where, cells_where """ - conditions = conditions or {} + conditions = dict(conditions or {}) conditions.update(kwargs) for key, attr in self.vertices(True): @@ -1331,7 +1344,11 @@ def vertices_where(self, conditions=None, data=False, **kwargs): else: yield key - def vertices_where_predicate(self, predicate, data=False): + def vertices_where_predicate( + self, + predicate: Callable[[Vertex, VertexAttributeView], bool], + data: bool = False, + ) -> Iterator[Any]: """Get vertices for which a certain condition or set of conditions is true using a lambda function. Parameters @@ -1350,8 +1367,8 @@ def vertices_where_predicate(self, predicate, data=False): See Also -------- - :meth:`vertices_where` - :meth:`edges_where_predicate`, :meth:`faces_where_predicate`, :meth:`cells_where_predicate` + vertices_where + edges_where_predicate, faces_where_predicate, cells_where_predicate """ for key, attr in self.vertices(True): @@ -1365,7 +1382,11 @@ def vertices_where_predicate(self, predicate, data=False): # Vertex Attributes # -------------------------------------------------------------------------- - def update_default_vertex_attributes(self, attr_dict=None, **kwattr): + def update_default_vertex_attributes( + self, + attr_dict: Optional[Mapping[str, Any]] = None, + **kwattr: Any, + ) -> None: """Update the default vertex attributes. Parameters @@ -1381,19 +1402,24 @@ def update_default_vertex_attributes(self, attr_dict=None, **kwattr): See Also -------- - :meth:`update_default_edge_attributes`, :meth:`update_default_face_attributes`, :meth:`update_default_cell_attributes` + update_default_edge_attributes, update_default_face_attributes, update_default_cell_attributes Notes ----- Named arguments overwrite correpsonding name-value pairs in the attribute dictionary. """ - if not attr_dict: - attr_dict = {} - attr_dict.update(kwattr) - self.default_vertex_attributes.update(attr_dict) + attributes = dict(attr_dict or {}) + attributes.update(kwattr) + self.default_vertex_attributes.update(attributes) + + @overload + def vertex_attribute(self, vertex: Vertex, name: str) -> Any: ... - def vertex_attribute(self, vertex, name, value=None): + @overload + def vertex_attribute(self, vertex: Vertex, name: str, value: Any) -> None: ... + + def vertex_attribute(self, vertex: Vertex, name: str, value: Any = _MISSING) -> Any: """Get or set an attribute of a vertex. Parameters @@ -1407,9 +1433,10 @@ def vertex_attribute(self, vertex, name, value=None): Returns ------- - object | None - The value of the attribute, - or None when the function is used as a "setter". + Any + The attribute value when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -1418,14 +1445,14 @@ def vertex_attribute(self, vertex, name, value=None): See Also -------- - :meth:`unset_vertex_attribute` - :meth:`vertex_attributes`, :meth:`vertices_attribute`, :meth:`vertices_attributes` - :meth:`edge_attribute`, :meth:`face_attribute`, :meth:`cell_attribute` + unset_vertex_attribute + vertex_attributes, vertices_attribute, vertices_attributes + edge_attribute, face_attribute, cell_attribute """ if vertex not in self._vertex: raise KeyError(vertex) - if value is not None: + if value is not _MISSING: self._vertex[vertex][name] = value return None if name in self._vertex[vertex]: @@ -1434,7 +1461,7 @@ def vertex_attribute(self, vertex, name, value=None): if name in self.default_vertex_attributes: return self.default_vertex_attributes[name] - def unset_vertex_attribute(self, vertex, name): + def unset_vertex_attribute(self, vertex: Vertex, name: str) -> None: """Unset the attribute of a vertex. Parameters @@ -1455,7 +1482,7 @@ def unset_vertex_attribute(self, vertex, name): See Also -------- - :meth:`vertex_attribute` + vertex_attribute Notes ----- @@ -1466,7 +1493,12 @@ def unset_vertex_attribute(self, vertex, name): if name in self._vertex[vertex]: del self._vertex[vertex][name] - def vertex_attributes(self, vertex, names=None, values=None): + def vertex_attributes( + self, + vertex: Vertex, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + ) -> Any: """Get or set multiple attributes of a vertex. Parameters @@ -1494,8 +1526,8 @@ def vertex_attributes(self, vertex, names=None, values=None): See Also -------- - :meth:`vertex_attribute`, :meth:`vertices_attribute`, :meth:`vertices_attributes` - :meth:`edge_attributes`, :meth:`face_attributes`, :meth:`cell_attributes` + vertex_attribute, vertices_attribute, vertices_attributes + edge_attributes, face_attributes, cell_attributes """ if vertex not in self._vertex: @@ -1519,7 +1551,28 @@ def vertex_attributes(self, vertex, names=None, values=None): values.append(None) return values - def vertices_attribute(self, name, value=None, keys=None): + @overload + def vertices_attribute( + self, + name: str, + *, + keys: Optional[Iterable[Vertex]] = None, + ) -> list[Any]: ... + + @overload + def vertices_attribute( + self, + name: str, + value: Any, + keys: Optional[Iterable[Vertex]] = None, + ) -> None: ... + + def vertices_attribute( + self, + name: str, + value: Any = _MISSING, + keys: Optional[Iterable[Vertex]] = None, + ) -> Optional[list[Any]]: """Get or set an attribute of multiple vertices. Parameters @@ -1528,15 +1581,15 @@ def vertices_attribute(self, name, value=None, keys=None): The name of the attribute. value : object, optional The value of the attribute. - Default is None. keys : list[int], optional A list of vertex identifiers. Returns ------- - list[Any] | None - The value of the attribute for each vertex, - or None if the function is used as a "setter". + list[Any] + The attribute values when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -1545,18 +1598,23 @@ def vertices_attribute(self, name, value=None, keys=None): See Also -------- - :meth:`vertex_attribute`, :meth:`vertex_attributes`, :meth:`vertices_attributes` - :meth:`edges_attribute`, :meth:`faces_attribute`, :meth:`cells_attribute` + vertex_attribute, vertex_attributes, vertices_attributes + edges_attribute, faces_attribute, cells_attribute """ vertices = keys or self.vertices() - if value is not None: + if value is not _MISSING: for vertex in vertices: self.vertex_attribute(vertex, name, value) return return [self.vertex_attribute(vertex, name) for vertex in vertices] - def vertices_attributes(self, names=None, values=None, keys=None): + def vertices_attributes( + self, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + keys: Optional[Iterable[Vertex]] = None, + ) -> Any: """Get or set multiple attributes of multiple vertices. Parameters @@ -1586,12 +1644,12 @@ def vertices_attributes(self, names=None, values=None, keys=None): See Also -------- - :meth:`vertex_attribute`, :meth:`vertex_attributes`, :meth:`vertices_attribute` - :meth:`edges_attributes`, :meth:`faces_attributes`, :meth:`cells_attributes` + vertex_attribute, vertex_attributes, vertices_attribute + edges_attributes, faces_attributes, cells_attributes """ vertices = keys or self.vertices() - if values: + if values is not None: for vertex in vertices: self.vertex_attributes(vertex, names, values) return @@ -1601,7 +1659,7 @@ def vertices_attributes(self, names=None, values=None, keys=None): # Vertex Topology # -------------------------------------------------------------------------- - def has_vertex(self, vertex): + def has_vertex(self, vertex: Vertex) -> bool: """Verify that a vertex is in the volmesh. Parameters @@ -1617,12 +1675,12 @@ def has_vertex(self, vertex): See Also -------- - :meth:`has_edge`, :meth:`has_face`, :meth:`has_cell` + has_edge, has_face, has_cell """ return vertex in self._vertex - def vertex_neighbors(self, vertex): + def vertex_neighbors(self, vertex: Vertex) -> list[Vertex]: """Return the vertex neighbors of a vertex. Parameters @@ -1637,14 +1695,14 @@ def vertex_neighbors(self, vertex): See Also -------- - :meth:`vertex_degree`, :meth:`vertex_min_degree`, :meth:`vertex_max_degree` - :meth:`vertex_faces`, :meth:`vertex_halffaces`, :meth:`vertex_cells` - :meth:`vertex_neighborhood` + vertex_degree, vertex_min_degree, vertex_max_degree + vertex_faces, vertex_halffaces, vertex_cells + vertex_neighborhood """ - return self._plane[vertex].keys() + return list(self._plane[vertex]) - def vertex_neighborhood(self, vertex, ring=1): + def vertex_neighborhood(self, vertex: Vertex, ring: int = 1) -> list[Vertex]: """Return the vertices in the neighborhood of a vertex. Parameters @@ -1659,15 +1717,23 @@ def vertex_neighborhood(self, vertex, ring=1): list[int] The vertices in the neighborhood. + Raises + ------ + ValueError + If the ring is smaller than 1. + See Also -------- - :meth:`vertex_neighbors` + vertex_neighbors Notes ----- The vertices in the neighborhood are unordered. """ + if ring < 1: + raise ValueError("The neighborhood ring should be greater than or equal to 1.") + nbrs = set(self.vertex_neighbors(vertex)) i = 1 while True: @@ -1680,7 +1746,7 @@ def vertex_neighborhood(self, vertex, ring=1): i += 1 return list(nbrs - set([vertex])) - def vertex_degree(self, vertex): + def vertex_degree(self, vertex: Vertex) -> int: """Count the neighbors of a vertex. Parameters @@ -1695,12 +1761,12 @@ def vertex_degree(self, vertex): See Also -------- - :meth:`vertex_neighbors`, :meth:`vertex_min_degree`, :meth:`vertex_max_degree` + vertex_neighbors, vertex_min_degree, vertex_max_degree """ return len(self.vertex_neighbors(vertex)) - def vertex_min_degree(self): + def vertex_min_degree(self) -> int: """Compute the minimum degree of all vertices. Returns @@ -1710,14 +1776,14 @@ def vertex_min_degree(self): See Also -------- - :meth:`vertex_degree`, :meth:`vertex_max_degree` + vertex_degree, vertex_max_degree """ if not self._vertex: return 0 return min(self.vertex_degree(vertex) for vertex in self.vertices()) - def vertex_max_degree(self): + def vertex_max_degree(self) -> int: """Compute the maximum degree of all vertices. Returns @@ -1727,14 +1793,14 @@ def vertex_max_degree(self): See Also -------- - :meth:`vertex_degree`, :meth:`vertex_min_degree` + vertex_degree, vertex_min_degree """ if not self._vertex: return 0 return max(self.vertex_degree(vertex) for vertex in self.vertices()) - def vertex_edges(self, vertex): + def vertex_edges(self, vertex: Vertex) -> list[Edge]: """Compute the edges connected to a given vertex. Parameters @@ -1750,7 +1816,7 @@ def vertex_edges(self, vertex): """ return [(vertex, nbr) for nbr in sorted(self.vertex_neighbors(vertex))] - def vertex_halffaces(self, vertex): + def vertex_halffaces(self, vertex: Vertex) -> list[Halfface]: """Return all halffaces connected to a vertex. Parameters @@ -1765,7 +1831,7 @@ def vertex_halffaces(self, vertex): See Also -------- - :meth:`vertex_neighbors`, :meth:`vertex_faces`, :meth:`vertex_cells` + vertex_neighbors, vertex_faces, vertex_cells """ u = vertex @@ -1778,7 +1844,7 @@ def vertex_halffaces(self, vertex): faces.append(face) return faces - def vertex_cells(self, vertex): + def vertex_cells(self, vertex: Vertex) -> list[Cell]: """Return all cells connected to a vertex. Parameters @@ -1793,7 +1859,7 @@ def vertex_cells(self, vertex): See Also -------- - :meth:`vertex_neighbors`, :meth:`vertex_faces`, :meth:`vertex_halffaces` + vertex_neighbors, vertex_faces, vertex_halffaces """ u = vertex @@ -1806,7 +1872,7 @@ def vertex_cells(self, vertex): cells.append(cell) return cells - def is_vertex_on_boundary(self, vertex): + def is_vertex_on_boundary(self, vertex: Vertex) -> bool: """Verify that a vertex is on a boundary. Parameters @@ -1822,7 +1888,7 @@ def is_vertex_on_boundary(self, vertex): See Also -------- - :meth:`is_edge_on_boundary`, :meth:`is_face_on_boundary`, :meth:`is_cell_on_boundary` + is_edge_on_boundary, is_face_on_boundary, is_cell_on_boundary """ halffaces = self.vertex_halffaces(vertex) @@ -1835,7 +1901,7 @@ def is_vertex_on_boundary(self, vertex): # Vertex Geometry # -------------------------------------------------------------------------- - def vertex_coordinates(self, vertex, axes="xyz"): + def vertex_coordinates(self, vertex: Vertex, axes: str = "xyz") -> list[float]: """Return the coordinates of a vertex. Parameters @@ -1853,12 +1919,12 @@ def vertex_coordinates(self, vertex, axes="xyz"): See Also -------- - :meth:`vertex_point`, :meth:`vertex_laplacian`, :meth:`vertex_neighborhood_centroid` + vertex_point, vertex_laplacian, vertex_neighborhood_centroid """ return [self._vertex[vertex][axis] for axis in axes] - def vertex_point(self, vertex): + def vertex_point(self, vertex: Vertex) -> Point: """Return the point representation of a vertex. Parameters @@ -1873,12 +1939,12 @@ def vertex_point(self, vertex): See Also -------- - :meth:`vertex_laplacian`, :meth:`vertex_neighborhood_centroid` + vertex_laplacian, vertex_neighborhood_centroid """ return Point(*self.vertex_coordinates(vertex)) - def vertex_laplacian(self, vertex): + def vertex_laplacian(self, vertex: Vertex) -> Vector: """Compute the vector from a vertex to the centroid of its neighbors. Parameters @@ -1893,14 +1959,14 @@ def vertex_laplacian(self, vertex): See Also -------- - :meth:`vertex_point`, :meth:`vertex_neighborhood_centroid` + vertex_point, vertex_neighborhood_centroid """ c = self.vertex_neighborhood_centroid(vertex) p = self.vertex_coordinates(vertex) return Vector(*subtract_vectors(c, p)) - def vertex_neighborhood_centroid(self, vertex): + def vertex_neighborhood_centroid(self, vertex: Vertex) -> Point: """Compute the point at the centroid of the neighbors of a vertex. Parameters @@ -1915,7 +1981,7 @@ def vertex_neighborhood_centroid(self, vertex): See Also -------- - :meth:`vertex_point`, :meth:`vertex_laplacian` + vertex_point, vertex_laplacian """ return Point(*centroid_points([self.vertex_coordinates(nbr) for nbr in self.vertex_neighbors(vertex)])) @@ -1924,7 +1990,16 @@ def vertex_neighborhood_centroid(self, vertex): # Edge Accessors # -------------------------------------------------------------------------- - def edges(self, data=False): + @overload + def edges(self, data: Literal[False] = False) -> Iterator[Edge]: ... + + @overload + def edges(self, data: Literal[True]) -> Iterator[tuple[Edge, EdgeAttributeView]]: ... + + @overload + def edges(self, data: bool) -> Iterator[Union[Edge, tuple[Edge, EdgeAttributeView]]]: ... + + def edges(self, data: bool = False) -> Iterator[Any]: """Iterate over the edges of the volmesh. Parameters @@ -1934,13 +2009,14 @@ def edges(self, data=False): Yields ------ - tuple[int, int] | tuple[tuple[int, int], dict[str, Any]] - If `data` is False, the next edge as a (u, v) tuple. - If `data` is True, the next edge as a ((u, v), attr) tuple. + tuple[int, int] + The edge identifier if `data` is `False`. + tuple[tuple[int, int], EdgeAttributeView] + The edge identifier and its attributes if `data` is `True`. See Also -------- - :meth:`vertices`, :meth:`faces`, :meth:`cells` + vertices, faces, cells """ seen = set() @@ -1955,7 +2031,12 @@ def edges(self, data=False): else: yield (vertex, nbr), self.edge_attributes((vertex, nbr)) - def edges_where(self, conditions=None, data=False, **kwargs): + def edges_where( + self, + conditions: Optional[Mapping[str, Any]] = None, + data: bool = False, + **kwargs: Any, + ) -> Iterator[Any]: """Get edges for which a certain condition or set of conditions is true. Parameters @@ -1977,11 +2058,11 @@ def edges_where(self, conditions=None, data=False, **kwargs): See Also -------- - :meth:`edges_where_predicate` - :meth:`vertices_where`, :meth:`faces_where`, :meth:`cells_where` + edges_where_predicate + vertices_where, faces_where, cells_where """ - conditions = conditions or {} + conditions = dict(conditions or {}) conditions.update(kwargs) for key in self.edges(): @@ -2020,7 +2101,11 @@ def edges_where(self, conditions=None, data=False, **kwargs): else: yield key - def edges_where_predicate(self, predicate, data=False): + def edges_where_predicate( + self, + predicate: Callable[[Edge, EdgeAttributeView], bool], + data: bool = False, + ) -> Iterator[Any]: """Get edges for which a certain condition or set of conditions is true using a lambda function. Parameters @@ -2039,8 +2124,8 @@ def edges_where_predicate(self, predicate, data=False): See Also -------- - :meth:`edges_where` - :meth:`vertices_where_predicate`, :meth:`faces_where_predicate`, :meth:`cells_where_predicate` + edges_where + vertices_where_predicate, faces_where_predicate, cells_where_predicate """ for key, attr in self.edges(True): @@ -2054,7 +2139,11 @@ def edges_where_predicate(self, predicate, data=False): # Edge Attributes # -------------------------------------------------------------------------- - def update_default_edge_attributes(self, attr_dict=None, **kwattr): + def update_default_edge_attributes( + self, + attr_dict: Optional[Mapping[str, Any]] = None, + **kwattr: Any, + ) -> None: """Update the default edge attributes. Parameters @@ -2070,19 +2159,24 @@ def update_default_edge_attributes(self, attr_dict=None, **kwattr): See Also -------- - :meth:`update_default_vertex_attributes`, :meth:`update_default_face_attributes`, :meth:`update_default_cell_attributes` + update_default_vertex_attributes, update_default_face_attributes, update_default_cell_attributes Notes ----- Named arguments overwrite correpsonding key-value pairs in the attribute dictionary. """ - if not attr_dict: - attr_dict = {} - attr_dict.update(kwattr) - self.default_edge_attributes.update(attr_dict) + attributes = dict(attr_dict or {}) + attributes.update(kwattr) + self.default_edge_attributes.update(attributes) - def edge_attribute(self, edge, name, value=None): + @overload + def edge_attribute(self, edge: Edge, name: str) -> Any: ... + + @overload + def edge_attribute(self, edge: Edge, name: str, value: Any) -> None: ... + + def edge_attribute(self, edge: Edge, name: str, value: Any = _MISSING) -> Any: """Get or set an attribute of an edge. Parameters @@ -2096,8 +2190,10 @@ def edge_attribute(self, edge, name, value=None): Returns ------- - object | None - The value of the attribute, or None when the function is used as a "setter". + Any + The attribute value when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -2106,16 +2202,16 @@ def edge_attribute(self, edge, name, value=None): See Also -------- - :meth:`unset_edge_attribute` - :meth:`edge_attributes`, :meth:`edges_attribute`, :meth:`edges_attributes` - :meth:`vertex_attribute`, :meth:`face_attribute`, :meth:`cell_attribute` + unset_edge_attribute + edge_attributes, edges_attribute, edges_attributes + vertex_attribute, face_attribute, cell_attribute """ u, v = edge if u not in self._plane or v not in self._plane[u]: raise KeyError(edge) - key = str(tuple(sorted(edge))) - if value is not None: + key = edge_data_key(edge) + if value is not _MISSING: if key not in self._edge_data: self._edge_data[key] = {} self._edge_data[key][name] = value @@ -2125,7 +2221,7 @@ def edge_attribute(self, edge, name, value=None): if name in self.default_edge_attributes: return self.default_edge_attributes[name] - def unset_edge_attribute(self, edge, name): + def unset_edge_attribute(self, edge: Edge, name: str) -> None: """Unset the attribute of an edge. Parameters @@ -2146,7 +2242,7 @@ def unset_edge_attribute(self, edge, name): See Also -------- - :meth:`edge_attribute` + edge_attribute Notes ----- @@ -2157,11 +2253,16 @@ def unset_edge_attribute(self, edge, name): u, v = edge if u not in self._plane or v not in self._plane[u]: raise KeyError(edge) - key = str(tuple(sorted(edge))) + key = edge_data_key(edge) if key in self._edge_data and name in self._edge_data[key]: del self._edge_data[key][name] - def edge_attributes(self, edge, names=None, values=None): + def edge_attributes( + self, + edge: Edge, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + ) -> Any: """Get or set multiple attributes of an edge. Parameters @@ -2187,22 +2288,21 @@ def edge_attributes(self, edge, names=None, values=None): See Also -------- - :meth:`edge_attribute`, :meth:`edges_attribute`, :meth:`edges_attributes` - :meth:`vertex_attributes`, :meth:`face_attributes`, :meth:`cell_attributes` + edge_attribute, edges_attribute, edges_attributes + vertex_attributes, face_attributes, cell_attributes """ u, v = edge if u not in self._plane or v not in self._plane[u]: raise KeyError(edge) - key = str(tuple(sorted(edge))) - if names and values: + key = edge_data_key(edge) + if names and values is not None: for name, value in zip(names, values): if key not in self._edge_data: self._edge_data[key] = {} self._edge_data[key][name] = value return if not names: - key = str(tuple(sorted(edge))) return EdgeAttributeView(self.default_edge_attributes, self._edge_data.setdefault(key, {})) values = [] for name in names: @@ -2210,7 +2310,28 @@ def edge_attributes(self, edge, names=None, values=None): values.append(value) return values - def edges_attribute(self, name, value=None, edges=None): + @overload + def edges_attribute( + self, + name: str, + *, + edges: Optional[Iterable[Edge]] = None, + ) -> list[Any]: ... + + @overload + def edges_attribute( + self, + name: str, + value: Any, + edges: Optional[Iterable[Edge]] = None, + ) -> None: ... + + def edges_attribute( + self, + name: str, + value: Any = _MISSING, + edges: Optional[Iterable[Edge]] = None, + ) -> Optional[list[Any]]: """Get or set an attribute of multiple edges. Parameters @@ -2219,15 +2340,15 @@ def edges_attribute(self, name, value=None, edges=None): The name of the attribute. value : object, optional The value of the attribute. - Default is None. edges : list[tuple[int, int]], optional A list of edge identifiers. Returns ------- - list[Any] | None - A list containing the value per edge of the requested attribute, - or None if the function is used as a "setter". + list[Any] + The attribute values when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -2236,18 +2357,23 @@ def edges_attribute(self, name, value=None, edges=None): See Also -------- - :meth:`edge_attribute`, :meth:`edge_attributes`, :meth:`edges_attributes` - :meth:`vertex_attribute`, :meth:`face_attribute`, :meth:`cell_attribute` + edge_attribute, edge_attributes, edges_attributes + vertex_attribute, face_attribute, cell_attribute """ edges = edges or self.edges() - if value is not None: + if value is not _MISSING: for edge in edges: self.edge_attribute(edge, name, value) return return [self.edge_attribute(edge, name) for edge in edges] - def edges_attributes(self, names=None, values=None, edges=None): + def edges_attributes( + self, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + edges: Optional[Iterable[Edge]] = None, + ) -> Any: """Get or set multiple attributes of multiple edges. Parameters @@ -2275,12 +2401,12 @@ def edges_attributes(self, names=None, values=None, edges=None): See Also -------- - :meth:`edge_attribute`, :meth:`edge_attributes`, :meth:`edges_attribute` - :meth:`vertex_attributes`, :meth:`face_attributes`, :meth:`cell_attributes` + edge_attribute, edge_attributes, edges_attribute + vertex_attributes, face_attributes, cell_attributes """ edges = edges or self.edges() - if values: + if values is not None: for edge in edges: self.edge_attributes(edge, names, values) return @@ -2290,7 +2416,7 @@ def edges_attributes(self, names=None, values=None, edges=None): # Edge Topology # -------------------------------------------------------------------------- - def has_edge(self, edge): + def has_edge(self, edge: Edge) -> bool: """Verify that the volmesh contains a directed edge (u, v). Parameters @@ -2306,12 +2432,13 @@ def has_edge(self, edge): See Also -------- - :meth:`has_vertex`, :meth:`has_face`, :meth:`has_cell` + has_vertex, has_face, has_cell """ - return edge in set(self.edges()) + u, v = edge + return u in self._plane and v in self._plane[u] - def edge_halffaces(self, edge): + def edge_halffaces(self, edge: Edge) -> list[Halfface]: """Ordered halffaces around edge (u, v). Parameters @@ -2326,7 +2453,7 @@ def edge_halffaces(self, edge): See Also -------- - :meth:`edge_cells` + edge_cells """ u, v = edge @@ -2338,7 +2465,7 @@ def edge_halffaces(self, edge): halffaces.append(face) return halffaces - def edge_cells(self, edge): + def edge_cells(self, edge: Edge) -> list[Cell]: """Ordered cells around edge (u, v). Parameters @@ -2351,15 +2478,25 @@ def edge_cells(self, edge): list[int] Ordered list of keys identifying the ordered cells. + Raises + ------ + RuntimeError + If an edge halfface is not assigned to a cell. + See Also -------- - :meth:`edge_halffaces` + edge_halffaces """ - halffaces = self.edge_halffaces(edge) - return [self.halfface_cell(halfface) for halfface in halffaces] + cells = [] + for halfface in self.edge_halffaces(edge): + cell = self.halfface_cell(halfface) + if cell is None: + raise RuntimeError("An edge halfface is not assigned to a cell.") + cells.append(cell) + return cells - def is_edge_on_boundary(self, edge): + def is_edge_on_boundary(self, edge: Edge) -> bool: """Verify that an edge is on the boundary. Parameters @@ -2375,7 +2512,7 @@ def is_edge_on_boundary(self, edge): See Also -------- - :meth:`is_vertex_on_boundary`, :meth:`is_face_on_boundary`, :meth:`is_cell_on_boundary` + is_vertex_on_boundary, is_face_on_boundary, is_cell_on_boundary Notes ----- @@ -2390,7 +2527,7 @@ def is_edge_on_boundary(self, edge): # Edge Geometry # -------------------------------------------------------------------------- - def edge_coordinates(self, edge, axes="xyz"): + def edge_coordinates(self, edge: Edge, axes: str = "xyz") -> tuple[list[float], list[float]]: """Return the coordinates of the start and end point of an edge. Parameters @@ -2408,15 +2545,15 @@ def edge_coordinates(self, edge, axes="xyz"): See Also -------- - :meth:`edge_start`, :meth:`edge_end`, :meth:`edge_midpoint`, :meth:`edge_point` - :meth:`edge_vector`, :meth:`edge_direction`, :meth:`edge_line` - :meth:`edge_length` + edge_start, edge_end, edge_midpoint, edge_point + edge_vector, edge_direction, edge_line + edge_length """ u, v = edge return self.vertex_coordinates(u, axes=axes), self.vertex_coordinates(v, axes=axes) - def edge_start(self, edge): + def edge_start(self, edge: Edge) -> Point: """Return the start point of an edge. Parameters @@ -2431,12 +2568,12 @@ def edge_start(self, edge): See Also -------- - :meth:`edge_end`, :meth:`edge_midpoint`, :meth:`edge_point` + edge_end, edge_midpoint, edge_point """ return self.vertex_point(edge[0]) - def edge_end(self, edge): + def edge_end(self, edge: Edge) -> Point: """Return the end point of an edge. Parameters @@ -2451,12 +2588,12 @@ def edge_end(self, edge): See Also -------- - :meth:`edge_start`, :meth:`edge_midpoint`, :meth:`edge_point` + edge_start, edge_midpoint, edge_point """ return self.vertex_point(edge[1]) - def edge_midpoint(self, edge): + def edge_midpoint(self, edge: Edge) -> Point: """Return the midpoint of an edge. Parameters @@ -2471,13 +2608,13 @@ def edge_midpoint(self, edge): See Also -------- - :meth:`edge_start`, :meth:`edge_end`, :meth:`edge_point` + edge_start, edge_end, edge_point """ a, b = self.edge_coordinates(edge) return Point(0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1]), 0.5 * (a[2] + b[2])) - def edge_point(self, edge, t=0.5): + def edge_point(self, edge: Edge, t: float = 0.5) -> Point: """Return the point at a parametric location along an edge. Parameters @@ -2496,7 +2633,7 @@ def edge_point(self, edge, t=0.5): See Also -------- - :meth:`edge_start`, :meth:`edge_end`, :meth:`edge_midpoint` + edge_start, edge_end, edge_midpoint """ if t == 0: @@ -2510,7 +2647,7 @@ def edge_point(self, edge, t=0.5): ab = subtract_vectors(b, a) return Point(*add_vectors(a, scale_vector(ab, t))) - def edge_vector(self, edge): + def edge_vector(self, edge: Edge) -> Vector: """Return the vector of an edge. Parameters @@ -2525,13 +2662,13 @@ def edge_vector(self, edge): See Also -------- - :meth:`edge_direction`, :meth:`edge_line` + edge_direction, edge_line """ a, b = self.edge_coordinates(edge) return Vector.from_start_end(a, b) - def edge_direction(self, edge): + def edge_direction(self, edge: Edge) -> Vector: """Return the direction vector of an edge. Parameters @@ -2546,12 +2683,12 @@ def edge_direction(self, edge): See Also -------- - :meth:`edge_vector`, :meth:`edge_line` + edge_vector, edge_line """ return Vector(*normalize_vector(self.edge_vector(edge))) - def edge_line(self, edge): + def edge_line(self, edge: Edge) -> Line: """Return the line representation of an edge. Parameters @@ -2566,12 +2703,12 @@ def edge_line(self, edge): See Also -------- - :meth:`edge_vector`, :meth:`edge_direction` + edge_vector, edge_direction """ return Line(self.edge_start(edge), self.edge_end(edge)) - def edge_length(self, edge): + def edge_length(self, edge: Edge) -> float: """Return the length of an edge. Parameters @@ -2592,7 +2729,16 @@ def edge_length(self, edge): # (Half)Face Accessors # -------------------------------------------------------------------------- - def halffaces(self, data=False): + @overload + def halffaces(self, data: Literal[False] = False) -> Iterator[Halfface]: ... + + @overload + def halffaces(self, data: Literal[True]) -> Iterator[tuple[Halfface, FaceAttributeView]]: ... + + @overload + def halffaces(self, data: bool) -> Iterator[Union[Halfface, tuple[Halfface, FaceAttributeView]]]: ... + + def halffaces(self, data: bool = False) -> Iterator[Any]: """Iterate over the halffaces of the volmesh. Parameters @@ -2602,13 +2748,14 @@ def halffaces(self, data=False): Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next halfface identifier. - If `data` is True, the next halfface as a (halfface, attr) tuple. + int + The halfface identifier if `data` is `False`. + tuple[int, FaceAttributeView] + The halfface identifier and its attributes if `data` is `True`. See Also -------- - :meth:`vertices`, :meth:`edges`, :meth:`cells` + vertices, edges, cells """ for hface in self._halfface: @@ -2617,7 +2764,16 @@ def halffaces(self, data=False): else: yield hface, self.face_attributes(hface) - def faces(self, data=False): + @overload + def faces(self, data: Literal[False] = False) -> Iterator[Face]: ... + + @overload + def faces(self, data: Literal[True]) -> Iterator[tuple[Face, FaceAttributeView]]: ... + + @overload + def faces(self, data: bool) -> Iterator[Union[Face, tuple[Face, FaceAttributeView]]]: ... + + def faces(self, data: bool = False) -> Iterator[Any]: """ "Iterate over the halffaces of the volmesh and yield faces. Parameters @@ -2627,13 +2783,14 @@ def faces(self, data=False): Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next face identifier. - If `data` is True, the next face as a (face, attr) tuple. + int + The face identifier if `data` is `False`. + tuple[int, FaceAttributeView] + The face identifier and its attributes if `data` is `True`. See Also -------- - :meth:`vertices`, :meth:`edges`, :meth:`cells` + vertices, edges, cells Notes ----- @@ -2647,7 +2804,7 @@ def faces(self, data=False): seen = set() faces = [] for face in self._halfface: - key = "-".join(map(str, sorted(self.halfface_vertices(face)))) + key = face_data_key(self.halfface_vertices(face)) if key in seen: continue seen.add(key) @@ -2658,7 +2815,12 @@ def faces(self, data=False): else: yield face, self.face_attributes(face) - def faces_where(self, conditions=None, data=False, **kwargs): + def faces_where( + self, + conditions: Optional[Mapping[str, Any]] = None, + data: bool = False, + **kwargs: Any, + ) -> Iterator[Any]: """Get faces for which a certain condition or set of conditions is true. Parameters @@ -2680,11 +2842,11 @@ def faces_where(self, conditions=None, data=False, **kwargs): See Also -------- - :meth:`faces_where_predicate` - :meth:`vertices_where`, :meth:`edges_where`, :meth:`cells_where` + faces_where_predicate + vertices_where, edges_where, cells_where """ - conditions = conditions or {} + conditions = dict(conditions or {}) conditions.update(kwargs) for fkey in self.faces(): @@ -2723,7 +2885,11 @@ def faces_where(self, conditions=None, data=False, **kwargs): else: yield fkey - def faces_where_predicate(self, predicate, data=False): + def faces_where_predicate( + self, + predicate: Callable[[Face, FaceAttributeView], bool], + data: bool = False, + ) -> Iterator[Any]: """Get faces for which a certain condition or set of conditions is true using a lambda function. Parameters @@ -2742,8 +2908,8 @@ def faces_where_predicate(self, predicate, data=False): See Also -------- - :meth:`faces_where` - :meth:`vertices_where_predicate`, :meth:`edges_where_predicate`, :meth:`cells_where_predicate` + faces_where + vertices_where_predicate, edges_where_predicate, cells_where_predicate """ for fkey, attr in self.faces(True): @@ -2757,7 +2923,11 @@ def faces_where_predicate(self, predicate, data=False): # Face Attributes # -------------------------------------------------------------------------- - def update_default_face_attributes(self, attr_dict=None, **kwattr): + def update_default_face_attributes( + self, + attr_dict: Optional[Mapping[str, Any]] = None, + **kwattr: Any, + ) -> None: """Update the default face attributes. Parameters @@ -2773,19 +2943,24 @@ def update_default_face_attributes(self, attr_dict=None, **kwattr): See Also -------- - :meth:`update_default_vertex_attributes`, :meth:`update_default_edge_attributes`, :meth:`update_default_cell_attributes` + update_default_vertex_attributes, update_default_edge_attributes, update_default_cell_attributes Notes ----- Named arguments overwrite correpsonding key-value pairs in the attribute dictionary. """ - if not attr_dict: - attr_dict = {} - attr_dict.update(kwattr) - self.default_face_attributes.update(attr_dict) + attributes = dict(attr_dict or {}) + attributes.update(kwattr) + self.default_face_attributes.update(attributes) + + @overload + def face_attribute(self, face: Face, name: str) -> Any: ... + + @overload + def face_attribute(self, face: Face, name: str, value: Any) -> None: ... - def face_attribute(self, face, name, value=None): + def face_attribute(self, face: Face, name: str, value: Any = _MISSING) -> Any: """Get or set an attribute of a face. Parameters @@ -2799,8 +2974,10 @@ def face_attribute(self, face, name, value=None): Returns ------- - object | None - The value of the attribute, or None when the function is used as a "setter". + Any + The attribute value when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -2809,15 +2986,15 @@ def face_attribute(self, face, name, value=None): See Also -------- - :meth:`unset_face_attribute` - :meth:`face_attributes`, :meth:`faces_attribute`, :meth:`faces_attributes` - :meth:`vertex_attribute`, :meth:`edge_attribute`, :meth:`cell_attribute` + unset_face_attribute + face_attributes, faces_attribute, faces_attributes + vertex_attribute, edge_attribute, cell_attribute """ if face not in self._halfface: raise KeyError(face) - key = str(tuple(sorted(self.halfface_vertices(face)))) - if value is not None: + key = face_data_key(self.halfface_vertices(face)) + if value is not _MISSING: if key not in self._face_data: self._face_data[key] = {} self._face_data[key][name] = value @@ -2827,7 +3004,7 @@ def face_attribute(self, face, name, value=None): if name in self.default_face_attributes: return self.default_face_attributes[name] - def unset_face_attribute(self, face, name): + def unset_face_attribute(self, face: Face, name: str) -> None: """Unset the attribute of a face. Parameters @@ -2848,7 +3025,7 @@ def unset_face_attribute(self, face, name): See Also -------- - :meth:`face_attribute` + face_attribute Notes ----- @@ -2858,11 +3035,16 @@ def unset_face_attribute(self, face, name): """ if face not in self._halfface: raise KeyError(face) - key = str(tuple(sorted(self.halfface_vertices(face)))) + key = face_data_key(self.halfface_vertices(face)) if key in self._face_data and name in self._face_data[key]: del self._face_data[key][name] - def face_attributes(self, face, names=None, values=None): + def face_attributes( + self, + face: Face, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + ) -> Any: """Get or set multiple attributes of a face. Parameters @@ -2888,14 +3070,14 @@ def face_attributes(self, face, names=None, values=None): See Also -------- - :meth:`face_attribute`, :meth:`faces_attribute`, :meth:`faces_attributes` - :meth:`vertex_attributes`, :meth:`edge_attributes`, :meth:`cell_attributes` + face_attribute, faces_attribute, faces_attributes + vertex_attributes, edge_attributes, cell_attributes """ if face not in self._halfface: raise KeyError(face) - key = str(tuple(sorted(self.halfface_vertices(face)))) - if names and values: + key = face_data_key(self.halfface_vertices(face)) + if names and values is not None: for name, value in zip(names, values): if key not in self._face_data: self._face_data[key] = {} @@ -2909,7 +3091,28 @@ def face_attributes(self, face, names=None, values=None): values.append(value) return values - def faces_attribute(self, name, value=None, faces=None): + @overload + def faces_attribute( + self, + name: str, + *, + faces: Optional[Iterable[Face]] = None, + ) -> list[Any]: ... + + @overload + def faces_attribute( + self, + name: str, + value: Any, + faces: Optional[Iterable[Face]] = None, + ) -> None: ... + + def faces_attribute( + self, + name: str, + value: Any = _MISSING, + faces: Optional[Iterable[Face]] = None, + ) -> Optional[list[Any]]: """Get or set an attribute of multiple faces. Parameters @@ -2918,15 +3121,15 @@ def faces_attribute(self, name, value=None, faces=None): The name of the attribute. value : object, optional The value of the attribute. - Default is None. faces : list[int], optional A list of face identifiers. Returns ------- - list[Any] | None - A list containing the value per face of the requested attribute, - or None if the function is used as a "setter". + list[Any] + The attribute values when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -2935,18 +3138,23 @@ def faces_attribute(self, name, value=None, faces=None): See Also -------- - :meth:`face_attribute`, :meth:`face_attributes`, :meth:`faces_attributes` - :meth:`vertex_attribute`, :meth:`edge_attribute`, :meth:`cell_attribute` + face_attribute, face_attributes, faces_attributes + vertex_attribute, edge_attribute, cell_attribute """ faces = faces or self.faces() - if value is not None: + if value is not _MISSING: for face in faces: self.face_attribute(face, name, value) return return [self.face_attribute(face, name) for face in faces] - def faces_attributes(self, names=None, values=None, faces=None): + def faces_attributes( + self, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + faces: Optional[Iterable[Face]] = None, + ) -> Any: """Get or set multiple attributes of multiple faces. Parameters @@ -2976,12 +3184,12 @@ def faces_attributes(self, names=None, values=None, faces=None): See Also -------- - :meth:`face_attribute`, :meth:`face_attributes`, :meth:`faces_attribute` - :meth:`vertex_attributes`, :meth:`edge_attributes`, :meth:`cell_attributes` + face_attribute, face_attributes, faces_attribute + vertex_attributes, edge_attributes, cell_attributes """ faces = faces or self.faces() - if values: + if values is not None: for face in faces: self.face_attributes(face, names, values) return @@ -2991,7 +3199,7 @@ def faces_attributes(self, names=None, values=None, faces=None): # Face Topology # -------------------------------------------------------------------------- - def has_halfface(self, halfface): + def has_halfface(self, halfface: Halfface) -> bool: """Verify that a face is part of the volmesh. Parameters @@ -3007,12 +3215,12 @@ def has_halfface(self, halfface): See Also -------- - :meth:`has_vertex`, :meth:`has_edge`, :meth:`has_cell` + has_vertex, has_edge, has_cell """ return halfface in self._halfface - def halfface_vertices(self, halfface): + def halfface_vertices(self, halfface: Halfface) -> list[Vertex]: """The vertices of a halfface. Parameters @@ -3027,12 +3235,12 @@ def halfface_vertices(self, halfface): See Also -------- - :meth:`halfface_edges`, :meth:`halfface_halfedges` + halfface_edges, halfface_halfedges """ return self._halfface[halfface] - def halfface_halfedges(self, halfface): + def halfface_halfedges(self, halfface: Halfface) -> list[Edge]: """The halfedges of a halfface. Parameters @@ -3047,13 +3255,13 @@ def halfface_halfedges(self, halfface): See Also -------- - :meth:`halfface_edges`, :meth:`halfface_vertices` + halfface_edges, halfface_vertices """ vertices = self.halfface_vertices(halfface) return list(pairwise(vertices + vertices[0:1])) - def halfface_cell(self, halfface): + def halfface_cell(self, halfface: Halfface) -> Optional[Cell]: """The cell to which the halfface belongs to. Parameters @@ -3063,18 +3271,18 @@ def halfface_cell(self, halfface): Returns ------- - int - Identifier of the cell. + int | None + Identifier of the cell, if the halfface belongs to a cell. See Also -------- - :meth:`halfface_opposite_cell` + halfface_opposite_cell """ u, v, w = self._halfface[halfface][:3] return self._plane[u][v][w] - def halfface_opposite_cell(self, halfface): + def halfface_opposite_cell(self, halfface: Halfface) -> Optional[Cell]: """The cell to which the opposite halfface belongs to. Parameters @@ -3084,18 +3292,18 @@ def halfface_opposite_cell(self, halfface): Returns ------- - int - Identifier of the cell. + int | None + Identifier of the opposite cell, if it exists. See Also -------- - :meth:`halfface_cell` + halfface_cell """ u, v, w = self._halfface[halfface][:3] return self._plane[w][v][u] - def halfface_opposite_halfface(self, halfface): + def halfface_opposite_halfface(self, halfface: Halfface) -> Optional[Halfface]: """The opposite face of a face. Parameters @@ -3110,7 +3318,7 @@ def halfface_opposite_halfface(self, halfface): See Also -------- - :meth:`halfface_adjacent_halfface` + halfface_adjacent_halfface Notes ----- @@ -3122,7 +3330,7 @@ def halfface_opposite_halfface(self, halfface): nbr = self._plane[w][v][u] return None if nbr is None else self._cell[nbr][w][v] - def halfface_vertex_ancestor(self, halfface, vertex): + def halfface_vertex_ancestor(self, halfface: Halfface, vertex: Vertex) -> Vertex: """Return the vertex before the specified vertex in a specific face. Parameters @@ -3144,13 +3352,13 @@ def halfface_vertex_ancestor(self, halfface, vertex): See Also -------- - :meth:`halfface_vertex_descendent` + halfface_vertex_descendent """ i = self._halfface[halfface].index(vertex) return self._halfface[halfface][i - 1] - def halfface_vertex_descendent(self, halfface, vertex): + def halfface_vertex_descendent(self, halfface: Halfface, vertex: Vertex) -> Vertex: """Return the vertex after the specified vertex in a specific face. Parameters @@ -3172,7 +3380,7 @@ def halfface_vertex_descendent(self, halfface, vertex): See Also -------- - :meth:`halfface_vertex_ancestor` + halfface_vertex_ancestor """ if self._halfface[halfface][-1] == vertex: @@ -3180,7 +3388,7 @@ def halfface_vertex_descendent(self, halfface, vertex): i = self._halfface[halfface].index(vertex) return self._halfface[halfface][i + 1] - def halfface_manifold_neighbors(self, halfface): + def halfface_manifold_neighbors(self, halfface: Halfface) -> list[Halfface]: """Return the halfface neighbors of a halfface that are on the same manifold. Parameters @@ -3195,7 +3403,7 @@ def halfface_manifold_neighbors(self, halfface): See Also -------- - :meth:`halfface_manifold_neighborhood` + halfface_manifold_neighborhood Notes ----- @@ -3204,15 +3412,17 @@ def halfface_manifold_neighbors(self, halfface): """ nbrs = [] cell = self.halfface_cell(halfface) + if cell is None: + return nbrs for u, v in self.halfface_halfedges(halfface): nbr_halfface = self._cell[cell][v][u] - nbr_cell = self._plane[u][v][nbr_halfface] + nbr_cell = self.halfface_opposite_cell(nbr_halfface) if nbr_cell is not None: nbr = self._cell[nbr_cell][v][u] nbrs.append(nbr) return nbrs - def halfface_manifold_neighborhood(self, halfface, ring=1): + def halfface_manifold_neighborhood(self, halfface: Halfface, ring: int = 1) -> list[Halfface]: """Return the halfface neighborhood of a halfface across their edges. Parameters @@ -3225,15 +3435,23 @@ def halfface_manifold_neighborhood(self, halfface, ring=1): list[int] The list of neighboring halffaces. + Raises + ------ + ValueError + If the ring is smaller than 1. + See Also -------- - :meth:`halfface_manifold_neighbors` + halfface_manifold_neighbors Notes ----- Neighboring halffaces on the same cell are not included. """ + if ring < 1: + raise ValueError("The neighborhood ring should be greater than or equal to 1.") + nbrs = set(self.halfface_manifold_neighbors(halfface)) i = 1 while True: @@ -3246,7 +3464,7 @@ def halfface_manifold_neighborhood(self, halfface, ring=1): i += 1 return list(nbrs - set([halfface])) - def is_halfface_on_boundary(self, halfface): + def is_halfface_on_boundary(self, halfface: Halfface) -> bool: """Verify that a face is on the boundary. Parameters @@ -3262,7 +3480,7 @@ def is_halfface_on_boundary(self, halfface): See Also -------- - :meth:`is_vertex_on_boundary`, :meth:`is_edge_on_boundary`, :meth:`is_cell_on_boundary` + is_vertex_on_boundary, is_edge_on_boundary, is_cell_on_boundary """ u, v, w = self._halfface[halfface][:3] @@ -3272,7 +3490,7 @@ def is_halfface_on_boundary(self, halfface): # Face Geometry # -------------------------------------------------------------------------- - def face_vertices(self, face): + def face_vertices(self, face: Face) -> list[Vertex]: """The vertices of a face. Parameters @@ -3288,7 +3506,7 @@ def face_vertices(self, face): """ return self.halfface_vertices(face) - def face_coordinates(self, face, axes="xyz"): + def face_coordinates(self, face: Face, axes: str = "xyz") -> list[list[float]]: """Compute the coordinates of the vertices of a face. Parameters @@ -3306,13 +3524,13 @@ def face_coordinates(self, face, axes="xyz"): See Also -------- - :meth:`face_points`, :meth:`face_polygon`, :meth:`face_normal`, :meth:`face_centroid`, :meth:`face_center` - :meth:`face_area`, :meth:`face_flatness`, :meth:`face_aspect_ratio` + face_points, face_polygon, face_normal, face_centroid, face_center + face_area, face_flatness, face_aspect_ratio """ return [self.vertex_coordinates(vertex, axes=axes) for vertex in self.face_vertices(face)] - def face_points(self, face): + def face_points(self, face: Face) -> list[Point]: """Compute the points of the vertices of a face. Parameters @@ -3327,12 +3545,12 @@ def face_points(self, face): See Also -------- - :meth:`face_polygon`, :meth:`face_normal`, :meth:`face_centroid`, :meth:`face_center` + face_polygon, face_normal, face_centroid, face_center """ return [self.vertex_point(vertex) for vertex in self.face_vertices(face)] - def face_polygon(self, face): + def face_polygon(self, face: Face) -> Polygon: """Compute the polygon of a face. Parameters @@ -3347,12 +3565,12 @@ def face_polygon(self, face): See Also -------- - :meth:`face_points`, :meth:`face_normal`, :meth:`face_centroid`, :meth:`face_center` + face_points, face_normal, face_centroid, face_center """ return Polygon(self.face_points(face)) - def face_normal(self, face, unitized=True): + def face_normal(self, face: Face, unitized: bool = True) -> Vector: """Compute the oriented normal of a face. Parameters @@ -3369,12 +3587,12 @@ def face_normal(self, face, unitized=True): See Also -------- - :meth:`face_points`, :meth:`face_polygon`, :meth:`face_centroid`, :meth:`face_center` + face_points, face_polygon, face_centroid, face_center """ return Vector(*normal_polygon(self.face_coordinates(face), unitized=unitized)) - def face_centroid(self, face): + def face_centroid(self, face: Face) -> Point: """Compute the point at the centroid of a face. Parameters @@ -3389,12 +3607,12 @@ def face_centroid(self, face): See Also -------- - :meth:`face_points`, :meth:`face_polygon`, :meth:`face_normal`, :meth:`face_center` + face_points, face_polygon, face_normal, face_center """ return Point(*centroid_points(self.face_coordinates(face))) - def face_center(self, face): + def face_center(self, face: Face) -> Point: """Compute the point at the center of mass of a face. Parameters @@ -3409,12 +3627,12 @@ def face_center(self, face): See Also -------- - :meth:`face_points`, :meth:`face_polygon`, :meth:`face_normal`, :meth:`face_centroid` + face_points, face_polygon, face_normal, face_centroid """ return Point(*centroid_polygon(self.face_coordinates(face))) - def face_area(self, face): + def face_area(self, face: Face) -> float: """Compute the oriented area of a face. Parameters @@ -3429,12 +3647,12 @@ def face_area(self, face): See Also -------- - :meth:`face_flatness`, :meth:`face_aspect_ratio` + face_flatness, face_aspect_ratio """ return length_vector(self.face_normal(face, unitized=False)) - def face_flatness(self, face, maxdev=0.02): + def face_flatness(self, face: Face, maxdev: float = 0.02) -> float: """Compute the flatness of a face. Parameters @@ -3449,7 +3667,7 @@ def face_flatness(self, face, maxdev=0.02): See Also -------- - :meth:`face_area`, :meth:`face_aspect_ratio` + face_area, face_aspect_ratio Notes ----- @@ -3468,7 +3686,7 @@ def face_flatness(self, face, maxdev=0.02): deviation = dev return deviation - def face_aspect_ratio(self, face): + def face_aspect_ratio(self, face: Face) -> float: """Face aspect ratio as the ratio between the lengths of the maximum and minimum face edges. Parameters @@ -3483,7 +3701,7 @@ def face_aspect_ratio(self, face): See Also -------- - :meth:`face_area`, :meth:`face_flatness` + face_area, face_flatness References ---------- @@ -3506,7 +3724,16 @@ def face_aspect_ratio(self, face): # Cell Accessors # -------------------------------------------------------------------------- - def cells(self, data=False): + @overload + def cells(self, data: Literal[False] = False) -> Iterator[Cell]: ... + + @overload + def cells(self, data: Literal[True]) -> Iterator[tuple[Cell, CellAttributeView]]: ... + + @overload + def cells(self, data: bool) -> Iterator[Union[Cell, tuple[Cell, CellAttributeView]]]: ... + + def cells(self, data: bool = False) -> Iterator[Any]: """Iterate over the cells of the volmesh. Parameters @@ -3516,13 +3743,14 @@ def cells(self, data=False): Yields ------ - int | tuple[int, dict[str, Any]] - If `data` is False, the next cell identifier. - If `data` is True, the next cell as a (cell, attr) tuple. + int + The cell identifier if `data` is `False`. + tuple[int, CellAttributeView] + The cell identifier and its attributes if `data` is `True`. See Also -------- - :meth:`vertices`, :meth:`edges`, :meth:`faces` + vertices, edges, faces """ for cell in self._cell: @@ -3531,7 +3759,12 @@ def cells(self, data=False): else: yield cell, self.cell_attributes(cell) - def cells_where(self, conditions=None, data=False, **kwargs): + def cells_where( + self, + conditions: Optional[Mapping[str, Any]] = None, + data: bool = False, + **kwargs: Any, + ) -> Iterator[Any]: """Get cells for which a certain condition or set of conditions is true. Parameters @@ -3553,11 +3786,11 @@ def cells_where(self, conditions=None, data=False, **kwargs): See Also -------- - :meth:`cells_where_predicate` - :meth:`vertices_where`, :meth:`edges_where`, :meth:`faces_where` + cells_where_predicate + vertices_where, edges_where, faces_where """ - conditions = conditions or {} + conditions = dict(conditions or {}) conditions.update(kwargs) for ckey in self.cells(): @@ -3596,7 +3829,11 @@ def cells_where(self, conditions=None, data=False, **kwargs): else: yield ckey - def cells_where_predicate(self, predicate, data=False): + def cells_where_predicate( + self, + predicate: Callable[[Cell, CellAttributeView], bool], + data: bool = False, + ) -> Iterator[Any]: """Get cells for which a certain condition or set of conditions is true using a lambda function. Parameters @@ -3615,8 +3852,8 @@ def cells_where_predicate(self, predicate, data=False): See Also -------- - :meth:`cells_where` - :meth:`vertices_where_predicate`, :meth:`edges_where_predicate`, :meth:`faces_where_predicate` + cells_where + vertices_where_predicate, edges_where_predicate, faces_where_predicate """ for ckey, attr in self.cells(True): @@ -3630,7 +3867,11 @@ def cells_where_predicate(self, predicate, data=False): # Cell Attributes # -------------------------------------------------------------------------- - def update_default_cell_attributes(self, attr_dict=None, **kwattr): + def update_default_cell_attributes( + self, + attr_dict: Optional[Mapping[str, Any]] = None, + **kwattr: Any, + ) -> None: """Update the default cell attributes. Parameters @@ -3646,19 +3887,24 @@ def update_default_cell_attributes(self, attr_dict=None, **kwattr): See Also -------- - :meth:`update_default_vertex_attributes`, :meth:`update_default_edge_attributes`, :meth:`update_default_face_attributes` + update_default_vertex_attributes, update_default_edge_attributes, update_default_face_attributes Notes ----- Named arguments overwrite corresponding cell-value pairs in the attribute dictionary. """ - if not attr_dict: - attr_dict = {} - attr_dict.update(kwattr) - self.default_cell_attributes.update(attr_dict) + attributes = dict(attr_dict or {}) + attributes.update(kwattr) + self.default_cell_attributes.update(attributes) - def cell_attribute(self, cell, name, value=None): + @overload + def cell_attribute(self, cell: Cell, name: str) -> Any: ... + + @overload + def cell_attribute(self, cell: Cell, name: str, value: Any) -> None: ... + + def cell_attribute(self, cell: Cell, name: str, value: Any = _MISSING) -> Any: """Get or set an attribute of a cell. Parameters @@ -3672,8 +3918,10 @@ def cell_attribute(self, cell, name, value=None): Returns ------- - object | None - The value of the attribute, or None when the function is used as a "setter". + Any + The attribute value when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -3682,14 +3930,14 @@ def cell_attribute(self, cell, name, value=None): See Also -------- - :meth:`unset_cell_attribute` - :meth:`cell_attributes`, :meth:`cells_attribute`, :meth:`cells_attributes` - :meth:`vertex_attribute`, :meth:`edge_attribute`, :meth:`face_attribute` + unset_cell_attribute + cell_attributes, cells_attribute, cells_attributes + vertex_attribute, edge_attribute, face_attribute """ if cell not in self._cell: raise KeyError(cell) - if value is not None: + if value is not _MISSING: if cell not in self._cell_data: self._cell_data[cell] = {} self._cell_data[cell][name] = value @@ -3699,7 +3947,7 @@ def cell_attribute(self, cell, name, value=None): if name in self.default_cell_attributes: return self.default_cell_attributes[name] - def unset_cell_attribute(self, cell, name): + def unset_cell_attribute(self, cell: Cell, name: str) -> None: """Unset the attribute of a cell. Parameters @@ -3720,7 +3968,7 @@ def unset_cell_attribute(self, cell, name): See Also -------- - :meth:`cell_attribute` + cell_attribute Notes ----- @@ -3734,7 +3982,12 @@ def unset_cell_attribute(self, cell, name): if name in self._cell_data[cell]: del self._cell_data[cell][name] - def cell_attributes(self, cell, names=None, values=None): + def cell_attributes( + self, + cell: Cell, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + ) -> Any: """Get or set multiple attributes of a cell. Parameters @@ -3760,8 +4013,8 @@ def cell_attributes(self, cell, names=None, values=None): See Also -------- - :meth:`cell_attribute`, :meth:`cells_attribute`, :meth:`cells_attributes` - :meth:`vertex_attributes`, :meth:`edge_attributes`, :meth:`face_attributes` + cell_attribute, cells_attribute, cells_attributes + vertex_attributes, edge_attributes, face_attributes """ if cell not in self._cell: @@ -3780,7 +4033,28 @@ def cell_attributes(self, cell, names=None, values=None): values.append(value) return values - def cells_attribute(self, name, value=None, cells=None): + @overload + def cells_attribute( + self, + name: str, + *, + cells: Optional[Iterable[Cell]] = None, + ) -> list[Any]: ... + + @overload + def cells_attribute( + self, + name: str, + value: Any, + cells: Optional[Iterable[Cell]] = None, + ) -> None: ... + + def cells_attribute( + self, + name: str, + value: Any = _MISSING, + cells: Optional[Iterable[Cell]] = None, + ) -> Optional[list[Any]]: """Get or set an attribute of multiple cells. Parameters @@ -3794,9 +4068,10 @@ def cells_attribute(self, name, value=None, cells=None): Returns ------- - list[Any] | None - A list containing the value per face of the requested attribute, - or None if the function is used as a "setter". + list[Any] + The attribute values when `value` is not provided. + None + When `value` is provided. Raises ------ @@ -3805,19 +4080,24 @@ def cells_attribute(self, name, value=None, cells=None): See Also -------- - :meth:`cell_attribute`, :meth:`cell_attributes`, :meth:`cells_attributes` - :meth:`vertex_attribute`, :meth:`edge_attribute`, :meth:`face_attribute` + cell_attribute, cell_attributes, cells_attributes + vertex_attribute, edge_attribute, face_attribute """ if not cells: cells = self.cells() - if value is not None: + if value is not _MISSING: for cell in cells: self.cell_attribute(cell, name, value) return return [self.cell_attribute(cell, name) for cell in cells] - def cells_attributes(self, names=None, values=None, cells=None): + def cells_attributes( + self, + names: Optional[Sequence[str]] = None, + values: Optional[Sequence[Any]] = None, + cells: Optional[Iterable[Cell]] = None, + ) -> Any: """Get or set multiple attributes of multiple cells. Parameters @@ -3847,8 +4127,8 @@ def cells_attributes(self, names=None, values=None, cells=None): See Also -------- - :meth:`cell_attribute`, :meth:`cell_attributes`, :meth:`cells_attribute` - :meth:`vertex_attributes`, :meth:`edge_attributes`, :meth:`face_attributes` + cell_attribute, cell_attributes, cells_attribute + vertex_attributes, edge_attributes, face_attributes """ if not cells: @@ -3863,7 +4143,23 @@ def cells_attributes(self, names=None, values=None, cells=None): # Cell Topology # -------------------------------------------------------------------------- - def cell_vertices(self, cell): + def has_cell(self, cell: Cell) -> bool: + """Verify that a cell is part of the volmesh. + + Parameters + ---------- + cell + The identifier of the cell. + + Returns + ------- + bool + True if the cell exists, False otherwise. + + """ + return cell in self._cell + + def cell_vertices(self, cell: Cell) -> list[Vertex]: """The vertices of a cell. Parameters @@ -3878,7 +4174,7 @@ def cell_vertices(self, cell): See Also -------- - :meth:`cell_edges`, :meth:`cell_faces`, :meth:`cell_halfedges` + cell_edges, cell_faces, cell_halfedges Notes ----- @@ -3888,7 +4184,7 @@ def cell_vertices(self, cell): """ return list(set([vertex for face in self.cell_faces(cell) for vertex in self.halfface_vertices(face)])) - def cell_halfedges(self, cell): + def cell_halfedges(self, cell: Cell) -> list[Edge]: """The halfedges of a cell. Parameters @@ -3903,7 +4199,7 @@ def cell_halfedges(self, cell): See Also -------- - :meth:`cell_edges`, :meth:`cell_faces`, :meth:`cell_vertices` + cell_edges, cell_faces, cell_vertices Notes ----- @@ -3916,7 +4212,7 @@ def cell_halfedges(self, cell): halfedges += self.halfface_halfedges(face) return halfedges - def cell_edges(self, cell): + def cell_edges(self, cell: Cell) -> list[Edge]: """Return all edges of a cell. Parameters @@ -3931,7 +4227,7 @@ def cell_edges(self, cell): See Also -------- - :meth:`cell_halfedges`, :meth:`cell_faces`, :meth:`cell_vertices` + cell_halfedges, cell_faces, cell_vertices Notes ----- @@ -3939,9 +4235,18 @@ def cell_edges(self, cell): but in the context of a cell of the `VolMesh`. """ - return list(set(self.cell_halfedges(cell))) + seen = set() + edges = [] + for edge in self.cell_halfedges(cell): + u, v = edge + if (u, v) in seen or (v, u) in seen: + continue + seen.add((u, v)) + seen.add((v, u)) + edges.append(edge) + return edges - def cell_faces(self, cell): + def cell_faces(self, cell: Cell) -> list[Halfface]: """The faces of a cell. Parameters @@ -3956,7 +4261,7 @@ def cell_faces(self, cell): See Also -------- - :meth:`cell_halfedges`, :meth:`cell_edges`, :meth:`cell_vertices` + cell_halfedges, cell_edges, cell_vertices Notes ----- @@ -3969,7 +4274,7 @@ def cell_faces(self, cell): faces.update(self._cell[cell][vertex].values()) return list(faces) - def cell_vertex_neighbors(self, cell, vertex): + def cell_vertex_neighbors(self, cell: Cell, vertex: Vertex) -> list[Vertex]: """Ordered vertex neighbors of a vertex of a cell. Parameters @@ -3986,7 +4291,7 @@ def cell_vertex_neighbors(self, cell, vertex): See Also -------- - :meth:`cell_vertex_faces` + cell_vertex_faces Notes ----- @@ -3998,7 +4303,7 @@ def cell_vertex_neighbors(self, cell, vertex): """ if vertex not in self.cell_vertices(cell): raise KeyError(vertex) - nbr_vertices = self._cell[cell][vertex].keys() + nbr_vertices = list(self._cell[cell][vertex]) v = nbr_vertices[0] ordered_vkeys = [v] for i in range(len(nbr_vertices) - 1): @@ -4007,7 +4312,7 @@ def cell_vertex_neighbors(self, cell, vertex): ordered_vkeys.append(v) return ordered_vkeys - def cell_vertex_faces(self, cell, vertex): + def cell_vertex_faces(self, cell: Cell, vertex: Vertex) -> list[Halfface]: """Ordered faces connected to a vertex of a cell. Parameters @@ -4024,7 +4329,7 @@ def cell_vertex_faces(self, cell, vertex): See Also -------- - :meth:`cell_vertex_neighbors` + cell_vertex_neighbors Notes ----- @@ -4034,7 +4339,7 @@ def cell_vertex_faces(self, cell, vertex): but in the context of a cell of the `VolMesh`. """ - nbr_vertices = self._cell[cell][vertex].keys() + nbr_vertices = list(self._cell[cell][vertex]) u = vertex v = nbr_vertices[0] ordered_faces = [] @@ -4044,7 +4349,7 @@ def cell_vertex_faces(self, cell, vertex): ordered_faces.append(face) return ordered_faces - def cell_halfedge_face(self, cell, halfedge): + def cell_halfedge_face(self, cell: Cell, halfedge: Edge) -> Halfface: """Find the face corresponding to a specific halfedge of a cell. Parameters @@ -4061,7 +4366,7 @@ def cell_halfedge_face(self, cell, halfedge): See Also -------- - :meth:`cell_halfedge_opposite_face` + cell_halfedge_opposite_face Notes ----- @@ -4072,7 +4377,7 @@ def cell_halfedge_face(self, cell, halfedge): u, v = halfedge return self._cell[cell][u][v] - def cell_halfedge_opposite_face(self, cell, halfedge): + def cell_halfedge_opposite_face(self, cell: Cell, halfedge: Edge) -> Halfface: """Find the opposite face corresponding to a specific halfedge of a cell. Parameters @@ -4089,13 +4394,13 @@ def cell_halfedge_opposite_face(self, cell, halfedge): See Also -------- - :meth:`cell_halfedge_face` + cell_halfedge_face """ u, v = halfedge return self._cell[cell][v][u] - def cell_face_neighbors(self, cell, face): + def cell_face_neighbors(self, cell: Cell, face: Halfface) -> list[Halfface]: """Find the faces adjacent to a given face of a cell. Parameters @@ -4107,12 +4412,12 @@ def cell_face_neighbors(self, cell, face): Returns ------- - int - The identifier of the face. + list[int] + The identifiers of the adjacent faces. See Also -------- - :meth:`cell_neighbors` + cell_neighbors Notes ----- @@ -4127,7 +4432,7 @@ def cell_face_neighbors(self, cell, face): nbrs.append(nbr) return nbrs - def cell_neighbors(self, cell): + def cell_neighbors(self, cell: Cell) -> list[Cell]: """Find the neighbors of a given cell. Parameters @@ -4142,19 +4447,21 @@ def cell_neighbors(self, cell): See Also -------- - :meth:`cell_face_neighbors` + cell_face_neighbors """ nbrs = [] + seen = set() for u in self._cell[cell]: for face in self._cell[cell][u].values(): a, b, c = self._halfface[face][:3] nbr = self._plane[c][b][a] - if nbr is not None: + if nbr is not None and nbr not in seen: + seen.add(nbr) nbrs.append(nbr) return nbrs - def is_cell_on_boundary(self, cell): + def is_cell_on_boundary(self, cell: Cell) -> bool: """Verify that a cell is on the boundary. Parameters @@ -4170,7 +4477,7 @@ def is_cell_on_boundary(self, cell): See Also -------- - :meth:`is_vertex_on_boundary`, :meth:`is_edge_on_boundary`, :meth:`is_face_on_boundary` + is_vertex_on_boundary, is_edge_on_boundary, is_face_on_boundary """ faces = self.cell_faces(cell) @@ -4183,7 +4490,7 @@ def is_cell_on_boundary(self, cell): # Cell Geometry # -------------------------------------------------------------------------- - def cell_points(self, cell): + def cell_points(self, cell: Cell) -> list[Point]: """Compute the points of the vertices of a cell. Parameters @@ -4198,12 +4505,12 @@ def cell_points(self, cell): See Also -------- - :meth:`cell_lines`, :meth:`cell_polygons` + cell_lines, cell_polygons """ return [self.vertex_point(vertex) for vertex in self.cell_vertices(cell)] - def cell_lines(self, cell): + def cell_lines(self, cell: Cell) -> list[Line]: """Compute the lines of the edges of a cell. Parameters @@ -4218,12 +4525,12 @@ def cell_lines(self, cell): See Also -------- - :meth:`cell_points`, :meth:`cell_polygons` + cell_points, cell_polygons """ return [self.edge_line(edge) for edge in self.cell_edges(cell)] - def cell_polygons(self, cell): + def cell_polygons(self, cell: Cell) -> list[Polygon]: """Compute the polygons of the faces of a cell. Parameters @@ -4238,12 +4545,12 @@ def cell_polygons(self, cell): See Also -------- - :meth:`cell_points`, :meth:`cell_lines` + cell_points, cell_lines """ return [self.face_polygon(face) for face in self.cell_faces(cell)] - def cell_centroid(self, cell): + def cell_centroid(self, cell: Cell) -> Point: """Compute the point at the centroid of a cell. Parameters @@ -4258,13 +4565,13 @@ def cell_centroid(self, cell): See Also -------- - :meth:`cell_center` + cell_center """ vertices = self.cell_vertices(cell) return Point(*centroid_points([self.vertex_coordinates(vertex) for vertex in vertices])) - def cell_center(self, cell): + def cell_center(self, cell: Cell) -> Point: """Compute the point at the center of mass of a cell. Parameters @@ -4279,13 +4586,13 @@ def cell_center(self, cell): See Also -------- - :meth:`cell_centroid` + cell_centroid """ vertices, faces = self.cell_to_vertices_and_faces(cell) return Point(*centroid_polyhedron((vertices, faces))) - def cell_vertex_normal(self, cell, vertex): + def cell_vertex_normal(self, cell: Cell, vertex: Vertex) -> Vector: """Return the normal vector at the vertex of a boundary cell as the weighted average of the normals of the neighboring faces. @@ -4306,7 +4613,7 @@ def cell_vertex_normal(self, cell, vertex): vectors = [self.face_normal(face) for face in self.vertex_halffaces(vertex) if face in cell_faces] return Vector(*normalize_vector(centroid_points(vectors))) - def cell_polyhedron(self, cell): + def cell_polyhedron(self, cell: Cell) -> Polyhedron: """Construct a polyhedron from the vertices and faces of a cell. Parameters @@ -4327,7 +4634,7 @@ def cell_polyhedron(self, cell): # Boundaries # -------------------------------------------------------------------------- - def vertices_on_boundaries(self): + def vertices_on_boundaries(self) -> list[Vertex]: """Find the vertices on the boundary. Returns @@ -4337,7 +4644,7 @@ def vertices_on_boundaries(self): See Also -------- - :meth:`faces_on_boundaries`, :meth:`cells_on_boundaries` + faces_on_boundaries, cells_on_boundaries """ vertices = set() @@ -4346,7 +4653,7 @@ def vertices_on_boundaries(self): vertices.update(self.halfface_vertices(face)) return list(vertices) - def halffaces_on_boundaries(self): + def halffaces_on_boundaries(self) -> list[Halfface]: """Find the faces on the boundary. Returns @@ -4356,7 +4663,7 @@ def halffaces_on_boundaries(self): See Also -------- - :meth:`vertices_on_boundaries`, :meth:`cells_on_boundaries` + vertices_on_boundaries, cells_on_boundaries """ faces = set() @@ -4365,7 +4672,7 @@ def halffaces_on_boundaries(self): faces.add(face) return list(faces) - def cells_on_boundaries(self): + def cells_on_boundaries(self) -> list[Cell]: """Find the cells on the boundary. Returns @@ -4375,19 +4682,21 @@ def cells_on_boundaries(self): See Also -------- - :meth:`vertices_on_boundaries`, :meth:`faces_on_boundaries` + vertices_on_boundaries, faces_on_boundaries """ cells = set() for face in self.halffaces_on_boundaries(): - cells.add(self.halfface_cell(face)) + cell = self.halfface_cell(face) + if cell is not None: + cells.add(cell) return list(cells) # -------------------------------------------------------------------------- # Transformations # -------------------------------------------------------------------------- - def transform(self, T): + def transform(self, T: Transformation) -> None: """Transform the mesh. Parameters @@ -4403,7 +4712,7 @@ def transform(self, T): Examples -------- >>> from compas.datastructures import Mesh - >>> from compas.geometry import matrix_from_axis_and_angle + >>> from compas.linalg import matrix_from_axis_and_angle >>> mesh = Mesh.from_polyhedron(6) >>> T = matrix_from_axis_and_angle([0, 0, 1], math.pi / 4) >>> mesh.transform(T) diff --git a/src/compas/files/__init__.py b/src/compas/files/__init__.py index e7af9738fde0..e5460b04f8ac 100644 --- a/src/compas/files/__init__.py +++ b/src/compas/files/__init__.py @@ -1,27 +1,25 @@ """ This package defines a number of file formats and provides functionality for reading and writing data in these formats. -""" - -from __future__ import absolute_import -from .gltf.gltf import GLTF -from .gltf.gltf_content import GLTFContent # noqa: F401 -from .gltf.gltf_exporter import GLTFExporter # noqa: F401 -from .gltf.gltf_mesh import GLTFMesh # noqa: F401 -from .gltf.gltf_parser import GLTFParser # noqa: F401 -from .gltf.gltf_reader import GLTFReader # noqa: F401 -from .obj import OBJ, OBJParser, OBJReader, OBJWriter # noqa: F401 -from .off import OFF, OFFReader, OFFWriter # noqa: F401 -from .ply import PLY, PLYParser, PLYReader, PLYWriter # noqa: F401 -from .stl import STL, STLParser, STLReader, STLWriter # noqa: F401 -from .xml import XML, XMLElement, XMLReader, XMLWriter, prettify_string # noqa: F401 +""" +# ruff: noqa: F401 -__all__ = [ - "GLTF", - "OBJ", - "OFF", - "PLY", - "STL", - "XML", - "prettify_string", -] +from .gltf.gltf import read_gltf, write_gltf +from .gltf.gltf_document import GLTFDocument +from .gltf.gltf_encoder import GLTFEncoder +from .gltf.gltf_mesh import GLTFMesh +from .gltf.gltf_parser import GLTFParser +from .gltf.gltf_reader import GLTFReader +from .gltf.gltf_payload import GLTFPayload +from .gltf.gltf_resources import GLTFResourceLoader, GLTFSource +from .gltf.gltf_types import GLTFConversionWarning +from .gltf.gltf_writer import GLTFWriter +from .obj import OBJData, OBJParser, OBJReader, OBJWriter, obj_data, read_obj, read_obj_meshes, weld_obj_data, write_obj +from .obj_document import OBJDocument, OBJElementReference, OBJFace, OBJGroup, OBJLine, OBJObject, OBJPoint, OBJVertexReference +from .off import OFFParser, OFFReader, OFFWriter, read_off, write_off +from .off_document import OFFDocument +from .ply import PLYData, PLYParser, PLYReader, PLYWriter, ply_data, read_ply, write_ply +from .ply_document import PLYDocument, PLYElement, PLYProperty +from .stl import STLData, STLParser, STLReader, STLWriter, read_stl, stl_data, weld_stl_data, write_stl +from .stl_document import STLDocument, STLFacet, STLSolid +from .xml import parse_xml, read_xml, write_xml, xml_to_string diff --git a/src/compas/files/_xml/__init__.py b/src/compas/files/_xml/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/src/compas/files/_xml/xml_cli.py b/src/compas/files/_xml/xml_cli.py deleted file mode 100644 index 11c9898ae6f6..000000000000 --- a/src/compas/files/_xml/xml_cli.py +++ /dev/null @@ -1,213 +0,0 @@ -# -*- coding: UTF-8 -*- -# -# This module has been adapted from aglyph.compat.ipyetree -# -# MIT license -# Copyright (c) 2006-2016 Matthew Zipay -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -"""This module defines an :class:`xml.etree.ElementTree.XMLParser` that -delegates to the .NET `System.Xml.XmlReader -`_ XML -parser to parse an XML document. - -`IronPython `_ is not able to load CPython's -:mod:`xml.parsers.expat` module, and so the default parser used by -ElementTree does not exist is most releases. -""" - -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -import xml.etree.ElementTree as ET -from urllib import addinfourl - -import compas -from compas.files._xml.xml_shared import shared_xml_from_file -from compas.files._xml.xml_shared import shared_xml_from_string - -if compas.IPY: - import clr - - clr.AddReference("System.Xml") - - from System.IO import MemoryStream - from System.IO import StreamReader - from System.IO import StringReader - from System.Text import Encoding - from System.Text.RegularExpressions import Regex - from System.Text.RegularExpressions import RegexOptions - from System.Xml import DtdProcessing - from System.Xml import Formatting - from System.Xml import ValidationType - from System.Xml import XmlDocument - from System.Xml import XmlNodeType - from System.Xml import XmlReader - from System.Xml import XmlReaderSettings - from System.Xml import XmlTextWriter - - CRE_ENCODING = Regex("encoding=['\"](?.*?)['\"]", RegexOptions.Compiled) - - -def prettify_string(rough_string): - """Return an XML string with added whitespace for legibility, - using .NET infrastructure. - - Parameters - ---------- - rough_string : str - XML string - """ - mStream = MemoryStream() - writer = XmlTextWriter(mStream, Encoding.UTF8) - document = XmlDocument() - - document.LoadXml(rough_string) - - writer.Formatting = Formatting.Indented - - writer.WriteStartDocument() - document.WriteContentTo(writer) - writer.Flush() - mStream.Flush() - - mStream.Position = 0 - - sReader = StreamReader(mStream) - - formattedXml = sReader.ReadToEnd() - - return formattedXml - - -def xml_from_file(source, tree_parser=None): - tree_parser = tree_parser or CLRXMLTreeParser - return shared_xml_from_file(source, tree_parser, addinfourl) - - -def xml_from_string(text, tree_parser=None): - tree_parser = tree_parser or CLRXMLTreeParser - return shared_xml_from_string(text, tree_parser) - - -class CLRXMLTreeParser(ET.XMLParser): - """Parses XML using .NET infrastructure. - - This is a sub-class of :class:`xml.etree.ElementTree.XMLParser` - that delegates parsing to the .NET `System.Xml.XmlReader - `_ - parser. - - Parameters - ---------- - target : :class:`xml.etree.ElementTree.TreeBuilder` - Target object (if omitted, a standard ``TreeBuilder`` instance is used) - validating : bool - ``True`` to use a validating parser, otherwise ``False`` - """ - - def __init__(self, target=None, validating=False): - if not compas.IPY: - raise Exception("CLRXMLTreeParser can only be used from IronPython") - - settings = XmlReaderSettings() - settings.IgnoreComments = True - settings.IgnoreProcessingInstructions = True - settings.IgnoreWhitespace = True - if not validating: - settings.DtdProcessing = DtdProcessing.Ignore - settings.ValidationType = getattr(ValidationType, "None") - else: - settings.DtdProcessing = DtdProcessing.Parse - settings.ValidationType = ValidationType.DTD - self.settings = settings - self._target = target or ET.TreeBuilder() - self._buffer = [] - self._document_encoding = "UTF-8" # default - - def feed(self, data): - """Add more XML data to be parsed. - - Parameters - ---------- - data : str - raw XML read from a stream - - Notes - ----- - All *data* across calls to this method are buffered - internally; the parser itself is not actually created - until the :meth:`close` method is called. - - """ - self._buffer.append(data) - - def close(self): - """Parse the XML from the internal buffer to build an - element tree. - - Returns - ------- - :class:`xml.etree.ElementTree.ElementTree` - The root element of the XML document - """ - xml_string = "".join(self._buffer) - self._buffer = None - reader = XmlReader.Create(StringReader(xml_string), self.settings) - while reader.Read(): - if reader.IsStartElement(): - self._start_element(reader) - elif reader.NodeType in [XmlNodeType.Text, XmlNodeType.CDATA]: - self._target.data(reader.Value.decode(self._document_encoding)) - elif reader.NodeType == XmlNodeType.EndElement: - self._target.end(self._get_expanded_tag(reader)) - elif reader.NodeType == XmlNodeType.XmlDeclaration: - self._parse_xml_declaration(reader.Value) - return self._target.close() - - def _get_expanded_tag(self, reader): - """Expand tag name to include namespace URIs if needed""" - if not reader.NamespaceURI: - return reader.LocalName - - return "{{{}}}{}".format(reader.NamespaceURI, reader.LocalName) - - def _parse_xml_declaration(self, xml_decl): - """Parse the document encoding from XML declaration.""" - enc_name = CRE_ENCODING.Match(xml_decl).Groups["enc_name"].Value - - if enc_name: - self._document_encoding = enc_name - - def _start_element(self, reader): - """Notify the tree builder that a start element has been - encountered.""" - attributes = {} - name = self._get_expanded_tag(reader) - - while reader.MoveToNextAttribute(): - attributes[reader.Name] = reader.Value - - reader.MoveToElement() - self._target.start(name, attributes) - - if reader.IsEmptyElement: - self._target.end(name) diff --git a/src/compas/files/_xml/xml_cpython.py b/src/compas/files/_xml/xml_cpython.py deleted file mode 100644 index 43a6d2c48820..000000000000 --- a/src/compas/files/_xml/xml_cpython.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -import xml.etree.ElementTree as ET -from http.client import HTTPResponse -from xml.dom import minidom - -from compas.files._xml.xml_shared import shared_xml_from_file -from compas.files._xml.xml_shared import shared_xml_from_string - - -def prettify_string(rough_string): - """Return an XML string with added whitespace for legibility. - - Parameters - ---------- - rough_string : str - XML string - """ - reparsed = minidom.parseString(rough_string) - return reparsed.toprettyxml(indent=" ", encoding="utf-8") - - -def xml_from_file(source, tree_parser=None): - tree_parser = tree_parser or ET.XMLParser - return shared_xml_from_file(source, tree_parser, HTTPResponse) - - -def xml_from_string(text, tree_parser=None): - tree_parser = tree_parser or ET.XMLParser - return shared_xml_from_string(text, tree_parser) diff --git a/src/compas/files/_xml/xml_pre_38.py b/src/compas/files/_xml/xml_pre_38.py deleted file mode 100644 index 6771edef6b88..000000000000 --- a/src/compas/files/_xml/xml_pre_38.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -import xml.etree.ElementTree as ET - -from compas import _iotools -from compas.files._xml.xml_cpython import prettify_string # noqa: F401 - -# doesn't need special handling for pre-3.8 so we just import - - -def xml_from_file(source, tree_parser=None): - if tree_parser: - raise NotImplementedError("XML parsing on CPython 3.7 and older does not support a custom tree parser") - - tree_parser = ET.XMLPullParser - parser = tree_parser(events=("start", "start-ns")) - - with _iotools.open_file(source) as file: - for data in _iotools.iter_file(file): - parser.feed(data) - return _process_all_events(parser) - - -def xml_from_string(text, tree_parser=None): - if tree_parser: - raise NotImplementedError("XML parsing on CPython 3.7 and older does not support a custom tree parser") - - tree_parser = ET.XMLPullParser - parser = tree_parser(events=("start", "end", "start-ns", "end-ns")) - parser.feed(text) - return _process_all_events(parser) - - -def _process_all_events(parser): - root = None - current_namespaces = {} - - for event, event_data in parser.read_events(): - if event == "start": - element = event_data - if not root: - root = element - - if len(current_namespaces): - element.attrib.update(current_namespaces) - - current_namespaces = {} - - if event == "start-ns": - prefix, uri = event_data - ns_prefix = "xmlns:" + prefix if prefix else "xmlns" - current_namespaces[ns_prefix] = uri - - parser.close() - - return root diff --git a/src/compas/files/_xml/xml_shared.py b/src/compas/files/_xml/xml_shared.py deleted file mode 100644 index 6ce0ba9f70d0..000000000000 --- a/src/compas/files/_xml/xml_shared.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -import xml.etree.ElementTree as ET - -from compas import _iotools - - -def shared_xml_from_file(source, tree_parser, http_response_type): - target = TreeBuilderWithNamespaces() - with _iotools.open_file(source, "r") as f: - tree = ET.parse(f, tree_parser(target=target)) - return tree.getroot() - - -def shared_xml_from_string(text, tree_parser): - target = TreeBuilderWithNamespaces() - root = ET.fromstring(text, tree_parser(target=target)) - return root - - -class TreeBuilderWithNamespaces(ET.TreeBuilder): - def start(self, tag, attrs): - if hasattr(self, "_current_namespaces") and len(self._current_namespaces): - attrs.update(self._current_namespaces) - - element = super(TreeBuilderWithNamespaces, self).start(tag, attrs) - - # reset current namespaces - self._current_namespaces = {} - - return element - - def start_ns(self, prefix, uri): - if not hasattr(self, "_current_namespaces"): - self._current_namespaces = {} - - ns_prefix = "xmlns:" + prefix if prefix else "xmlns" - self._current_namespaces[ns_prefix] = uri diff --git a/src/compas/files/gltf/constants.py b/src/compas/files/gltf/constants.py index 7ccefd4a3ad0..330ded3b35c5 100644 --- a/src/compas/files/gltf/constants.py +++ b/src/compas/files/gltf/constants.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - COMPONENT_TYPE_BYTE = 5120 COMPONENT_TYPE_UNSIGNED_BYTE = 5121 COMPONENT_TYPE_SHORT = 5122 @@ -17,6 +13,14 @@ TYPE_MAT3 = "MAT3" TYPE_MAT4 = "MAT4" +MODE_POINTS = 0 +MODE_LINES = 1 +MODE_LINE_LOOP = 2 +MODE_LINE_STRIP = 3 +MODE_TRIANGLES = 4 +MODE_TRIANGLE_STRIP = 5 +MODE_TRIANGLE_FAN = 6 + COMPONENT_TYPE_ENUM = { COMPONENT_TYPE_BYTE: "b", COMPONENT_TYPE_UNSIGNED_BYTE: "B", @@ -36,15 +40,6 @@ TYPE_MAT4: 16, } -NUM_BYTES_BY_COMPONENT_TYPE = { - COMPONENT_TYPE_BYTE: 1, - COMPONENT_TYPE_UNSIGNED_BYTE: 1, - COMPONENT_TYPE_SHORT: 2, - COMPONENT_TYPE_UNSIGNED_SHORT: 2, - COMPONENT_TYPE_UNSIGNED_INT: 4, - COMPONENT_TYPE_FLOAT: 4, -} - MODE_BY_VERTEX_COUNT = { 3: None, 2: 1, @@ -52,8 +47,8 @@ } VERTEX_COUNT_BY_MODE = { - 0: 1, - 1: 2, - 4: 3, + MODE_POINTS: 1, + MODE_LINES: 2, + MODE_TRIANGLES: 3, None: 3, } diff --git a/src/compas/files/gltf/data_classes.py b/src/compas/files/gltf/data_classes.py index e7d4855a0db1..3e800bbece8f 100644 --- a/src/compas/files/gltf/data_classes.py +++ b/src/compas/files/gltf/data_classes.py @@ -1,657 +1,213 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from collections.abc import Iterator +from dataclasses import dataclass +from dataclasses import field +from typing import Any +from typing import Optional -class AlphaMode(object): - BLEND = "BLEND" - MASK = "MASK" - OPAQUE = "OPAQUE" +class BaseGLTFDataClass: + """Base behavior for structured glTF values with extras and extensions.""" + extras: Any + extensions: Optional[dict[str, Any]] -class MineType(object): - JPEG = "image/jpeg" - PNG = "image/png" + def add_extension(self, extension: Any) -> None: + """Attach a typed extension by its declared key.""" + if not self.extensions: + self.extensions = {} + self.extensions[extension.key] = extension + def iter_data(self) -> Iterator["BaseGLTFDataClass"]: + """Iterate recursively over this value and nested structured values. -# I changed the name of this so as not to collide with compas.Base -class BaseGLTFDataClass(object): - IS_BASE_GLTF_DATA = True # only needed for ipy in `GLTFContent.check_extensions_texture_recursively` + Yields + ------ + BaseGLTFDataClass + This value followed by nested values. - def __init__(self, extras=None, extensions=None): - self.extras = extras - self.extensions = extensions + """ + yield self + for value in vars(self).values(): + yield from _iter_data(value) - def add_extension(self, extension): - if not self.extensions: - self.extensions = {} - self.extensions.update({extension.key: extension}) + def extension_keys(self) -> set[str]: + """Collect extension keys from this value hierarchy. + + Returns + ------- + set[str] + Referenced extension keys. + + """ + keys = set() + for item in self.iter_data(): + if item.extensions: + keys.update(item.extensions) + return keys - def extensions_to_data(self, **kwargs): - if not self.extensions: - return None - return {key: value.to_data(**kwargs) if hasattr(value, "to_data") else value for key, value in self.extensions.items()} - - @classmethod - def extensions_from_data(cls, data): - # i hate hate hate this local import, but i don't see a good way around it - from compas.files.gltf.extensions import SUPPORTED_EXTENSIONS - - if not data: - return None - extensions = {} - for key, value_data in data.items(): - if key in SUPPORTED_EXTENSIONS: - extensions[key] = SUPPORTED_EXTENSIONS[key].from_data(value_data) - else: - extensions[key] = value_data - return extensions - - def to_data(self, *args, **kwargs): - dct = {} - if self.extras is not None: - dct["extras"] = self.extras - if self.extensions is not None: - dct["extensions"] = self.extensions_to_data() - return dct +def _iter_data(value: Any) -> Iterator[BaseGLTFDataClass]: + if isinstance(value, BaseGLTFDataClass): + yield from value.iter_data() + elif isinstance(value, dict): + for item in value.values(): + yield from _iter_data(item) + elif isinstance(value, (list, tuple)): + for item in value: + yield from _iter_data(item) + +@dataclass class SamplerData(BaseGLTFDataClass): - def __init__( - self, - mag_filter=None, - min_filter=None, - wrap_s=None, - wrap_t=None, - name=None, - extras=None, - extensions=None, - ): - super(SamplerData, self).__init__(extras, extensions) - self.mag_filter = mag_filter - self.min_filter = min_filter - self.wrap_s = wrap_s - self.wrap_t = wrap_t - self.name = name - - def to_data(self): - sampler_dict = {} - if self.mag_filter is not None: - sampler_dict["magFilter"] = self.mag_filter - if self.min_filter is not None: - sampler_dict["minFilter"] = self.min_filter - if self.wrap_s is not None: - sampler_dict["wrapS"] = self.wrap_s - if self.wrap_t is not None: - sampler_dict["wrapT"] = self.wrap_t - if self.name is not None: - sampler_dict["name"] = self.name - if self.extras is not None: - sampler_dict["extras"] = self.extras - if self.extensions is not None: - sampler_dict["extensions"] = self.extensions_to_data() - return sampler_dict - - @classmethod - def from_data(cls, sampler): - if sampler is None: - return None - return cls( - mag_filter=sampler.get("magFilter"), - min_filter=sampler.get("minFilter"), - wrap_s=sampler.get("wrapS"), - wrap_t=sampler.get("wrapT"), - name=sampler.get("name"), - extras=sampler.get("extras"), - extensions=cls.extensions_from_data(sampler.get("extensions")), - ) + mag_filter: Optional[int] = None + min_filter: Optional[int] = None + wrap_s: Optional[int] = None + wrap_t: Optional[int] = None + name: Optional[str] = None + extras: Any = None + extensions: Optional[dict[str, Any]] = None +@dataclass class TextureData(BaseGLTFDataClass): - def __init__(self, sampler=None, source=None, name=None, extras=None, extensions=None): - super(TextureData, self).__init__(extras, extensions) - self.sampler = sampler - self.source = source - self.name = name - - def to_data(self, sampler_index_by_key, image_index_by_key): - texture_dict = {} - if self.sampler is not None: - texture_dict["sampler"] = sampler_index_by_key[self.sampler] - if self.source is not None: - texture_dict["source"] = image_index_by_key[self.source] - if self.name is not None: - texture_dict["name"] = self.name - if self.extras is not None: - texture_dict["extras"] = self.extras - if self.extensions is not None: - texture_dict["extensions"] = self.extensions_to_data() - return texture_dict - - @classmethod - def from_data(cls, texture): - if texture is None: - return None - return cls( - sampler=texture.get("sampler"), - source=texture.get("source"), - name=texture.get("name"), - extras=texture.get("extras"), - extensions=cls.extensions_from_data(texture.get("extensions")), - ) + sampler: Optional[int] = None + source: Optional[int] = None + name: Optional[str] = None + extras: Any = None + extensions: Optional[dict[str, Any]] = None +@dataclass class TextureInfoData(BaseGLTFDataClass): - IS_TEXTURE_INFO_DATA = True # only needed for ipy in `GLTFContent.check_extensions_texture_recursively` - - def __init__(self, index, tex_coord=None, extras=None, extensions=None): - super(TextureInfoData, self).__init__(extras, extensions) - self.index = index - self.tex_coord = tex_coord - - def to_data(self, texture_index_by_key): - texture_info_dict = {"index": texture_index_by_key[self.index]} - if self.tex_coord is not None: - texture_info_dict["texCoord"] = self.tex_coord - if self.extras is not None: - texture_info_dict["extras"] = self.extras - if self.extensions is not None: - texture_info_dict["extensions"] = self.extensions_to_data() - return texture_info_dict - - @classmethod - def from_data(cls, texture_info): - if texture_info is None: - return None - return cls( - index=texture_info["index"], - tex_coord=texture_info.get("texCoord"), - extras=texture_info.get("extras"), - extensions=cls.extensions_from_data(texture_info.get("extensions")), - ) + index: int + tex_coord: Optional[int] = None + extras: Any = None + extensions: Optional[dict[str, Any]] = None +@dataclass class OcclusionTextureInfoData(TextureInfoData): - def __init__(self, index, tex_coord=None, extras=None, extensions=None, strength=None): - super(OcclusionTextureInfoData, self).__init__(index, tex_coord, extras, extensions) - self.strength = strength - - def to_data(self, texture_index_by_key): - texture_info_dict = super(OcclusionTextureInfoData, self).to_data(texture_index_by_key) - if self.strength is not None: - texture_info_dict["strength"] = self.strength - return texture_info_dict - - @classmethod - def from_data(cls, texture_info): - if texture_info is None: - return None - return cls( - index=texture_info["index"], - tex_coord=texture_info.get("texCoord"), - extras=texture_info.get("extras"), - extensions=cls.extensions_from_data(texture_info.get("extensions")), - strength=texture_info.get("strength"), - ) + strength: Optional[float] = None +@dataclass class NormalTextureInfoData(TextureInfoData): - def __init__(self, index, tex_coord=None, extras=None, extensions=None, scale=None): - super(NormalTextureInfoData, self).__init__(index, tex_coord, extras, extensions) - self.scale = scale - - def to_data(self, texture_index_by_key): - texture_info_dict = super(NormalTextureInfoData, self).to_data(texture_index_by_key) - if self.scale is not None: - texture_info_dict["scale"] = self.scale - return texture_info_dict - - @classmethod - def from_data(cls, texture_info): - if texture_info is None: - return None - return cls( - index=texture_info["index"], - tex_coord=texture_info.get("texCoord"), - extras=texture_info.get("extras"), - extensions=cls.extensions_from_data(texture_info.get("extensions")), - scale=texture_info.get("scale"), - ) + scale: Optional[float] = None +@dataclass class PBRMetallicRoughnessData(BaseGLTFDataClass): - def __init__( - self, - base_color_factor=None, - base_color_texture=None, - metallic_factor=None, - roughness_factor=None, - metallic_roughness_texture=None, - extras=None, - extensions=None, - ): - super(PBRMetallicRoughnessData, self).__init__(extras, extensions) - self.base_color_factor = base_color_factor - self.base_color_texture = base_color_texture - self.metallic_factor = metallic_factor - self.roughness_factor = roughness_factor - self.metallic_roughness_texture = metallic_roughness_texture - - def to_data(self, texture_index_by_key): - roughness_dict = {} - if self.base_color_factor is not None: - roughness_dict["baseColorFactor"] = self.base_color_factor - if self.base_color_texture is not None: - roughness_dict["baseColorTexture"] = self.base_color_texture.to_data(texture_index_by_key) - if self.metallic_factor is not None: - roughness_dict["metallicFactor"] = self.metallic_factor - if self.roughness_factor is not None: - roughness_dict["roughnessFactor"] = self.roughness_factor - if self.metallic_roughness_texture is not None: - roughness_dict["metallicRoughnessTexture"] = self.metallic_roughness_texture.to_data(texture_index_by_key) - if self.extras is not None: - roughness_dict["extras"] = self.extras - if self.extensions is not None: - roughness_dict["extensions"] = self.extensions_to_data() - return roughness_dict - - @classmethod - def from_data(cls, texture_info): - if texture_info is None: - return None - return cls( - base_color_factor=texture_info.get("baseColorFactor"), - base_color_texture=TextureInfoData.from_data(texture_info.get("baseColorTexture")), - metallic_factor=texture_info.get("metallicFactor"), - roughness_factor=texture_info.get("roughnessFactor"), - metallic_roughness_texture=TextureInfoData.from_data(texture_info.get("metallicRoughnessTexture")), - extras=texture_info.get("extras"), - extensions=cls.extensions_from_data(texture_info.get("extensions")), - ) + base_color_factor: Optional[list[float]] = None + base_color_texture: Optional[TextureInfoData] = None + metallic_factor: Optional[float] = None + roughness_factor: Optional[float] = None + metallic_roughness_texture: Optional[TextureInfoData] = None + extras: Any = None + extensions: Optional[dict[str, Any]] = None +@dataclass class MaterialData(BaseGLTFDataClass): - def __init__( - self, - name=None, - extras=None, - pbr_metallic_roughness=None, # PBRMetallicRoughnessData - normal_texture=None, # NormalTextureInfoData - occlusion_texture=None, # OcclusionTextureInfoData - emissive_texture=None, # TextureInfoData - emissive_factor=None, - alpha_mode=None, - alpha_cutoff=None, - double_sided=None, - extensions=None, - ): - super(MaterialData, self).__init__(extras, extensions) - self.name = name - self.pbr_metallic_roughness = pbr_metallic_roughness - self.normal_texture = normal_texture - self.occlusion_texture = occlusion_texture - self.emissive_texture = emissive_texture - self.emissive_factor = emissive_factor - self.alpha_mode = alpha_mode - self.alpha_cutoff = alpha_cutoff - self.double_sided = double_sided - - def to_data(self, texture_index_by_key): - material_dict = {} - if self.name is not None: - material_dict["name"] = self.name - if self.extras is not None: - material_dict["extras"] = self.extras - if self.pbr_metallic_roughness is not None: - material_dict["pbrMetallicRoughness"] = self.pbr_metallic_roughness.to_data(texture_index_by_key) - if self.normal_texture is not None: - material_dict["normalTexture"] = self.normal_texture.to_data(texture_index_by_key) - if self.occlusion_texture is not None: - material_dict["materialTexture"] = self.occlusion_texture.to_data(texture_index_by_key) - if self.emissive_texture is not None: - material_dict["emissiveTexture"] = self.emissive_texture.to_data(texture_index_by_key) - if self.emissive_factor is not None: - material_dict["emissiveFactor"] = self.emissive_factor - if self.alpha_mode is not None: - material_dict["alphaMode"] = self.alpha_mode - if self.alpha_cutoff is not None: - material_dict["alphaFactor"] = self.alpha_cutoff - if self.double_sided is not None: - material_dict["doubleSided"] = self.double_sided - if self.extensions is not None: - material_dict["extensions"] = self.extensions_to_data(texture_index_by_key=texture_index_by_key) - return material_dict - - @classmethod - def from_data(cls, material): - if material is None: - return None - return cls( - name=material.get("name"), - extras=material.get("extras"), - pbr_metallic_roughness=PBRMetallicRoughnessData.from_data(material.get("pbrMetallicRoughness")), - normal_texture=NormalTextureInfoData.from_data(material.get("normalTexture")), - occlusion_texture=OcclusionTextureInfoData.from_data(material.get("occlusionTexture")), - emissive_texture=TextureInfoData.from_data(material.get("emissiveTexture")), - emissive_factor=material.get("emissiveFactor"), - alpha_mode=material.get("alphaMode"), - alpha_cutoff=material.get("alphaCutoff"), - double_sided=material.get("doubleSided"), - extensions=cls.extensions_from_data(material.get("extensions")), - ) - - + name: Optional[str] = None + extras: Any = None + pbr_metallic_roughness: Optional[PBRMetallicRoughnessData] = None + normal_texture: Optional[NormalTextureInfoData] = None + occlusion_texture: Optional[OcclusionTextureInfoData] = None + emissive_texture: Optional[TextureInfoData] = None + emissive_factor: Optional[list[float]] = None + alpha_mode: Optional[str] = None + alpha_cutoff: Optional[float] = None + double_sided: Optional[bool] = None + extensions: Optional[dict[str, Any]] = None + + +@dataclass class CameraData(BaseGLTFDataClass): - def __init__( - self, - type_, - orthographic=None, - perspective=None, - name=None, - extras=None, - extensions=None, - ): - super(CameraData, self).__init__(extras, extensions) - self.type = type_ - self.orthographic = orthographic - self.perspective = perspective - self.name = name - - def to_data(self): - camera_dict = {"type": self.type} - if self.orthographic is not None: - camera_dict["orthographic"] = self.orthographic - if self.perspective is not None: - camera_dict["perspective"] = self.perspective - if self.name is not None: - camera_dict["name"] = self.name - if self.extras is not None: - camera_dict["extras"] = self.extras - if self.extensions is not None: - camera_dict["extensions"] = self.extensions_to_data() - return camera_dict - - @classmethod - def from_data(cls, camera): - if camera is None: - return None - return cls( - type_=camera["type"], - orthographic=camera.get("orthographic"), - perspective=camera.get("perspective"), - name=camera.get("name"), - extras=camera.get("extras"), - extensions=cls.extensions_from_data(camera.get("extensions")), - ) + type_: str + orthographic: Optional[dict[str, Any]] = None + perspective: Optional[dict[str, Any]] = None + name: Optional[str] = None + extras: Any = None + extensions: Optional[dict[str, Any]] = None + @property + def type(self) -> str: + return self.type_ + +@dataclass class AnimationSamplerData(BaseGLTFDataClass): - def __init__(self, input_, output, interpolation=None, extras=None, extensions=None): - super(AnimationSamplerData, self).__init__(extras, extensions) - self.input = input_ - self.output = output - self.interpolation = interpolation - - def to_data(self, input_accessor, output_accessor): - sampler_dict = { - "input": input_accessor, - "output": output_accessor, - } - if self.interpolation is not None: - sampler_dict["interpolation"] = self.interpolation - if self.extras is not None: - sampler_dict["extras"] = self.extras - if self.extensions is not None: - sampler_dict["extensions"] = self.extensions_to_data() - return sampler_dict - - @classmethod - def from_data(cls, sampler, input_, output): - if sampler is None: - return None - return cls( - input_=input_, - output=output, - interpolation=sampler.get("interpolation"), - extras=sampler.get("extras"), - extensions=cls.extensions_from_data(sampler.get("extensions")), - ) + input_: list[Any] + output: list[Any] + interpolation: Optional[str] = None + extras: Any = None + extensions: Optional[dict[str, Any]] = None + + @property + def input(self) -> list[Any]: + return self.input_ +@dataclass class TargetData(BaseGLTFDataClass): - def __init__(self, path, node=None, extras=None, extensions=None): - super(TargetData, self).__init__(extras, extensions) - self.path = path - self.node = node - - def to_data(self, node_index_by_key): - target_dict = {"path": self.path} - if self.node is not None: - target_dict["node"] = node_index_by_key[self.node] - if self.extras is not None: - target_dict["extras"] = self.extras - if self.extensions is not None: - target_dict["extensions"] = self.extensions_to_data() - return target_dict - - @classmethod - def from_data(cls, target): - if target is None: - return None - return cls( - path=target["path"], - node=target.get("node"), - extras=target.get("extras"), - extensions=cls.extensions_from_data(target.get("extensions")), - ) + path: str + node: Optional[int] = None + extras: Any = None + extensions: Optional[dict[str, Any]] = None +@dataclass class ChannelData(BaseGLTFDataClass): - def __init__(self, sampler, target, extras=None, extensions=None): - super(ChannelData, self).__init__(extras, extensions) - self.sampler = sampler - self.target = target - - def to_data(self, node_index_by_key, sampler_index_by_key): - channel_dict = { - "sampler": sampler_index_by_key[self.sampler], - "target": self.target.to_data(node_index_by_key), - } - if self.extras is not None: - channel_dict["extras"] = self.extras - if self.extensions is not None: - channel_dict["extensions"] = self.extensions_to_data() - return channel_dict - - @classmethod - def from_data(cls, channel): - if channel is None: - return None - return cls( - sampler=channel["sampler"], - target=TargetData.from_data(channel["target"]), - extras=channel.get("extras"), - extensions=cls.extensions_from_data(channel.get("extensions")), - ) + sampler: int + target: TargetData + extras: Any = None + extensions: Optional[dict[str, Any]] = None +@dataclass class AnimationData(BaseGLTFDataClass): - def __init__(self, channels, samplers_dict, name=None, extras=None, extensions=None): - super(AnimationData, self).__init__(extras, extensions) - self.channels = channels - self.samplers_dict = samplers_dict - self.name = name - - self._sampler_index_by_key = None - - def to_data(self, samplers_list, node_index_by_key): - channels = [channel_data.to_data(node_index_by_key, self._sampler_index_by_key) for channel_data in self.channels] - animation_dict = { - "channels": channels, - "samplers": samplers_list, - } - if self.name is not None: - animation_dict["name"] = self.name - if self.extras is not None: - animation_dict["extras"] = self.extras - if self.extensions is not None: - animation_dict["extensions"] = self.extensions_to_data() - return animation_dict - - def get_sampler_index_by_key(self): + channels: list[ChannelData] + samplers_dict: dict[int, AnimationSamplerData] + name: Optional[str] = None + extras: Any = None + extensions: Optional[dict[str, Any]] = None + _sampler_index_by_key: Optional[dict[int, int]] = field(init=False, default=None, repr=False, compare=False) + + def get_sampler_index_by_key(self) -> dict[int, int]: self._sampler_index_by_key = {key: index for index, key in enumerate(self.samplers_dict)} return self._sampler_index_by_key - @classmethod - def from_data(cls, animation, channel_data_list, sampler_dict): - if animation is None: - return None - return cls( - channels=channel_data_list, - samplers_dict=sampler_dict, - name=animation.get("name"), - extras=animation.get("extras"), - extensions=cls.extensions_from_data(animation.get("extensions")), - ) - +@dataclass class SkinData(BaseGLTFDataClass): - def __init__( - self, - joints, - inverse_bind_matrices=None, - skeleton=None, - name=None, - extras=None, - extensions=None, - ): - super(SkinData, self).__init__(extras, extensions) - self.joints = joints - self.inverse_bind_matrices = inverse_bind_matrices - self.skeleton = skeleton - self.name = name - - def to_data(self, node_index_by_key, accessor_index): - node_indices = [node_index_by_key.get(item) for item in self.joints if node_index_by_key.get(item) is not None] - skin_dict = {"joints": node_indices} - if self.skeleton is not None: - skin_dict["skeleton"] = self.skeleton - if self.name is not None: - skin_dict["name"] = self.name - if self.extras is not None: - skin_dict["extras"] = self.extras - if self.inverse_bind_matrices is not None: - skin_dict["inverseBindMatrices"] = accessor_index - if self.extensions is not None: - skin_dict["extensions"] = self.extensions_to_data() # type: ignore - return skin_dict - - @classmethod - def from_data(cls, skin, inverse_bind_matrices): - if skin is None: - return None - return cls( - joints=skin["joints"], - inverse_bind_matrices=inverse_bind_matrices, - skeleton=skin.get("skeleton"), - name=skin.get("name"), - extras=skin.get("extras"), - extensions=cls.extensions_from_data(skin.get("extensions")), - ) + joints: list[int] + inverse_bind_matrices: Optional[list[Any]] = None + skeleton: Optional[int] = None + name: Optional[str] = None + extras: Any = None + extensions: Optional[dict[str, Any]] = None +@dataclass class ImageData(BaseGLTFDataClass): - def __init__( - self, - data=None, - uri=None, - mime_type=None, - name=None, - extras=None, - extensions=None, - ): - super(ImageData, self).__init__(extras, extensions) - self.uri = uri - self.mime_type = mime_type - self.name = name - self.data = data - - def to_data(self, uri, buffer_view): - image_dict = {} - if self.name is not None: - image_dict["name"] = self.name - if self.extras is not None: - image_dict["extras"] = self.extras - if self.mime_type is not None: - image_dict["mimeType"] = self.mime_type - if uri is not None: - image_dict["uri"] = uri - elif buffer_view is not None: - image_dict["bufferView"] = buffer_view - elif self.uri is not None: - image_dict["uri"] = self.uri - if self.extensions is not None: - image_dict["extensions"] = self.extensions_to_data() - return image_dict - - @classmethod - def from_data(cls, image, data, mime_type): - if image is None: - return None - return cls( - uri=image.get("uri"), - mime_type=image.get("mimeType") or mime_type, - name=image.get("name"), - extras=image.get("extras"), - extensions=cls.extensions_from_data(image.get("extensions")), - data=data, - ) + data: Optional[bytes] = None + uri: Optional[str] = None + mime_type: Optional[str] = None + name: Optional[str] = None + extras: Any = None + extensions: Optional[dict[str, Any]] = None +@dataclass class PrimitiveData(BaseGLTFDataClass): - def __init__( - self, - attributes, - indices=None, - material=None, - mode=None, - targets=None, - extras=None, - extensions=None, - ): - super(PrimitiveData, self).__init__(extras, extensions) - self.attributes = attributes or {} - self.indices = indices - self.material = material - self.mode = mode - self.targets = targets - - def to_data(self, indices_accessor, attributes_dict, targets_dict, material_index_by_key): - primitive_dict = {"indices": indices_accessor} - if self.material is not None: - primitive_dict["material"] = material_index_by_key[self.material] - if self.mode is not None: - primitive_dict["mode"] = self.mode - if self.extras is not None: - primitive_dict["extras"] = self.extras - if attributes_dict: - primitive_dict["attributes"] = attributes_dict - if targets_dict: - primitive_dict["targets"] = targets_dict - if self.extensions is not None: - primitive_dict["extensions"] = self.extensions_to_data() - return primitive_dict - - @classmethod - def from_data(cls, primitive, attributes, indices, target_list): - if primitive is None: - return None - return cls( - attributes=attributes, - indices=indices, - material=primitive.get("material"), - mode=primitive.get("mode"), - targets=target_list, - extras=primitive.get("extras"), - extensions=cls.extensions_from_data(primitive.get("extensions")), - ) + attributes: dict[str, list[Any]] + indices: Optional[list[int]] = None + material: Optional[int] = None + mode: Optional[int] = None + targets: Optional[list[dict[str, list[Any]]]] = None + extras: Any = None + extensions: Optional[dict[str, Any]] = None + + def __post_init__(self) -> None: + if not self.attributes: + self.attributes = {} diff --git a/src/compas/files/gltf/extensions.py b/src/compas/files/gltf/extensions.py index a53c25d6da15..07a5d4a397cc 100644 --- a/src/compas/files/gltf/extensions.py +++ b/src/compas/files/gltf/extensions.py @@ -1,310 +1,130 @@ +from dataclasses import dataclass +from typing import Any +from typing import ClassVar +from typing import Optional + from .data_classes import BaseGLTFDataClass from .data_classes import NormalTextureInfoData from .data_classes import TextureInfoData -def create_if_data(cls, data, attr): - return cls.from_data(data.get(attr)) if attr in data and data[attr] is not None else None - - +@dataclass class KHR_materials_transmission(BaseGLTFDataClass): - """glTF extension that defines the optical transmission of a material. + """Optical transmission material extension. + + References + ---------- + - [KHR_materials_transmission](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_transmission) - https://github.com/KhronosGroup/glTF/blob/master/extensions/2.0/Khronos/KHR_materials_transmission """ - key = "KHR_materials_transmission" - - def __init__( - self, - transmission_factor=None, - transmission_texture=None, - extensions=None, - extras=None, - ): - super(KHR_materials_transmission, self).__init__(extras, extensions) - self.transmission_factor = transmission_factor - self.transmission_texture = transmission_texture - - def to_data(self, texture_index_by_key, **kwargs): - dct = {} - if self.transmission_factor is not None: - dct["transmissionFactor"] = self.transmission_factor - if self.transmission_texture is not None: - dct["transmissionTexture"] = self.transmission_texture.to_data(texture_index_by_key) - if self.extras is not None: - dct["extras"] = self.extras - if self.extensions is not None: - dct["extensions"] = self.extensions_to_data() - return dct - - @classmethod - def from_data(cls, dct): - if dct is None: - return None - return cls( - transmission_factor=dct.get("transmissionFactor"), - transmission_texture=create_if_data(TextureInfoData, dct, "transmissionTexture"), - extensions=cls.extensions_from_data(dct.get("extensions")), - extras=dct.get("extras"), - ) + key: ClassVar[str] = "KHR_materials_transmission" + transmission_factor: Optional[float] = None + transmission_texture: Optional[TextureInfoData] = None + extensions: Optional[dict[str, Any]] = None + extras: Any = None + +@dataclass class KHR_materials_specular(BaseGLTFDataClass): - """glTF extension that defines the optical transmission of a material. + """Specular reflectance material extension. + + References + ---------- + - [KHR_materials_specular](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_specular) - https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_specular """ - key = "KHR_materials_specular" - - def __init__( - self, - specular_factor=None, - specular_texture=None, - specular_color_factor=None, - specular_color_texture=None, - extensions=None, - extras=None, - ): - super(KHR_materials_specular, self).__init__(extras, extensions) - self.specular_factor = specular_factor - self.specular_texture = specular_texture - self.specular_color_factor = specular_color_factor - self.specular_color_texture = specular_color_texture - - def to_data(self, texture_index_by_key, **kwargs): - dct = {} - if self.specular_factor is not None: - dct["specularFactor"] = self.specular_factor - if self.specular_texture is not None: - dct["specularTexture"] = self.specular_texture.to_data(texture_index_by_key) - if self.specular_color_factor is not None: - dct["specularColorFactor"] = self.specular_color_factor - if self.specular_color_texture is not None: - dct["specularColorTexture"] = self.specular_color_texture.to_data(texture_index_by_key) - if self.extras is not None: - dct["extras"] = self.extras - if self.extensions is not None: - dct["extensions"] = self.extensions_to_data() - return dct - - @classmethod - def from_data(cls, dct): - if dct is None: - return None - return cls( - specular_factor=dct.get("specularFactor"), - specular_texture=create_if_data(TextureInfoData, dct, "specularTexture"), - specular_color_factor=dct.get("specularColorFactor"), - specular_color_texture=create_if_data(TextureInfoData, dct, "specularColorTexture"), - extensions=cls.extensions_from_data(dct.get("extensions")), - extras=dct.get("extras"), - ) + key: ClassVar[str] = "KHR_materials_specular" + + specular_factor: Optional[float] = None + specular_texture: Optional[TextureInfoData] = None + specular_color_factor: Optional[list[float]] = None + specular_color_texture: Optional[TextureInfoData] = None + extensions: Optional[dict[str, Any]] = None + extras: Any = None +@dataclass class KHR_materials_ior(BaseGLTFDataClass): - """glTF extension that defines the optical transmission of a material. + """Index-of-refraction material extension. + + References + ---------- + - [KHR_materials_ior](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_ior) - https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_ior """ - key = "KHR_materials_ior" - - def __init__( - self, - ior=None, - extensions=None, - extras=None, - ): - super(KHR_materials_ior, self).__init__(extras, extensions) - self.ior = ior - - def to_data(self, texture_index_by_key, **kwargs): - dct = {} - if self.ior is not None: - dct["ior"] = self.ior - if self.extras is not None: - dct["extras"] = self.extras - if self.extensions is not None: - dct["extensions"] = self.extensions_to_data() - return dct - - @classmethod - def from_data(cls, dct): - if dct is None: - return None - return cls( - ior=dct.get("ior"), - extensions=cls.extensions_from_data(dct.get("extensions")), - extras=dct.get("extras"), - ) + key: ClassVar[str] = "KHR_materials_ior" + + ior: Optional[float] = None + extensions: Optional[dict[str, Any]] = None + extras: Any = None +@dataclass class KHR_materials_clearcoat(BaseGLTFDataClass): - """glTF extension that defines the clearcoat material layer. + """Clearcoat material extension. + + References + ---------- + - [KHR_materials_clearcoat](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_clearcoat) - https://github.com/KhronosGroup/glTF/blob/master/extensions/2.0/Khronos/KHR_materials_clearcoat """ - key = "KHR_materials_clearcoat" - - def __init__( - self, - clearcoat_factor=None, - clearcoat_texture=None, - clearcoat_roughness_factor=None, - clearcoat_roughness_texture=None, - clearcoat_normal_texture=None, - extensions=None, - extras=None, - ): - super(KHR_materials_clearcoat, self).__init__(extras, extensions) - self.clearcoat_factor = clearcoat_factor - self.clearcoat_texture = clearcoat_texture - self.clearcoat_roughness_factor = clearcoat_roughness_factor - self.clearcoat_roughness_texture = clearcoat_roughness_texture - self.clearcoat_normal_texture = clearcoat_normal_texture - - def to_data(self, texture_index_by_key, **kwargs): - dct = {} - if self.clearcoat_factor is not None: - dct["clearcoatFactor"] = self.clearcoat_factor - if self.clearcoat_texture is not None: - dct["clearcoatTexture"] = self.clearcoat_texture.to_data(texture_index_by_key) - if self.clearcoat_roughness_factor is not None: - dct["clearcoatRoughnessFactor"] = self.clearcoat_roughness_factor - if self.clearcoat_roughness_texture is not None: - dct["clearcoatRoughnessTexture"] = self.clearcoat_roughness_texture.to_data(texture_index_by_key) - if self.clearcoat_normal_texture is not None: - dct["clearcoatNormalTexture"] = self.clearcoat_normal_texture.to_data(texture_index_by_key) - if self.extras is not None: - dct["extras"] = self.extras - if self.extensions is not None: - dct["extensions"] = self.extensions_to_data() - return dct - - @classmethod - def from_data(cls, dct): - if dct is None: - return None - return cls( - clearcoat_factor=dct.get("clearcoatFactor"), - clearcoat_texture=create_if_data(TextureInfoData, dct, "clearcoatTexture"), - clearcoat_roughness_factor=dct.get("clearcoatRoughnessFactor"), - clearcoat_roughness_texture=create_if_data(TextureInfoData, dct, "clearcoatRoughnessTexture"), - clearcoat_normal_texture=create_if_data(NormalTextureInfoData, dct, "clearcoatNormalTexture"), - extensions=cls.extensions_from_data(dct.get("extensions")), - extras=dct.get("extras"), - ) + key: ClassVar[str] = "KHR_materials_clearcoat" + clearcoat_factor: Optional[float] = None + clearcoat_texture: Optional[TextureInfoData] = None + clearcoat_roughness_factor: Optional[float] = None + clearcoat_roughness_texture: Optional[TextureInfoData] = None + clearcoat_normal_texture: Optional[NormalTextureInfoData] = None + extensions: Optional[dict[str, Any]] = None + extras: Any = None + +@dataclass class KHR_Texture_Transform(BaseGLTFDataClass): - """glTF extension that enables shifting and scaling UV coordinates on a per-texture basis. + """Texture-coordinate transformation extension. + + References + ---------- + - [KHR_texture_transform](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_texture_transform) - https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Khronos/KHR_texture_transform """ - key = "KHR_texture_transform" - - def __init__( - self, - offset=None, - rotation=None, - scale=None, - tex_coord=None, - extensions=None, - extras=None, - ): - super(KHR_Texture_Transform, self).__init__(extras, extensions) - self.offset = offset # or [0.0, 0.0] - self.rotation = rotation # or 0. - self.scale = scale # or [1., 1.] - self.tex_coord = tex_coord - - def to_data(self, **kwargs): - dct = {} - if self.offset is not None: - dct["offset"] = self.offset - if self.rotation is not None: - dct["rotation"] = self.rotation - if self.scale is not None: - dct["scale"] = self.scale - if self.tex_coord is not None: - dct["texCoord"] = self.tex_coord - if self.extras is not None: - dct["extras"] = self.extras - if self.extensions is not None: - dct["extensions"] = self.extensions_to_data() - return dct - - @classmethod - def from_data(cls, dct): - if dct is None: - return None - return cls( - offset=dct.get("offset"), - rotation=dct.get("rotation"), - scale=dct.get("scale"), - tex_coord=dct.get("texCoord"), - extensions=cls.extensions_from_data(dct.get("extensions")), - extras=dct.get("extras"), - ) + key: ClassVar[str] = "KHR_texture_transform" + + offset: Optional[list[float]] = None + rotation: Optional[float] = None + scale: Optional[list[float]] = None + tex_coord: Optional[int] = None + extensions: Optional[dict[str, Any]] = None + extras: Any = None +@dataclass class KHR_materials_pbrSpecularGlossiness(BaseGLTFDataClass): - """glTF extension that defines the specular-glossiness material model from Physically-Based Rendering (PBR) methodology.""" - - key = "KHR_materials_pbrSpecularGlossiness" - - def __init__( - self, - diffuse_factor=None, - diffuse_texture=None, - specular_factor=None, - glossiness_factor=None, - specular_glossiness_texture=None, - extensions=None, - extras=None, - ): - super(KHR_materials_pbrSpecularGlossiness, self).__init__(extras, extensions) - self.diffuse_factor = diffuse_factor or [1.0, 1.0, 1.0, 1.0] - self.diffuse_texture = diffuse_texture - self.specular_factor = specular_factor or [1.0, 1.0, 1.0] - self.glossiness_factor = glossiness_factor or 1.0 - self.specular_glossiness_texture = specular_glossiness_texture - - def to_data(self, texture_index_by_key, **kwargs): - dct = {} - if self.diffuse_factor is not None: - dct["diffuseFactor"] = self.diffuse_factor - if self.diffuse_texture is not None: - dct["diffuseTexture"] = self.diffuse_texture.to_data(texture_index_by_key) - if self.specular_factor is not None: - dct["specularFactor"] = self.specular_factor - if self.glossiness_factor is not None: - dct["glossinessFactor"] = self.glossiness_factor - if self.specular_glossiness_texture is not None: - dct["specularGlossinessTexture"] = self.specular_glossiness_texture.to_data(texture_index_by_key) - if self.extras is not None: - dct["extras"] = self.extras - if self.extensions is not None: - dct["extensions"] = self.extensions_to_data() - return dct - - @classmethod - def from_data(cls, dct): - if dct is None: - return None - return cls( - diffuse_factor=dct.get("diffuseFactor"), - diffuse_texture=create_if_data(TextureInfoData, dct, "diffuseTexture"), - specular_factor=dct.get("specularFactor"), - glossiness_factor=dct.get("glossinessFactor"), - specular_glossiness_texture=create_if_data(TextureInfoData, dct, "specularGlossinessTexture"), - extensions=cls.extensions_from_data(dct.get("extensions")), - extras=dct.get("extras"), - ) + """Specular-glossiness material extension.""" + + key: ClassVar[str] = "KHR_materials_pbrSpecularGlossiness" + + diffuse_factor: Optional[list[float]] = None + diffuse_texture: Optional[TextureInfoData] = None + specular_factor: Optional[list[float]] = None + glossiness_factor: Optional[float] = None + specular_glossiness_texture: Optional[TextureInfoData] = None + extensions: Optional[dict[str, Any]] = None + extras: Any = None + + def __post_init__(self) -> None: + if self.diffuse_factor is None: + self.diffuse_factor = [1.0, 1.0, 1.0, 1.0] + if self.specular_factor is None: + self.specular_factor = [1.0, 1.0, 1.0] + if self.glossiness_factor is None: + self.glossiness_factor = 1.0 SUPPORTED_EXTENSIONS = { diff --git a/src/compas/files/gltf/gltf.py b/src/compas/files/gltf/gltf.py index 5ca2c61331d8..89b110d2af3c 100644 --- a/src/compas/files/gltf/gltf.py +++ b/src/compas/files/gltf/gltf.py @@ -1,92 +1,70 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +"""Convenience functions for reading and writing glTF documents.""" -from compas.files.gltf.gltf_exporter import GLTFExporter -from compas.files.gltf.gltf_parser import GLTFParser -from compas.files.gltf.gltf_reader import GLTFReader +from os import PathLike +from pathlib import Path +from typing import Optional +from typing import cast +from compas import _iotools -class GLTF(object): - """Class for working with files in glTF format. +from .gltf_document import GLTFDocument +from .gltf_encoder import GLTFEncoder +from .gltf_parser import GLTFParser +from .gltf_reader import GLTFReader +from .gltf_resources import GLTFResourceLoader +from .gltf_types import GLTFFormat +from .gltf_writer import GLTFWriter - Caution: Extensions and most other application specific data are unsupported, - and their data may be lost upon import. - Attributes - ---------- - filepath : str - Path to the location of the glTF file. - content : :class:`compas.files.GLTFContent` - reader : :class:`compas.files.GLTFReader` - parser : :class:`compas.files.GLTFParser` - exporter : :class:`compas.files.GLTFExporter` +def read_gltf( + source: _iotools.IOSource, + resource_loader: Optional[GLTFResourceLoader] = None, +) -> GLTFDocument: + """Read a glTF document. - References + Parameters ---------- - .. [1] https://github.com/KhronosGroup/glTF/blob/master/specification/2.0/figures/gltfOverview-2.0.0b.png - - """ - - def __init__(self, filepath=None): - self.filepath = filepath - self._content = None - - self._is_parsed = False - self._reader = None - self._parser = None - - self._exporter = None - - def read(self): - """Read the glTF located at :attr:`compas.files.GLTF.filepath` and load its content.""" - self._reader = GLTFReader(self.filepath) - self._parser = GLTFParser(self._reader) - self._is_parsed = True + source + Path, URL, text stream, or binary stream. + resource_loader + Optional loader for external buffers and images. - self._content = self._parser.content + Returns + ------- + GLTFDocument + Parsed semantic document. - @property - def reader(self): - if not self._is_parsed: - self.read() - return self._reader - - @property - def parser(self): - if not self._is_parsed: - self.read() - return self._parser - - @property - def content(self): - return self._content - - @content.setter - def content(self, value): - if not self._is_parsed: - self._is_parsed = True - self._content = value - - @property - def exporter(self): - if not self._exporter: - self._exporter = GLTFExporter(self.filepath, self.content) - return self._exporter + """ + return GLTFParser(GLTFReader(source, resource_loader).read()).parse() - def export(self, embed_data=False): - """Export the content of this :class:`compas.files.GLTF` to the location - :attr:`compas.files.GLTF.filepath`, with file format determined by the given extension. - Parameters - ---------- - embed_data : bool - When set to ``True``, mesh and other data will be embedded in the glTF, - and no external binary file will be created. The default value is ``False``. +def write_gltf( + target: _iotools.IOTarget, + document: GLTFDocument, + format: Optional[GLTFFormat] = None, + embed_data: bool = False, +) -> None: + """Encode and write a glTF document. - Returns - ------- + Parameters + ---------- + target + Path or writable stream. + document + Semantic glTF document. + format + Explicit format required for stream targets. + embed_data + Embed binary data in JSON glTF output. - """ - self.exporter.embed_data = embed_data - self.exporter.export() + """ + if format is None: + if not isinstance(target, (str, PathLike)): + raise ValueError("A glTF format is required for stream targets.") + suffix = Path(target).suffix.lower() + if suffix not in (".gltf", ".glb"): + raise ValueError("The target must use a .gltf or .glb extension.") + format = cast(GLTFFormat, suffix[1:]) + filename = Path(target).stem if isinstance(target, (str, PathLike)) else "model" + payload = GLTFEncoder(format, embed_data, filename).encode(document) + GLTFWriter(target).write(payload) diff --git a/src/compas/files/gltf/gltf_accessors.py b/src/compas/files/gltf/gltf_accessors.py new file mode 100644 index 000000000000..b80670d0951c --- /dev/null +++ b/src/compas/files/gltf/gltf_accessors.py @@ -0,0 +1,210 @@ +"""Buffer, buffer-view, accessor, and image decoding for glTF.""" + +import base64 +import binascii +import re +import struct +from typing import Any +from typing import Optional + +from .constants import COMPONENT_TYPE_BYTE +from .constants import COMPONENT_TYPE_ENUM +from .constants import COMPONENT_TYPE_SHORT +from .constants import COMPONENT_TYPE_UNSIGNED_BYTE +from .constants import COMPONENT_TYPE_UNSIGNED_SHORT +from .constants import NUM_COMPONENTS_BY_TYPE_ENUM +from .gltf_container import GLTFParseError +from .gltf_resources import GLTFResourceLoader +from .gltf_types import AccessorData +from .gltf_types import GLTFJson + + +def is_data_uri(uri: str) -> bool: + """Determine whether a URI contains embedded data.""" + return uri.startswith("data:") + + +def decode_data_uri(uri: str) -> bytes: + """Decode a base64 data URI. + + Returns + ------- + bytes + Decoded contents. + + """ + try: + metadata, encoded = uri.split(",", 1) + if ";base64" not in metadata: + raise GLTFParseError("Only base64 glTF data URIs are supported.") + return base64.b64decode(encoded, validate=True) + except (ValueError, binascii.Error) as error: + if isinstance(error, GLTFParseError): + raise + raise GLTFParseError("Invalid glTF data URI.") from error + + +def data_uri_mime_type(uri: Optional[str]) -> Optional[str]: + """Extract the media type from a data URI.""" + if not uri or not is_data_uri(uri): + return None + match = re.match(r"data:([^;,]+)", uri) + return match.group(1) if match else None + + +class GLTFAccessorDecoder: + """Resolve buffers and decode accessors from parsed glTF JSON.""" + + def __init__( + self, + document: GLTFJson, + binary_chunk: Optional[bytes], + resource_loader: Optional[GLTFResourceLoader], + ) -> None: + self.document = document + self.binary_chunk = binary_chunk + self.resource_loader = resource_loader + self._buffers: dict[int, bytes] = {} + + def decode_all(self) -> list[AccessorData]: + """Decode every accessor. + + Returns + ------- + list[AccessorData] + Decoded accessor values in source order. + + """ + return [self.decode(accessor) for accessor in self.document.get("accessors", [])] + + def decode(self, accessor: GLTFJson) -> AccessorData: + count = accessor["count"] + component_type = accessor["componentType"] + type_ = accessor["type"] + components = NUM_COMPONENTS_BY_TYPE_ENUM[type_] + if "sparse" not in accessor and "bufferView" not in accessor: + return None + if "bufferView" in accessor: + values = self._read_buffer_view( + accessor["bufferView"], count, component_type, accessor.get("byteOffset", 0), type_ + ) + else: + values = [(0,) * components for _ in range(count)] + + sparse = accessor.get("sparse") + if sparse: + indices = sparse["indices"] + sparse_indices = self._read_buffer_view( + indices["bufferView"], sparse["count"], indices["componentType"], indices.get("byteOffset", 0), "SCALAR" + ) + sparse_values_spec = sparse["values"] + sparse_values = self._read_buffer_view( + sparse_values_spec["bufferView"], + sparse["count"], + component_type, + sparse_values_spec.get("byteOffset", 0), + type_, + ) + for index, value_index in enumerate(sparse_indices): + values[value_index] = sparse_values[index] + + if accessor.get("normalized", False): + values = [_normalize(value, component_type) for value in values] + return values + + def buffer_view_bytes(self, index: int) -> bytes: + """Read the exact bytes of a buffer view. + + Returns + ------- + bytes + Buffer-view contents. + + """ + view = self.document["bufferViews"][index] + buffer = self._get_buffer(view["buffer"]) + offset = view.get("byteOffset", 0) + end = offset + view["byteLength"] + if end > len(buffer): + raise GLTFParseError("Buffer view exceeds its buffer.") + return buffer[offset:end] + + def resource_bytes(self, uri: str) -> bytes: + """Resolve an embedded or external resource URI. + + Returns + ------- + bytes + Resource contents. + + """ + if is_data_uri(uri): + return decode_data_uri(uri) + if self.resource_loader is None: + raise GLTFParseError(f"No resource loader is available for {uri!r}.") + return self.resource_loader.read(uri) + + def _read_buffer_view( + self, index: int, count: int, component_type: int, accessor_offset: int, type_: str + ) -> list[Any]: + view = self.document["bufferViews"][index] + format_char = COMPONENT_TYPE_ENUM[component_type] + components = NUM_COMPONENTS_BY_TYPE_ENUM[type_] + component_size = struct.calcsize("<" + format_char) + if type_ == "MAT2" and component_size == 1: + format_ = "<" + (format_char * 2 + "xx") * 2 + elif type_ == "MAT3" and component_size == 1: + format_ = "<" + (format_char * 3 + "x") * 3 + elif type_ == "MAT3" and component_size == 2: + format_ = "<" + (format_char * 3 + "xx") * 3 + else: + format_ = "<" + format_char * components + item_size = struct.calcsize(format_) + stride = view.get("byteStride", item_size) + if stride < item_size: + raise GLTFParseError("Buffer-view stride is smaller than an accessor item.") + offset = view.get("byteOffset", 0) + accessor_offset + buffer = self._get_buffer(view["buffer"]) + if count and offset + (count - 1) * stride + item_size > len(buffer): + raise GLTFParseError("Accessor exceeds its buffer.") + unpack = struct.Struct(format_).unpack_from + values = [unpack(buffer, offset + item * stride) for item in range(count)] + return [value[0] for value in values] if components == 1 else values + + def _get_buffer(self, index: int) -> bytes: + if index in self._buffers: + return self._buffers[index] + spec = self.document["buffers"][index] + uri = spec.get("uri") + if uri is None: + if self.binary_chunk is None: + raise GLTFParseError("A buffer has no URI and no GLB binary chunk.") + data = self.binary_chunk + elif is_data_uri(uri): + data = decode_data_uri(uri) + else: + if self.resource_loader is None: + raise GLTFParseError(f"No resource loader is available for {uri!r}.") + data = self.resource_loader.read(uri) + if len(data) < spec["byteLength"]: + raise GLTFParseError("Buffer is shorter than its declared byte length.") + self._buffers[index] = data + return data + + +def _normalize(value: Any, component_type: int) -> Any: + scalar = not isinstance(value, tuple) + values = (value,) if scalar else value + result = [] + for component in values: + if component_type == COMPONENT_TYPE_BYTE: + result.append(max(component / 127.0, -1.0)) + elif component_type == COMPONENT_TYPE_UNSIGNED_BYTE: + result.append(component / 255.0) + elif component_type == COMPONENT_TYPE_SHORT: + result.append(max(component / 32767.0, -1.0)) + elif component_type == COMPONENT_TYPE_UNSIGNED_SHORT: + result.append(component / 65535.0) + else: + result.append(float(component)) + return result[0] if scalar else tuple(result) diff --git a/src/compas/files/gltf/gltf_children.py b/src/compas/files/gltf/gltf_children.py index 32e3527a8a55..e599665b10bc 100644 --- a/src/compas/files/gltf/gltf_children.py +++ b/src/compas/files/gltf/gltf_children.py @@ -1,64 +1,97 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +"""Validated node-key collection used by glTF scenes and nodes.""" +from collections.abc import Iterable +from collections.abc import Iterator +from collections.abc import MutableSequence +from typing import TYPE_CHECKING +from typing import Optional -class GLTFChildren(object): - def __init__(self, context, values): +if TYPE_CHECKING: + from .gltf_document import GLTFDocument + + +class GLTFChildren(MutableSequence[int]): + """Mutable node-key sequence validated against a document.""" + + def __init__(self, context: "GLTFDocument", values: Iterable[int] = ()) -> None: self._values = list(values) self._context = context - for v in values: - self.check_node_context(v) + for value in self._values: + self._validate(value) - def __repr__(self): + def __repr__(self) -> str: return repr(self._values) - def __iter__(self): - return iter(self._values) + def __getitem__(self, index): + return self._values[index] + + def __setitem__(self, index, value) -> None: + if isinstance(index, slice): + values = list(value) + for item in values: + self._validate(item) + self._values[index] = values + else: + self._validate(value) + self._values[index] = value + + def __delitem__(self, index) -> None: + del self._values[index] - def __len__(self): + def __len__(self) -> int: return len(self._values) - def __bool__(self): - return bool(self._values) + def __iter__(self) -> Iterator[int]: + return iter(self._values) - def check_node_context(self, v): - if v not in self._context.nodes: - raise Exception("Cannot find Node {}.".format(v)) + def insert(self, index: int, value: int) -> None: + self._validate(value) + self._values.insert(index, value) - def append(self, value): - self.check_node_context(value) - self._values.append(value) + def _validate(self, value: int) -> None: + if value not in self._context.nodes: + raise ValueError(f"Cannot find glTF node {value}.") - def extend(self, values): - for value in values: - self.check_node_context(value) - self._values.extend(values) + def pop(self, index: int = -1) -> int: + """Remove and return a node key. - def insert(self, index, value): - self.check_node_context(value) - self._values.insert(index, value) + Returns + ------- + int + Removed node key. + + """ + return self._values.pop(index) + + def index(self, value: int, start: int = 0, stop: Optional[int] = None) -> int: + """Return the position of a node key. - def remove(self, value): - self._values.remove(value) + Returns + ------- + int + Position of the first matching key. - def pop(self, index=None): - self._values.pop(index or (len(self._values) - 1)) + """ + return self._values.index(value, start, len(self._values) if stop is None else stop) - def clear(self): - self._values.clear() + def count(self, value: int) -> int: + """Count occurrences of a node key. - def index(self, value, start=None, end=None): - self._values.index(value, start or 0, end or (len(self._values) - 1)) + Returns + ------- + int + Number of occurrences. - def count(self, value): - self._values.count(value) + """ + return self._values.count(value) - def sort(self, key=None, reverse=False): - self._values.sort(key=key, reverse=reverse) + def copy(self) -> list[int]: + """Return an independent list of node keys. - def reverse(self): - self._values.reverse() + Returns + ------- + list[int] + Copied node keys. - def copy(self): + """ return self._values.copy() diff --git a/src/compas/files/gltf/gltf_container.py b/src/compas/files/gltf/gltf_container.py new file mode 100644 index 000000000000..64301dd718ab --- /dev/null +++ b/src/compas/files/gltf/gltf_container.py @@ -0,0 +1,73 @@ +"""JSON glTF and binary GLB container decoding.""" + +import json +import struct +from typing import Optional + +from .gltf_types import GLTFFormat +from .gltf_types import GLTFJson + + +class GLTFParseError(ValueError): + """Error raised for invalid glTF data.""" + + +def parse_container(data: bytes) -> tuple[GLTFFormat, GLTFJson, Optional[bytes]]: + """Decode the primary JSON glTF or GLB container. + + Returns + ------- + tuple[GLTFFormat, GLTFJson, bytes | None] + Container format, JSON document, and optional GLB binary chunk. + + """ + if data[:4] == b"glTF": + return _parse_glb(data) + try: + document = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise GLTFParseError("Invalid JSON glTF data.") from error + if not isinstance(document, dict): + raise GLTFParseError("The glTF JSON root must be an object.") + _validate_version(document) + return "gltf", document, None + + +def _parse_glb(data: bytes) -> tuple[GLTFFormat, GLTFJson, Optional[bytes]]: + if len(data) < 20: + raise GLTFParseError("GLB data is incomplete.") + magic, version, declared_length = struct.unpack_from("<4sII", data) + if magic != b"glTF" or version != 2: + raise GLTFParseError("Invalid GLB header.") + if declared_length != len(data): + raise GLTFParseError("GLB length does not match its header.") + + offset = 12 + chunks: list[tuple[bytes, bytes]] = [] + while offset < len(data): + if offset + 8 > len(data): + raise GLTFParseError("GLB chunk header is incomplete.") + length, chunk_type = struct.unpack_from(" len(data): + raise GLTFParseError("GLB chunk data is incomplete.") + chunks.append((chunk_type, data[offset : offset + length])) + offset += length + if not chunks or chunks[0][0] != b"JSON": + raise GLTFParseError("The first GLB chunk must contain JSON.") + if len(chunks) > 2 or len(chunks) == 2 and chunks[1][0] != b"BIN\0": + raise GLTFParseError("GLB contains an unsupported chunk layout.") + try: + document = json.loads(chunks[0][1].decode("utf-8").rstrip(" \0")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise GLTFParseError("Invalid JSON in GLB container.") from error + if not isinstance(document, dict): + raise GLTFParseError("The glTF JSON root must be an object.") + _validate_version(document) + return "glb", document, chunks[1][1] if len(chunks) == 2 else None + + +def _validate_version(document: GLTFJson) -> None: + asset = document.get("asset") + if not isinstance(asset, dict) or asset.get("version") != "2.0": + raise GLTFParseError("glTF asset version 2.0 is required.") diff --git a/src/compas/files/gltf/gltf_content.py b/src/compas/files/gltf/gltf_content.py deleted file mode 100644 index 94b655b7acf1..000000000000 --- a/src/compas/files/gltf/gltf_content.py +++ /dev/null @@ -1,615 +0,0 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from compas.files.gltf.data_classes import TextureInfoData -from compas.files.gltf.gltf_mesh import GLTFMesh -from compas.files.gltf.gltf_node import GLTFNode -from compas.files.gltf.gltf_scene import GLTFScene -from compas.files.gltf.helpers import get_weighted_mesh_vertices -from compas.geometry import multiply_matrices -from compas.geometry import transform_points - - -class GLTFContent(object): - """ - Class for managing the content of a glTF file. - - Attributes - ---------- - scenes : dict - Dictionary containing (int, :class:`compas.files.GLTFScene`) pairs. - default_scene_key : int or None - Key of the scene to be displayed on loading the glTF. - nodes : dict - Dictionary containing (int, :class:`compas.files.GLTFNode`) pairs. - meshes : dict - Dictionary containing (int, :class:`compas.files.GLTFMesh`) pairs. - cameras : dict - Dictionary containing (int, :class:`compas.files.data_classes.CameraData`) pairs. - animations : dict - Dictionary containing (int, :class:`compas.files.data_classes.AnimationData`) pairs. - skins : dict - Dictionary containing (int, :class:`compas.files.data_classes.SkinData`) pairs. - materials : dict - Dictionary containing (int, :class:`compas.files.data_classes.MaterialData`) pairs. - textures : dict - Dictionary containing (int, :class:`compas.files.data_classes.TextureData`) pairs. - samplers : dict - Dictionary containing (int, :class:`compas.files.data_classes.SamplerData`) pairs. - images : dict - Dictionary containing (int, :class:`compas.files.data_classes.ImageData`) pairs. - extras : object - extensions : object - - """ - - def __init__(self): - self.scenes = {} - self.default_scene_key = None - self.nodes = {} - self.meshes = {} - self.cameras = {} - self.animations = {} - self.skins = {} - self.materials = {} - self.textures = {} - self.samplers = {} - self.images = {} - self.extras = None - self.extensions = None - self.extensions_used = None - - @property - def default_or_first_scene(self): - key = self.default_scene_key or 0 - return self.scenes[key] - - def check_if_forest(self): - """Raises an exception if :attr:`compas.files.GLTFContent.nodes` is not a disjoint - union of rooted trees. - - Returns - ------- - - """ - visited_nodes = set() - - def visit(key): - node = self.nodes[key] - if key in visited_nodes: - raise Exception("Nodes do not form a rooted forest.") - visited_nodes.add(key) - for child_key in node.children: - visit(child_key) - - for scene in self.scenes.values(): - for node_key in scene.children: - visit(node_key) - - def remove_orphans(self): - """Removes orphaned objects. - - Returns - ------- - - """ - node_visit_log = {key: False for key in self.nodes} - mesh_visit_log = {key: False for key in self.meshes} - camera_visit_log = {key: False for key in self.cameras} - material_visit_log = {key: False for key in self.materials} - texture_visit_log = {key: False for key in self.textures} - sampler_visit_log = {key: False for key in self.samplers} - image_visit_log = {key: False for key in self.images} - - def visit_node(key): - node = self.nodes[key] - node_visit_log[key] = True - if node.mesh_key is not None: - mesh_visit_log[node.mesh_key] = True - if node.camera is not None: - camera_visit_log[node.camera] = True - for child_key in node.children: - visit_node(child_key) - - # walk through scenes and update visit logs of nodes, meshes, and cameras. - for scene in self.scenes.values(): - for node_key in scene.children: - visit_node(node_key) - - # remove unvisited nodes - self._remove_unvisited(node_visit_log, self.nodes) - - # remove unvisited meshes - self._remove_unvisited(mesh_visit_log, self.meshes) - - # remove unvisited cameras - self._remove_unvisited(camera_visit_log, self.cameras) - - # remove animations referencing no existing nodes - for animation_key, animation in self.animations.items(): - visited_sampler_keys = [] - for channel in animation.channels: - if not node_visit_log[channel.target.node]: - animation.channels.remove(channel) - else: - visited_sampler_keys.append(channel.sampler) - animation.samplers_dict = {key: animation.samplers_dict[key] for key in animation.samplers_dict if key in visited_sampler_keys} - if not animation.samplers_dict: - del self.animations[animation_key] - - # remove skins referencing no existing nodes - for key, skin_data in self.skins.items(): - for joint_key in skin_data.joints: - if not node_visit_log[joint_key]: - skin_data.joints.remove(joint_key) - if not skin_data.joints: - del self.skins[key] - - # walk through existing meshes and update materials visit log - for mesh in self.meshes.values(): - for primitive in mesh.primitive_data_list: - if primitive.material is not None: - material_visit_log[primitive.material] = True - - # remove unvisited materials - self._remove_unvisited(material_visit_log, self.materials) - - # walk through existing materials and update textures visit log - def check_extensions_texture_recursively(item): - # get the extensions that are in the attributes - for a in dir(item): - if not a.startswith("__") and not callable(getattr(item, a)): - # ipy does not like this one: if isinstance(getattr(item, a), TextureInfoData): - if getattr(getattr(item, a), "IS_TEXTURE_INFO_DATA", False): - texture_visit_log[getattr(item, a).index] = True - # ipy does not like this one: elif isinstance(getattr(item, a), BaseGLTFDataClass): - elif getattr(getattr(item, a), "IS_BASE_GLTF_DATA", False): - check_extensions_texture_recursively(getattr(item, a)) - if item.extensions is not None: - for _, e in item.extensions.items(): - check_extensions_texture_recursively(e) - - for material in self.materials.values(): - check_extensions_texture_recursively(material) - - # remove unvisited textures - self._remove_unvisited(texture_visit_log, self.textures) - - # walk through existing textures and update visit logs of samplers and images - for texture in self.textures.values(): - if texture.sampler is not None: - sampler_visit_log[texture.sampler] = True - if texture.source is not None: - image_visit_log[texture.source] = True - - # remove unvisited samplers - self._remove_unvisited(sampler_visit_log, self.samplers) - - # remove unvisited images - self._remove_unvisited(image_visit_log, self.images) - - def _remove_unvisited(self, log, dictionary): - for key, visited in log.items(): - if not visited: - del dictionary[key] - - def update_node_transforms_and_positions(self): - """Walks through all nodes and updates their transforms and positions. To be used when - scene or nodes have been added or the nodes' matrices or TRS attributes have been set or updated. - - Returns - ------- - - """ - for scene in self.scenes.values(): - self.update_scene_transforms_and_positions(scene) - - def update_scene_transforms_and_positions(self, scene): - """Walks through the scene tree and updates transforms and positions. To be used when - nodes have been added or the nodes' matrices or TRS attributes have been set or updated. - - Parameters - ---------- - scene : :class:`compas.files.GLTFScene` - - Returns - ------- - - """ - 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] - while queue: - cur_key = queue.pop(0) - cur = self.nodes[cur_key] - for child_key in cur.children: - child = self.nodes[child_key] - child.transform = multiply_matrices(cur.transform, child.matrix or child.get_matrix_from_trs()) - child.position = transform_points([origin], child.transform)[0] - queue.append(child_key) - - def get_node_faces(self, node): - """Returns the faces of the mesh at ``node``, if any. - - Parameters - ---------- - node : :class:`compas.files.GLTFNode` - - Returns - ------- - list - """ - mesh_data = self.meshes.get(node.mesh_key) - if mesh_data is None: - return None - return mesh_data.faces - - def get_node_vertices(self, node): - """Returns the vertices of the mesh at ``node``, if any. - - Parameters - ---------- - node : :class:`compas.files.GLTFNode` - - Returns - ------- - list - """ - mesh_data = self.meshes.get(node.mesh_key) - if mesh_data is None: - return None - if node.weights is None: - return mesh_data.vertices - return get_weighted_mesh_vertices(mesh_data, node.weights) - - def get_node_by_name(self, name): - """Returns the node with a specific name. - - Parameters - ---------- - name : str - The name of the node - - Returns - ------- - node : :class:`compas.files.GLTFNode` or `None` - """ - for key in self.nodes: - if self.nodes[key].name == name: - return self.nodes[key] - return None - - @classmethod - def _get_next_available_key(cls, adict): - key = len(adict) - while key in adict: - key += 1 - return key - - def add_material(self, material): - """Adds a material to the content. - - Parameters - ---------- - material : :class:`compas.files.data_classes.MaterialData` - The material to add - - Returns - ------- - int - """ - key = self._get_next_available_key(self.materials) - self.materials[key] = material - return key - - def add_texture(self, texture): - """Adds a texture to the content. - - Parameters - ---------- - texture : :class:`compas.files.data_classes.TextureData` - The texture to add - - Returns - ------- - int - """ - key = self._get_next_available_key(self.textures) - self.textures[key] = texture - return key - - def add_image(self, image): - """Adds an image to the content. - - Parameters - ---------- - image : :class:`compas.files.data_classes.ImageData` - The image to add - - Returns - ------- - int - """ - key = self._get_next_available_key(self.images) - self.images[key] = image - return key - - def get_material_index_by_name(self, name): - """Returns the index of the material. - - Parameters - ---------- - name : str - The name of the material - - Returns - ------- - int or None - """ - for key, material in self.materials.items(): - if material.name == name: - return key - return None - - def add_scene(self, name=None, extras=None): - """Adds a scene to the content. - - Parameters - ---------- - name : str - extras : object - - Returns - ------- - :class:`compas.files.GLTFScene` - """ - return GLTFScene(self, name=name, extras=extras) - - def add_node_to_scene(self, scene, node_name=None, node_extras=None): - """Creates a :class:`compas.files.GLTFNode` and adds this node to the children of ``scene``. - - Parameters - ---------- - scene : :class:`compas.files.GLTFScene` - node_name : str - node_extras : object - - Returns - ------- - :class:`compas.files.GLTFNode` - """ - if scene not in self.scenes.values(): - raise Exception("Cannot find scene.") - node = GLTFNode(self, node_name, node_extras) - scene.children.append(node.key) - return node - - def add_child_to_node(self, parent_node, child_name=None, child_extras=None): - """Creates a :class:`compas.files.GLTFNode` and adds this node to the children of ``parent_node``. - - Parameters - ---------- - parent_node : :class:`compas.files.GLTFNode` - child_name : str - child_extras : object - - Returns - ------- - :class:`compas.files.GLTFNode` - """ - child_node = GLTFNode(self, child_name, child_extras) - parent_node.children.append(child_node.key) - return child_node - - def add_mesh(self, mesh): - """Creates a :class:`compas.files.GLTFMesh` object from a compas mesh, and adds this - to the content. - - Parameters - ---------- - mesh : :class:`compas.datastructures.Mesh` - - Returns - ------- - :class:`compas.files.GLTFMesh` - """ - return GLTFMesh.from_mesh(self, mesh) - - def add_mesh_to_node(self, node, mesh): - """Adds an existing mesh to ``node`` if ``mesh`` is a valid mesh key, or through ``add_mesh`` creates and adds a - mesh to ``node``. - - Parameters - ---------- - node : :class:`compas.files.GLTFNode` - mesh : Union[:class:`compas.datastructures.Mesh`, int] - - Returns - ------- - :class:`compas.files.GLTFMesh` - """ - if isinstance(mesh, int): - mesh_data = self.meshes[mesh] - else: - mesh_data = self.add_mesh(mesh) - node.mesh_key = mesh_data.key - return mesh_data - - def get_nodes_from_scene(self, scene): - """Returns dictionary of nodes in the given scene, without a specified root. - - Parameters - ---------- - scene : :class:`compas.files.GLTFScene` - - Returns - ------- - dict - """ - node_dict = {} - - def visit(key): - node_dict[key] = self.nodes[key] - for child in self.nodes[key].children: - visit(child) - - for child_key in scene.children: - visit(child_key) - - return node_dict - - def get_scene_positions_and_edges(self, scene): - """Returns a tuple containing a dictionary of positions and a list of tuples representing edges. - - Parameters - ---------- - scene : :class:`compas.files.GLTFScene` - - Returns - ------- - tuple - """ - positions_dict = {"root": [0, 0, 0]} - edges_list = [] - - def visit(node, key): - for child_key in node.children: - positions_dict[child_key] = self.nodes[child_key].position - edges_list.append((key, child_key)) - visit(self.nodes[child_key], child_key) - - visit(scene, "root") - - return positions_dict, edges_list - - -# ============================================================================== -# Main -# ============================================================================== - -if __name__ == "__main__": - import os - import urllib - - import compas - from compas.datastructures import Mesh - from compas.files.gltf.data_classes import ImageData - from compas.files.gltf.data_classes import MaterialData - from compas.files.gltf.data_classes import MineType - from compas.files.gltf.data_classes import PBRMetallicRoughnessData - from compas.files.gltf.data_classes import TextureData - from compas.files.gltf.extensions import KHR_materials_pbrSpecularGlossiness - from compas.files.gltf.extensions import KHR_Texture_Transform - from compas.files.gltf.gltf import GLTF - from compas.geometry import Box - from compas.geometry import Frame - from compas.utilities import download_file_from_remote - - dirname = os.path.join(compas.APPDATA, "data", "gltfs") - gltf_filepath = os.path.join(dirname, "compas.gltf") - - image_uri = "compas_icon_white.png" - image_file = os.path.join(dirname, image_uri) - try: - download_file_from_remote("https://compas.dev/images/compas_icon_white.png", image_file) - except urllib.error.HTTPError: - pass - - cnt = GLTFContent() - scene = cnt.add_scene() - - # image's uri should be the relative path to the image from the filepath given at the time of export, - # so if the image will sit in the same directory as the resultant gltf, the uri is just the name of the file. - # it's the exporter's job to manage how things are stored in the buffer, and it would only store image data - # in the buffer if it is exporting as glb. otherwise the uri will just stay the relative path to the image - # and the gltf only makes sense when bundled with these external files. - image_data = ImageData( - name=image_uri, - mime_type=MineType.PNG, - uri=image_file, - ) - image_idx = cnt.add_image(image_data) - # TextureData.source takes the key of the ImageData that it should use as 'source' - texture = TextureData(source=image_idx) - texture_idx = cnt.add_texture(texture) - - texture = TextureData(source=image_idx) - texture_idx2 = cnt.add_texture(texture) - - material = MaterialData() - material.name = "Texture" - material.pbr_metallic_roughness = PBRMetallicRoughnessData() - material.pbr_metallic_roughness.metallic_factor = 0.0 - material.pbr_metallic_roughness.base_color_texture = TextureInfoData(index=texture_idx) - material_key = cnt.add_material(material) - - # add extension - pbr_specular_glossiness = KHR_materials_pbrSpecularGlossiness() - pbr_specular_glossiness.diffuse_factor = [ - 0.980392158, - 0.980392158, - 0.980392158, - 1.0, - ] - pbr_specular_glossiness.specular_factor = [0.0, 0.0, 0.0] - pbr_specular_glossiness.glossiness_factor = 0.0 - texture_transform = KHR_Texture_Transform() - texture_transform.rotation = 0.0 - texture_transform.scale = [2.0, 2.0] - # same here, TextureInfoData uses the key of the TextureData - pbr_specular_glossiness.diffuse_texture = TextureInfoData(texture_idx2) - pbr_specular_glossiness.diffuse_texture.add_extension(texture_transform) - material.add_extension(pbr_specular_glossiness) - - # add box - box = Box(Frame.worldXY(), 1, 1, 1) - mesh = Mesh.from_shape(box) - mesh.quads_to_triangles() - - node = scene.add_child() - mesh_data = node.add_mesh(mesh) - normals = [mesh.vertex_normal(k) for k in mesh.vertices()] - - texcoord_0 = [(0, 0) for _ in mesh.vertices()] - - """ - for fkey in mesh.faces(): - vkeys = mesh.face_vertices(fkey) - plane = mesh.face_plane(fkey) - frame = Frame.from_plane(plane) - coords = mesh.face_coordinates(fkey) - for vkey, xyz in zip(vkeys, coords): - u, v, _ = frame.to_local_coordinates(Point(*xyz)) - texcoord_0[vkey] = (u, v) # not ideal, gets overwritten - """ - - # here is the tricky part... for this material to be valid and applied to this mesh, - # each of the primitives must have within the attribute `attributes` a key of the form `TEXCOORD_{some integer}`. - # the value of this thing should be a list of pairs of floats representing the UV texture coordinates for each vertex. - # if `{some integer}` is 0 then there's nothing else to do. but if a primitive has multiple `TEXTCOORD_{some integer}`s, - # then the various `TextureInfoData.tex_coord` associated to this material have to be updated with the appropriate `{some integer}`. - - # would work better if each vertex could have 4 different texture coordinates - texcoord_0 = [ - (0.0, 1.0), - (0.0, 0.0), - (1.0, 0.0), - (1.0, 1.0), - (0.0, 0.0), - (1.0, 0.0), - (1.0, 1.0), - (0.0, 1.0), - ] - - pd = node.mesh_data.primitive_data_list[0] - pd.material = material_key - pd.attributes["TEXCOORD_0"] = texcoord_0 - pd.attributes["NORMAL"] = normals - - gltf = GLTF(gltf_filepath) - gltf.content = cnt - gltf.export(embed_data=False) diff --git a/src/compas/files/gltf/gltf_conversions.py b/src/compas/files/gltf/gltf_conversions.py new file mode 100644 index 000000000000..76dd733d17af --- /dev/null +++ b/src/compas/files/gltf/gltf_conversions.py @@ -0,0 +1,463 @@ +"""Conversions between glTF documents and COMPAS scenes.""" + +import warnings +from contextlib import contextmanager +from typing import TYPE_CHECKING +from typing import Any +from typing import Optional +from typing import Sequence +from typing import Union +from typing import cast + +from compas.datastructures import Mesh +from compas.files.gltf.constants import MODE_LINE_LOOP +from compas.files.gltf.constants import MODE_LINE_STRIP +from compas.files.gltf.constants import MODE_LINES +from compas.files.gltf.constants import MODE_POINTS +from compas.files.gltf.constants import MODE_TRIANGLE_FAN +from compas.files.gltf.constants import MODE_TRIANGLE_STRIP +from compas.files.gltf.constants import MODE_TRIANGLES +from compas.files.gltf.data_classes import PrimitiveData +from compas.files.gltf.gltf_mesh import GLTFMesh +from compas.files.gltf.gltf_types import GLTFConversionWarning +from compas.geometry import Geometry +from compas.geometry import Line +from compas.geometry import Pointcloud +from compas.geometry import Polyline +from compas.geometry import Transformation +from compas.scene import GeometryObject +from compas.scene import Group +from compas.scene import MeshObject +from compas.scene import Scene +from compas.scene.context import ITEM_SCENEOBJECT +from compas.scene.context import register + +if TYPE_CHECKING: + from compas.datastructures import TreeNode # noqa: F401 + from compas.files.gltf.gltf_document import GLTFDocument + from compas.files.gltf.gltf_node import GLTFNode + from compas.scene import SceneObject # noqa: F401 + + +@contextmanager +def _base_scene_context(): + """Provide base scene objects without triggering visualization plugin discovery.""" + original = {context: registry.copy() for context, registry in ITEM_SCENEOBJECT.items()} + register(Geometry, GeometryObject, context=None) + register(Mesh, MeshObject, context=None) + try: + yield + finally: + ITEM_SCENEOBJECT.clear() + for context, registry in original.items(): + ITEM_SCENEOBJECT[context].update(registry) + + +def _indexed_positions( + positions: Sequence[Sequence[float]], + indices: Optional[Sequence[int]], +) -> list[Sequence[float]]: + indices = indices if indices is not None else range(len(positions)) + return [positions[index] for index in indices] + + +def gltf_points_to_pointcloud( + positions: Sequence[Sequence[float]], + indices: Optional[Sequence[int]] = None, + name: Optional[str] = None, +) -> Pointcloud: + """Convert a glTF points primitive to a COMPAS point cloud. + + Returns + ------- + Pointcloud + Converted point cloud. + + """ + return Pointcloud(_indexed_positions(positions, indices), name=name) + + +def gltf_lines_to_lines( + positions: Sequence[Sequence[float]], + indices: Optional[Sequence[int]] = None, + name: Optional[str] = None, +) -> list[Line]: + """Convert a glTF lines primitive to COMPAS lines. + + Returns + ------- + list[Line] + Converted independent lines. + + """ + indices = list(indices) if indices is not None else list(range(len(positions))) + return [Line(positions[indices[index]], positions[indices[index + 1]], name=name) for index in range(0, len(indices) - 1, 2)] + + +def gltf_line_strip_to_polyline( + positions: Sequence[Sequence[float]], + indices: Optional[Sequence[int]] = None, + name: Optional[str] = None, +) -> Polyline: + """Convert a glTF line strip primitive to a COMPAS polyline. + + Returns + ------- + Polyline + Converted open polyline. + + """ + return Polyline(_indexed_positions(positions, indices), name=name) + + +def gltf_line_loop_to_polyline( + positions: Sequence[Sequence[float]], + indices: Optional[Sequence[int]] = None, + name: Optional[str] = None, +) -> Polyline: + """Convert a glTF line loop primitive to a closed COMPAS polyline. + + Returns + ------- + Polyline + Converted closed polyline. + + """ + points = _indexed_positions(positions, indices) + if points: + points.append(points[0]) + return Polyline(points, name=name) + + +def gltf_triangles_to_mesh( + positions: Sequence[Sequence[float]], + indices: Optional[Sequence[int]] = None, + name: Optional[str] = None, +) -> Mesh: + """Convert a glTF triangles primitive to a COMPAS mesh. + + Returns + ------- + Mesh + Converted mesh. + + """ + indices = list(indices) if indices is not None else list(range(len(positions))) + faces = [list(indices[index : index + 3]) for index in range(0, len(indices) - 2, 3)] + return _named_mesh(positions, faces, name) + + +def gltf_triangle_strip_to_mesh( + positions: Sequence[Sequence[float]], + indices: Optional[Sequence[int]] = None, + name: Optional[str] = None, +) -> Mesh: + """Convert a glTF triangle strip primitive to a COMPAS mesh. + + Returns + ------- + Mesh + Converted mesh with explicit triangle faces. + + """ + indices = list(indices) if indices is not None else list(range(len(positions))) + faces = [] + for index in range(len(indices) - 2): + a, b, c = indices[index : index + 3] + faces.append([a, b, c] if index % 2 == 0 else [b, a, c]) + return _named_mesh(positions, faces, name) + + +def gltf_triangle_fan_to_mesh( + positions: Sequence[Sequence[float]], + indices: Optional[Sequence[int]] = None, + name: Optional[str] = None, +) -> Mesh: + """Convert a glTF triangle fan primitive to a COMPAS mesh. + + Returns + ------- + Mesh + Converted mesh with explicit triangle faces. + + """ + indices = list(indices) if indices is not None else list(range(len(positions))) + faces = [[indices[0], indices[index], indices[index + 1]] for index in range(1, len(indices) - 1)] + return _named_mesh(positions, faces, name) + + +def _named_mesh( + positions: Sequence[Sequence[float]], + faces: Sequence[Sequence[int]], + name: Optional[str], +) -> Mesh: + mesh = Mesh.from_vertices_and_faces(positions, faces) + if name is not None: + mesh.name = name + return mesh + + +def gltf_primitive_to_compas( + primitive: PrimitiveData, + positions: Optional[Sequence[Sequence[float]]] = None, + name: Optional[str] = None, +) -> list[Any]: + """Convert a glTF primitive to the closest COMPAS geometry objects. + + The optional positions override is used for node-specific morph targets. + + Returns + ------- + list[Mesh | Pointcloud | Polyline | Line] + Converted geometry. Independent glTF lines produce multiple objects. + + """ + mode = MODE_TRIANGLES if primitive.mode is None else primitive.mode + positions = positions if positions is not None else primitive.attributes["POSITION"] + indices = primitive.indices + + if mode == MODE_POINTS: + return [gltf_points_to_pointcloud(positions, indices, name)] + if mode == MODE_LINES: + return gltf_lines_to_lines(positions, indices, name) + if mode == MODE_LINE_STRIP: + return [gltf_line_strip_to_polyline(positions, indices, name)] + if mode == MODE_LINE_LOOP: + return [gltf_line_loop_to_polyline(positions, indices, name)] + if mode == MODE_TRIANGLES: + return [gltf_triangles_to_mesh(positions, indices, name)] + if mode == MODE_TRIANGLE_STRIP: + return [gltf_triangle_strip_to_mesh(positions, indices, name)] + if mode == MODE_TRIANGLE_FAN: + return [gltf_triangle_fan_to_mesh(positions, indices, name)] + raise ValueError("Unsupported glTF primitive mode: {}.".format(mode)) + + +def gltf_to_scene( + document: "GLTFDocument", + scene_key: Optional[int] = None, + scene_type: type[Scene] = Scene, +) -> Scene: + """Convert a glTF scene to a COMPAS scene. + + Parameters + ---------- + document + Source glTF document. + scene_key + Scene to convert. By default, use the document's default or first scene. + scene_type + COMPAS scene type to construct. + + Returns + ------- + Scene + COMPAS scene preserving the glTF node hierarchy and local transformations. + + """ + if not document.scenes: + return scene_type(name="Scene") + source_scene = document.scenes[scene_key] if scene_key is not None else document.default_or_first_scene + scene = scene_type(name=source_scene.name or "Scene") + objects_by_primitive: dict[tuple[int, tuple[float, ...], int], list[Any]] = {} + + def add_node(node: "GLTFNode", parent: "Union[SceneObject, TreeNode]") -> None: + group = scene.add_group( + name=node.name or "Node {}".format(node.key), + parent=parent, + transformation=Transformation( # pyright: ignore[reportArgumentType] + node.get_matrix_from_trs() if node.matrix is None else node.matrix + ), + ) # type: ignore[arg-type] + mesh = node.mesh_data + if mesh is not None: + weighted_positions = node.vertices + assert weighted_positions is not None + offset = 0 + for primitive_index, primitive in enumerate(mesh.primitive_data_list): + count = len(primitive.attributes["POSITION"]) + positions = weighted_positions[offset : offset + count] + primitive_name = node.name or mesh.mesh_name + weights = tuple(node.weights if node.weights is not None else mesh.weights or []) + primitive_key = (mesh.key, weights, primitive_index) + objects = objects_by_primitive.get(primitive_key) + if objects is None: + objects = gltf_primitive_to_compas(primitive, positions, primitive_name) + objects_by_primitive[primitive_key] = objects + for object_index, item in enumerate(objects): + item_name = primitive_name + if len(mesh.primitive_data_list) > 1: + item_name = "{} primitive {}".format(primitive_name or "Mesh", primitive_index) + if len(objects) > 1: + item_name = "{} line {}".format(item_name or "Lines", object_index) + scene.add(item, parent=group, name=item_name) # type: ignore[arg-type] + offset += count + for child_key in node.children: + add_node(document.nodes[child_key], group) + + with _base_scene_context(): + for root_key in source_scene.children: + assert scene.root is not None + add_node(document.nodes[root_key], scene.root) + return scene + + +def mesh_to_gltf_primitive(mesh: Mesh) -> PrimitiveData: + """Convert a COMPAS mesh to a glTF triangles primitive. + + Returns + ------- + PrimitiveData + Converted primitive. + + """ + vertices, faces = mesh.to_vertices_and_faces(triangulated=True) + 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") + vertex_normals = mesh.vertices_attribute("vertex_normal") + vertex_colors = mesh.vertices_attribute("vertex_color") + if texture_coordinates and texture_coordinates[0] is not None: + primitive.attributes["TEXCOORD_0"] = texture_coordinates + if vertex_normals and vertex_normals[0] is not None: + primitive.attributes["NORMAL"] = vertex_normals + if vertex_colors and vertex_colors[0] is not None: + primitive.attributes["COLOR_0"] = vertex_colors + return primitive + + +def pointcloud_to_gltf_primitive(pointcloud: Pointcloud) -> PrimitiveData: + """Convert a COMPAS point cloud to a glTF points primitive. + + Returns + ------- + PrimitiveData + Converted primitive. + + """ + positions = [list(point) for point in pointcloud.points] + return PrimitiveData({"POSITION": positions}, list(range(len(positions))), mode=MODE_POINTS) + + +def lines_to_gltf_primitive(lines: Sequence[Line]) -> PrimitiveData: + """Convert COMPAS lines to a glTF independent-lines primitive. + + Returns + ------- + PrimitiveData + Converted primitive. + + """ + positions = [list(point) for line in lines for point in (line.start, line.end)] + return PrimitiveData({"POSITION": positions}, list(range(len(positions))), mode=MODE_LINES) + + +def line_to_gltf_primitive(line: Line) -> PrimitiveData: + """Convert a COMPAS line to a glTF independent-lines primitive. + + Returns + ------- + PrimitiveData + Converted primitive. + + """ + return lines_to_gltf_primitive([line]) + + +def polyline_to_gltf_primitive(polyline: Polyline) -> PrimitiveData: + """Convert a COMPAS polyline to a glTF line strip or line loop primitive. + + Returns + ------- + PrimitiveData + Converted primitive. + + """ + positions = [list(point) for point in polyline.points] + closed = len(positions) > 2 and positions[0] == positions[-1] + if closed: + positions.pop() + mode = MODE_LINE_LOOP if closed else MODE_LINE_STRIP + return PrimitiveData({"POSITION": positions}, list(range(len(positions))), mode=mode) + + +def compas_to_gltf_primitive(item: Any) -> Optional[PrimitiveData]: + """Convert supported COMPAS geometry to a glTF primitive. + + Returns + ------- + PrimitiveData | None + Converted primitive, or `None` if the item is unsupported. + + """ + if isinstance(item, Mesh): + return mesh_to_gltf_primitive(item) + if isinstance(item, Pointcloud): + return pointcloud_to_gltf_primitive(item) + if isinstance(item, Line): + return line_to_gltf_primitive(item) + if isinstance(item, Polyline): + return polyline_to_gltf_primitive(item) + return None + + +def _geometry_mesh(document: "GLTFDocument", item: Any) -> Optional[GLTFMesh]: + primitive = compas_to_gltf_primitive(item) + if primitive is None: + return None + return GLTFMesh([primitive], document, mesh_name=item.name) + + +def scene_to_gltf(scene: Scene) -> "GLTFDocument": + """Convert a COMPAS scene to a glTF document. + + Unsupported scene items are omitted with a `GLTFConversionWarning`. Their + scene nodes and descendants are preserved. + + Parameters + ---------- + scene + Source COMPAS scene. + + Returns + ------- + GLTFDocument + Converted glTF document. + + """ + from compas.files.gltf.gltf_document import GLTFDocument + + document = GLTFDocument() + target_scene = document.add_scene(name=scene.name) + document.default_scene_key = target_scene.key + mesh_by_item_id: dict[int, int] = {} + + def add_object(sceneobject: "SceneObject", parent: "Optional[GLTFNode]" = None) -> None: + if parent is None: + node = target_scene.add_child(node_name=sceneobject.name) + else: + node = parent.add_child(child_name=sceneobject.name) + if sceneobject.transformation is not None: + node.matrix = [list(row) for row in sceneobject.transformation.matrix] + + if not isinstance(sceneobject, Group): + mesh_key = mesh_by_item_id.get(id(sceneobject.item)) + mesh_data = document.meshes[mesh_key] if mesh_key is not None else _geometry_mesh(document, sceneobject.item) + if mesh_data is None: + warnings.warn( + "Scene object {!r} contains unsupported item type {} and was omitted from glTF geometry.".format( + sceneobject.name, type(sceneobject.item).__name__ + ), + GLTFConversionWarning, + stacklevel=2, + ) + else: + mesh_by_item_id[id(sceneobject.item)] = mesh_data.key + node.mesh_key = mesh_data.key + for child in sceneobject.children: + add_object(cast("SceneObject", child), node) + + assert scene.root is not None + for child in scene.root.children: + add_object(cast("SceneObject", child)) + return document diff --git a/src/compas/files/gltf/gltf_document.py b/src/compas/files/gltf/gltf_document.py new file mode 100644 index 000000000000..6bd8e6599271 --- /dev/null +++ b/src/compas/files/gltf/gltf_document.py @@ -0,0 +1,622 @@ +from copy import deepcopy +from typing import TYPE_CHECKING +from typing import Any +from typing import Optional +from typing import Union +from typing import cast + +from compas.files.gltf.data_classes import AnimationData +from compas.files.gltf.data_classes import CameraData +from compas.files.gltf.data_classes import ImageData +from compas.files.gltf.data_classes import MaterialData +from compas.files.gltf.data_classes import SamplerData +from compas.files.gltf.data_classes import SkinData +from compas.files.gltf.data_classes import TextureData +from compas.files.gltf.data_classes import TextureInfoData +from compas.files.gltf.gltf_mesh import GLTFMesh +from compas.files.gltf.gltf_node import GLTFNode +from compas.files.gltf.gltf_scene import GLTFScene +from compas.files.gltf.helpers import get_weighted_mesh_vertices +from compas.geometry import transform_points +from compas.linalg.matrices import multiply_matrices + +if TYPE_CHECKING: + from compas.datastructures import Mesh + + +class GLTFDocument: + """Semantic content and scene-editing model of a glTF document. + + Attributes + ---------- + scenes : dict + Dictionary containing (int, GLTFScene) pairs. + default_scene_key : int or None + Key of the scene to be displayed on loading the glTF. + nodes : dict + Dictionary containing (int, GLTFNode) pairs. + meshes : dict + Dictionary containing (int, GLTFMesh) pairs. + cameras : dict + Dictionary containing (int, CameraData) pairs. + animations : dict + Dictionary containing (int, AnimationData) pairs. + skins : dict + Dictionary containing (int, SkinData) pairs. + materials : dict + Dictionary containing (int, MaterialData) pairs. + textures : dict + Dictionary containing (int, TextureData) pairs. + samplers : dict + Dictionary containing (int, SamplerData) pairs. + images : dict + Dictionary containing (int, ImageData) pairs. + extras : object + extensions : object + + """ + + def __init__(self) -> None: + self.scenes: dict[int, GLTFScene] = {} + self.default_scene_key: Optional[int] = None + self.nodes: dict[int, GLTFNode] = {} + self.meshes: dict[int, GLTFMesh] = {} + self.cameras: dict[int, CameraData] = {} + self.animations: dict[int, AnimationData] = {} + self.skins: dict[int, SkinData] = {} + self.materials: dict[int, MaterialData] = {} + self.textures: dict[int, TextureData] = {} + self.samplers: dict[int, SamplerData] = {} + self.images: dict[int, ImageData] = {} + self.extras: Any = None + self.extensions: Any = None + self.extensions_used: Optional[list[str]] = None + self.extensions_required: Optional[list[str]] = None + self.asset: dict[str, Any] = {"version": "2.0"} + self.unknown: dict[str, Any] = {} + + @property + def default_or_first_scene(self) -> GLTFScene: + key = self.default_scene_key or 0 + return self.scenes[key] + + def check_if_forest(self) -> None: + """Verify that the nodes form a disjoint union of rooted trees. + + Raises + ------ + ValueError + If a node has multiple parents or the hierarchy contains a cycle. + + """ + parent_counts = {key: 0 for key in self.nodes} + for node in self.nodes.values(): + for child_key in node.children: + parent_counts[child_key] += 1 + if any(count > 1 for count in parent_counts.values()): + raise ValueError("A glTF node cannot have multiple parents.") + + states = {key: 0 for key in self.nodes} + + def visit(key): + if states[key] == 1: + raise ValueError("glTF node hierarchy contains a cycle.") + if states[key] == 2: + return + states[key] = 1 + for child_key in self.nodes[key].children: + visit(child_key) + states[key] = 2 + + for node_key in self.nodes: + visit(node_key) + + def validate(self) -> None: + """Validate references and scene-graph structure.""" + if self.asset.get("version") != "2.0": + raise ValueError("glTF asset version 2.0 is required.") + used = set(self.extensions_used or []) + required = set(self.extensions_required or []) + if not required.issubset(used): + raise ValueError("Required glTF extensions must also appear in extensionsUsed.") + if self.default_scene_key is not None and self.default_scene_key not in self.scenes: + raise ValueError(f"Cannot find default glTF scene {self.default_scene_key}.") + for scene in self.scenes.values(): + for key in scene.children: + if key not in self.nodes: + raise ValueError(f"Cannot find glTF scene node {key}.") + for node in self.nodes.values(): + for key in node.children: + if key not in self.nodes: + raise ValueError(f"Cannot find child glTF node {key}.") + if node.mesh_key is not None and node.mesh_key not in self.meshes: + raise ValueError(f"Cannot find glTF mesh {node.mesh_key}.") + if node.camera is not None and node.camera not in self.cameras: + raise ValueError(f"Cannot find glTF camera {node.camera}.") + if node.skin is not None and node.skin not in self.skins: + raise ValueError(f"Cannot find glTF skin {node.skin}.") + for mesh in self.meshes.values(): + for primitive in mesh.primitive_data_list: + if "POSITION" not in primitive.attributes: + raise ValueError("glTF mesh primitives require a POSITION attribute.") + if primitive.mode not in (None, 0, 1, 2, 3, 4, 5, 6): + raise ValueError(f"Unsupported glTF primitive mode {primitive.mode}.") + vertex_count = len(primitive.attributes["POSITION"]) + if vertex_count == 0: + raise ValueError("glTF POSITION attributes cannot be empty.") + if primitive.indices is not None and any(index < 0 or index >= vertex_count for index in primitive.indices): + raise ValueError("glTF primitive index is outside its POSITION accessor.") + if primitive.material is not None and primitive.material not in self.materials: + raise ValueError(f"Cannot find glTF material {primitive.material}.") + for sampler in self.samplers.values(): + if sampler.mag_filter is not None and sampler.mag_filter not in (9728, 9729): + raise ValueError(f"Invalid glTF magnification filter {sampler.mag_filter}.") + if sampler.min_filter is not None and sampler.min_filter not in (9728, 9729, 9984, 9985, 9986, 9987): + raise ValueError(f"Invalid glTF minification filter {sampler.min_filter}.") + if sampler.wrap_s is not None and sampler.wrap_s not in (33071, 33648, 10497): + raise ValueError(f"Invalid glTF wrap mode {sampler.wrap_s}.") + if sampler.wrap_t is not None and sampler.wrap_t not in (33071, 33648, 10497): + raise ValueError(f"Invalid glTF wrap mode {sampler.wrap_t}.") + for texture in self.textures.values(): + if texture.sampler is not None and texture.sampler not in self.samplers: + raise ValueError(f"Cannot find glTF sampler {texture.sampler}.") + if texture.source is not None and texture.source not in self.images: + raise ValueError(f"Cannot find glTF image {texture.source}.") + for material in self.materials.values(): + if material.alpha_mode is not None and material.alpha_mode not in ("OPAQUE", "MASK", "BLEND"): + raise ValueError(f"Invalid glTF alpha mode {material.alpha_mode!r}.") + for item in material.iter_data(): + if isinstance(item, TextureInfoData) and item.index not in self.textures: + raise ValueError(f"Cannot find glTF texture {item.index}.") + for animation in self.animations.values(): + for channel in animation.channels: + if channel.sampler not in animation.samplers_dict: + raise ValueError(f"Cannot find glTF animation sampler {channel.sampler}.") + if channel.target.node is not None and channel.target.node not in self.nodes: + raise ValueError(f"Cannot find glTF animation target node {channel.target.node}.") + if channel.target.path not in ("translation", "rotation", "scale", "weights", "pointer"): + raise ValueError(f"Invalid glTF animation target path {channel.target.path!r}.") + for sampler in animation.samplers_dict.values(): + if sampler.interpolation not in (None, "LINEAR", "STEP", "CUBICSPLINE"): + raise ValueError(f"Invalid glTF animation interpolation {sampler.interpolation!r}.") + for skin in self.skins.values(): + if not skin.joints: + raise ValueError("glTF skins require at least one joint.") + for joint in skin.joints: + if joint not in self.nodes: + raise ValueError(f"Cannot find glTF skin joint {joint}.") + if skin.skeleton is not None and skin.skeleton not in self.nodes: + raise ValueError(f"Cannot find glTF skin skeleton {skin.skeleton}.") + if skin.inverse_bind_matrices is not None and len(skin.inverse_bind_matrices) != len(skin.joints): + raise ValueError("glTF inverse bind matrix count must match the number of joints.") + for camera in self.cameras.values(): + if camera.type not in ("perspective", "orthographic"): + raise ValueError(f"Invalid glTF camera type {camera.type!r}.") + if camera.type == "perspective" and camera.perspective is None: + raise ValueError("Perspective glTF cameras require perspective properties.") + if camera.type == "orthographic" and camera.orthographic is None: + raise ValueError("Orthographic glTF cameras require orthographic properties.") + self.check_if_forest() + + def without_orphans(self) -> "GLTFDocument": + """Return an independent document with unreachable data removed. + + Returns + ------- + GLTFDocument + Cleaned document copy. + + """ + content = deepcopy(self) + content.remove_orphans() + return content + + def remove_orphans(self) -> None: + """Remove unreachable objects.""" + node_visit_log = {key: False for key in self.nodes} + mesh_visit_log = {key: False for key in self.meshes} + camera_visit_log = {key: False for key in self.cameras} + material_visit_log = {key: False for key in self.materials} + texture_visit_log = {key: False for key in self.textures} + sampler_visit_log = {key: False for key in self.samplers} + image_visit_log = {key: False for key in self.images} + + def visit_node(key): + node = self.nodes[key] + node_visit_log[key] = True + if node.mesh_key is not None: + mesh_visit_log[node.mesh_key] = True + if node.camera is not None: + camera_visit_log[node.camera] = True + for child_key in node.children: + visit_node(child_key) + + # walk through scenes and update visit logs of nodes, meshes, and cameras. + for scene in self.scenes.values(): + for node_key in scene.children: + visit_node(node_key) + + # remove unvisited nodes + self._remove_unvisited(node_visit_log, self.nodes) + + # remove unvisited meshes + self._remove_unvisited(mesh_visit_log, self.meshes) + + # remove unvisited cameras + self._remove_unvisited(camera_visit_log, self.cameras) + + # remove animations referencing no existing nodes + for animation_key, animation in list(self.animations.items()): + animation.channels = [channel for channel in animation.channels if channel.target.node is None or node_visit_log[channel.target.node]] + visited_sampler_keys = {channel.sampler for channel in animation.channels} + animation.samplers_dict = {key: animation.samplers_dict[key] for key in animation.samplers_dict if key in visited_sampler_keys} + if not animation.samplers_dict: + del self.animations[animation_key] + + # remove skins referencing no existing nodes + for key, skin_data in list(self.skins.items()): + skin_data.joints = [joint_key for joint_key in skin_data.joints if node_visit_log[joint_key]] + if not skin_data.joints: + del self.skins[key] + + # walk through existing meshes and update materials visit log + for mesh in self.meshes.values(): + for primitive in mesh.primitive_data_list: + if primitive.material is not None: + material_visit_log[primitive.material] = True + + # remove unvisited materials + self._remove_unvisited(material_visit_log, self.materials) + + # walk through existing materials and update textures visit log + for material in self.materials.values(): + for item in material.iter_data(): + if isinstance(item, TextureInfoData): + texture_visit_log[item.index] = True + + # remove unvisited textures + self._remove_unvisited(texture_visit_log, self.textures) + + # walk through existing textures and update visit logs of samplers and images + for texture in self.textures.values(): + if texture.sampler is not None: + sampler_visit_log[texture.sampler] = True + if texture.source is not None: + image_visit_log[texture.source] = True + + # remove unvisited samplers + self._remove_unvisited(sampler_visit_log, self.samplers) + + # remove unvisited images + self._remove_unvisited(image_visit_log, self.images) + + def _remove_unvisited(self, log: dict[int, bool], dictionary: dict[int, Any]) -> None: + for key, visited in log.items(): + if not visited: + del dictionary[key] + + def update_node_transforms_and_positions(self) -> None: + """Update transforms and positions throughout all scenes.""" + for scene in self.scenes.values(): + self.update_scene_transforms_and_positions(scene) + + def update_scene_transforms_and_positions(self, scene: GLTFScene) -> None: + """Update transforms and positions throughout a scene tree. + + Parameters + ---------- + scene + Scene to update. + + """ + 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 = cast(list[float], transform_points([origin], node.transform)[0]) + queue = [node_key] + while queue: + cur_key = queue.pop(0) + cur = self.nodes[cur_key] + for child_key in cur.children: + child = self.nodes[child_key] + child.transform = cast( + list[list[float]], + multiply_matrices(cur.transform, child.matrix or child.get_matrix_from_trs()), + ) + child.position = cast(list[float], transform_points([origin], child.transform)[0]) + queue.append(child_key) + + def get_node_faces(self, node: GLTFNode) -> Optional[list[tuple[int, ...]]]: + """Return the faces of the mesh attached to a node. + + Parameters + ---------- + node + Node containing the mesh. + + Returns + ------- + list[tuple[int, ...]] | None + Mesh faces, if the node references a mesh. + + """ + if node.mesh_key is None: + return None + mesh_data = self.meshes.get(node.mesh_key) + if mesh_data is None: + return None + return mesh_data.faces + + def get_node_vertices(self, node: GLTFNode) -> Optional[list[tuple[float, ...]]]: + """Return the vertices of the mesh attached to a node. + + Parameters + ---------- + node + Node containing the mesh. + + Returns + ------- + list[tuple[float, ...]] | None + Mesh vertices, with node morph weights applied when present. + + """ + if node.mesh_key is None: + return None + mesh_data = self.meshes.get(node.mesh_key) + if mesh_data is None: + return None + if node.weights is None: + return mesh_data.vertices + return get_weighted_mesh_vertices(mesh_data, node.weights) + + def get_node_by_name(self, name: str) -> Optional[GLTFNode]: + """Return the node with a specific name. + + Parameters + ---------- + name + Name to match. + + Returns + ------- + GLTFNode | None + Matching node, if found. + + """ + for key in self.nodes: + if self.nodes[key].name == name: + return self.nodes[key] + return None + + @classmethod + def _get_next_available_key(cls, adict: dict[int, Any]) -> int: + key = len(adict) + while key in adict: + key += 1 + return key + + def add_material(self, material: MaterialData) -> int: + """Add a material to the document. + + Parameters + ---------- + material + Material to add. + + Returns + ------- + int + Assigned material key. + + """ + key = self._get_next_available_key(self.materials) + self.materials[key] = material + return key + + def add_texture(self, texture: TextureData) -> int: + """Add a texture to the document. + + Parameters + ---------- + texture + Texture to add. + + Returns + ------- + int + Assigned texture key. + + """ + key = self._get_next_available_key(self.textures) + self.textures[key] = texture + return key + + def add_image(self, image: ImageData) -> int: + """Add an image to the document. + + Parameters + ---------- + image + Image to add. + + Returns + ------- + int + Assigned image key. + + """ + key = self._get_next_available_key(self.images) + self.images[key] = image + return key + + def get_material_index_by_name(self, name: str) -> Optional[int]: + """Return the key of a material with a specific name. + + Parameters + ---------- + name + Name to match. + + Returns + ------- + int | None + Matching material key, if found. + + """ + for key, material in self.materials.items(): + if material.name == name: + return key + return None + + def add_scene(self, name: Optional[str] = None, extras: Any = None) -> GLTFScene: + """Add a scene to the document. + + Parameters + ---------- + name + Optional scene name. + extras + Application-specific scene data. + + Returns + ------- + GLTFScene + + """ + return GLTFScene(self, name=name, extras=extras) + + def add_node_to_scene(self, scene: GLTFScene, node_name: Optional[str] = None, node_extras: Any = None) -> GLTFNode: + """Create a node and add it as a scene root. + + Parameters + ---------- + scene + Parent scene. + node_name + Optional node name. + node_extras + Application-specific node data. + + Returns + ------- + GLTFNode + + """ + if scene not in self.scenes.values(): + raise ValueError("Cannot find glTF scene.") + node = GLTFNode(self, node_name, node_extras) + scene.children.append(node.key) + return node + + def add_child_to_node(self, parent_node: GLTFNode, child_name: Optional[str] = None, child_extras: Any = None) -> GLTFNode: + """Create a node and add it as a child of another node. + + Parameters + ---------- + parent_node + Parent node. + child_name + Optional child name. + child_extras + Application-specific child data. + + Returns + ------- + GLTFNode + + """ + child_node = GLTFNode(self, child_name, child_extras) + parent_node.children.append(child_node.key) + return child_node + + def add_mesh(self, mesh: "Mesh") -> GLTFMesh: + """Convert a COMPAS mesh and add it to the document. + + Parameters + ---------- + mesh + Mesh to add. + + Returns + ------- + GLTFMesh + + """ + return GLTFMesh.from_mesh(self, mesh) + + def add_mesh_to_node(self, node: GLTFNode, mesh: "Union[int, Mesh]") -> GLTFMesh: + """Attach an existing or converted mesh to a node. + + Parameters + ---------- + node + Destination node. + mesh + Existing mesh key or mesh to add. + + Returns + ------- + GLTFMesh + + """ + if isinstance(mesh, int): + mesh_data = self.meshes[mesh] + else: + mesh_data = self.add_mesh(mesh) + node.mesh_key = mesh_data.key + return mesh_data + + def get_nodes_from_scene(self, scene: GLTFScene) -> dict[int, GLTFNode]: + """Return the nodes reachable from a scene. + + Parameters + ---------- + scene + Scene to traverse. + + Returns + ------- + dict[int, GLTFNode] + Nodes keyed by document key. + + """ + node_dict = {} + + def visit(key): + node_dict[key] = self.nodes[key] + for child in self.nodes[key].children: + visit(child) + + for child_key in scene.children: + visit(child_key) + + return node_dict + + def get_scene_positions_and_edges(self, scene: GLTFScene): + """Return node positions and hierarchy edges for a scene. + + Parameters + ---------- + scene + Scene to inspect. + + Returns + ------- + tuple[dict[Any, Any], list[tuple[Any, int]]] + Node positions and hierarchy edges. + + """ + positions_dict: dict[Any, Any] = {"root": [0, 0, 0]} + edges_list: list[tuple[Any, int]] = [] + + def visit(node, key): + for child_key in node.children: + positions_dict[child_key] = self.nodes[child_key].position + edges_list.append((key, child_key)) + visit(self.nodes[child_key], child_key) + + visit(scene, "root") + + return positions_dict, edges_list diff --git a/src/compas/files/gltf/gltf_encoder.py b/src/compas/files/gltf/gltf_encoder.py new file mode 100644 index 000000000000..e403cb2ad9ca --- /dev/null +++ b/src/compas/files/gltf/gltf_encoder.py @@ -0,0 +1,605 @@ +import base64 +import os +import struct +from typing import Any + +from compas.files.gltf.constants import COMPONENT_TYPE_ENUM +from compas.files.gltf.constants import COMPONENT_TYPE_FLOAT +from compas.files.gltf.constants import COMPONENT_TYPE_UNSIGNED_INT +from compas.files.gltf.constants import COMPONENT_TYPE_UNSIGNED_SHORT +from compas.files.gltf.constants import NUM_COMPONENTS_BY_TYPE_ENUM +from compas.files.gltf.constants import TYPE_MAT4 +from compas.files.gltf.constants import TYPE_SCALAR +from compas.files.gltf.constants import TYPE_VEC2 +from compas.files.gltf.constants import TYPE_VEC3 +from compas.files.gltf.constants import TYPE_VEC4 +from compas.files.gltf.data_classes import BaseGLTFDataClass +from compas.files.gltf.data_classes import CameraData +from compas.files.gltf.data_classes import ImageData +from compas.files.gltf.data_classes import MaterialData +from compas.files.gltf.data_classes import NormalTextureInfoData +from compas.files.gltf.data_classes import OcclusionTextureInfoData +from compas.files.gltf.data_classes import PBRMetallicRoughnessData +from compas.files.gltf.data_classes import SamplerData +from compas.files.gltf.data_classes import TextureData + +from .gltf_document import GLTFDocument +from .gltf_payload import GLTFPayload +from .gltf_types import GLTFFormat + + +class GLTFEncoder: + """Encode a semantic glTF document without writing targets.""" + + def __init__( + self, + format: GLTFFormat = "gltf", + embed_data: bool = False, + filename: str = "model", + ) -> None: + if format not in ("gltf", "glb"): + raise ValueError(f"Unsupported glTF format: {format}") + self._filename = filename + self._format: GLTFFormat = format + self._embed_data = embed_data + self._content: GLTFDocument + self._gltf_dict = {} + self._mesh_index_by_key = {} + self._node_index_by_key = {} + self._scene_index_by_key = {} + self._camera_index_by_key = {} + self._skin_index_by_key = {} + self._material_index_by_key = {} + self._texture_index_by_key = {} + self._sampler_index_by_key = {} + self._image_index_by_key = {} + self._buffer = b"" + + def encode(self, content: GLTFDocument) -> GLTFPayload: + """Encode a document into a target-independent payload. + + Returns + ------- + GLTFPayload + JSON data, binary data, and adjacent resources. + + """ + content.validate() + self._content = content.without_orphans() + + self._set_initial_gltf_dict() + self._mesh_index_by_key = self._get_index_by_key(self._content.meshes) + self._node_index_by_key = self._get_index_by_key(self._content.nodes) + self._scene_index_by_key = self._get_index_by_key(self._content.scenes) + self._camera_index_by_key = self._get_index_by_key(self._content.cameras) + self._skin_index_by_key = self._get_index_by_key(self._content.skins) + self._material_index_by_key = self._get_index_by_key(self._content.materials) + self._texture_index_by_key = self._get_index_by_key(self._content.textures) + self._sampler_index_by_key = self._get_index_by_key(self._content.samplers) + self._image_index_by_key = self._get_index_by_key(self._content.images) + self._buffer = b"" + + self._add_meshes() + self._add_nodes() + self._add_scenes() + self._add_cameras() + self._add_skins() + self._add_materials() + self._add_textures() + self._add_samplers() + self._add_images() + self._add_animations() + self._add_buffer() + self._add_extensions() + + format = self._format + resources = {} + if format == "gltf" and not self._embed_data and self._buffer: + resources[f"{self._filename}.bin"] = self._buffer + if format == "gltf" and not self._embed_data: + for image in self._content.images.values(): + if image.uri and image.data is not None: + resources[os.path.basename(image.uri)] = image.data + return GLTFPayload(format, self._gltf_dict, self._buffer, resources) + + def _get_index_by_key(self, d): + return {key: index for index, key in enumerate(d)} + + def _add_extensions_recursively(self, item): + if not isinstance(item, BaseGLTFDataClass): + return + keys = item.extension_keys() + if not keys: + return + if self._content.extensions_used is None: + self._content.extensions_used = [] + extensions_used = self._content.extensions_used + assert extensions_used is not None + for key in sorted(keys): + if key not in extensions_used: + extensions_used.append(key) + + def _add_images(self): + if not self._content.images: + return + images_list: list[Any] = [None] * len(self._content.images) + for key, image_data in self._content.images.items(): + if image_data.uri: + basename = os.path.basename(image_data.uri) + else: + basename = None + if self._embed_data: + uri = self._construct_image_data_uri(image_data) + buffer_view = None + elif self._format == "glb": + uri = None + buffer_view = self._construct_buffer_view(image_data.data) + elif basename: + uri = basename + buffer_view = None + else: + uri = None + buffer_view = self._construct_buffer_view(image_data.data) + images_list[self._image_index_by_key[key]] = self._image_json(image_data, uri, buffer_view) + self._add_extensions_recursively(image_data) + self._gltf_dict["images"] = images_list + + def _construct_image_data_uri(self, image_data): + if image_data.data is None: + return None + return "data:" + (image_data.mime_type if image_data.mime_type else "") + ";base64," + base64.b64encode(image_data.data).decode("ascii") + + def _add_extensions(self): + if self._content.extensions_used: + self._gltf_dict["extensionsUsed"] = self._content.extensions_used + if self._content.extensions_required: + self._gltf_dict["extensionsRequired"] = self._content.extensions_required + + def _add_samplers(self): + if not self._content.samplers: + return + samplers_list: list[Any] = [None] * len(self._content.samplers) + for key, sampler_data in self._content.samplers.items(): + samplers_list[self._sampler_index_by_key[key]] = self._sampler_json(sampler_data) + self._gltf_dict["samplers"] = samplers_list + + def _add_textures(self): + if not self._content.textures: + return + textures_list: list[Any] = [None] * len(self._content.textures) + for key, texture_data in self._content.textures.items(): + textures_list[self._texture_index_by_key[key]] = self._texture_json(texture_data) + self._add_extensions_recursively(texture_data) + self._gltf_dict["textures"] = textures_list + + def _add_materials(self): + if not self._content.materials: + return + materials_list: list[Any] = [None] * len(self._content.materials) + for key, material_data in self._content.materials.items(): + materials_list[self._material_index_by_key[key]] = self._material_json(material_data) + self._add_extensions_recursively(material_data) + self._gltf_dict["materials"] = materials_list + + def _add_skins(self): + if not self._content.skins: + return + skins_list: list[Any] = [None] * len(self._content.skins) + for key, skin_data in self._content.skins.items(): + accessor_index = self._construct_accessor(skin_data.inverse_bind_matrices, COMPONENT_TYPE_FLOAT, TYPE_MAT4) + skins_list[self._skin_index_by_key[key]] = self._skin_json(skin_data, accessor_index) + self._add_extensions_recursively(skin_data) + self._gltf_dict["skins"] = skins_list + + def _add_cameras(self): + if not self._content.cameras: + return + camera_list: list[Any] = [None] * len(self._content.cameras) + for key, camera_data in self._content.cameras.items(): + camera_list[self._camera_index_by_key[key]] = self._camera_json(camera_data) + self._add_extensions_recursively(camera_data) + self._gltf_dict["cameras"] = camera_list + + def _add_meshes(self): + if not self._content.meshes: + return + mesh_list: list[Any] = [None] * len(self._content.meshes) + for key, mesh_data in self._content.meshes.items(): + primitives = self._construct_primitives(mesh_data) + mesh_list[self._mesh_index_by_key[key]] = self._mesh_json(mesh_data, primitives) + self._add_extensions_recursively(mesh_data) + self._gltf_dict["meshes"] = mesh_list + + def _add_buffer(self): + if not self._buffer: + return + buffer: dict[str, Any] = {"byteLength": len(self._buffer)} + if self._embed_data: + buffer["uri"] = "data:application/octet-stream;base64," + base64.b64encode(self._buffer).decode("ascii") + elif self._format == "gltf": + buffer["uri"] = f"{self._filename}.bin" + self._gltf_dict["buffers"] = [buffer] + + def _add_animations(self): + if not self._content.animations: + return None + animation_list = [] + for animation_data in self._content.animations.values(): + samplers_list = self._construct_animation_samplers_list(animation_data) + animation_list.append(self._animation_json(animation_data, samplers_list)) + self._add_extensions_recursively(animation_data) + self._gltf_dict["animations"] = animation_list + + def _construct_animation_samplers_list(self, animation_data): + sampler_index_by_key = animation_data.get_sampler_index_by_key() + samplers_list: list[Any] = [None] * len(sampler_index_by_key) + for key, sampler_data in animation_data.samplers_dict.items(): + input_accessor = self._construct_accessor( + sampler_data.input, + COMPONENT_TYPE_FLOAT, + TYPE_SCALAR, + include_bounds=True, + ) + type_ = TYPE_VEC3 + if isinstance(sampler_data.output[0], int) or isinstance(sampler_data.output[0], float): + type_ = TYPE_SCALAR + elif len(sampler_data.output[0]) == 4: + type_ = TYPE_VEC4 + output_accessor = self._construct_accessor(sampler_data.output, COMPONENT_TYPE_FLOAT, type_) + samplers_list[sampler_index_by_key[key]] = self._animation_sampler_json(sampler_data, input_accessor, output_accessor) + return samplers_list + + def _set_initial_gltf_dict(self): + gltf_dict = dict(self._content.unknown) + gltf_dict["asset"] = dict(self._content.asset) + if self._content.extras: + gltf_dict["extras"] = self._content.extras + if self._content.extensions: + gltf_dict["extensions"] = self._content.extensions + self._gltf_dict = gltf_dict + + def _add_scenes(self): + if not self._content.scenes: + return + if self._content.default_scene_key is not None: + self._gltf_dict["scene"] = self._scene_index_by_key[self._content.default_scene_key] + else: + self._gltf_dict["scene"] = list(self._content.scenes.values())[0].key + scene_list: list[Any] = [None] * len(self._content.scenes.values()) + for key, scene in self._content.scenes.items(): + scene_list[self._scene_index_by_key[key]] = self._scene_json(scene) + self._gltf_dict["scenes"] = scene_list + + def _add_nodes(self): + if not self._content.nodes: + return + node_list: list[Any] = [None] * len(self._content.nodes) + for key, node in self._content.nodes.items(): + node_list[self._node_index_by_key[key]] = self._node_json(node) + self._gltf_dict["nodes"] = node_list + + def _construct_primitives(self, mesh_data): + primitives = [] + for primitive_data in mesh_data.primitive_data_list: + component_type = COMPONENT_TYPE_UNSIGNED_SHORT + if primitive_data.indices and max(primitive_data.indices) > 65535: + component_type = COMPONENT_TYPE_UNSIGNED_INT + indices_accessor = self._construct_accessor(primitive_data.indices, component_type, TYPE_SCALAR) + + attributes = {} + for attr in primitive_data.attributes: + component_type = COMPONENT_TYPE_UNSIGNED_INT if attr.startswith("JOINT") else COMPONENT_TYPE_FLOAT + type_ = TYPE_VEC3 + if len(primitive_data.attributes[attr][0]) == 4: + type_ = TYPE_VEC4 + if len(primitive_data.attributes[attr][0]) == 2: + type_ = TYPE_VEC2 + attributes[attr] = self._construct_accessor(primitive_data.attributes[attr], component_type, type_, True) + + targets = [] + for target in primitive_data.targets or []: + target_dict = {} + for attr in target: + component_type = COMPONENT_TYPE_FLOAT + type_ = TYPE_VEC3 + target_dict[attr] = self._construct_accessor(target[attr], component_type, type_, True) + targets.append(target_dict) + + primitive_dict = self._primitive_json(primitive_data, indices_accessor, attributes, targets) + + primitives.append(primitive_dict) + return primitives + + def _construct_accessor(self, data, component_type, type_, include_bounds=False): + if data is None: + return None + count = len(data) + + fmt_char = COMPONENT_TYPE_ENUM[component_type] + fmt = "<" + fmt_char * NUM_COMPONENTS_BY_TYPE_ENUM[type_] + + component_size = struct.calcsize("<" + fmt_char) + if type_ == "MAT2" and component_size == 1: + fmt = " 0: - with open(self.get_bin_path(), "wb") as f: - f.write(self._buffer) - - if self._ext == ".glb": - with open(self.gltf_filepath, "wb") as f: - gltf_data = gltf_json.encode() - - length_gltf = len(gltf_data) - spaces_gltf = (4 - (length_gltf & 3)) & 3 - length_gltf += spaces_gltf - - length_bin = len(self._buffer) - zeros_bin = (4 - (length_bin & 3)) & 3 - length_bin += zeros_bin - - length = 12 + 8 + length_gltf - if length_bin > 0: - length += 8 + length_bin - - f.write("glTF".encode("ascii")) - f.write(struct.pack(" 0: - f.write(struct.pack(" None: self.mesh_name = mesh_name self.weights = weights self.primitive_data_list = primitive_data_list @@ -55,7 +66,7 @@ def __init__( self.context = context self._set_key() - def _set_key(self): + def _set_key(self) -> None: key = len(self.context.meshes) while key in self.context.meshes: key += 1 @@ -63,69 +74,79 @@ def _set_key(self): self._key = key @property - def key(self): + def key(self) -> int: + assert self._key is not None return self._key @property - def vertices(self): + def vertices(self) -> list[tuple[float, ...]]: if not self.weights: return get_unweighted_primitive_vertices(self.primitive_data_list) return get_weighted_mesh_vertices(self, self.weights) @property - def faces(self): + def faces(self) -> list[tuple[int, ...]]: faces = [] shift = 0 for primitive_data in self.primitive_data_list: - shifted_indices = self.shift_indices(primitive_data.indices, shift) + indices = primitive_data.indices or range(len(primitive_data.attributes["POSITION"])) + shifted_indices = self.shift_indices(indices, shift) group_size = VERTEX_COUNT_BY_MODE[primitive_data.mode] grouped_indices = self.group_indices(shifted_indices, group_size) faces.extend(grouped_indices) shift += len(primitive_data.attributes["POSITION"]) return faces - def shift_indices(self, indices, shift): - """Given a list of indices, returns a list of indices, all shifted by ``shift``. + def shift_indices(self, indices: Sequence[int], shift: int) -> list[int]: + """Shift every index by an offset. Parameters ---------- - indices : list - shift : int + indices + Indices to shift. + shift + Offset added to every index. Returns ------- - list + list[int] + Shifted indices. """ return [index + shift for index in indices] - def group_indices(self, indices, group_size): - """Returns a list of the elements of ``indices`` grouped into tuples of size ``group_size``. + def group_indices(self, indices: Sequence[int], group_size: int) -> list[tuple[int, ...]]: + """Group a flat index sequence into fixed-size tuples. Parameters ---------- - indices : list - group_size : int + indices + Flat index sequence. + group_size + Number of indices per group. Returns ------- - list + list[tuple[int, ...]] + Grouped indices. """ it = [iter(indices)] * group_size return list(zip(*it)) @classmethod - def validate_faces(cls, faces): - """Raises an exception if not all faces in ``faces`` are defining either all triangles, lines - or points. + def validate_faces(cls, faces: Sequence[Sequence[Hashable]]) -> None: + """Validate that all index groups consistently represent points, lines, or triangles. Parameters ---------- - faces : list + faces + Point, line, or triangle index groups. - Returns - ------- + Raises + ------ + Exception + If index groups have unsupported or inconsistent sizes. """ if not faces: @@ -138,50 +159,66 @@ def validate_faces(cls, faces): raise NotImplementedError("Invalid mesh. Expected mesh composed of points, lines xor triangles.") @classmethod - def validate_vertices(cls, vertices): + def validate_vertices( + cls, vertices: Union[Sequence[Sequence[float]], Mapping[Hashable, Sequence[float]]] + ) -> None: """Raise an exception if there are either too many vertices, or the vertices do not represent points in 3-space. Parameters ---------- - vertices : list - - Returns - ------- + vertices + Vertex coordinates by position or key. """ if len(vertices) > 4294967295: # This restriction could be removed by splitting into multiple primitives. raise Exception("Invalid mesh. Too many vertices.") - positions = list(vertices.values()) if isinstance(vertices, dict) else vertices + if isinstance(vertices, Mapping): + positions = list(vertices.values()) + else: + positions = vertices for position in positions: if len(position) != 3: raise Exception("Invalid mesh. Vertices are expected to be points in 3-space.") @classmethod - def from_vertices_and_faces(cls, context, vertices, faces, mesh_name=None, extras=None): - """Construct a :class:`compas.files.GLTFMesh` object from lists of vertices and faces. + def from_vertices_and_faces( + cls, + context: "GLTFDocument", + vertices: Union[Sequence[Sequence[float]], Mapping[Hashable, Sequence[float]]], + faces: Sequence[Sequence[Hashable]], + mesh_name: Optional[str] = None, + extras: Any = None, + ) -> Self: + """Construct a GLTFMesh object from lists of vertices and faces. Vertices can be given as either a list of xyz-tuples or -lists, in which case the faces reference vertices by index, or vertices can be given as a dictionary of key-value pairs where the values are xyz-tuples or -lists and the faces reference the keys. Parameters ---------- - context : :class:`compas.files.GLTFContent` - vertices : Union[list, dict] - faces : list - mesh_name : str - extras : object + context + Destination document. + vertices + Vertex coordinates by position or key. + faces + Point, line, or triangle index groups. + mesh_name + Optional mesh name. + extras + Application-specific data. Returns ------- - :class:`compas.files.GLTFMesh` + GLTFMesh + Created mesh data. """ cls.validate_faces(faces) cls.validate_vertices(vertices) mode = get_mode(faces) - if isinstance(vertices, dict): + if isinstance(vertices, Mapping): index_by_key = {} positions = [] for key, position in vertices.items(): @@ -189,25 +226,29 @@ def from_vertices_and_faces(cls, context, vertices, faces, mesh_name=None, extra index_by_key[key] = len(positions) - 1 face_list = [index_by_key[key] for key in itertools.chain(*faces)] else: - positions = vertices - face_list = list(itertools.chain(*faces)) + positions = [list(position) for position in vertices] + face_list = cast(list[int], list(itertools.chain(*faces))) - primitive = PrimitiveData({"POSITION": positions}, face_list, None, mode, None, None) + primitive = PrimitiveData({"POSITION": cast(list[Any], positions)}, face_list, None, mode, None, None) return cls([primitive], context, mesh_name=mesh_name, extras=extras) @classmethod - def from_mesh(cls, context, mesh): - """Construct a :class:`compas.files.GLTFMesh` object from a compas mesh. + def from_mesh(cls, context: "GLTFDocument", mesh: "Mesh") -> Self: + """Construct a GLTFMesh object from a compas mesh. Parameters ---------- - context : :class:`compas.files.GLTFContent` - mesh : :class:`compas.datastructures.Mesh` + context + Destination document. + mesh + Source mesh. Returns ------- - :class:`compas.files.GLTFMesh` + GLTFMesh + Created mesh data. + """ vertices, faces = mesh.to_vertices_and_faces() texture_coordinates = mesh.vertices_attribute("texture_coordinate") @@ -216,58 +257,10 @@ def from_mesh(cls, context, mesh): mesh_data = cls.from_vertices_and_faces(context, vertices, faces) pd = mesh_data.primitive_data_list[0] - if texture_coordinates[0] is not None: + if texture_coordinates and texture_coordinates[0] is not None: pd.attributes["TEXCOORD_0"] = texture_coordinates - if vertex_normals[0] is not None: + if vertex_normals and vertex_normals[0] is not None: pd.attributes["NORMAL"] = vertex_normals - if vertex_colors[0] is not None: + if vertex_colors and vertex_colors[0] is not None: pd.attributes["COLOR_0"] = vertex_colors return mesh_data - - def to_data(self, primitives): - """Returns a JSONable dictionary object in accordance with glTF specifications. - - Parameters - ---------- - primitives : list - - Returns - ------- - dict - """ - mesh_dict = {"primitives": primitives} - if self.mesh_name is not None: - mesh_dict["name"] = self.mesh_name - if self.weights is not None: - mesh_dict["weights"] = self.weights - if self.extras is not None: - mesh_dict["extras"] = self.extras - if self.extensions is not None: - mesh_dict["extensions"] = self.extensions - return mesh_dict - - @classmethod - def from_data(cls, mesh, context, primitive_data_list): - """Creates a :class:`compas.files.GLTFMesh` from a glTF node dictionary - and inserts it in the provided context. - - Parameters - ---------- - mesh : dict - context : :class:`compas.files.GLTFContent` - primitive_data_list : list - - Returns - ------- - :class:`compas.files.GLTFMesh` - """ - if mesh is None: - return None - return cls( - primitive_data_list=primitive_data_list, - context=context, - mesh_name=mesh.get("name"), - weights=mesh.get("weights"), - extras=mesh.get("extras"), - extensions=mesh.get("extensions"), - ) diff --git a/src/compas/files/gltf/gltf_node.py b/src/compas/files/gltf/gltf_node.py index 23d6bee91d2c..2582aa24425e 100644 --- a/src/compas/files/gltf/gltf_node.py +++ b/src/compas/files/gltf/gltf_node.py @@ -1,20 +1,26 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import fabs +from typing import TYPE_CHECKING +from typing import Any +from typing import Iterable +from typing import Optional +from typing import Union +from typing import cast from compas.files.gltf.gltf_children import GLTFChildren -from compas.files.gltf.helpers import get_matrix_from_col_major_list -from compas.files.gltf.helpers import matrix_to_col_major_order -from compas.geometry import identity_matrix -from compas.geometry import matrix_from_quaternion -from compas.geometry import matrix_from_scale_factors -from compas.geometry import matrix_from_translation -from compas.geometry import multiply_matrices +from compas.linalg.matrices import multiply_matrices +from compas.linalg.transformations import identity_matrix +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 + +if TYPE_CHECKING: + from compas.datastructures import Mesh + + from .gltf_document import GLTFDocument + from .gltf_mesh import GLTFMesh -class GLTFNode(object): +class GLTFNode: """Object representing the COMPAS consumable part of a glTF node. Attributes @@ -22,22 +28,22 @@ class GLTFNode(object): name : str Name of the node. children : GLTFChildren - Validated list of keys referencing :attr:`compas.files.GLTFNode.context.nodes`. + Validated list of keys referencing GLTFNode.context.nodes. matrix : list of lists Matrix representing the displacement from node's parent to the node. Default value is the identity matrix. Cannot be set when any of translation, rotation or scale is set. translation : list[float] xyz-coordinates of the translation displacement of the node. - Cannot be set when :attr:`compas.files.GLTFNode.matrix` is set. + Cannot be set when GLTFNode.matrix is set. rotation : list[float] Unit quaternion representing the rotational displacement of the node. - Cannot be set when :attr:`compas.files.GLTFNode.matrix` is set. + Cannot be set when GLTFNode.matrix is set. scale : list[float] List of length 3 representing the scaling displacement of the node. - Cannot be set when :attr:`compas.files.GLTFNode.matrix` is set. + Cannot be set when GLTFNode.matrix is set. mesh_key : int - Key of the mesh within :attr:`compas.files.GLTFNode.context.meshes`. + Key of the mesh within GLTFNode.context.meshes. weights : list[float] Weights used for computing morph targets in the attached mesh. position : tuple @@ -45,15 +51,15 @@ class GLTFNode(object): transform : list of lists Matrix representing the displacement from the root node to the node. key : int - Key of the node used in :attr:`compas.files.GLTFNode.context.nodes`. + Key of the node used in GLTFNode.context.nodes. camera : int - Key of the camera in :attr:`compas.files.GLTFNode.context.cameras`. + Key of the camera in GLTFNode.context.cameras. skin : int - Key of the skin in :attr:`compas.files.GLTFNode.context.skins`. + Key of the skin in GLTFNode.context.skins. extras : object Application-specific data. extensions : object - context : GLTFContent + context : GLTFDocument GLTF context in which the node exists. mesh_data : GLTFMesh GLTFMesh used by this node. @@ -64,7 +70,13 @@ class GLTFNode(object): """ - def __init__(self, context, name=None, extras=None, extensions=None): + def __init__( + self, + context: "GLTFDocument", + name: Optional[str] = None, + extras: Any = None, + extensions: Any = None, + ) -> None: self.name = name self._children = GLTFChildren(context, []) self._matrix = None @@ -72,10 +84,10 @@ def __init__(self, context, name=None, extras=None, extensions=None): self._rotation = None self._scale = None self._mesh_key = None - self.weights = None + self.weights: Optional[list[float]] = None - self.position = None - self.transform = None + self.position: Optional[list[float]] = None + self.transform: Optional[list[list[float]]] = None self._key = None self._camera = None @@ -86,7 +98,7 @@ def __init__(self, context, name=None, extras=None, extensions=None): self.context = context self._set_key() - def _set_key(self): + def _set_key(self) -> None: key = len(self.context.nodes) while key in self.context.nodes: key += 1 @@ -94,117 +106,120 @@ def _set_key(self): self._key = key @property - def key(self): + def key(self) -> int: + assert self._key is not None return self._key @property - def children(self): + def children(self) -> GLTFChildren: return self._children @children.setter - def children(self, value): + def children(self, value: Optional[Iterable[int]]) -> None: self._children = GLTFChildren(self.context, value or []) @property - def mesh_key(self): + def mesh_key(self) -> Optional[int]: return self._mesh_key @mesh_key.setter - def mesh_key(self, value): + def mesh_key(self, value: Optional[int]) -> None: if value is not None and value not in self.context.meshes: - raise Exception("Cannot find mesh {}".format(value)) + raise ValueError(f"Cannot find glTF mesh {value}.") self._mesh_key = value @property - def camera(self): + def camera(self) -> Optional[int]: return self._camera @camera.setter - def camera(self, value): + def camera(self, value: Optional[int]) -> None: if value is not None and value not in self.context.cameras: - raise Exception("Cannot find camera {}".format(value)) + raise ValueError(f"Cannot find glTF camera {value}.") self._camera = value @property - def skin(self): + def skin(self) -> Optional[int]: return self._skin @skin.setter - def skin(self, value): - if value is not None and value not in self.context.skin: - raise Exception("Cannot find skin {}".format(value)) + def skin(self, value: Optional[int]) -> None: + if value is not None and value not in self.context.skins: + raise ValueError(f"Cannot find glTF skin {value}.") self._skin = value @property - def translation(self): + def translation(self) -> Optional[list[float]]: return self._translation @translation.setter - def translation(self, value): + def translation(self, value: Optional[list[float]]) -> None: if value is None: self._translation = value return if self._matrix: - raise Exception("Cannot set translation when matrix is set.") + raise ValueError("Cannot set translation when matrix is set.") if not isinstance(value, list) or len(value) != 3: - raise Exception("Invalid translation. Translations are expected to be of the form [x, y, z].") + raise ValueError("Invalid translation. Expected [x, y, z].") self._translation = value @property - def rotation(self): + def rotation(self) -> Optional[list[float]]: return self._rotation @rotation.setter - def rotation(self, value): + def rotation(self, value: Optional[list[float]]) -> None: if value is None: self._rotation = value return if self._matrix: - raise Exception("Cannot set rotation when matrix is set.") + raise ValueError("Cannot set rotation when matrix is set.") if not isinstance(value, list) or len(value) != 4 or fabs(sum([q**2 for q in value]) - 1) > 1e-03: - raise Exception("Invalid rotation. Rotations are expected to be given as unit quaternions of the form [q1, q2, q3, q4]") + raise ValueError("Invalid rotation. Expected a unit quaternion [x, y, z, w].") self._rotation = value @property - def scale(self): + def scale(self) -> Optional[list[float]]: return self._scale @scale.setter - def scale(self, value): + def scale(self, value: Optional[list[float]]) -> None: if value is None: self._scale = value return if self._matrix: - raise Exception("Cannot set scale when matrix is set.") + raise ValueError("Cannot set scale when matrix is set.") if not isinstance(value, list) or len(value) != 3: - raise Exception("Invalid scale. Scales are expected to be of the form [s1, s2, s3]") + raise ValueError("Invalid scale. Expected [x, y, z].") self._scale = value @property - def matrix(self): + def matrix(self) -> Optional[list[list[float]]]: 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): + def matrix(self, value: Optional[list[list[float]]]) -> None: if value is None: self._matrix = value return if self.translation or self.rotation or self.scale: - raise Exception("Cannot set matrix when translation, rotation or scale is set.") + raise ValueError("Cannot set matrix when translation, rotation, or scale is set.") if not isinstance(value, list) or not value or not value[0] or not isinstance(value[0], list): - raise Exception("Invalid matrix. A list of lists is expected.") + raise ValueError("Invalid matrix. Expected a list of lists.") if len(value) != 4 or len(value[0]) != 4: - raise Exception("Invalid matrix. A 4x4 matrix is expected.") + raise ValueError("Invalid matrix. Expected a 4x4 matrix.") if value[3] != [0, 0, 0, 1]: - raise Exception( + raise ValueError( "Invalid matrix. A matrix without shear or skew is expected. It must be of the form TRS, where T is a translation, R is a rotation and S is a scaling." ) self._matrix = value @property - def mesh_data(self): + def mesh_data(self) -> "Optional[GLTFMesh]": + if self.mesh_key is None: + return None return self.context.meshes.get(self.mesh_key) @property @@ -215,16 +230,16 @@ def vertices(self): def faces(self): return self.context.get_node_faces(self) - def get_matrix_from_trs(self): - """If the node's displacement from the origin is given by its translation, rotation and - scale attributes, this method returns the matrix given by the composition of these - attributes. + def get_matrix_from_trs(self) -> list[list[float]]: + """Compose the node's translation, rotation, and scale into a matrix. Returns ------- + list[list[float]] + Composed transformation matrix. """ - matrix = identity_matrix(4) + matrix = cast(list[list[float]], identity_matrix(4)) if self.translation: translation = matrix_from_translation(self.translation) matrix = multiply_matrices(matrix, translation) @@ -234,115 +249,37 @@ def get_matrix_from_trs(self): if self.scale: scale = matrix_from_scale_factors(self.scale) matrix = multiply_matrices(matrix, scale) - return matrix + return cast(list[list[float]], matrix) - def add_child(self, child_name=None, child_extras=None): - """Creates a :class:`compas.files.GLTFNode` with name `child_name` (default `None`) and extras `child_extras` - (default `None`), and adds this node to the children of this node. + def add_child(self, child_name: Optional[str] = None, child_extras: Any = None) -> "GLTFNode": + """Create and attach a child node. Parameters ---------- - child_name : str - child_extras : object + child_name + Optional child name. + child_extras + Application-specific child data. Returns ------- - :class:`compas.files.GLTFNode` + GLTFNode + Created child node. + """ return self.context.add_child_to_node(self, child_name, child_extras) - def add_mesh(self, mesh): - """Adds an existing mesh to this node if `mesh` is a valid mesh key, or creates and adds a - mesh to this node and its context. + def add_mesh(self, mesh: "Union[int, Mesh]") -> "GLTFMesh": + """Attach an existing or converted mesh to this node. Parameters ---------- - mesh : Union[int, Mesh] + mesh + Existing mesh key or mesh to convert. Returns - ------- + GLTFMesh + Attached mesh data. """ return self.context.add_mesh_to_node(self, mesh) - - def to_data( - self, - node_index_by_key, - mesh_index_by_key, - camera_index_by_key, - skin_index_by_key, - ): - """Returns a JSONable dictionary object in accordance with glTF specifications. - - Parameters - ---------- - node_index_by_key : dict - mesh_index_by_key : dict - camera_index_by_key : dict - skin_index_by_key : dict - - Returns - ------- - dict - """ - node_dict = {} - if self.name is not None: - node_dict["name"] = self.name - if self.children: - node_dict["children"] = [node_index_by_key[key] for key in self.children] - if self.matrix and self.matrix != identity_matrix(4): - node_dict["matrix"] = matrix_to_col_major_order(self.matrix) - else: - if self.translation: - node_dict["translation"] = self.translation - if self.rotation: - node_dict["rotation"] = self.rotation - if self.scale: - node_dict["scale"] = self.scale - if self.mesh_key is not None: - node_dict["mesh"] = mesh_index_by_key[self.mesh_key] - if self._camera is not None: - node_dict["camera"] = camera_index_by_key[self._camera] - if self._skin is not None: - node_dict["skin"] = skin_index_by_key[self._skin] - if self.extras: - node_dict["extras"] = self.extras - if self.extensions is not None: - node_dict["extensions"] = self.extensions - return node_dict - - @classmethod - def from_data(cls, node, context): - """Creates a :class:`compas.files.GLTFNode` from a glTF node dictionary - and inserts it in the provided context. - - Parameters - ---------- - node : dict - context : :class:`compas.files.GLTFContent` - - Returns - ------- - :class:`compas.files.GLTFNode` - """ - if node is None: - return None - gltf_node = cls( - context=context, - name=node.get("name"), - extras=node.get("extras"), - extensions=node.get("extensions"), - ) - # Accessing protected attribute to bypass validation: - # Nodes may reference children that haven't yet been added to the GLTFContent - gltf_node.children._values = node.get("children", []) - - gltf_node.translation = node.get("translation") - gltf_node.rotation = node.get("rotation") - gltf_node.scale = node.get("scale") - gltf_node.matrix = get_matrix_from_col_major_list(node["matrix"]) if "matrix" in node else None - gltf_node.weights = node.get("weights") - gltf_node.mesh_key = node.get("mesh") - gltf_node.camera = node.get("camera") - gltf_node.skin = node.get("skin") - return gltf_node diff --git a/src/compas/files/gltf/gltf_parser.py b/src/compas/files/gltf/gltf_parser.py index bb5881f148d6..9bf61a9b4d04 100644 --- a/src/compas/files/gltf/gltf_parser.py +++ b/src/compas/files/gltf/gltf_parser.py @@ -1,115 +1,334 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from compas.files.gltf.data_classes import AnimationData -from compas.files.gltf.data_classes import AnimationSamplerData -from compas.files.gltf.data_classes import CameraData -from compas.files.gltf.data_classes import ChannelData -from compas.files.gltf.data_classes import MaterialData -from compas.files.gltf.data_classes import PrimitiveData -from compas.files.gltf.data_classes import SamplerData -from compas.files.gltf.data_classes import SkinData -from compas.files.gltf.data_classes import TextureData -from compas.files.gltf.gltf_content import GLTFContent -from compas.files.gltf.gltf_mesh import GLTFMesh -from compas.files.gltf.gltf_node import GLTFNode -from compas.files.gltf.gltf_scene import GLTFScene - - -class GLTFParser(object): - """Parse the contents of the reader into a GLTFContent object. - - Parameters - ---------- - reader : :class:`compas.files.GLTFReader` - - Attributes - ---------- - reader : :class:`compas.files.GLTFReader` - content : :class:`compas.files.GLTFContent` - """ - - def __init__(self, reader): - self.reader = reader - self.content = GLTFContent() - - self.parse() - - def parse(self): - self.content.default_scene_key = self._get_default_scene() - self.content.extras = self._get_extras() - self.content.extensions = self._get_extensions() - - self.content.images = {key: image_data for key, image_data in enumerate(self.reader.image_data)} - self.content.samplers = {key: SamplerData.from_data(sampler) for key, sampler in enumerate(self.reader.json.get("samplers", []))} - self.content.textures = {key: TextureData.from_data(texture) for key, texture in enumerate(self.reader.json.get("textures", []))} - self.content.materials = {key: MaterialData.from_data(material) for key, material in enumerate(self.reader.json.get("materials", []))} - self.content.cameras = {key: CameraData.from_data(camera) for key, camera in enumerate(self.reader.json.get("cameras", []))} - self.content.skins = {key: SkinData.from_data(skin, self.reader.data[skin["inverseBindMatrices"]]) for key, skin in enumerate(self.reader.json.get("skins", []))} - self.content.animations = {key: self._get_animation_data(animation) for key, animation in enumerate(self.reader.json.get("animations", []))} - - for mesh in self.reader.json.get("meshes", []): - self._add_gltf_mesh(mesh) - for node in self.reader.json.get("nodes", []): - self._add_gltf_node(node) - for scene in self.reader.json.get("scenes", []): - self._add_gltf_scene(scene) - - self.content.update_node_transforms_and_positions() - - def _get_animation_data(self, animation): - sampler_data_dict = {} - for index, sampler in enumerate(animation["samplers"]): - input_ = self.reader.data[sampler["input"]] - output = self.reader.data[sampler["output"]] - sampler_data_dict[index] = AnimationSamplerData.from_data(sampler, input_, output) - channel_data_list = [ChannelData.from_data(channel) for channel in animation["channels"]] - return AnimationData.from_data(animation, channel_data_list, sampler_data_dict) - - def _get_extras(self): - return self.reader.json.get("extras") - - def _get_default_scene(self): - return self.reader.json.get("scene") - - def _get_extensions(self): - return self.reader.json.get("extensions") - - def _add_gltf_scene(self, scene): - GLTFScene.from_data(scene, self.content) - - def _add_gltf_node(self, node): - GLTFNode.from_data(node, self.content) - - def _add_gltf_mesh(self, mesh): - primitive_data_list = [] - for primitive in mesh["primitives"]: - if "POSITION" not in primitive["attributes"]: - continue - - attributes = {} - for attr, attr_accessor_index in primitive["attributes"].items(): - attributes[attr] = self.reader.data[attr_accessor_index] - - indices = self._get_indices(primitive, len(attributes["POSITION"])) - - target_list = [] - for target in primitive.get("targets", []): - target_data = {attr: self.reader.data[accessor_index] for attr, accessor_index in target.items()} - target_list.append(target_data) - - primitive_data = PrimitiveData.from_data(primitive, attributes, indices, target_list) - - primitive_data_list.append(primitive_data) - - GLTFMesh.from_data(mesh, self.content, primitive_data_list) - - def _get_indices(self, primitive, num_vertices): - if "indices" not in primitive: - return self.get_generic_indices(num_vertices) - indices_accessor_index = primitive["indices"] - return self.reader.data[indices_accessor_index] +"""Parser from raw glTF sources to semantic content.""" - def get_generic_indices(self, num_vertices): - return list(range(num_vertices)) +from typing import cast + +from .data_classes import AnimationData +from .data_classes import AnimationSamplerData +from .data_classes import CameraData +from .data_classes import ChannelData +from .data_classes import ImageData +from .data_classes import MaterialData +from .data_classes import NormalTextureInfoData +from .data_classes import OcclusionTextureInfoData +from .data_classes import PBRMetallicRoughnessData +from .data_classes import PrimitiveData +from .data_classes import SamplerData +from .data_classes import SkinData +from .data_classes import TargetData +from .data_classes import TextureData +from .data_classes import TextureInfoData +from .extensions import SUPPORTED_EXTENSIONS +from .extensions import KHR_materials_clearcoat +from .extensions import KHR_materials_ior +from .extensions import KHR_materials_pbrSpecularGlossiness +from .extensions import KHR_materials_specular +from .extensions import KHR_materials_transmission +from .extensions import KHR_Texture_Transform +from .gltf_accessors import GLTFAccessorDecoder +from .gltf_accessors import data_uri_mime_type +from .gltf_container import parse_container +from .gltf_document import GLTFDocument +from .gltf_mesh import GLTFMesh +from .gltf_node import GLTFNode +from .gltf_resources import GLTFSource +from .gltf_scene import GLTFScene +from .gltf_types import AccessorData +from .gltf_types import GLTFJson + + +class GLTFParser: + """Parse a complete primary source and its referenced resources.""" + + def __init__(self, source: GLTFSource) -> None: + self.source = source + + def parse(self) -> GLTFDocument: + """Parse semantic glTF content. + + Returns + ------- + GLTFDocument + Parsed scenes and associated data. + + """ + _, document, binary_chunk = parse_container(self.source.data) + decoder = GLTFAccessorDecoder(document, binary_chunk, self.source.resource_loader) + accessors = decoder.decode_all() + content = GLTFDocument() + content.default_scene_key = document.get("scene") + content.extras = document.get("extras") + content.extensions = document.get("extensions") + content.extensions_used = document.get("extensionsUsed") + content.extensions_required = document.get("extensionsRequired") + content.asset = document["asset"].copy() + known = { + "accessors", "animations", "asset", "bufferViews", "buffers", "cameras", "extensions", + "extensionsRequired", "extensionsUsed", "extras", "images", "materials", "meshes", "nodes", + "samplers", "scene", "scenes", "skins", "textures", + } + content.unknown = {key: value for key, value in document.items() if key not in known} + content.images = { + key: _image_data(image, decoder) for key, image in enumerate(document.get("images", [])) + } + content.samplers = { + key: _sampler_data(value) + for key, value in enumerate(document.get("samplers", [])) + } + content.textures = { + key: _texture_data(value) + for key, value in enumerate(document.get("textures", [])) + } + content.materials = { + key: _material_data(value) + for key, value in enumerate(document.get("materials", [])) + } + content.cameras = { + key: _camera_data(value) + for key, value in enumerate(document.get("cameras", [])) + } + content.skins = { + key: _skin_data(value, accessors[value["inverseBindMatrices"]] if "inverseBindMatrices" in value else None) + for key, value in enumerate(document.get("skins", [])) + } + content.animations = { + key: _animation_data(value, accessors) for key, value in enumerate(document.get("animations", [])) + } + for mesh in document.get("meshes", []): + _add_mesh(mesh, accessors, content) + for node in document.get("nodes", []): + _add_node(node, content) + for scene in document.get("scenes", []): + _add_scene(scene, content) + content.validate() + content.update_node_transforms_and_positions() + return content + + +def _image_data(image: GLTFJson, decoder: GLTFAccessorDecoder) -> ImageData: + uri = image.get("uri") + mime_type = image.get("mimeType") or data_uri_mime_type(uri) + data = decoder.buffer_view_bytes(image["bufferView"]) if "bufferView" in image else None + if uri: + data = decoder.resource_bytes(uri) + return ImageData( + uri=image.get("uri"), + mime_type=image.get("mimeType") or mime_type, + name=image.get("name"), + extras=image.get("extras"), + extensions=_extensions_from_json(image.get("extensions")), + data=data, + ) + + +def _animation_data(animation: GLTFJson, accessors: list[AccessorData]) -> AnimationData: + samplers = { + index: AnimationSamplerData( + input_=accessors[value["input"]], + output=accessors[value["output"]], + interpolation=value.get("interpolation"), + extras=value.get("extras"), + extensions=_extensions_from_json(value.get("extensions")), + ) + for index, value in enumerate(animation["samplers"]) + } + channels = [] + for value in animation["channels"]: + target = value["target"] + channels.append( + ChannelData( + sampler=value["sampler"], + target=TargetData( + path=target["path"], + node=target.get("node"), + extras=target.get("extras"), + extensions=_extensions_from_json(target.get("extensions")), + ), + extras=value.get("extras"), + extensions=_extensions_from_json(value.get("extensions")), + ) + ) + return AnimationData( + channels=channels, + samplers_dict=samplers, + name=animation.get("name"), + extras=animation.get("extras"), + extensions=_extensions_from_json(animation.get("extensions")), + ) + + +def _add_mesh(mesh: GLTFJson, accessors: list[AccessorData], content: GLTFDocument) -> None: + primitives = [] + for primitive in mesh["primitives"]: + if "POSITION" not in primitive["attributes"]: + continue + attributes = {name: accessors[index] for name, index in primitive["attributes"].items()} + indices = accessors[primitive["indices"]] if "indices" in primitive else list(range(len(attributes["POSITION"]))) + targets = [ + {name: accessors[index] for name, index in target.items()} for target in primitive.get("targets", []) + ] + primitives.append( + PrimitiveData( + attributes=attributes, + indices=indices, + material=primitive.get("material"), + mode=primitive.get("mode"), + targets=targets, + extras=primitive.get("extras"), + extensions=_extensions_from_json(primitive.get("extensions")), + ) + ) + GLTFMesh( + primitive_data_list=primitives, + context=content, + mesh_name=mesh.get("name"), + weights=mesh.get("weights"), + extras=mesh.get("extras"), + extensions=_extensions_from_json(mesh.get("extensions")), + ) + + +def _add_node(node: GLTFJson, content: GLTFDocument) -> None: + result = GLTFNode(content, node.get("name"), node.get("extras"), _extensions_from_json(node.get("extensions"))) + # Child nodes may occur later in the source array and therefore do not yet + # exist in the semantic document during this construction pass. + result.children._values = node.get("children", []) + if "matrix" in node: + from .helpers import get_matrix_from_col_major_list + + result.matrix = get_matrix_from_col_major_list(node["matrix"]) + else: + result.translation = node.get("translation") + result.rotation = node.get("rotation") + result.scale = node.get("scale") + result.mesh_key = node.get("mesh") + result.weights = node.get("weights") + result.camera = node.get("camera") + result.skin = node.get("skin") + + +def _add_scene(scene: GLTFJson, content: GLTFDocument) -> None: + GLTFScene( + context=content, + children=scene.get("nodes"), + name=scene.get("name"), + extras=scene.get("extras"), + extensions=_extensions_from_json(scene.get("extensions")), + ) + + +def _sampler_data(data: GLTFJson) -> SamplerData: + return SamplerData( + data.get("magFilter"), + data.get("minFilter"), + data.get("wrapS"), + data.get("wrapT"), + data.get("name"), + data.get("extras"), + _extensions_from_json(data.get("extensions")), + ) + + +def _texture_data(data: GLTFJson) -> TextureData: + return TextureData( + data.get("sampler"), + data.get("source"), + data.get("name"), + data.get("extras"), + _extensions_from_json(data.get("extensions")), + ) + + +def _texture_info(data, cls=TextureInfoData): + if data is None: + return None + kwargs = { + "index": data["index"], "tex_coord": data.get("texCoord"), "extras": data.get("extras"), + "extensions": _extensions_from_json(data.get("extensions")), + } + if cls is NormalTextureInfoData: + kwargs["scale"] = data.get("scale") + if cls is OcclusionTextureInfoData: + kwargs["strength"] = data.get("strength") + return cls(**kwargs) + + +def _material_data(data: GLTFJson) -> MaterialData: + pbr = data.get("pbrMetallicRoughness") + pbr_data = None + if pbr is not None: + pbr_data = PBRMetallicRoughnessData( + base_color_factor=pbr.get("baseColorFactor"), + base_color_texture=_texture_info(pbr.get("baseColorTexture")), + metallic_factor=pbr.get("metallicFactor"), roughness_factor=pbr.get("roughnessFactor"), + metallic_roughness_texture=_texture_info(pbr.get("metallicRoughnessTexture")), + extras=pbr.get("extras"), extensions=_extensions_from_json(pbr.get("extensions")), + ) + return MaterialData( + name=data.get("name"), extras=data.get("extras"), pbr_metallic_roughness=pbr_data, + normal_texture=cast(NormalTextureInfoData, _texture_info(data.get("normalTexture"), NormalTextureInfoData)), + occlusion_texture=cast( + OcclusionTextureInfoData, + _texture_info(data.get("occlusionTexture"), OcclusionTextureInfoData), + ), + emissive_texture=_texture_info(data.get("emissiveTexture")), emissive_factor=data.get("emissiveFactor"), + alpha_mode=data.get("alphaMode"), alpha_cutoff=data.get("alphaCutoff"), double_sided=data.get("doubleSided"), + extensions=_extensions_from_json(data.get("extensions")), + ) + + +def _camera_data(data: GLTFJson) -> CameraData: + return CameraData(data["type"], data.get("orthographic"), data.get("perspective"), data.get("name"), data.get("extras"), _extensions_from_json(data.get("extensions"))) + + +def _skin_data(data: GLTFJson, inverse_bind_matrices) -> SkinData: + return SkinData(data["joints"], inverse_bind_matrices, data.get("skeleton"), data.get("name"), data.get("extras"), _extensions_from_json(data.get("extensions"))) + + +def _extensions_from_json(data): + if not data: + return None + result = {} + for key, value in data.items(): + cls = SUPPORTED_EXTENSIONS.get(key) + result[key] = _extension_from_json(cls, value) if cls else value + return result + + +def _extension_from_json(cls, data): + common = {"extras": data.get("extras"), "extensions": _extensions_from_json(data.get("extensions"))} + if cls is KHR_materials_transmission: + return cls(data.get("transmissionFactor"), _texture_info(data.get("transmissionTexture")), **common) + if cls is KHR_materials_specular: + return cls( + data.get("specularFactor"), + _texture_info(data.get("specularTexture")), + data.get("specularColorFactor"), + _texture_info(data.get("specularColorTexture")), + **common, + ) + if cls is KHR_materials_ior: + return cls(data.get("ior"), **common) + if cls is KHR_materials_clearcoat: + return cls( + data.get("clearcoatFactor"), + _texture_info(data.get("clearcoatTexture")), + data.get("clearcoatRoughnessFactor"), + _texture_info(data.get("clearcoatRoughnessTexture")), + cast( + NormalTextureInfoData, + _texture_info(data.get("clearcoatNormalTexture"), NormalTextureInfoData), + ), + **common, + ) + if cls is KHR_Texture_Transform: + return cls(data.get("offset"), data.get("rotation"), data.get("scale"), data.get("texCoord"), **common) + if cls is KHR_materials_pbrSpecularGlossiness: + return cls( + data.get("diffuseFactor"), + _texture_info(data.get("diffuseTexture")), + data.get("specularFactor"), + data.get("glossinessFactor"), + _texture_info(data.get("specularGlossinessTexture")), + **common, + ) + return data diff --git a/src/compas/files/gltf/gltf_payload.py b/src/compas/files/gltf/gltf_payload.py new file mode 100644 index 000000000000..8bb865ed7954 --- /dev/null +++ b/src/compas/files/gltf/gltf_payload.py @@ -0,0 +1,17 @@ +"""Encoded glTF output independent of target I/O.""" + +from dataclasses import dataclass +from dataclasses import field + +from .gltf_types import GLTFFormat +from .gltf_types import GLTFJson + + +@dataclass +class GLTFPayload: + """Serializable primary glTF data and adjacent resources.""" + + format: GLTFFormat + json: GLTFJson + binary: bytes = b"" + resources: dict[str, bytes] = field(default_factory=dict) diff --git a/src/compas/files/gltf/gltf_reader.py b/src/compas/files/gltf/gltf_reader.py index 6dd4a4a6ee2c..dfb89a2ffa1e 100644 --- a/src/compas/files/gltf/gltf_reader.py +++ b/src/compas/files/gltf/gltf_reader.py @@ -1,310 +1,37 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +"""Primary source acquisition for glTF files.""" -import base64 -import json -import os -import re -import struct +from os import PathLike +from pathlib import Path +from typing import Optional -from compas.files.gltf.constants import COMPONENT_TYPE_BYTE -from compas.files.gltf.constants import COMPONENT_TYPE_ENUM -from compas.files.gltf.constants import COMPONENT_TYPE_SHORT -from compas.files.gltf.constants import COMPONENT_TYPE_UNSIGNED_BYTE -from compas.files.gltf.constants import COMPONENT_TYPE_UNSIGNED_SHORT -from compas.files.gltf.constants import NUM_COMPONENTS_BY_TYPE_ENUM -from compas.files.gltf.data_classes import ImageData +from compas import _iotools +from .gltf_resources import GLTFResourceLoader +from .gltf_resources import GLTFSource +from .gltf_resources import PathResourceLoader +from .gltf_resources import URLResourceLoader -class GLTFReader(object): - """Read the contents of a *glTF* or *glb* version 2 file using the json library. - Uses ideas from Khronos Group's glTF-Blender-IO. - Caution: Extensions are minimally supported and their data may be lost. - Parameters - ---------- - filepath: str - Path to the file. +class GLTFReader: + """Read a primary glTF source without parsing it.""" - Attributes - ---------- - filepath : str - String containing the path to the glTF. - json : dict - Dictionary object containing the contents of the glTF. - data : list - List of lists containing data read from binary files. - image_data : list - List containing image data. - """ + def __init__(self, source: _iotools.IOSource, resource_loader: Optional[GLTFResourceLoader] = None) -> None: + self.source = source + self.resource_loader = resource_loader - def __init__(self, filepath): - self.filepath = filepath + def read(self) -> GLTFSource: + """Read the primary source and configure relative resource loading. - self.json = None - self.data = [] - self.image_data = [] + Returns + ------- + GLTFSource + Complete primary data and an optional resource loader. - self._bin_content = None - self._glb_buffer = None - self._buffers = {} - - self.read() - - def read(self): - with open(self.filepath, "rb") as f: - self._bin_content = self._get_memoryview(f.read()) - - is_glb = self._bin_content[:4] == b"glTF" - - if not is_glb: - content = self._bin_content.tobytes().decode("utf-8") - self.json = json.loads(content) - else: - self._load_from_glb() - - self._release_buffer(self._bin_content) - self._bin_content = None - - self._check_version() - - if self.json: - for accessor in self.json.get("accessors", []): - accessor_data = self._access_data(accessor) - self.data.append(accessor_data) - - for image in self.json.get("images", []): - mime_type = self.get_mime_type(image.get("uri")) - data = None - if "bufferView" in image: - data = self._get_attr_data(image, "bufferView") - if "uri" in image and self.is_data_uri(image["uri"]): - data = base64.b64decode(self.get_data_uri_data(image["uri"])) - - image_data = ImageData.from_data(image, data, mime_type) - self.image_data.append(image_data) - - self._release_buffers() - - def _load_from_glb(self): - header = self._unpack_content("<4sII") - file_size = header[2] - - if file_size != len(self._bin_content): - raise Exception("Bad glTF. File size does not match.") - - offset = 12 - - # load json - type_, _length, json_bytes, offset = self._load_chunk(offset) - if type_ != b"JSON": - raise Exception("Bad glTF. First chunk not in JSON format") - json_str = json_bytes.tobytes().decode("utf-8") - self.json = json.loads(json_str) - - # load binary buffer - if offset < len(self._bin_content): - type_, _length, bytes_, offset = self._load_chunk(offset) - if type_ == b"BIN\0": - self._glb_buffer = bytes_ - - def _load_chunk(self, offset): - chunk_header = self._unpack_content(" bytes: + """Read a resource. + + Returns + ------- + bytes + Resource contents. + + """ + ... + + +@dataclass +class GLTFSource: + """Complete primary source and optional external-resource loader.""" + + data: bytes + resource_loader: Optional[GLTFResourceLoader] = None + + +@dataclass +class PathResourceLoader: + """Load resources relative to a filesystem directory.""" + + directory: Path + + def read(self, uri: str) -> bytes: + """Read a relative resource. + + Returns + ------- + bytes + Resource contents. + + """ + return _iotools.read_bytes(self.directory / uri) + + +@dataclass +class URLResourceLoader: + """Load resources relative to a source URL.""" + + base_url: str + + def read(self, uri: str) -> bytes: + """Read a relative resource. + + Returns + ------- + bytes + Resource contents. + + """ + return _iotools.read_bytes(urljoin(self.base_url, uri)) diff --git a/src/compas/files/gltf/gltf_scene.py b/src/compas/files/gltf/gltf_scene.py index cd1556d8908d..ed3d95dfe056 100644 --- a/src/compas/files/gltf/gltf_scene.py +++ b/src/compas/files/gltf/gltf_scene.py @@ -1,11 +1,16 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import TYPE_CHECKING +from typing import Any +from typing import Iterable +from typing import Optional from compas.files.gltf.gltf_children import GLTFChildren +if TYPE_CHECKING: + from .gltf_document import GLTFDocument + from .gltf_node import GLTFNode -class GLTFScene(object): + +class GLTFScene: """Object representing the COMPAS consumable part of a glTF scene. Attributes @@ -13,13 +18,13 @@ class GLTFScene(object): name : str Name of the scene. children : GLTFChildren - Validated list of keys referencing :attr:`compas.files.GLTFScene.context.nodes`. + Validated list of keys referencing GLTFScene.context.nodes. extras : object extensions : object - context : GLTFContent + context : GLTFDocument GLTF context in which the scene exists. key : int - Key of the scene within :attr:`compas.files.GLTFContent.scenes`. + Key of the scene within GLTFDocument.scenes. nodes : dict Dictionary of nodes in the given scene, without a specified root. positions_and_edges : tuple @@ -27,7 +32,14 @@ class GLTFScene(object): """ - def __init__(self, context, children=None, name=None, extras=None, extensions=None): + def __init__( + self, + context: "GLTFDocument", + children: Optional[Iterable[int]] = None, + name: Optional[str] = None, + extras: Any = None, + extensions: Any = None, + ) -> None: self.name = name self._children = GLTFChildren(context, children or []) self.extras = extras @@ -37,7 +49,7 @@ def __init__(self, context, children=None, name=None, extras=None, extensions=No self.context = context self._set_key() - def _set_key(self): + def _set_key(self) -> None: key = len(self.context.scenes) while key in self.context.scenes: key += 1 @@ -45,81 +57,40 @@ def _set_key(self): self._key = key @property - def key(self): + def key(self) -> int: + assert self._key is not None return self._key @property - def children(self): + def children(self) -> GLTFChildren: return self._children @children.setter - def children(self, value): + def children(self, value: Optional[Iterable[int]]) -> None: self._children = GLTFChildren(self.context, value or []) @property - def nodes(self): + def nodes(self) -> dict[int, "GLTFNode"]: return self.context.get_nodes_from_scene(self) @property def positions_and_edges(self): return self.context.get_scene_positions_and_edges(self) - def add_child(self, node_name=None, node_extras=None): - """Creates a :class:`compas.files.GLTFNode` and adds this node to the children of `scene`. + def add_child(self, node_name: Optional[str] = None, node_extras: Any = None) -> "GLTFNode": + """Create a node and add it as a scene root. Parameters ---------- - node_name : str - node_extras : object + node_name + Optional node name. + node_extras + Application-specific node data. Returns ------- - :class:`compas.fikes.GLTFNode` - """ - return self.context.add_node_to_scene(self, node_name, node_extras) + GLTFNode + Created node. - def to_data(self, node_index_by_key): - """Returns a JSONable dictionary object in accordance with glTF specifications. - - Parameters - ---------- - node_index_by_key : dict - - Returns - ------- - dict """ - scene_dict = {} - if self.children: - scene_dict["nodes"] = [node_index_by_key[key] for key in self.children] - if self.name: - scene_dict["name"] = self.name - if self.extras: - scene_dict["extras"] = self.extras - if self.extensions: - scene_dict["extensions"] = self.extensions - return scene_dict - - @classmethod - def from_data(cls, scene, context): - """Creates a :class:`compas.files.GLTFScene` from a glTF scene dictionary - and inserts it in the provided context. - - Parameters - ---------- - scene : dict - context : :class:`compas.files.GLTFContent` - - Returns - ------- - :class:`compas.files.GLTFScene` - """ - if scene is None: - return None - return cls( - context=context, - children=scene.get("nodes"), - name=scene.get("name"), - extras=scene.get("extras"), - extensions=scene.get("extensions"), - ) + return self.context.add_node_to_scene(self, node_name, node_extras) diff --git a/src/compas/files/gltf/gltf_types.py b/src/compas/files/gltf/gltf_types.py new file mode 100644 index 000000000000..59ea69375e75 --- /dev/null +++ b/src/compas/files/gltf/gltf_types.py @@ -0,0 +1,12 @@ +"""Shared types for glTF I/O.""" + +from typing import Any +from typing import Literal + +GLTFFormat = Literal["gltf", "glb"] +GLTFJson = dict[str, Any] +AccessorData = Any + + +class GLTFConversionWarning(UserWarning): + """Warning emitted when scene content cannot be represented in glTF.""" diff --git a/src/compas/files/gltf/gltf_writer.py b/src/compas/files/gltf/gltf_writer.py new file mode 100644 index 000000000000..974be0b37007 --- /dev/null +++ b/src/compas/files/gltf/gltf_writer.py @@ -0,0 +1,46 @@ +"""Target writing for encoded glTF payloads.""" + +import json +import struct +from os import PathLike +from pathlib import Path + +from compas import _iotools + +from .gltf_payload import GLTFPayload + + +class GLTFWriter: + """Write an encoded payload and any adjacent resources.""" + + def __init__(self, target: _iotools.IOTarget) -> None: + self.target = target + + def write(self, payload: GLTFPayload) -> None: + """Write an encoded payload. + + """ + if payload.format == "glb": + _iotools.write_bytes(self.target, _glb_bytes(payload)) + return + data = json.dumps(payload.json, indent=4).encode("utf-8") + _iotools.write_bytes(self.target, data) + if not payload.resources: + return + if not isinstance(self.target, (str, PathLike)): + raise ValueError("External glTF resources require a filesystem target.") + directory = Path(self.target).parent + for name, resource in payload.resources.items(): + _iotools.write_bytes(directory / name, resource) + + +def _glb_bytes(payload: GLTFPayload) -> bytes: + json_data = json.dumps(payload.json, indent=4).encode("utf-8") + json_data += b" " * (-len(json_data) % 4) + binary = payload.binary + b"\0" * (-len(payload.binary) % 4) + length = 12 + 8 + len(json_data) + (8 + len(binary) if binary else 0) + result = b"glTF" + struct.pack(">> from compas.datastructures import Mesh - >>> from compas.files import OBJ - - Write mesh data to a file. + """ - >>> mesh = Mesh.from_polyhedron(12) - >>> obj = OBJ("mesh.obj") - >>> obj.write(mesh) + vertices: list[list[float]] + points: list[int] + lines: list[list[int]] + polylines: list[list[int]] + faces: list[list[int]] + objects: dict[str, tuple[dict[int, list[float]], list[list[int]]]] + groups: dict[str, list[tuple[str, int]]] - Read mesh data from a file. - >>> obj = OBJ("mesh.obj") - >>> obj.read() - >>> mesh = Mesh.from_vertices_and_faces(obj.vertices, obj.faces) +def read_obj(source: OBJSource, encoding: str = "utf-8") -> OBJDocument: + """Read an OBJ source into an OBJ document. - Reading and writing of multiple meshes as separate objects in a single OBJ file. + Parameters + ---------- + source + Path, URL, text stream, or binary stream containing OBJ data. + encoding + Encoding used for text streams and source decoding. - >>> from compas.geometry import Pointcloud, Translation - >>> from compas.datastructures import Mesh - >>> from compas.files import OBJ + Returns + ------- + OBJDocument + Parsed OBJ document. - Write mesh data to a file. + """ + data = OBJReader(source, encoding=encoding).read() + return OBJParser(data, encoding=encoding).parse() - >>> meshes = [] - >>> for point in Pointcloud.from_bounds(10, 10, 10, 100): - ... mesh = Mesh.from_polyhedron(12) - ... mesh.transform(Translation.from_vector(point)) - ... meshes.append(mesh) - >>> obj = OBJ("meshes.obj") - >>> obj.write(meshes) - Read mesh data from a file. +def obj_data(document: OBJDocument) -> OBJData: + """Project an OBJ document to geometric data. - >>> obj = OBJ("meshes.obj") - >>> obj.read() - >>> meshes = [] - >>> for name in obj.objects: - ... mesh = Mesh.from_vertices_and_faces(*obj.objects[name]) - ... mesh.name = name - ... meshes.append(mesh) + Parameters + ---------- + document + Parsed OBJ document. + Returns + ------- + OBJData + Geometric data with plain vertex indices. """ + index_index = {index: index for index in range(len(document.vertices))} + return _project_obj_data(document, list(document.vertices), index_index) + - def __init__(self, filepath, precision=None): - self.filepath = filepath - self.precision = precision - self._is_parsed = False - self._reader = None - self._parser = None - self._writer = None - - def read(self): - """Read and parse the contents of the file. - - Returns - ------- - None - """ - self._reader = OBJReader(self.filepath) - self._parser = OBJParser(self._reader, precision=self.precision) - self._reader.open() - self._reader.pre() - self._reader.read() - self._reader.post() - self._parser.parse() - self._is_parsed = True - - def write(self, mesh, unweld=False, **kwargs): - """Write a mesh to the file. - - Parameters - ---------- - mesh : :class:`compas.datastructures.Mesh` - The mesh. - unweld : bool, optional - Flag indicating that the vertices of the faces should be unwelded. - author : str, optional - The author name to include in the header. - email : str, optional - The email of the author to include in the header. - date : str, optional - The date to include in the header. - - Returns - ------- - None - - """ - self._writer = OBJWriter(self.filepath, mesh, precision=self.precision, unweld=unweld, **kwargs) - self._writer.write() - - @property - def reader(self): - if not self._is_parsed: - self.read() - return self._reader - - @property - def parser(self): - if not self._is_parsed: - self.read() - return self._parser - - @property - def vertices(self): - return self.parser.vertices - - @property - def lines(self): - return self.parser.lines - - @property - def faces(self): - return self.parser.faces - - @property - def objects(self): - return self.parser.objects - - @property - def groups(self): - return self.parser.groups - - -class OBJReader(object): - """Class for reading raw geometric data from OBJ files. +def weld_obj_data(document: OBJDocument, precision: Optional[int] = None) -> OBJData: + """Project an OBJ document to explicitly welded geometric data. Parameters ---------- - filepath : path string | file-like object | URL string - A path, a file-like object or a URL pointing to a file. + document + Parsed OBJ document. + precision + Precision used to identify coincident vertices. - Attributes - ---------- - vertices : list[list[float, float, float]] - List of lists of vertex coordinates. - weights : list[float] - List of vertex weights. - normals : list[list[float, float, float]] - List of lists of normal components. - points : list[int] - List of references to vertex coordinates. - lines : list[tuple[int, int]] - List of pairs of references to vertex coordinates. - faces : list[list[int] - List of lists of references to vertex coordinates. - groups : dict[str, tuple[list[int], list[list[int]]]] - Groups of mesh objects defined by their vertices and faces. - objects : dict[str, tuple[list[int], list[list[int]]]] - Named mesh objects defined by their vertices and faces. + Returns + ------- + OBJData + Welded geometric data with plain vertex indices. """ - - def __init__(self, filepath): - self.filepath = filepath - self.content = None - # vertex data - self.vertices = [] - self.weights = [] - self.textures = [] - self.normals = [] - # polygonal geometry - self.points = [] - self.lines = [] - self.faces = [] - # free-form geometry - # self.curves = [] - # self.curves2 = [] - # self.surfaces = [] - # free-form attributes - # self.deg = None - # self.bmat = None - # self.step = None - # self.cstype = None - # free-form statements - # parm, trim, hole, scrv, sp, end - # grouping - self.groups = defaultdict(list) - self.objects = defaultdict(list) - self.group = None - self.object = None - - def open(self): - """Open the file and read its contents. - - Returns - ------- - None - - """ - with _iotools.open_file(self.filepath, "r") as f: - self.content = f.readlines() - - def pre(self): - """Pre-process the contents. - - Returns - ------- - None - - """ - lines = [] - is_continuation = False - needs_decode = None - - for line in self.content: - # Check this only one time - if needs_decode is None: - needs_decode = hasattr(line, "decode") - if needs_decode: - line = line.decode("utf-8") - line = line.rstrip() - if not line: - continue - if is_continuation: - lines[-1] = lines[-1][:-2] + line - else: - lines.append(line) - if line[-1] == "\\": - is_continuation = True - else: - is_continuation = False - self.content = iter(lines) - - def post(self): - """Post-process the contents. - - Returns - ------- - None - - """ - pass - - def read(self): - """Read the contents of the file, line by line. - - Every line is split into a *head* and a *tail*. - The *head* determines the meaning of the data found in *tail*. - - * ``#``: comment - * ``v``: vertex coordinates - * ``vt``: vertex texture - * ``vn``: vertex normal - * ``vp``: parameter vertex - * ``p``: point - * ``l``: line - * ``f``: face - * ``deg``: freeform attribute *degree* - * ``bmat``: freeform attribute *basis matrix* - * ``step``: freeform attribute *step size* - * ``cstype``: freeform attribute *curve or surface type* - * ``o``: start of named object - * ``g``: start of a named group - - Returns - ------- - None - - """ - if not self.content: - return - for line in self.content: - parts = line.split() - if not parts: - continue - head = parts[0] - tail = parts[1:] - if head == "#": - self._read_comment(tail) - continue - if head == "v": - self._read_vertex_coordinates(tail) - continue - if head == "vt": - self._read_vertex_texture(tail) - continue - if head == "vn": - self._read_vertex_normal(tail) - continue - if head == "vp": - self._read_parameter_vertex(tail) - continue - if head in ("p", "l", "f"): - self._read_polygonal_geometry(head, tail) - continue - if head in ("deg", "bmat", "step", "cstype"): - self._read_freeform_attribute(head, tail) - continue - if head in ("curv", "curv2", "surf"): - self._read_freeform_geometry(head, tail) - continue - if head in ("parm", "trim", "hole", "scrv", "sp", "end"): - self._read_freeform_statement(head, tail) - continue - if head in ("g", "s", "mg", "o"): - self._read_grouping(head, tail) - continue - - def _read_comment(self, data): - """Read a comment. - - Comments start with ``#``. - - """ - pass - - def _read_vertex_coordinates(self, data): - """Read the coordinates of a vertex. - - Two types of formats are possible: - - * x y z - * x y z w - - """ - if len(data) == 3: - self.vertices.append([float(x) for x in data]) - self.weights.append(1.0) - return - if len(data) == 4: - self.vertices.append([float(x) for x in data[:3]]) - self.weights.append(float(data[3])) - - def _read_vertex_texture(self, data): - pass - - def _read_vertex_normal(self, data): - pass - - def _read_parameter_vertex(self, data): - pass - - def _read_polygonal_geometry(self, name, data): - # point - if name == "p": - self.points.append(int(data[0]) - 1) - ref = "p", len(self.points) - 1 - self.groups[self.group].append(ref) - self.objects[self.object].append(ref) - # line - elif name == "l": - if len(data) < 2: - return - self.lines.append([int(i) - 1 for i in data]) - ref = "l", len(self.lines) - 1 - self.groups[self.group].append(ref) - self.objects[self.object].append(ref) - # face - elif name == "f": - if len(data) < 3: - return - face = [] - for d in data: - parts = d.split("/") - i = int(parts[0]) - 1 - face.append(i) - self.faces.append(face) - ref = "f", len(self.faces) - 1 - self.groups[self.group].append(ref) - self.objects[self.object].append(ref) - - def _read_freeform_attribute(self, name, data): - if name == "deg": - self.deg = [int(i) for i in data] - return - if name == "bmat": - return - if name == "step": - return - if name == "cstype": - self.cstype = data - return - - def _read_freeform_geometry(self, name, data): - # curv u0 u1 v1 v2 ... - # u0: starting parameter value for the curve - # u1: ending parameter value for the curve - # v1: vertex reference number for control point - # v2: vertex reference number for control point - # ... - if name == "curv": - if self.deg[0] == 1: - if len(data) == 4: - self.lines.append((int(data[2]) - 1, int(data[3]) - 1)) - ref = "l", len(self.lines) - 1 - self.groups[self.group].append(ref) - self.objects[self.object].append(ref) - return - if len(data) > 4: - self.lines.append([int(d) - 1 for d in data[2:]]) - ref = "l", len(self.lines) - 1 - self.groups[self.group].append(ref) - self.objects[self.object].append(ref) - return - - def _read_freeform_statement(self, name, data): - pass - - def _read_grouping(self, name, data): - if name == "o": - self.object = " ".join(data) - self.objects[self.object] = [] - return - if name == "g": - self.group = " ".join(data) - self.groups[self.group] = [] - self.objects[self.object].append(("g", self.group)) - return - - -class OBJParser(object): - """Class for parsing data from a OBJ file. - - The parser converts the raw geometric data of the file - into corresponding COMPAS geometry objects and data structures. + index_key = OrderedDict( + (index, TOL.geometric_key(xyz, precision)) for index, xyz in enumerate(document.vertices) + ) + vertex = OrderedDict((key, document.vertices[index]) for index, key in index_key.items()) + vertex_index = {key: index for index, key in enumerate(vertex)} + index_index = {index: vertex_index[key] for index, key in index_key.items()} + return _project_obj_data(document, list(vertex.values()), index_index) + + +def read_obj_meshes( + source: OBJSource, + weld: bool = False, + precision: Optional[int] = None, +) -> list["Mesh"]: + """Read all polygon meshes from an OBJ source. Parameters ---------- - reader : :class:`OBJReader` - A OBJ file reader. - precision : int, optional - Precision for converting numbers to strings. - Default is :attr:`TOL.precision`. - - Attributes - ---------- - reader : :class:`OBJReader` - An OBJ file reader. - vertices : list[list[float, float, float]] - List of lists of parsed vertex coordinates. - Parsed vertices are unique up to the specified precision. - points : list[int] - List of references to parsed vertex coordinates. - lines : list[tuple[int, int]] - List of pairs of references to parsed vertex coordinates. - polylines : list[list[int] - List of lists of references to parsed vertex coordinates. - faces : list[list[int] - List of lists of references to parsed vertex coordinates. - groups : dict[str, tuple[list[int], list[list[int]]]] - Groups of mesh objects defined by their parsed vertices and faces. - objects : dict[str, tuple[list[int], list[list[int]]]] - Named mesh objects defined by their parsed vertices and faces. + source + Path, URL, text stream, or binary stream containing OBJ data. + weld + If True, explicitly weld coincident vertices before constructing the meshes. + precision + Precision used when welding vertices. + + Returns + ------- + list[Mesh] + Meshes corresponding to the named OBJ objects. Files without named + objects produce one mesh. """ - - def __init__(self, reader, precision=None): - self.precision = precision - self.reader = reader - self.vertices = None - # self.weights = None - # self.textures = None - # self.normals = None - self.points = None - self.lines = None - self.polylines = None - self.faces = None - # self.curves = None - # self.curves2 = None - # self.surfaces = None - self.groups = None - self.objects = None - - def parse(self): - """Parse the the data found by the reader. - - Returns - ------- - None - - """ - index_key = OrderedDict() - vertex = OrderedDict() - - for i, xyz in enumerate(iter(self.reader.vertices)): - key = TOL.geometric_key(xyz, self.precision) - index_key[i] = key - vertex[key] = xyz - - vertex_index = {key: index for index, key in enumerate(vertex)} - index_index = {index: vertex_index[key] for index, key in iter(index_key.items())} - - self.vertices = [xyz for xyz in iter(vertex.values())] - self.points = [index_index[index] for index in self.reader.points] - self.lines = [[index_index[index] for index in line] for line in self.reader.lines if len(line) == 2] - self.polylines = [[index_index[index] for index in line] for line in self.reader.lines if len(line) > 2] - self.faces = [[index_index[index] for index in face] for face in self.reader.faces] - self.groups = self.reader.groups - self.objects = {} - for name in self.reader.objects: - faces = [] - for item in self.reader.objects[name]: - if item[0] == "f": - faces.append(self.faces[item[1]]) - vertices = {} - for face in faces: - for vertex in face: - vertices[vertex] = self.vertices[vertex] - self.objects[name] = vertices, faces - - -class OBJWriter(object): - """Class for writing geometric data to a OBJ file. + from compas.datastructures import Mesh + + document = read_obj(source) + data = weld_obj_data(document, precision) if weld else obj_data(document) + meshes = [] + claimed_faces = set() + + for name, obj in document.objects.items(): + face_indices = [reference.index for reference in obj.elements if reference.kind == "face"] + if not face_indices: + continue + claimed_faces.update(face_indices) + mesh = _mesh_from_obj_faces(Mesh, data, face_indices) + mesh.name = name + meshes.append(mesh) + + remaining = [index for index in range(len(data.faces)) if index not in claimed_faces] + if remaining: + meshes.insert(0, _mesh_from_obj_faces(Mesh, data, remaining)) + + return meshes + + +def _mesh_from_obj_faces(mesh_type: Any, data: OBJData, face_indices: Iterable[int]) -> "Mesh": + vertex_index = {} + vertices = [] + faces = [] + for face_index in face_indices: + face = [] + for vertex in data.faces[face_index]: + if vertex not in vertex_index: + vertex_index[vertex] = len(vertices) + vertices.append(data.vertices[vertex]) + face.append(vertex_index[vertex]) + faces.append(face) + return mesh_type.from_vertices_and_faces(vertices, faces) + + +def _project_obj_data( + document: OBJDocument, + vertices: list[list[float]], + index_index: dict[int, int], +) -> OBJData: + points = [index_index[reference.vertex] for point in document.points for reference in point.vertices] + elements = [[index_index[reference.vertex] for reference in line.vertices] for line in document.lines] + lines = [line for line in elements if len(line) == 2] + polylines = [line for line in elements if len(line) > 2] + faces = [[index_index[reference.vertex] for reference in face.vertices] for face in document.faces] + groups = { + name: [(reference.kind[0], reference.index) for reference in group.elements] + for name, group in document.groups.items() + } + objects = {} + for name, obj in document.objects.items(): + object_faces = [faces[reference.index] for reference in obj.elements if reference.kind == "face"] + object_vertices = {index: vertices[index] for face in object_faces for index in face} + objects[name] = object_vertices, object_faces + + return OBJData(vertices, points, lines, polylines, faces, objects, groups) + + +def write_obj( + target: OBJTarget, + data: Any, + precision: Optional[int] = None, + unweld: bool = False, + author: Optional[str] = None, + email: Optional[str] = None, + date: Optional[str] = None, +) -> None: + """Write an OBJ document, mesh, or collection of meshes. Parameters ---------- - filepath : path string | file-like object | URL string - A path, a file-like object or a URL pointing to a file. - meshes : list[:class:`compas.datastructures.Mesh`] - list of meshes to write to the file. - precision : str, optional - COMPAS precision specification for parsing geometric data. - unweld : bool, optional - Flag indicating that the face vertices should be unwelded. - author : str, optional - The author name to include in the header. - email : str, optional - The email of the author to include in the header. - date : str, optional - The date to include in the header. + target + Path or writable text or binary stream. + data + OBJ document, mesh, or collection of meshes to write. + precision + Number of digits after the decimal point. + unweld + If True, write unique vertices for every mesh face. + author + Author name to include in the mesh compatibility header. + email + Author email to include in the mesh compatibility header. + date + Date to include in the mesh compatibility header. + + Returns + ------- + None """ - - def __init__( - self, - filepath, - meshes, - precision=None, - unweld=False, - author=None, - email=None, - date=None, - ): - self.filepath = filepath - self.meshes = meshes if isinstance(meshes, (list, tuple)) else [meshes] - self.author = author - self.email = email - self.date = date - self.precision = precision or TOL.precision - self.unweld = unweld - self.v = sum(mesh.number_of_vertices() for mesh in self.meshes) - self.f = sum(mesh.number_of_faces() for mesh in self.meshes) - self.e = sum(mesh.number_of_edges() for mesh in self.meshes) - self._v = 1 - self.file = None - - def write(self): - """Write the meshes to the file. - - Returns - ------- - None - - """ - with _iotools.open_file(self.filepath, "w") as self.file: - self._write_header() - self._write_meshes() - - def _write_header(self): - if not self.file: - return - self.file.write("# OBJ\n") - self.file.write("# COMPAS\n") - self.file.write("# version: {}\n".format(compas.__version__)) - self.file.write("# precision: {}\n".format(self.precision)) - self.file.write("# V F E: {} {} {}\n".format(self.v, self.f, self.e)) - if self.author: - self.file.write("# author: {}\n".format(self.author)) - if self.email: - self.file.write("# email: {}\n".format(self.email)) - if self.date: - self.file.write("# date: {}\n".format(self.date)) - self.file.write("\n") - - def _write_meshes(self): - if not self.file: - return - for index, mesh in enumerate(self.meshes): - name = mesh.name - if name == "Mesh": - name = "Mesh {}".format(index) - self.file.write("o {}\n".format(name)) - if self.unweld: - self._write_vertices_and_faces(mesh) - else: - self._write_vertices(mesh) - self._write_faces(mesh) - self._v += mesh.number_of_vertices() - - def _write_vertices(self, mesh): - if not self.file: - return - for key in mesh.vertices(): - x, y, z = mesh.vertex_coordinates(key) - self.file.write( - "v {0} {1} {2}\n".format( - TOL.format_number(x, self.precision), - TOL.format_number(y, self.precision), - TOL.format_number(z, self.precision), - ) - ) - - def _write_faces(self, mesh): - if not self.file: - return + precision = TOL.precision if precision is None else precision + if isinstance(data, OBJDocument): + document = deepcopy(data) + else: + meshes = list(data) if isinstance(data, (list, tuple)) else [data] + document = _document_from_meshes(meshes, unweld=unweld) + document.comments.extend( + [ + "OBJ", + "COMPAS", + f"version: {compas.__version__}", + f"precision: {precision}", + "V F E: {} {} {}".format( + sum(mesh.number_of_vertices() for mesh in meshes), + sum(mesh.number_of_faces() for mesh in meshes), + sum(mesh.number_of_edges() for mesh in meshes), + ), + ] + ) + if author: + document.comments.append(f"author: {author}") + if email: + document.comments.append(f"email: {email}") + if date: + document.comments.append(f"date: {date}") + OBJWriter(target, precision=precision).write(document) + + +def _document_from_meshes(meshes: Iterable[Any], unweld: bool = False) -> OBJDocument: + document = OBJDocument() + for index, mesh in enumerate(meshes): + name = mesh.name if mesh.name != "Mesh" else f"Mesh {index}" + obj = document.objects.setdefault(name, OBJObject(name)) + + if unweld: + for face in mesh.faces(): + references = [] + for vertex in mesh.face_vertices(face): + document.vertices.append(mesh.vertex_coordinates(vertex)) + document.vertex_weights.append(1.0) + references.append(OBJVertexReference(len(document.vertices) - 1)) + document.faces.append(OBJFace(references)) + reference = OBJElementReference("face", len(document.faces) - 1) + document.elements.append(reference) + obj.elements.append(reference) + continue + + offset = len(document.vertices) vertex_index = mesh.vertex_index() + document.vertices.extend(mesh.vertex_coordinates(vertex) for vertex in mesh.vertices()) + document.vertex_weights.extend([1.0] * mesh.number_of_vertices()) for face in mesh.faces(): - vertices = mesh.face_vertices(face) - vertices = [vertex_index[key] + self._v for key in vertices] - vertices_str = " ".join([str(index) for index in vertices]) - self.file.write("f {0}\n".format(vertices_str)) - - def _write_vertices_and_faces(self, mesh): - if not self.file: - return - for face in mesh.faces(): - vertices = mesh.face_vertices(face) - indices = [] - for vertex in vertices: - x, y, z = mesh.vertex_coordinates(vertex) - self.file.write( - "v {0} {1} {2}\n".format( - TOL.format_number(x, self.precision), - TOL.format_number(y, self.precision), - TOL.format_number(z, self.precision), - ) - ) - indices.append(self._v) - self._v += 1 - indices_str = " ".join([str(i) for i in indices]) - self.file.write("f {0}\n".format(indices_str)) + references = [OBJVertexReference(offset + vertex_index[vertex]) for vertex in mesh.face_vertices(face)] + document.faces.append(OBJFace(references)) + reference = OBJElementReference("face", len(document.faces) - 1) + document.elements.append(reference) + obj.elements.append(reference) + return document diff --git a/src/compas/files/obj_document.py b/src/compas/files/obj_document.py new file mode 100644 index 000000000000..bf5fb4697b7f --- /dev/null +++ b/src/compas/files/obj_document.py @@ -0,0 +1,228 @@ +"""Semantic document model for parsed OBJ data. + +The document contains normalized, zero-based references and no file, parser, +or writer state. + +""" + +from dataclasses import dataclass +from dataclasses import field +from typing import Literal +from typing import Optional +from typing import Union + + +@dataclass(frozen=True) +class OBJVertexReference: + """Reference to an OBJ vertex and its optional texture and normal data. + + Parameters + ---------- + vertex + Zero-based vertex index. + texture + Zero-based texture-vertex index. + normal + Zero-based vertex-normal index. + + """ + + vertex: int + texture: Optional[int] = None + normal: Optional[int] = None + + +@dataclass +class OBJPoint: + """OBJ point element. + + Parameters + ---------- + vertices + Vertex references defining the point element. + + """ + + vertices: list[OBJVertexReference] + + +@dataclass +class OBJLine: + """OBJ line or polyline element. + + Parameters + ---------- + vertices + Ordered vertex references defining the line. + + """ + + vertices: list[OBJVertexReference] + + +@dataclass +class OBJFace: + """OBJ polygonal face element. + + Parameters + ---------- + vertices + Ordered vertex references defining the face. + material + Material active when the face was defined. + smoothing + Smoothing group active when the face was defined. + + """ + + vertices: list[OBJVertexReference] + material: Optional[str] = None + smoothing: Optional[str] = None + + +OBJElementKind = Literal["point", "line", "face"] + + +@dataclass(frozen=True) +class OBJElementReference: + """Reference from an object or group to a document element. + + Parameters + ---------- + kind + Kind of referenced element. + index + Zero-based index in the corresponding document collection. + + """ + + kind: OBJElementKind + index: int + + +@dataclass +class OBJObject: + """Named OBJ object and its ordered element references.""" + + name: str + elements: list[OBJElementReference] = field(default_factory=list) + + +@dataclass +class OBJGroup: + """Named OBJ group and its ordered element references.""" + + name: str + elements: list[OBJElementReference] = field(default_factory=list) + + +@dataclass +class OBJDocument: + """Parsed and normalized semantic contents of an OBJ file. + + Attributes + ---------- + vertices + XYZ vertex coordinates. + vertex_weights + Optional homogeneous vertex weights. If provided, this collection must + have the same length as `vertices`. + texture_vertices + Texture coordinates with one to three components. + normals + XYZ vertex-normal coordinates. + points + Point elements. + lines + Line and polyline elements. + faces + Polygonal face elements. + elements + Point, line, and face references in original document order. + objects + Named objects in insertion order. + groups + Named groups in insertion order. + material_libraries + Referenced material-library paths in source order. + comments + Comments in source order, without comment markers. + + Notes + ----- + All indices are zero-based and negative OBJ indices have already been + resolved. The document preserves semantic associations, but does not aim + for byte-for-byte reproduction of the source text. + + """ + + vertices: list[list[float]] = field(default_factory=list) + vertex_weights: list[float] = field(default_factory=list) + texture_vertices: list[list[float]] = field(default_factory=list) + normals: list[list[float]] = field(default_factory=list) + points: list[OBJPoint] = field(default_factory=list) + lines: list[OBJLine] = field(default_factory=list) + faces: list[OBJFace] = field(default_factory=list) + elements: list[OBJElementReference] = field(default_factory=list) + objects: dict[str, OBJObject] = field(default_factory=dict) + groups: dict[str, OBJGroup] = field(default_factory=dict) + material_libraries: list[str] = field(default_factory=list) + comments: list[str] = field(default_factory=list) + + def validate(self) -> None: + """Validate the internal references and coordinate dimensions. + + Raises + ------ + ValueError + If coordinate dimensions, aligned data, or references are invalid. + + """ + if self.vertex_weights and len(self.vertex_weights) != len(self.vertices): + raise ValueError("Vertex weights must correspond one-to-one with vertices.") + + for vertex in self.vertices: + if len(vertex) != 3: + raise ValueError("OBJ vertices must have exactly three coordinates.") + + for texture in self.texture_vertices: + if not 1 <= len(texture) <= 3: + raise ValueError("OBJ texture vertices must have one to three coordinates.") + + for normal in self.normals: + if len(normal) != 3: + raise ValueError("OBJ normals must have exactly three coordinates.") + + for element in self._elements(): + for reference in element.vertices: + self._validate_vertex_reference(reference) + + for container in [*self.objects.values(), *self.groups.values()]: + for reference in container.elements: + self._validate_element_reference(reference) + + for reference in self.elements: + self._validate_element_reference(reference) + + def _elements(self) -> list[Union[OBJPoint, OBJLine, OBJFace]]: + return [*self.points, *self.lines, *self.faces] + + def _element_count(self, kind: OBJElementKind) -> int: + if kind == "point": + return len(self.points) + if kind == "line": + return len(self.lines) + return len(self.faces) + + def _validate_element_reference(self, reference: OBJElementReference) -> None: + size = self._element_count(reference.kind) + if reference.index < 0 or reference.index >= size: + raise ValueError("Document contains an invalid element reference.") + + def _validate_vertex_reference(self, reference: OBJVertexReference) -> None: + if reference.vertex < 0 or reference.vertex >= len(self.vertices): + raise ValueError("Element contains an invalid vertex reference.") + if reference.texture is not None and (reference.texture < 0 or reference.texture >= len(self.texture_vertices)): + raise ValueError("Element contains an invalid texture reference.") + if reference.normal is not None and (reference.normal < 0 or reference.normal >= len(self.normals)): + raise ValueError("Element contains an invalid normal reference.") diff --git a/src/compas/files/obj_parser.py b/src/compas/files/obj_parser.py new file mode 100644 index 000000000000..bba8ef2b221c --- /dev/null +++ b/src/compas/files/obj_parser.py @@ -0,0 +1,240 @@ +"""Parser for OBJ source data. + +The parser decodes OBJ source bytes, produces logical statements, and converts +them into a structured document. + +""" + +from dataclasses import dataclass +from typing import Iterator +from typing import Optional + +from .obj_document import OBJDocument +from .obj_document import OBJElementReference +from .obj_document import OBJFace +from .obj_document import OBJGroup +from .obj_document import OBJLine +from .obj_document import OBJObject +from .obj_document import OBJPoint +from .obj_document import OBJVertexReference + + +@dataclass(frozen=True) +class OBJStatement: + """Logical OBJ statement with its one-based source line.""" + + line: int + keyword: str + arguments: tuple[str, ...] + + +def _line_statements(line_number: int, line: str) -> Iterator[OBJStatement]: + content, marker, comment = line.partition("#") + content = content.strip() + if content: + keyword, *arguments = content.split() + yield OBJStatement(line_number, keyword, tuple(arguments)) + if marker and comment.strip(): + yield OBJStatement(line_number, "#", (comment.strip(),)) + + +def _statements(source: bytes, encoding: str) -> Iterator[OBJStatement]: + continuation = "" + start_line = 0 + for line_number, source_line in enumerate(source.decode(encoding).splitlines(), start=1): + line = source_line.rstrip() + if continuation: + line = continuation + line.lstrip() + else: + start_line = line_number + if line.endswith("\\"): + continuation = line[:-1].rstrip() + " " + continue + continuation = "" + yield from _line_statements(start_line, line) + if continuation: + yield from _line_statements(start_line, continuation.rstrip()) + + +class OBJParseError(ValueError): + """Error raised for invalid supported OBJ syntax.""" + + +class OBJParser: + """Parse logical OBJ statements into an OBJDocument. + + Parameters + ---------- + source + Complete OBJ source data. + encoding + Encoding used to decode source bytes. + + """ + + def __init__(self, source: bytes, encoding: str = "utf-8") -> None: + self.source = source + self.encoding = encoding + self.document = OBJDocument() + self._object: Optional[str] = None + self._groups: list[str] = [] + self._material: Optional[str] = None + self._smoothing: Optional[str] = None + self._degree: Optional[tuple[int, ...]] = None + + def parse(self) -> OBJDocument: + """Parse all statements. + + Returns + ------- + OBJDocument + Parsed and validated OBJ document. + + """ + for statement in _statements(self.source, self.encoding): + try: + self._parse_statement(statement) + except (IndexError, TypeError, ValueError) as error: + if isinstance(error, OBJParseError): + raise + raise OBJParseError(f"Invalid OBJ statement on line {statement.line}: {statement.keyword}") from error + + self.document.validate() + return self.document + + def _parse_statement(self, statement: OBJStatement) -> None: + keyword = statement.keyword + arguments = statement.arguments + + if keyword == "#": + self.document.comments.append(arguments[0]) + elif keyword == "v": + self._parse_vertex(arguments) + elif keyword == "vt": + self._parse_texture_vertex(arguments) + elif keyword == "vn": + self._parse_normal(arguments) + elif keyword == "p": + self._parse_point(arguments) + elif keyword == "l": + self._parse_line(arguments) + elif keyword == "f": + self._parse_face(arguments) + elif keyword == "deg": + self._degree = tuple(int(value) for value in arguments) + elif keyword == "curv": + self._parse_curve(arguments) + elif keyword == "o": + self._set_object(arguments) + elif keyword == "g": + self._set_groups(arguments) + elif keyword == "mtllib": + self.document.material_libraries.extend(arguments) + elif keyword == "usemtl": + self._material = " ".join(arguments) or None + elif keyword == "s": + smoothing = " ".join(arguments) + self._smoothing = None if smoothing in ("", "off", "0") else smoothing + + def _parse_vertex(self, arguments: tuple[str, ...]) -> None: + if len(arguments) not in (3, 4): + raise OBJParseError("OBJ vertices require three coordinates and an optional weight.") + self.document.vertices.append([float(value) for value in arguments[:3]]) + self.document.vertex_weights.append(float(arguments[3]) if len(arguments) == 4 else 1.0) + + def _parse_texture_vertex(self, arguments: tuple[str, ...]) -> None: + if not 1 <= len(arguments) <= 3: + raise OBJParseError("OBJ texture vertices require one to three coordinates.") + self.document.texture_vertices.append([float(value) for value in arguments]) + + def _parse_normal(self, arguments: tuple[str, ...]) -> None: + if len(arguments) != 3: + raise OBJParseError("OBJ normals require three coordinates.") + self.document.normals.append([float(value) for value in arguments]) + + def _parse_point(self, arguments: tuple[str, ...]) -> None: + if not arguments: + raise OBJParseError("OBJ point elements require at least one vertex.") + point = OBJPoint([self._parse_reference(value, allow_texture=False, allow_normal=False) for value in arguments]) + self.document.points.append(point) + self._associate(OBJElementReference("point", len(self.document.points) - 1)) + + def _parse_line(self, arguments: tuple[str, ...]) -> None: + if len(arguments) < 2: + raise OBJParseError("OBJ lines require at least two vertices.") + line = OBJLine([self._parse_reference(value, allow_normal=False) for value in arguments]) + self.document.lines.append(line) + self._associate(OBJElementReference("line", len(self.document.lines) - 1)) + + def _parse_face(self, arguments: tuple[str, ...]) -> None: + if len(arguments) < 3: + raise OBJParseError("OBJ faces require at least three vertices.") + face = OBJFace( + vertices=[self._parse_reference(value) for value in arguments], + material=self._material, + smoothing=self._smoothing, + ) + self.document.faces.append(face) + self._associate(OBJElementReference("face", len(self.document.faces) - 1)) + + def _parse_curve(self, arguments: tuple[str, ...]) -> None: + if self._degree != (1,): + return + if len(arguments) < 4: + raise OBJParseError("Linear OBJ curves require a parameter range and at least two vertices.") + line = OBJLine([self._parse_reference(value, allow_texture=False, allow_normal=False) for value in arguments[2:]]) + self.document.lines.append(line) + self._associate(OBJElementReference("line", len(self.document.lines) - 1)) + + def _parse_reference( + self, + value: str, + allow_texture: bool = True, + allow_normal: bool = True, + ) -> OBJVertexReference: + parts = value.split("/") + if len(parts) > 3 or not parts[0]: + raise OBJParseError("Invalid OBJ vertex reference.") + + vertex = self._resolve_index(parts[0], len(self.document.vertices), "vertex") + texture = None + normal = None + + if len(parts) > 1 and parts[1]: + if not allow_texture: + raise OBJParseError("Texture references are not allowed for this element.") + texture = self._resolve_index(parts[1], len(self.document.texture_vertices), "texture vertex") + if len(parts) > 2 and parts[2]: + if not allow_normal: + raise OBJParseError("Normal references are not allowed for this element.") + normal = self._resolve_index(parts[2], len(self.document.normals), "normal") + + return OBJVertexReference(vertex=vertex, texture=texture, normal=normal) + + @staticmethod + def _resolve_index(value: str, size: int, name: str) -> int: + index = int(value) + if index == 0: + raise OBJParseError(f"OBJ {name} indices cannot be zero.") + resolved = index - 1 if index > 0 else size + index + if resolved < 0 or resolved >= size: + raise OBJParseError(f"OBJ {name} index is out of range.") + return resolved + + def _set_object(self, arguments: tuple[str, ...]) -> None: + name = " ".join(arguments) + self._object = name or None + if self._object is not None: + self.document.objects.setdefault(self._object, OBJObject(self._object)) + + def _set_groups(self, arguments: tuple[str, ...]) -> None: + self._groups = list(arguments) + for name in self._groups: + self.document.groups.setdefault(name, OBJGroup(name)) + + def _associate(self, reference: OBJElementReference) -> None: + self.document.elements.append(reference) + if self._object is not None: + self.document.objects[self._object].elements.append(reference) + for name in self._groups: + self.document.groups[name].elements.append(reference) diff --git a/src/compas/files/obj_reader.py b/src/compas/files/obj_reader.py new file mode 100644 index 000000000000..6c28f794cd49 --- /dev/null +++ b/src/compas/files/obj_reader.py @@ -0,0 +1,29 @@ +"""Source acquisition for OBJ files.""" + +from os import PathLike +from typing import BinaryIO +from typing import TextIO +from typing import Union + +from compas import _iotools + +OBJSource = Union[str, PathLike[str], TextIO, BinaryIO] + + +class OBJReader: + """Read bytes from an OBJ source.""" + + def __init__(self, source: OBJSource, encoding: str = "utf-8") -> None: + self.source = source + self.encoding = encoding + + def read(self) -> bytes: + """Read the source. + + Returns + ------- + bytes + Complete OBJ source data. + + """ + return _iotools.read_bytes(self.source, encoding=self.encoding) diff --git a/src/compas/files/obj_writer.py b/src/compas/files/obj_writer.py new file mode 100644 index 000000000000..82cd7368b972 --- /dev/null +++ b/src/compas/files/obj_writer.py @@ -0,0 +1,137 @@ +"""Writer for structured OBJ documents. + +Notes +----- +Element-to-object and element-to-group lookups currently scan the document +containers during serialization. These associations could be indexed once if +this becomes significant for large documents. + +A future public `obj_to_string` function could expose serialization separately +from target writing, following the XML API. + +""" + +from os import PathLike +from typing import BinaryIO +from typing import Iterable +from typing import Optional +from typing import TextIO +from typing import Union + +from compas import _iotools + +from .obj_document import OBJDocument +from .obj_document import OBJElementReference +from .obj_document import OBJVertexReference + +OBJTarget = Union[str, PathLike[str], TextIO, BinaryIO] + + +def _number(value: float, precision: Optional[int]) -> str: + if precision is None: + return str(float(value)) + number = f"{value:.{precision}f}".rstrip("0").rstrip(".") + return "0" if number in ("", "-0") else number + + +def _reference(reference: OBJVertexReference) -> str: + vertex = str(reference.vertex + 1) + texture = "" if reference.texture is None else str(reference.texture + 1) + normal = "" if reference.normal is None else str(reference.normal + 1) + if reference.normal is not None: + return f"{vertex}/{texture}/{normal}" + if reference.texture is not None: + return f"{vertex}/{texture}" + return vertex + + +def _element_references(document: OBJDocument) -> Iterable[OBJElementReference]: + if document.elements: + return document.elements + return [ + *[OBJElementReference("point", index) for index in range(len(document.points))], + *[OBJElementReference("line", index) for index in range(len(document.lines))], + *[OBJElementReference("face", index) for index in range(len(document.faces))], + ] + + +def _object_for(document: OBJDocument, reference: OBJElementReference) -> Optional[str]: + return next((name for name, obj in document.objects.items() if reference in obj.elements), None) + + +def _groups_for(document: OBJDocument, reference: OBJElementReference) -> tuple[str, ...]: + return tuple(name for name, group in document.groups.items() if reference in group.elements) + + +def _lines(document: OBJDocument, precision: Optional[int]) -> list[str]: + lines = [f"# {comment}" for comment in document.comments] + if document.comments: + lines.append("") + + lines.extend(f"mtllib {library}" for library in document.material_libraries) + for index, vertex in enumerate(document.vertices): + values = [_number(value, precision) for value in vertex] + if document.vertex_weights: + weight = document.vertex_weights[index] + if weight != 1.0: + values.append(_number(weight, precision)) + lines.append("v " + " ".join(values)) + lines.extend("vt " + " ".join(_number(value, precision) for value in texture) for texture in document.texture_vertices) + lines.extend("vn " + " ".join(_number(value, precision) for value in normal) for normal in document.normals) + + current_object = None + current_groups: tuple[str, ...] = () + current_material = None + current_smoothing = None + for element_reference in _element_references(document): + object_name = _object_for(document, element_reference) + group_names = _groups_for(document, element_reference) + if object_name != current_object: + lines.append("o" if object_name is None else f"o {object_name}") + current_object = object_name + if group_names != current_groups: + lines.append("g" if not group_names else "g " + " ".join(group_names)) + current_groups = group_names + + if element_reference.kind == "point": + point = document.points[element_reference.index] + lines.append("p " + " ".join(_reference(reference) for reference in point.vertices)) + elif element_reference.kind == "line": + line = document.lines[element_reference.index] + lines.append("l " + " ".join(_reference(reference) for reference in line.vertices)) + else: + face = document.faces[element_reference.index] + if face.material != current_material: + lines.append("usemtl" if face.material is None else f"usemtl {face.material}") + current_material = face.material + if face.smoothing != current_smoothing: + lines.append("s off" if face.smoothing is None else f"s {face.smoothing}") + current_smoothing = face.smoothing + lines.append("f " + " ".join(_reference(reference) for reference in face.vertices)) + return lines + + +class OBJWriter: + """Write a structured OBJ document.""" + + def __init__(self, target: OBJTarget, precision: Optional[int] = None, encoding: str = "utf-8") -> None: + self.target = target + self.precision = precision + self.encoding = encoding + + def write(self, document: OBJDocument) -> None: + """Write an OBJ document. + + Parameters + ---------- + document + Document to write. + + Returns + ------- + None + + """ + document.validate() + data = ("\n".join(_lines(document, self.precision)) + "\n").encode(self.encoding) + _iotools.write_bytes(self.target, data, encoding=self.encoding) diff --git a/src/compas/files/off.py b/src/compas/files/off.py index 96b457a433c4..c664ff1eed06 100644 --- a/src/compas/files/off.py +++ b/src/compas/files/off.py @@ -1,296 +1,80 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +"""Convenience functions for reading and writing OFF data.""" -import compas -from compas import _iotools +from copy import deepcopy +from typing import Any +from typing import Optional +from typing import Union +from .off_document import OFFDocument +from .off_parser import OFFParser +from .off_reader import OFFReader +from .off_reader import OFFSource +from .off_writer import OFFTarget +from .off_writer import OFFWriter -class OFF(object): - """Class for working with OFF files. - Parameters - ---------- - filepath : path string | file-like object | URL string - A path, a file-like object or a URL pointing to a file. - - Attributes - ---------- - reader : :class:`OFFReader`, read-only - A OFF file reader. - - References - ---------- - * http://shape.cs.princeton.edu/benchmark/documentation/off_format.html - * http://www.geomview.org/docs/html/OFF.html - * http://segeval.cs.princeton.edu/public/off_format.html - - """ - - def __init__(self, filepath): - self.filepath = filepath - self._reader = None - self._is_read = False - self._writer = None - - @property - def reader(self): - if not self._is_read: - self.read() - return self._reader - - def read(self): - """Read and parse the contents of the file. - - Returns - ------- - None - - """ - self._reader = OFFReader(self.filepath) - self._reader.open() - self._reader.pre() - self._reader.read() - self._reader.post() - self._is_read = True - - def write(self, mesh, **kwargs): - """Write a mesh to the file. - - Parameters - ---------- - mesh : :class:`compas.datastructures.Mesh` - The mesh. - author : str, optional - The author name to include in the header. - email : str, optional - The email of the author to include in the header. - date : str, optional - The date to include in the header. - precision : str, optional - COMPAS precision specification for parsing geometric data. - - Returns - ------- - None - - """ - self._writer = OFFWriter(self.filepath, mesh, **kwargs) - self._writer.write() - - -class OFFReader(object): - """Class for reading raw geometric data from OFF files. +def read_off(source: OFFSource, encoding: str = "utf-8") -> OFFDocument: + """Read an OFF source into a document. Parameters ---------- - filepath : path string | file-like object | URL string - A path, a file-like object or a URL pointing to a file. + source + Path, URL, text stream, or binary stream containing OFF data. + encoding + Encoding used for text streams and source decoding. - Attributes - ---------- - vertices : list[list[float, float, float]] - List of lists of vertex coordinates. - faces : list[list[int] - List of lists of references to vertex coordinates. - - Notes - ----- - The OFF reader currently only supports reading of vertices and faces of polygon meshes. + Returns + ------- + OFFDocument + Parsed OFF document. """ + data = OFFReader(source, encoding=encoding).read() + return OFFParser(data, encoding=encoding).parse() - def __init__(self, filepath): - self.filepath = filepath - self.content = None - self.vertices = [] - self.faces = [] - self.v = 0 - self.f = 0 - self.e = 0 - - def open(self): - """Open the file and read its contents. - - Returns - ------- - None - - """ - with _iotools.open_file(self.filepath, "r") as f: - self.content = f.readlines() - - def pre(self): - """Pre-process the contents. - Returns - ------- - None - - """ - lines = [] - is_continuation = False - needs_decode = None - - for line in self.content: - # Check this only one time - if needs_decode is None: - needs_decode = hasattr(line, "decode") - if needs_decode: - line = line.decode("utf-8") - line = line.rstrip() - if not line: - continue - if is_continuation: - lines[-1] = lines[-1][:-2] + line - else: - lines.append(line) - if line[-1] == "\\": - is_continuation = True - else: - is_continuation = False - self.content = iter(lines) - - def post(self): - """Post-process the contents. - - Returns - ------- - None - - """ - pass - - def read(self): - """Read the contents of the file, line by line. - - OFF - # comments - - v f e - x y z - ... - x y z - degree list of vertices - - Returns - ------- - None - - """ - if not self.content: - return - - header = next(self.content) - if not header.lower() == "off": - return - - for line in self.content: - if line.startswith("#"): - continue - - parts = line.split() - if not parts: - continue - - if len(parts) == 3: - self.v, self.f, self.e = int(parts[0]), int(parts[1]), int(parts[2]) - break - - while len(self.vertices) < self.v: - line = next(self.content) - parts = line.split() - if parts: - self.vertices.append([float(axis) for axis in parts[:3]]) - - while len(self.faces) < self.f: - line = next(self.content) - parts = line.split() - if parts: - f = int(parts[0]) - face = [int(index) for index in parts[1 : f + 1]] - # if len(parts[1:]) >= f: - # face = [int(index) for index in parts[1:f + 1]] - # else: - # # add support for color info - # face = [int(index) for index in parts[1:]] - # while len(face) < f: - # line = next(self.content) - # line = line.strip() - # if not line: - # break - # parts = line.split() - # if not parts: - # break - # face += [int(index) for index in parts] - if len(face) == f: - self.faces.append(face) - - -class OFFWriter(object): - """Class for writing geometric data to a OBJ file. +def write_off( + target: OFFTarget, + data: Any, + precision: Optional[Union[int, str]] = None, + author: Optional[str] = None, + email: Optional[str] = None, + date: Optional[str] = None, +) -> None: + """Write an OFF document or mesh. Parameters ---------- - filepath : path string | file-like object | URL string - A path, a file-like object or a URL pointing to a file. - mesh : :class:`compas.datastructures.Mesh` - Mesh to write to the file. - author : str, optional - The author name to include in the header. - email : str, optional - The email of the author to include in the header. - date : str, optional - The date to include in the header. - precision : str, optional - COMPAS precision specification for parsing geometric data. + target + Path or writable text or binary stream. + data + OFF document or mesh to write. + precision + Decimal precision applied during serialization. + author + Author name to include as a comment. + email + Author email to include as a comment. + date + Date to include as a comment. + + Returns + ------- + None """ - - def __init__(self, filepath, mesh, author=None, email=None, date=None, precision=None): - self.filepath = filepath - self.mesh = mesh - self.author = author - self.email = email - self.date = date - self.precision = precision or compas.PRECISION - self.vertex_tpl = "{0:." + self.precision + "}" + " {1:." + self.precision + "}" + " {2:." + self.precision + "}\n" - self.v = mesh.number_of_vertices() - self.f = mesh.number_of_faces() - self.e = mesh.number_of_edges() - self.file = None - - def write(self): - """Write the meshes to the file. - - Returns - ------- - None - - """ - with _iotools.open_file(self.filepath, "w") as self.file: - self._write_header() - self._write_vertices() - self._write_faces() - - def _write_header(self): - self.file.write("OFF\n") - if self.author: - self.file.write("# author: {}\n".format(self.author)) - if self.email: - self.file.write("# email: {}\n".format(self.email)) - if self.date: - self.file.write("# date: {}\n".format(self.date)) - self.file.write("{} {} {}\n".format(self.v, self.f, self.e)) - - def _write_vertices(self): - for key in self.mesh.vertices(): - x, y, z = self.mesh.vertex_coordinates(key) - self.file.write(self.vertex_tpl.format(x, y, z)) - - def _write_faces(self): - vertex_index = self.mesh.vertex_index() - for face in self.mesh.faces(): - vertices = self.mesh.face_vertices(face) - v = len(vertices) - self.file.write("{0} {1}\n".format(v, " ".join([str(vertex_index[vertex]) for vertex in vertices]))) + document = deepcopy(data) if isinstance(data, OFFDocument) else _document_from_mesh(data) + if author: + document.comments.append(f"author: {author}") + if email: + document.comments.append(f"email: {email}") + if date: + document.comments.append(f"date: {date}") + OFFWriter(target, precision=precision).write(document) + + +def _document_from_mesh(mesh: Any) -> OFFDocument: + vertex_index = mesh.vertex_index() + vertices = [mesh.vertex_coordinates(vertex) for vertex in mesh.vertices()] + faces = [[vertex_index[vertex] for vertex in mesh.face_vertices(face)] for face in mesh.faces()] + return OFFDocument(vertices, faces, mesh.number_of_edges()) diff --git a/src/compas/files/off_document.py b/src/compas/files/off_document.py new file mode 100644 index 000000000000..d68506f28a8c --- /dev/null +++ b/src/compas/files/off_document.py @@ -0,0 +1,31 @@ +"""Structured representation of an OFF document.""" + +from dataclasses import dataclass +from dataclasses import field + + +@dataclass +class OFFDocument: + """Parsed OFF polygon data independent of file I/O.""" + + vertices: list[list[float]] = field(default_factory=list) + faces: list[list[int]] = field(default_factory=list) + edge_count: int = 0 + comments: list[str] = field(default_factory=list) + + def validate(self) -> None: + """Validate coordinate dimensions, counts, and face references. + + Returns + ------- + None + + """ + if self.edge_count < 0: + raise ValueError("OFF edge count cannot be negative.") + if any(len(vertex) != 3 for vertex in self.vertices): + raise ValueError("OFF vertices require exactly three coordinates.") + vertex_count = len(self.vertices) + for face in self.faces: + if any(vertex < 0 or vertex >= vertex_count for vertex in face): + raise ValueError("OFF face contains an invalid vertex index.") diff --git a/src/compas/files/off_parser.py b/src/compas/files/off_parser.py new file mode 100644 index 000000000000..c8c849f079c4 --- /dev/null +++ b/src/compas/files/off_parser.py @@ -0,0 +1,101 @@ +"""Parser for OFF source data.""" + +from typing import Iterator + +from .off_document import OFFDocument + + +class OFFParseError(ValueError): + """Error raised for invalid or unsupported OFF data.""" + + +def _logical_lines(source: bytes, encoding: str) -> Iterator[tuple[int, str, str]]: + continuation = "" + start_line = 0 + for line_number, source_line in enumerate(source.decode(encoding).splitlines(), start=1): + line = source_line.rstrip() + if continuation: + line = continuation + line.lstrip() + else: + start_line = line_number + if line.endswith("\\"): + continuation = line[:-1].rstrip() + " " + continue + continuation = "" + content, _, comment = line.partition("#") + yield start_line, content.strip(), comment.strip() + if continuation: + content, _, comment = continuation.partition("#") + yield start_line, content.strip(), comment.strip() + + +def _next_content(lines: Iterator[tuple[int, str, str]], document: OFFDocument) -> tuple[int, str, str]: + for line_number, content, comment in lines: + if comment: + document.comments.append(comment) + if content: + return line_number, content, comment + raise StopIteration + + +class OFFParser: + """Parse OFF source bytes into a document.""" + + def __init__(self, source: bytes, encoding: str = "utf-8") -> None: + self.source = source + self.encoding = encoding + + def parse(self) -> OFFDocument: + """Parse the complete OFF document. + + Returns + ------- + OFFDocument + Parsed OFF document. + + """ + document = OFFDocument() + lines = iter(_logical_lines(self.source, self.encoding)) + try: + line_number, header, _ = _next_content(lines, document) + parts = header.split() + if not parts or parts[0].lower() != "off": + raise OFFParseError(f"Invalid OFF header on line {line_number}.") + counts = parts[1:] + if not counts: + _, count_line, _ = _next_content(lines, document) + counts = count_line.split() + if len(counts) != 3: + raise OFFParseError("OFF counts require vertex, face, and edge values.") + vertex_count, face_count, document.edge_count = [int(value) for value in counts] + if vertex_count < 0 or face_count < 0 or document.edge_count < 0: + raise OFFParseError("OFF counts cannot be negative.") + + for _ in range(vertex_count): + _, line, _ = _next_content(lines, document) + values = line.split() + if len(values) < 3: + raise OFFParseError("OFF vertices require three coordinates.") + document.vertices.append([float(value) for value in values[:3]]) + + for _ in range(face_count): + _, line, _ = _next_content(lines, document) + values = line.split() + if not values: + raise OFFParseError("Invalid OFF face.") + degree = int(values[0]) + if degree < 0 or len(values) != degree + 1: + raise OFFParseError("OFF face degree does not match its vertex count.") + document.faces.append([int(value) for value in values[1:]]) + except (StopIteration, UnicodeDecodeError, ValueError) as error: + if isinstance(error, OFFParseError): + raise + raise OFFParseError("Invalid or incomplete OFF data.") from error + + for _, content, comment in lines: + if comment: + document.comments.append(comment) + if content: + raise OFFParseError("OFF data contains unexpected trailing values.") + document.validate() + return document diff --git a/src/compas/files/off_reader.py b/src/compas/files/off_reader.py new file mode 100644 index 000000000000..8ecd0524151d --- /dev/null +++ b/src/compas/files/off_reader.py @@ -0,0 +1,29 @@ +"""Source acquisition for OFF files.""" + +from os import PathLike +from typing import BinaryIO +from typing import TextIO +from typing import Union + +from compas import _iotools + +OFFSource = Union[str, PathLike[str], TextIO, BinaryIO] + + +class OFFReader: + """Read bytes from an OFF source.""" + + def __init__(self, source: OFFSource, encoding: str = "utf-8") -> None: + self.source = source + self.encoding = encoding + + def read(self) -> bytes: + """Read the source. + + Returns + ------- + bytes + Complete OFF source data. + + """ + return _iotools.read_bytes(self.source, encoding=self.encoding) diff --git a/src/compas/files/off_writer.py b/src/compas/files/off_writer.py new file mode 100644 index 000000000000..679d34cd6961 --- /dev/null +++ b/src/compas/files/off_writer.py @@ -0,0 +1,75 @@ +"""Writer for structured OFF documents. + +Notes +----- +A future public `off_to_string` function could expose serialization separately +from target writing, following the XML API. + +""" + +from os import PathLike +from typing import BinaryIO +from typing import Optional +from typing import TextIO +from typing import Union + +from compas import _iotools + +from .off_document import OFFDocument + +OFFTarget = Union[str, PathLike[str], TextIO, BinaryIO] + + +def _precision_digits(precision: Optional[Union[int, str]]) -> Optional[int]: + if precision is None: + return None + if isinstance(precision, int): + return precision + return int(precision.rstrip("f")) + + +def _number(value: float, precision: Optional[int]) -> str: + if precision is None: + return str(float(value)) + number = f"{value:.{precision}f}".rstrip("0").rstrip(".") + return "0" if number in ("", "-0") else number + + +def _lines(document: OFFDocument, precision: Optional[int]) -> list[str]: + lines = ["OFF"] + lines.extend(f"# {comment}" for comment in document.comments) + lines.append(f"{len(document.vertices)} {len(document.faces)} {document.edge_count}") + lines.extend(" ".join(_number(value, precision) for value in vertex) for vertex in document.vertices) + lines.extend(f"{len(face)} {' '.join(str(vertex) for vertex in face)}" for face in document.faces) + return lines + + +class OFFWriter: + """Write a structured OFF document.""" + + def __init__( + self, + target: OFFTarget, + precision: Optional[Union[int, str]] = None, + encoding: str = "utf-8", + ) -> None: + self.target = target + self.precision = _precision_digits(precision) + self.encoding = encoding + + def write(self, document: OFFDocument) -> None: + """Write an OFF document. + + Parameters + ---------- + document + Document to write. + + Returns + ------- + None + + """ + document.validate() + data = ("\n".join(_lines(document, self.precision)) + "\n").encode(self.encoding) + _iotools.write_bytes(self.target, data, encoding=self.encoding) diff --git a/src/compas/files/ply.py b/src/compas/files/ply.py index 286d77d41356..440b9b7b6ab7 100644 --- a/src/compas/files/ply.py +++ b/src/compas/files/ply.py @@ -1,697 +1,168 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +"""Convenience functions for reading and writing PLY data.""" -import struct +from copy import deepcopy +from dataclasses import dataclass +from typing import Any +from typing import Optional +from typing import Union +from typing import cast -import compas from compas import _iotools +from .ply_document import PLYDocument +from .ply_document import PLYElement +from .ply_document import PLYProperty +from .ply_parser import PLYParser +from .ply_reader import PLYReader +from .ply_types import PLYFormat +from .ply_types import PLYValue +from .ply_writer import PLYWriter -class PLY(object): - """Class for working with files in Polygon format, also known as Stanford triangle format. - Parameters - ---------- - filepath : path string | file-like object | URL string - A path, a file-like object or a URL pointing to a file. - precision : str, optional - A COMPAS precision specification. - - Attributes - ---------- - filepath : str - The path to the file. - precision : str - A COMPAS precision specification. - reader : :class:`PLYReader` - A PLY file reader. - parser : :class:`PLYParser` - A PLY data parser. - - References - ---------- - * http://paulbourke.net/dataformats/ply/ - - """ - - def __init__(self, filepath, precision=None): - self.filepath = filepath - self.precision = precision - self._is_parsed = False - self._reader = None - self._parser = None - self._writer = None - - @property - def reader(self): - if not self._is_parsed: - self.read() - return self._reader - - @property - def parser(self): - if not self._is_parsed: - self.read() - return self._parser +@dataclass +class PLYData: + """Mesh-oriented projection of a PLY document.""" - def read(self): - """Read the contents of the file. + vertices: list[list[float]] + edges: list[tuple[int, int]] + faces: list[list[int]] - Returns - ------- - None - """ - self._reader = PLYReader(self.filepath) - self._parser = PLYParser(self._reader, precision=self.precision) - self._is_parsed = True +def _scalar(record: dict[str, PLYValue], name: str) -> Union[int, float]: + value = record[name] + if isinstance(value, list): + raise TypeError(f"PLY property is not scalar: {name}") + return value - def write(self, mesh, **kwargs): - """Write a mesh to the file. - Parameters - ---------- - mesh : :class:`compas.datastructures.Mesh` - The mesh. - author : str, optional - The author name to include in the header. - email : str, optional - The email of the author to include in the header. - date : str, optional - The date to include in the header. - precision : str, optional - COMPAS precision specification for parsing geometric data. - - Returns - ------- - None - - """ - self._writer = PLYWriter(self.filepath, mesh, **kwargs) - self._writer.write() - - -class PLYReader(object): - """Class for reading raw geometric data from PLY files. +def read_ply(source: _iotools.IOSource) -> PLYDocument: + """Read a PLY source into a document. Parameters ---------- - filepath : path string | file-like object | URL string - A path, a file-like object or a URL pointing to a file. + source + Path, URL, text stream, or binary stream containing PLY data. - Attributes - ---------- - filepath : str - The path to the file. - file : file - The file object. - format : str - The format of the file. - comments : list - The comments in the header. - header : list - The lines of the header. - start_header : int - The number o the line containing the start of the header. - end_header : int - The number o the line containing the end of the header. - number_of_vertices : int - The number of vertices in the file. - number_of_edges : int - The number of edges in the file. - number_of_faces : int - The number of faces in the file. - vertex_properties : list[tuple] - The vertex properties. - Each property is a tuple of the property name and the property type. - edge_properties : list[tuple] - The edge properties. - Each property is a tuple of the property name and the property type. - face_properties : list - The face properties. - Each property is a tuple of the property name and the property type. - sections : list - The sections in the file. - Possible sections are ``vertex``, ``edge`` and ``face``. - vertices : list - The vertices found in the file. - Each vertex is a dictionary of property names and property values. - edges : list - The edges found in the file. - Each edge is a dictionary of property names and property values. - faces : list - The faces found in the file. - Each face is a dictionary of property names and property values. + Returns + ------- + PLYDocument + Parsed PLY document. """ + return PLYParser(PLYReader(source).read()).parse() - keywords = ["ply", "format", "comment", "element", "property", "end_header"] - - property_types = { - "char": int, - "uchar": int, - "short": int, - "ushort": int, - "int": int, - "int32": int, - "int64": int, - "uint": int, - "uint32": int, - "uint64": int, - "float": float, - "float32": float, - "float64": float, - "double": float, - } - - binary_property_types = { - "int8": "i1", - "char": "i1", - "uint8": "u1", - "uchar": "u1", - "int16": "i2", - "short": "i2", - "uint16": "u2", - "ushort": "u2", - "int32": "i4", - "int": "i4", - "uint32": "u4", - "uint": "u4", - "float32": "f4", - "float": "f4", - "float64": "f8", - "double": "f8", - } - - number_of_bytes_per_type = { - "char": 1, - "uchar": 1, - "short": 2, - "ushort": 2, - "int": 4, - "uint": 4, - "float": 4, - "double": 8, - } - - struct_format_per_type = { - "char": "c", - "uchar": "B", - "short": "h", - "ushort": "H", - "int": "i", - "uint": "I", - "float": "f", - "double": "d", - } - - binary_byte_order = {"binary_big_endian": ">", "binary_little_endian": "<"} - - def __init__(self, filepath): - self.filepath = filepath - self.file = None - self.format = None - self.comments = [] - self.header = [] - self.start_header = None - self.end_header = None - self.number_of_vertices = 0 - self.number_of_edges = 0 - self.number_of_faces = 0 - self.vertex_properties = [] - self.edge_properties = [] - self.face_properties = [] - self.sections = [] - self.vertices = [] - self.edges = [] - self.faces = [] - self.read() - - def is_valid(self): - """Verify that the file is valid by reading the header. - - Returns - ------- - bool - - """ - self._read_header() - if self.start_header and self.end_header: - return True - return False - - def is_binary(self): - """Verify that the file is in binary format. - - Returns - ------- - bool - - """ - if self.format == "binary_big_endian": - return True - if self.format == "binary_little_endian": - return True - return False - - def is_ascii(self): - """Verify that the file is in ASCII format. - - Returns - ------- - bool - - """ - if self.format == "ascii": - return True - return False - - def read(self): - """Read the contents of the file. - - Returns - ------- - None - - """ - self._read_header() - if self.format == "ascii": - self._read_data() - else: - self._read_data_binary() - - # ========================================================================== - # read the header - # ========================================================================== - - def _read_header(self): - # the header is always in ascii format - # read it as text - # otherwise file.tell() can't be used reliably - # to figure out where the header ends - with _iotools.open_file(self.filepath) as file: - file.seek(0) - - line = file.readline().rstrip() - - if line.lower() != "ply": - raise Exception("not a valid ply file") - - self.start_header = file.tell() - - element_type = None - - while True: - line = file.readline() - line = line.rstrip() - - self.header.append(line) - if line.startswith("format"): # type: ignore - element_type = None - self.format = line[len("format") + 1 :].split()[0] - - elif line.startswith("comment"): # type: ignore - element_type = None - self.comments.append(line[len("comment") + 1 :]) - - elif line.startswith("element"): # type: ignore - parts = line.split() - element_type = parts[1] - if element_type == "vertex": - self.sections.append("vertex") - self.number_of_vertices = int(parts[2]) - elif element_type == "edge": - self.sections.append("edge") - self.number_of_edges = int(parts[2]) - elif element_type == "face": - self.sections.append("face") - self.number_of_faces = int(parts[2]) - else: - element_type = None - raise Exception - - elif line.startswith("property"): # type: ignore - parts = line.split() - if element_type == "vertex": - property_type = parts[1] - property_name = parts[2] - self.vertex_properties.append((property_name, property_type)) - elif element_type == "edge": - property_type = parts[1] - property_name = parts[2] - self.edge_properties.append((property_name, property_type)) - elif element_type == "face": - property_type = parts[1] - if property_type == "list": - property_length = parts[2] - property_type = parts[3] - property_name = parts[4] - self.face_properties.append((property_name, property_type, property_length)) - else: - property_type = parts[1] - property_name = parts[2] - self.face_properties.append((property_name, property_type)) - else: - element_type = None - raise Exception - - elif line == "end_header": - element_type = None - self.end_header = file.tell() - break - - else: - pass - - # ========================================================================== - # read the data - # ========================================================================== - - def _read_data(self): - if not self.end_header: - raise Exception("header has not been read, or the file is not valid") - with _iotools.open_file(self.filepath) as self.file: - self.file.seek(self.end_header) - for section in self.sections: - if section == "vertex": - self._read_vertices() - elif section == "edge": - self._read_edges() - elif section == "face": - self._read_faces() - else: - print("user-defined elements are not supported: {0}".format(section)) - pass - - def _read_data_binary(self): - if not self.end_header: - raise Exception("header has not been read, or the file is not valid") - with _iotools.open_file(self.filepath, "rb") as self.file: - self.file.seek(self.end_header) - for section in self.sections: - if section == "vertex": - self._read_vertices_binary_wo_numpy() - elif section == "edge": - self._read_edges_binary_wo_numpy() - elif section == "face": - self._read_faces_binary_wo_numpy() - else: - print("user-defined elements are not supported: {0}".format(section)) - pass - - # ========================================================================== - # read the individual section - # ========================================================================== - - def _read_vertices(self): - n = len(self.vertex_properties) - for _ in range(self.number_of_vertices): - vertex = {} - i = 0 - while i < n: - line = next(self.file) - parts = line.rstrip().split() - for prop_str in parts: - prop_name, prop_type = self.vertex_properties[i] - vertex[prop_name] = self.property_types[prop_type](prop_str) - i += 1 - self.vertices.append(vertex) - # count = 0 - # for line in self.file: - # line = line.rstrip() - # parts = line.split() - # vertex = {} - # for i, prop in enumerate(self.vertex_properties): - # pname, ptype = prop - # vertex[pname] = self.property_types[ptype](parts[i]) - # self.vertices.append(vertex) - # count += 1 - # if count == self.number_of_vertices: - # break - - def _read_edges(self): - pass - - def _read_faces(self): - count = 0 - for line in self.file: - line = line.rstrip() - parts = line.split() - face = {} - for i, prop in enumerate(self.face_properties): - pname, ptype, plen = prop - face[pname] = [self.property_types[ptype](part) for part in parts[1:]] - self.faces.append(face) - count += 1 - if count == self.number_of_faces: - break - - # ========================================================================== - # binary read the individual section - # ========================================================================== - - # remove numpy dependency by reading the file in chuncks - # with each chunck equal to the size specified in the header? - # see: http://stackoverflow.com/questions/4566498/python-file-iterator-over-a-binary-file-with-newer-idiom - # see: http://stackoverflow.com/questions/27532738/python-iterate-through-binary-file-without-lines - - def _numpy_vertex_ptypes(self): - ext = self.binary_byte_order[self.format] - dt = [] - for prop in self.vertex_properties: - pname, ptype = prop - dt.append((pname, ext + self.binary_property_types[ptype])) - return dt - - def _numpy_face_ptypes(self): - ext = self.binary_byte_order[self.format] - dt = [] - for prop in self.face_properties: - if len(prop) == 2: - pname, ptype = prop - dt.append((pname, ext + self.binary_property_types[ptype])) - elif len(prop) == 3: - pname, ptype, plen = prop - dt.append(("size", ext + self.binary_property_types[plen])) - # this seems a nit of a hack - dt.append(("v1", ext + self.binary_property_types[ptype])) - dt.append(("v2", ext + self.binary_property_types[ptype])) - dt.append(("v3", ext + self.binary_property_types[ptype])) - else: - pass - return dt - - def _read_vertices_binary_wo_numpy(self): - ext = self.binary_byte_order[self.format] - fmt = ext - chunk = 0 - for prop in self.vertex_properties: - pname, ptype = prop - chunk += self.number_of_bytes_per_type[ptype] - fmt += self.struct_format_per_type[ptype] - for i in range(self.number_of_vertices): - data = self.file.read(chunk) - data = struct.unpack(fmt, data) - vertex = {} - for i, prop in enumerate(self.vertex_properties): - pname, ptype = prop - vertex[pname] = data[i] - self.vertices.append(vertex) - - def _read_vertices_binary(self): - # use pandas to read the data frames - import numpy as np - - for line in np.fromfile( - self.file, - dtype=np.dtype(self._numpy_vertex_ptypes()), - count=self.number_of_vertices, - ): - vertex = {} - for i, prop in enumerate(self.vertex_properties): - pname, ptype = prop - vertex[pname] = line[i] - self.vertices.append(vertex) - - def _read_edges_binary_wo_numpy(self): - pass - - def _read_edges_binary(self): - pass - - def _read_faces_binary_wo_numpy(self): - ext = self.binary_byte_order[self.format] - fmt = ext - chunk = 0 - for prop in self.face_properties: - if len(prop) == 2: - pname, ptype = prop - chunk += self.number_of_bytes_per_type[ptype] - fmt += self.struct_format_per_type[ptype] - elif len(prop) == 3: - pname, ptype, plen = prop - chunk += self.number_of_bytes_per_type[plen] - chunk += self.number_of_bytes_per_type[ptype] * 3 - fmt += self.struct_format_per_type[plen] - fmt += self.struct_format_per_type[ptype] * 3 - else: - pass - for i in range(self.number_of_faces): - data = self.file.read(chunk) - data = struct.unpack(fmt, data) - face = {} - for i, prop in enumerate(self.face_properties): - if len(prop) == 2: - pname, ptype = prop - face[pname] = data[i] - elif len(prop) == 3: - pname, ptype, plen = prop - face[pname] = list(data[2:]) - self.faces.append(face) - - def _read_faces_binary(self): - # use pandas to read the data frames - # how to deal with faces of variable length? - import numpy as np - - for line in np.fromfile( - self.file, - dtype=np.dtype(self._numpy_face_ptypes()), - count=self.number_of_faces, - ): - face = {} - for i, prop in enumerate(self.face_properties): - if len(prop) == 2: - pname, ptype = prop - face[pname] = line[i] - elif len(prop) == 3: - pname, ptype, plen = prop - # type(line) => numpy.void - # convert the line to a list - line = list(line) - face[pname] = line[2:] - else: - pass - self.faces.append(face) - - -class PLYParser(object): - """Class for parsing data from a OBJ file. - - The parser converts the raw geometric data of the file - into corresponding COMPAS geometry objects and data structures. +def ply_data(document: PLYDocument) -> PLYData: + """Project a PLY document to mesh-oriented data. Parameters ---------- - reader : :class:`PLYReader` - A PLY file reader. - precision : str - COMPAS precision specification for parsing geometric data. + document + Parsed PLY document. - Attributes - ---------- - vertices : list[tuple[float, float, float]] - The vertex coordinates. - edges : list[tuple[int, int]] - Pairs of vertex indices defining the start and end points of edges. - faces : list[list[int]] - Lists of vertex indices defining faces. + Returns + ------- + PLYData + Vertex coordinates, edges, and polygon faces. """ - - def __init__(self, reader, precision=None): - self.precision = precision - self.reader = reader - self.vertices = None - self.edges = None - self.faces = None - self.parse() - - def parse(self): - """Parse the contents found by a PLY file reader. - - Returns - ------- - None - - """ - self.vertices = [(vertex["x"], vertex["y"], vertex["z"]) for vertex in self.reader.vertices] - self.faces = [face["vertex_indices"] for face in self.reader.faces] - - -class PLYWriter(object): - """Class for writing geometric data to a PLY file. + document.validate() + vertex_element = document.element("vertex") + face_element = document.element("face") + edge_element = document.element("edge") + + vertices = [] + if vertex_element: + try: + vertices = [ + [float(_scalar(record, "x")), float(_scalar(record, "y")), float(_scalar(record, "z"))] + for record in vertex_element.data + ] + except (KeyError, TypeError) as error: + raise ValueError("PLY vertex elements require scalar x, y, and z properties.") from error + + faces = [] + if face_element: + prop = next((prop for prop in face_element.properties if prop.name in ("vertex_indices", "vertex_index")), None) + if prop: + faces = [[int(value) for value in cast(list[Union[int, float]], record[prop.name])] for record in face_element.data] + + edges = [] + if edge_element: + for record in edge_element.data: + names = ( + ("vertex1", "vertex2") + if "vertex1" in record and "vertex2" in record + else ("vertex_index1", "vertex_index2") + ) + if names[0] in record and names[1] in record: + start = cast(Union[int, float], record[names[0]]) + end = cast(Union[int, float], record[names[1]]) + edges.append((int(start), int(end))) + + vertex_count = len(vertices) + if any(vertex < 0 or vertex >= vertex_count for face in faces for vertex in face): + raise ValueError("PLY face contains an invalid vertex index.") + if any(vertex < 0 or vertex >= vertex_count for edge in edges for vertex in edge): + raise ValueError("PLY edge contains an invalid vertex index.") + return PLYData(vertices, edges, faces) + + +def write_ply( + target: _iotools.IOTarget, + data: Any, + precision: Optional[Union[int, str]] = None, + format: Optional[PLYFormat] = None, + author: Optional[str] = None, + email: Optional[str] = None, + date: Optional[str] = None, +) -> None: + """Write a PLY document or mesh. Parameters ---------- - filepath : path string | file-like object | URL string - A path, a file-like object or a URL pointing to a file. - mesh : :class:`compas.datastructures.Mesh` - Mesh to write to the file. - author : str, optional - The author name to include in the header. - email : str, optional - The email of the author to include in the header. - date : str, optional - The date to include in the header. - precision : str, optional - COMPAS precision specification for parsing geometric data. + target + Path or writable text or binary stream. + data + PLY document or mesh to write. + precision + Decimal precision applied to mesh vertex coordinates. + format + Output format. Documents retain their format by default; meshes default + to ASCII. + author + Author name to include as a comment. + email + Author email to include as a comment. + date + Date to include as a comment. + + Returns + ------- + None """ - - def __init__(self, filepath, mesh, author=None, email=None, date=None, precision=None): - self.filepath = filepath - self.mesh = mesh - self.author = author - self.email = email - self.date = date - self.precision = precision or compas.PRECISION - self.vertex_tpl = "{0:." + self.precision + "}" + " {1:." + self.precision + "}" + " {2:." + self.precision + "}\n" - self.v = mesh.number_of_vertices() - self.f = mesh.number_of_faces() - self.e = mesh.number_of_edges() - self.file = None - - def write(self): - """Write the data to a file. - - Returns - ------- - None - - """ - with _iotools.open_file(self.filepath, "w") as self.file: - self._write_header() - self._write_vertices() - self._write_faces() - - def _write_header(self): - self.file.write("PLY\n") - self.file.write("format ascii 1.0\n") - if self.author: - self.file.write("comment author: {}\n".format(self.author)) - if self.email: - self.file.write("comment email: {}\n".format(self.email)) - if self.date: - self.file.write("comment date: {}\n".format(self.date)) - self.file.write("element vertex {}\n".format(self.v)) - self.file.write("property float x\n") - self.file.write("property float y\n") - self.file.write("property float z\n") - self.file.write("element face {}\n".format(self.f)) - self.file.write("property list uchar int vertex_indices\n") - self.file.write("end_header\n") - - def _write_vertices(self): - for key in self.mesh.vertices(): - x, y, z = self.mesh.vertex_coordinates(key) - self.file.write(self.vertex_tpl.format(x, y, z)) - - def _write_faces(self): - vertex_index = self.mesh.vertex_index() - for face in self.mesh.faces(): - vertices = self.mesh.face_vertices(face) - v = len(vertices) - self.file.write("{0} {1}\n".format(v, " ".join([str(vertex_index[vertex]) for vertex in vertices]))) + document = deepcopy(data) if isinstance(data, PLYDocument) else _document_from_mesh(data) + if author: + document.comments.append(f"author: {author}") + if email: + document.comments.append(f"email: {email}") + if date: + document.comments.append(f"date: {date}") + PLYWriter(target, format=format, precision=precision).write(document) + + +def _document_from_mesh(mesh: Any) -> PLYDocument: + vertex_index = mesh.vertex_index() + vertex = PLYElement( + "vertex", + [PLYProperty("x", "float"), PLYProperty("y", "float"), PLYProperty("z", "float")], + ) + for key in mesh.vertices(): + xyz = mesh.vertex_coordinates(key) + vertex.data.append(dict(zip(("x", "y", "z"), xyz))) + + face = PLYElement("face", [PLYProperty("vertex_indices", "int", "uchar")]) + for key in mesh.faces(): + face.data.append({"vertex_indices": [vertex_index[vertex] for vertex in mesh.face_vertices(key)]}) + return PLYDocument(elements=[vertex, face]) diff --git a/src/compas/files/ply_document.py b/src/compas/files/ply_document.py new file mode 100644 index 000000000000..ec0bea3be3fc --- /dev/null +++ b/src/compas/files/ply_document.py @@ -0,0 +1,110 @@ +"""Structured representation of a PLY document.""" + +from dataclasses import dataclass +from dataclasses import field +from typing import Optional + +from .ply_types import PLY_SCALAR_TYPES +from .ply_types import PLYDataType +from .ply_types import PLYFormat +from .ply_types import PLYRecord +from .ply_types import validate_scalar + + +@dataclass(frozen=True) +class PLYProperty: + """Property in a PLY element schema.""" + + name: str + data_type: PLYDataType + list_count_type: Optional[PLYDataType] = None + + @property + def is_list(self) -> bool: + """Whether the property contains a variable-length list. + + Returns + ------- + bool + True if this is a list property. + + """ + return self.list_count_type is not None + + +@dataclass +class PLYElement: + """Element schema and records in a PLY document.""" + + name: str + properties: list[PLYProperty] = field(default_factory=list) + data: list[PLYRecord] = field(default_factory=list) + + +@dataclass +class PLYDocument: + """Parsed PLY data independent of file I/O.""" + + format: PLYFormat = "ascii" + version: str = "1.0" + comments: list[str] = field(default_factory=list) + object_info: list[str] = field(default_factory=list) + elements: list[PLYElement] = field(default_factory=list) + + def element(self, name: str) -> Optional[PLYElement]: + """Find an element by name. + + Parameters + ---------- + name + Element name. + + Returns + ------- + PLYElement, optional + Matching element, if present. + + """ + return next((element for element in self.elements if element.name == name), None) + + def validate(self) -> None: + """Validate element records against their schemas. + + Returns + ------- + None + + """ + if self.format not in ("ascii", "binary_little_endian", "binary_big_endian"): + raise ValueError(f"Unsupported PLY format: {self.format}") + names = set() + for element in self.elements: + if element.name in names: + raise ValueError(f"Duplicate PLY element: {element.name}") + names.add(element.name) + property_names = [prop.name for prop in element.properties] + if len(property_names) != len(set(property_names)): + raise ValueError(f"Duplicate property in PLY element: {element.name}") + for prop in element.properties: + if prop.data_type not in PLY_SCALAR_TYPES: + raise ValueError(f"Unsupported PLY scalar type: {prop.data_type}") + if prop.list_count_type and ( + prop.list_count_type not in PLY_SCALAR_TYPES + or not PLY_SCALAR_TYPES[prop.list_count_type].integer + ): + raise ValueError("PLY list counts require a supported integer type.") + for record in element.data: + if set(record) != set(property_names): + raise ValueError(f"PLY record does not match the {element.name} schema.") + for prop in element.properties: + value = record[prop.name] + if prop.is_list != isinstance(value, list): + raise ValueError(f"Invalid value for PLY property: {prop.name}") + if isinstance(value, list): + if prop.list_count_type is None: + raise ValueError(f"Missing list count type for PLY property: {prop.name}") + validate_scalar(len(value), prop.list_count_type) + for item in value: + validate_scalar(item, prop.data_type) + else: + validate_scalar(value, prop.data_type) diff --git a/src/compas/files/ply_parser.py b/src/compas/files/ply_parser.py new file mode 100644 index 000000000000..172397fb4587 --- /dev/null +++ b/src/compas/files/ply_parser.py @@ -0,0 +1,149 @@ +"""Parser for ASCII and binary PLY documents.""" + +import struct +from typing import cast + +from .ply_document import PLYDocument +from .ply_document import PLYElement +from .ply_document import PLYProperty +from .ply_types import PLY_SCALAR_TYPES +from .ply_types import PLYByteOrder +from .ply_types import PLYDataType +from .ply_types import PLYFormat +from .ply_types import parse_scalar +from .ply_types import unpack_scalar + + +class PLYParseError(ValueError): + """Error raised for invalid or unsupported PLY data.""" + + +def _split_header(source: bytes) -> tuple[list[str], bytes]: + lines = source.splitlines(keepends=True) + header = [] + offset = 0 + for raw_line in lines: + offset += len(raw_line) + line = raw_line.decode("ascii").strip() + header.append(line) + if line == "end_header": + return header, source[offset:] + raise PLYParseError("PLY header has no end_header statement.") + + +def _parse_header(header: list[str]) -> tuple[PLYDocument, list[int]]: + if not header or header[0].lower() != "ply": + raise PLYParseError("Not a PLY document.") + document = PLYDocument() + counts = [] + current = None + has_format = False + for line in header[1:]: + parts = line.split() + if not parts: + continue + if parts[0] == "format" and len(parts) == 3: + if parts[1] not in ("ascii", "binary_little_endian", "binary_big_endian"): + raise PLYParseError(f"Unsupported PLY format: {parts[1]}") + document.format = cast(PLYFormat, parts[1]) + document.version = parts[2] + has_format = True + elif parts[0] == "comment": + document.comments.append(line[len("comment") :].lstrip()) + elif parts[0] == "obj_info": + document.object_info.append(line[len("obj_info") :].lstrip()) + elif parts[0] == "element" and len(parts) == 3: + current = PLYElement(parts[1]) + document.elements.append(current) + counts.append(int(parts[2])) + elif parts[0] == "property" and current is not None: + if parts[1] == "list" and len(parts) == 5: + count_type = _data_type(parts[2]) + data_type = _data_type(parts[3]) + current.properties.append(PLYProperty(parts[4], data_type, count_type)) + elif len(parts) == 3: + current.properties.append(PLYProperty(parts[2], _data_type(parts[1]))) + else: + raise PLYParseError("Invalid PLY property declaration.") + if not has_format: + raise PLYParseError("PLY header has no format statement.") + return document, counts + + +def _data_type(data_type: str) -> PLYDataType: + if data_type not in PLY_SCALAR_TYPES: + raise PLYParseError(f"Unsupported PLY scalar type: {data_type}") + return cast(PLYDataType, data_type) + + +def _parse_ascii(body: bytes, document: PLYDocument, counts: list[int]) -> None: + tokens = iter(body.decode("ascii").split()) + try: + for element, count in zip(document.elements, counts): + for _ in range(count): + record = {} + for prop in element.properties: + if prop.list_count_type: + count = parse_scalar(next(tokens), prop.list_count_type) + if int(count) < 0: + raise ValueError("PLY list lengths cannot be negative.") + record[prop.name] = [parse_scalar(next(tokens), prop.data_type) for _ in range(int(count))] + else: + record[prop.name] = parse_scalar(next(tokens), prop.data_type) + element.data.append(record) + try: + next(tokens) + except StopIteration: + return + raise PLYParseError("ASCII PLY data contains unexpected trailing values.") + except (StopIteration, ValueError) as error: + raise PLYParseError("Invalid ASCII PLY element data.") from error + + +def _parse_binary(body: bytes, document: PLYDocument, counts: list[int]) -> None: + byte_order: PLYByteOrder = "<" if document.format == "binary_little_endian" else ">" + offset = 0 + try: + for element, count in zip(document.elements, counts): + for _ in range(count): + record = {} + for prop in element.properties: + if prop.list_count_type: + size, offset = unpack_scalar(body, offset, byte_order, prop.list_count_type) + values = [] + for _ in range(int(size)): + value, offset = unpack_scalar(body, offset, byte_order, prop.data_type) + values.append(value) + record[prop.name] = values + else: + record[prop.name], offset = unpack_scalar(body, offset, byte_order, prop.data_type) + element.data.append(record) + if offset != len(body): + raise PLYParseError("Binary PLY data contains unexpected trailing bytes.") + except (ValueError, struct.error) as error: + raise PLYParseError("Invalid binary PLY element data.") from error + + +class PLYParser: + """Parse PLY source bytes into a document.""" + + def __init__(self, source: bytes) -> None: + self.source = source + + def parse(self) -> PLYDocument: + """Parse the complete PLY document. + + Returns + ------- + PLYDocument + Parsed PLY document. + + """ + header, body = _split_header(self.source) + document, counts = _parse_header(header) + if document.format == "ascii": + _parse_ascii(body, document, counts) + else: + _parse_binary(body, document, counts) + document.validate() + return document diff --git a/src/compas/files/ply_reader.py b/src/compas/files/ply_reader.py new file mode 100644 index 000000000000..da0efb3b941d --- /dev/null +++ b/src/compas/files/ply_reader.py @@ -0,0 +1,22 @@ +"""Source acquisition for PLY files.""" + +from compas import _iotools + + +class PLYReader: + """Read bytes from a PLY source.""" + + def __init__(self, source: _iotools.IOSource, encoding: str = "utf-8") -> None: + self.source = source + self.encoding = encoding + + def read(self) -> bytes: + """Read the source. + + Returns + ------- + bytes + Complete PLY source data. + + """ + return _iotools.read_bytes(self.source, encoding=self.encoding) diff --git a/src/compas/files/ply_types.py b/src/compas/files/ply_types.py new file mode 100644 index 000000000000..efa5cf6df60d --- /dev/null +++ b/src/compas/files/ply_types.py @@ -0,0 +1,108 @@ +"""PLY scalar type definitions and codecs.""" + +import struct +from typing import Literal +from typing import NamedTuple +from typing import Optional +from typing import Union + +PLYFormat = Literal["ascii", "binary_little_endian", "binary_big_endian"] +PLYDataType = Literal[ + "char", + "int8", + "uchar", + "uint8", + "short", + "int16", + "ushort", + "uint16", + "int", + "int32", + "uint", + "uint32", + "int64", + "uint64", + "float", + "float32", + "double", + "float64", +] +PLYByteOrder = Literal["<", ">"] +PLYScalar = Union[int, float] +PLYValue = Union[PLYScalar, list[PLYScalar]] +PLYRecord = dict[str, PLYValue] + + +class PLYScalarType(NamedTuple): + """Storage and value constraints of a PLY scalar type.""" + + format: str + integer: bool + minimum: Optional[int] = None + maximum: Optional[int] = None + + +PLY_SCALAR_TYPES: dict[PLYDataType, PLYScalarType] = { + "char": PLYScalarType("b", True, -128, 127), + "int8": PLYScalarType("b", True, -128, 127), + "uchar": PLYScalarType("B", True, 0, 255), + "uint8": PLYScalarType("B", True, 0, 255), + "short": PLYScalarType("h", True, -32768, 32767), + "int16": PLYScalarType("h", True, -32768, 32767), + "ushort": PLYScalarType("H", True, 0, 65535), + "uint16": PLYScalarType("H", True, 0, 65535), + "int": PLYScalarType("i", True, -2147483648, 2147483647), + "int32": PLYScalarType("i", True, -2147483648, 2147483647), + "uint": PLYScalarType("I", True, 0, 4294967295), + "uint32": PLYScalarType("I", True, 0, 4294967295), + "int64": PLYScalarType("q", True, -9223372036854775808, 9223372036854775807), + "uint64": PLYScalarType("Q", True, 0, 18446744073709551615), + "float": PLYScalarType("f", False), + "float32": PLYScalarType("f", False), + "double": PLYScalarType("d", False), + "float64": PLYScalarType("d", False), +} + + +def validate_scalar(value: PLYScalar, data_type: PLYDataType) -> None: + """Validate a scalar value against a PLY type.""" + scalar_type = PLY_SCALAR_TYPES.get(data_type) + if scalar_type is None: + raise ValueError(f"Unsupported PLY scalar type: {data_type}") + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"Invalid value for PLY scalar type: {data_type}") + if scalar_type.integer: + if not isinstance(value, int): + raise ValueError(f"PLY {data_type} values must be integers.") + minimum = scalar_type.minimum + maximum = scalar_type.maximum + if minimum is None or maximum is None: + raise ValueError(f"PLY integer type has no defined range: {data_type}") + if value < minimum or value > maximum: + raise ValueError(f"PLY value is outside the range of {data_type}.") + + +def parse_scalar(value: str, data_type: PLYDataType) -> PLYScalar: + """Parse and validate an ASCII PLY scalar.""" + scalar_type = PLY_SCALAR_TYPES.get(data_type) + if scalar_type is None: + raise ValueError(f"Unsupported PLY scalar type: {data_type}") + result = int(value) if scalar_type.integer else float(value) + validate_scalar(result, data_type) + return result + + +def pack_scalar(value: PLYScalar, byte_order: PLYByteOrder, data_type: PLYDataType) -> bytes: + """Pack a validated binary PLY scalar.""" + validate_scalar(value, data_type) + return struct.pack(byte_order + PLY_SCALAR_TYPES[data_type].format, value) + + +def unpack_scalar(data: bytes, offset: int, byte_order: PLYByteOrder, data_type: PLYDataType) -> tuple[PLYScalar, int]: + """Unpack a binary PLY scalar and return its new offset.""" + scalar_type = PLY_SCALAR_TYPES.get(data_type) + if scalar_type is None: + raise ValueError(f"Unsupported PLY scalar type: {data_type}") + format = byte_order + scalar_type.format + value = struct.unpack_from(format, data, offset)[0] + return value, offset + struct.calcsize(format) diff --git a/src/compas/files/ply_writer.py b/src/compas/files/ply_writer.py new file mode 100644 index 000000000000..e94521c0e940 --- /dev/null +++ b/src/compas/files/ply_writer.py @@ -0,0 +1,129 @@ +"""Writer for structured ASCII and binary PLY documents. + +Notes +----- +A future public `ply_to_bytes` function could expose serialization separately +from target writing, following the XML API. + +""" + +from typing import Optional +from typing import Union +from typing import cast + +from compas import _iotools + +from .ply_document import PLYDocument +from .ply_types import PLYByteOrder +from .ply_types import PLYFormat +from .ply_types import PLYScalar +from .ply_types import pack_scalar + + +def _precision_digits(precision: Optional[Union[int, str]]) -> Optional[int]: + if precision is None: + return None + if isinstance(precision, int): + return precision + return int(precision.rstrip("f")) + + +def _output_format(document_format: PLYFormat, requested_format: Optional[PLYFormat]) -> PLYFormat: + if requested_format is None: + return document_format + return requested_format + + +def _format_number(value: PLYScalar, precision: Optional[int]) -> str: + if isinstance(value, int) or precision is None: + return repr(value) + number = f"{value:.{precision}f}".rstrip("0").rstrip(".") + return "0" if number in ("", "-0") else number + + +def _header(document: PLYDocument, format: PLYFormat) -> bytes: + lines = ["ply", f"format {format} {document.version}"] + lines.extend(f"comment {comment}" for comment in document.comments) + lines.extend(f"obj_info {info}" for info in document.object_info) + for element in document.elements: + lines.append(f"element {element.name} {len(element.data)}") + for prop in element.properties: + if prop.list_count_type: + lines.append(f"property list {prop.list_count_type} {prop.data_type} {prop.name}") + else: + lines.append(f"property {prop.data_type} {prop.name}") + lines.append("end_header") + return ("\n".join(lines) + "\n").encode("ascii") + + +def _ascii_body(document: PLYDocument, precision: Optional[int]) -> bytes: + lines = [] + for element in document.elements: + for record in element.data: + values = [] + for prop in element.properties: + value = record[prop.name] + if prop.list_count_type: + items = value if isinstance(value, list) else [] + values.extend([str(len(items)), *[_format_number(item, precision) for item in items]]) + else: + values.append(_format_number(cast(PLYScalar, value), precision)) + lines.append(" ".join(values)) + return (("\n".join(lines) + "\n") if lines else "").encode("ascii") + + +def _binary_body(document: PLYDocument, format: PLYFormat) -> bytes: + byte_order: PLYByteOrder = "<" if format == "binary_little_endian" else ">" + data = bytearray() + for element in document.elements: + for record in element.data: + for prop in element.properties: + value = record[prop.name] + if prop.list_count_type: + items = value if isinstance(value, list) else [] + data.extend(pack_scalar(len(items), byte_order, prop.list_count_type)) + for item in items: + data.extend(pack_scalar(item, byte_order, prop.data_type)) + else: + data.extend(pack_scalar(cast(PLYScalar, value), byte_order, prop.data_type)) + return bytes(data) + + +def _body(document: PLYDocument, format: PLYFormat, precision: Optional[int]) -> bytes: + if format == "ascii": + return _ascii_body(document, precision) + return _binary_body(document, format) + + +class PLYWriter: + """Write a structured PLY document.""" + + def __init__( + self, + target: _iotools.IOTarget, + format: Optional[PLYFormat] = None, + precision: Optional[Union[int, str]] = None, + ) -> None: + self.target: _iotools.IOTarget = target + self.format: Optional[PLYFormat] = format + self.precision: Optional[int] = _precision_digits(precision) + + def write(self, document: PLYDocument) -> None: + """Write a PLY document. + + Parameters + ---------- + document + Document to write. + + Returns + ------- + None + + """ + document.validate() + output_format = _output_format(document.format, self.format) + header = _header(document, output_format) + body = _body(document, output_format, self.precision) + data = header + body + _iotools.write_bytes(self.target, data, encoding="ascii") diff --git a/src/compas/files/stl.py b/src/compas/files/stl.py index eaf2a4b88ee7..4cd3da2cf5c4 100644 --- a/src/compas/files/stl.py +++ b/src/compas/files/stl.py @@ -1,436 +1,151 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +"""Convenience functions for reading and writing STL data.""" -import struct +from collections import OrderedDict +from copy import deepcopy +from dataclasses import dataclass +from typing import Any +from typing import Optional from compas import _iotools -from compas.geometry import Translation from compas.tolerance import TOL +from .stl_document import STLDocument +from .stl_document import STLFacet +from .stl_document import STLSolid +from .stl_parser import STLParser +from .stl_reader import STLReader +from .stl_types import STLFormat +from .stl_writer import STLWriter -class STL(object): - """Class for working with STL files. - Parameters - ---------- - filepath : path string | file-like object | URL string - A path, a file-like object or a URL pointing to a file. - precision : int, optional - Precision for converting numbers to strings. - Default is :attr:`TOL.precision`. - - Attributes - ---------- - reader : :class:`STLReader` - A STL file reader. - parser : :class:`STLParser` - A STL file parser. - - """ - - def __init__(self, filepath, precision=None): - self.filepath = filepath - self.precision = precision - self._is_parsed = False - self._reader = None - self._parser = None - self._writer = None - - @property - def reader(self): - if not self._is_parsed: - self.read() - return self._reader - - @property - def parser(self): - if not self._is_parsed: - self.read() - return self._parser +@dataclass +class STLData: + """Mesh-oriented projection of an STL document.""" - def read(self): - """Read and parse the contents of the file. + vertices: list[list[float]] + faces: list[list[int]] - Returns - ------- - None - """ - self._reader = STLReader(self.filepath) - self._parser = STLParser(self._reader, precision=self.precision) - self._is_parsed = True - - def write(self, mesh, **kwargs): - """Write a mesh to the file. - - Parameters - ---------- - mesh : :class:`compas.datastructures.Mesh` - The mesh. - binary : bool, optional - Flag indicating that the file should be written in binary format. - solid_name : str, optional - The name of the solid. - Defaults to the name of the mesh. - precision : str, optional - COMPAS precision specification for parsing geometric data. - - Returns - ------- - None - - """ - self._writer = STLWriter(self.filepath, mesh, **kwargs) - self._writer.write() - - -class STLReader(object): - """Class for reading raw geometric data from STL files. +def read_stl(source: _iotools.IOSource, encoding: str = "utf-8") -> STLDocument: + """Read an STL source into a document. Parameters ---------- - filepath : path string | file-like object | URL string - A path, a file-like object or a URL pointing to a file. + source + Path, URL, text stream, or binary stream containing STL data. + encoding + Encoding used for text streams and ASCII source decoding. - References - ---------- - * http://paulbourke.net/dataformats/stl/ + Returns + ------- + STLDocument + Parsed STL document. """ + data = STLReader(source, encoding=encoding).read() + return STLParser(data, encoding=encoding).parse() - def __init__(self, filepath): - self.filepath = filepath - self.file = None - self.header = None - self.facets = [] - self.read() - - def read(self): - """Read the data. - - Returns - ------- - None - - """ - is_binary = False - with _iotools.open_file(self.filepath, "rb") as file: - line = file.readline().strip() - if b"solid" in line: - is_binary = False - else: - is_binary = True - try: - if not is_binary: - self._read_ascii() - else: - self._read_binary() - except Exception: - # raise if it was already detected as binary, but failed anyway - if is_binary: - raise - # else, ascii parsing failed, try binary - is_binary = True - self._read_binary() - - # ========================================================================== - # ascii - # - # @see: https://en.wikipedia.org/wiki/STL_(file_format) - # - # solid name - # facet normal ni nj nk - # outer loop - # vertex v1x v1y v1z - # vertex v2x v2y v2z - # vertex v3x v3y v3z - # endloop - # endfacet - # endsolid name - # - # ========================================================================== - - def _read_ascii(self): - with _iotools.open_file(self.filepath, "r") as file: - self.file = file - self.file.seek(0) - self.facets = self._read_solids_ascii() - - def _read_solids_ascii(self): - if not self.file: - return - - solids = {} - facets = [] - - while True: - line = self.file.readline().strip() - - if not line: - break - - parts = line.split() - - if parts[0] == "solid": - if len(parts) == 2: - name = parts[1] - else: - name = "solid" - solids[name] = [] - - elif parts[0] == "endsolid": - name = None - - elif parts[0] == "facet": - facet = {"normal": None, "vertices": None} - if parts[1] == "normal": - facet["normal"] = [float(parts[i]) for i in range(2, 5)] - - elif parts[0] == "outer" and parts[1] == "loop": - vertices = [] - - elif parts[0] == "vertex": - xyz = [float(parts[i]) for i in range(1, 4)] - vertices.append(xyz) - - elif parts[0] == "endloop": - facet["vertices"] = vertices - - elif parts[0] == "endfacet": - solids[name].append(facet) - facets.append(facet) - # no known line start matches, maybe not ascii - elif not parts[0].isalnum(): - raise RuntimeError("File is not ASCII") - - return facets - - # ========================================================================== - # binary - # - # @see: https://en.wikipedia.org/wiki/STL_(file_format) - # - # UINT8[80] - Header - # UINT32 - Number of triangles - # - # foreach triangle - # REAL32[3] - Normal vector - # REAL32[3] - Vertex 1 - # REAL32[3] - Vertex 2 - # REAL32[3] - Vertex 3 - # UINT16 - Attribute byte count - # end - # - # ========================================================================== - - def _read_uint16(self): - bytes_ = self.file.read(2) - return struct.unpack(" STLData: + """Project an STL document without welding facet vertices. Parameters ---------- - reader : :class:`STLReader` - A STL file reader. - precision : str, optional - COMPAS precision specification for parsing geometric data. + document + Parsed STL document. - Attributes - ---------- - vertices : list[list[float]] - The vertex coordinates. - faces : list[list[int]] - The faces as lists of vertex indices. + Returns + ------- + STLData + Independent facet vertices and faces. """ - - def __init__(self, reader, precision=None): - self.precision = precision - self.reader = reader - self.vertices = None - self.faces = None - self.parse() - - def parse(self): - """Parse the the data found by the reader. - - Returns - ------- - None - - """ - gkey_index = {} - vertices = [] - faces = [] - for facet in self.reader.facets: + document.validate() + vertices = [] + faces = [] + for solid in document.solids: + for facet in solid.facets: face = [] - facet_vertices = facet["vertices"] - for i in range(3): - xyz = facet_vertices[i] - if "keys" in facet: - gkey = facet["keys"][i] - else: - gkey = TOL.geometric_key(xyz, self.precision) - if gkey not in gkey_index: - gkey_index[gkey] = len(vertices) - vertices.append(xyz) - face.append(gkey_index[gkey]) + for vertex in facet.vertices: + face.append(len(vertices)) + vertices.append(list(vertex)) faces.append(face) - self.vertices = vertices - self.faces = faces + return STLData(vertices, faces) -class STLWriter(object): - """Class for writing geometric data to a STL file. +def weld_stl_data(document: STLDocument, precision: Optional[int] = None) -> STLData: + """Project an STL document with explicitly welded facet vertices. Parameters ---------- - filepath : path string | file-like object | URL string - A path, a file-like object or a URL pointing to a file. - mesh : :class:`compas.datastructures.Mesh` - The mesh. - binary : bool, optional - Flag indicating that the file should be written in binary format. - solid_name : str, optional - The name of the solid. - Defaults to the name of the mesh. - precision : str, optional - COMPAS precision specification for parsing geometric data. - - """ - - def __init__(self, filepath, mesh, binary=False, solid_name=None, precision=None): - self.filepath = filepath - self.mesh = mesh - self.solid_name = solid_name or mesh.name - self.precision = precision - self.file = None - self.binary = binary - - @property - def _vertex_xyz(self): - bbox = self.mesh.aabb() - xmin, ymin, zmin = bbox.xmin, bbox.ymin, bbox.zmin - if not self.binary and (xmin < 0 or ymin < 0 or zmin < 0): - T = Translation.from_vector([-xmin, -ymin, -zmin]) - mesh = self.mesh.transformed(T) - else: - mesh = self.mesh - return {vertex: mesh.vertex_attributes(vertex, "xyz") for vertex in mesh.vertices()} - - def write(self): - """Write the data to a file. + document + Parsed STL document. + precision + Precision used to identify coincident vertices. - Returns - ------- - None + Returns + ------- + STLData + Welded vertices and indexed faces. - """ - if not self.mesh.is_trimesh(): - raise ValueError("Mesh must be triangular to be encoded in STL.") - if not self.binary: - with _iotools.open_file(self.filepath, "w") as self.file: - self._write_header() - self._write_faces() - self._write_footer() - else: - with _iotools.open_file(self.filepath, "wb") as self.file: - self.file.seek(0) - self._write_binary_header() - self._write_binary_num_faces() - self._write_binary_faces() - - def _write_header(self): - if not self.file: - return - self.file.write("solid {}\n".format(self.solid_name)) - - def _write_footer(self): - if not self.file: - return - self.file.write("endsolid {}\n".format(self.solid_name)) - - def _write_faces(self): - if not self.file: - return - vertex_xyz = self._vertex_xyz - for face in self.mesh.faces(): - normal = list(self.mesh.face_normal(face)) - self.file.write("facet normal {0} {1} {2}\n".format(*normal)) - self.file.write(" outer loop\n") - for vertex in self.mesh.face_vertices(face): - self.file.write(" vertex {0} {1} {2}\n".format(*vertex_xyz[vertex])) - self.file.write(" endloop\n") - self.file.write("endfacet\n") - - def _write_binary_header(self): - if not self.file: - return - self.file.write(b"\0" * 80) + """ + document.validate() + key_vertex = OrderedDict() + faces = [] + facet_keys = [] + for solid in document.solids: + for facet in solid.facets: + keys = [] + for vertex in facet.vertices: + key = TOL.geometric_key(vertex, precision) + key_vertex[key] = list(vertex) + keys.append(key) + facet_keys.append(keys) + key_index = {key: index for index, key in enumerate(key_vertex)} + faces.extend([[key_index[key] for key in keys] for keys in facet_keys]) + return STLData(list(key_vertex.values()), faces) + + +def write_stl( + target: _iotools.IOTarget, + data: Any, + binary: Optional[bool] = None, + solid_name: Optional[str] = None, + precision: Optional[int] = None, +) -> None: + """Write an STL document or triangular mesh. - def _write_binary_num_faces(self): - if not self.file: - return - try: - self.file.write(struct.pack(" STLDocument: + if not mesh.is_trimesh(): + raise ValueError("Mesh must be triangular to be encoded in STL.") + facets = [] + for face in mesh.faces(): + normal = list(mesh.face_normal(face)) + vertices = [mesh.vertex_coordinates(vertex) for vertex in mesh.face_vertices(face)] + facets.append(STLFacet(normal, vertices)) + return STLDocument(solids=[STLSolid(solid_name or mesh.name, facets)]) diff --git a/src/compas/files/stl_document.py b/src/compas/files/stl_document.py new file mode 100644 index 000000000000..6cc4816c294a --- /dev/null +++ b/src/compas/files/stl_document.py @@ -0,0 +1,54 @@ +"""Structured representation of an STL document.""" + +from dataclasses import dataclass +from dataclasses import field + +from .stl_types import STLFormat + + +@dataclass +class STLFacet: + """Triangular STL facet.""" + + normal: list[float] + vertices: list[list[float]] + attribute: int = 0 + + +@dataclass +class STLSolid: + """Named collection of STL facets.""" + + name: str = "solid" + facets: list[STLFacet] = field(default_factory=list) + + +@dataclass +class STLDocument: + """Parsed STL data independent of file I/O.""" + + format: STLFormat = "ascii" + solids: list[STLSolid] = field(default_factory=list) + header: bytes = b"" + + def validate(self) -> None: + """Validate facet dimensions and binary attributes. + + Returns + ------- + None + + """ + if self.format not in ("ascii", "binary"): + raise ValueError(f"Unsupported STL format: {self.format}") + if len(self.header) > 80: + raise ValueError("Binary STL headers cannot exceed 80 bytes.") + for solid in self.solids: + for facet in solid.facets: + if len(facet.normal) != 3: + raise ValueError("STL facet normals require three components.") + if len(facet.vertices) != 3 or any(len(vertex) != 3 for vertex in facet.vertices): + raise ValueError("STL facets require three vertices with three coordinates each.") + if facet.attribute < 0 or facet.attribute > 65535: + raise ValueError("STL facet attributes must fit in an unsigned 16-bit integer.") + diff --git a/src/compas/files/stl_parser.py b/src/compas/files/stl_parser.py new file mode 100644 index 000000000000..758f1df18598 --- /dev/null +++ b/src/compas/files/stl_parser.py @@ -0,0 +1,121 @@ +"""Parser for ASCII and binary STL source data.""" + +import struct + +from .stl_document import STLDocument +from .stl_document import STLFacet +from .stl_document import STLSolid + + +class STLParseError(ValueError): + """Error raised for invalid STL data.""" + + +def _is_binary(source: bytes) -> bool: + if len(source) < 84: + return False + facet_count = struct.unpack_from(" STLDocument: + if len(source) < 84: + raise STLParseError("Binary STL data is incomplete.") + header = source[:80] + facet_count = struct.unpack_from(" STLDocument: + try: + lines = [line.strip() for line in source.decode(encoding).splitlines() if line.strip()] + except UnicodeDecodeError as error: + raise STLParseError("STL data is neither valid binary nor decodable ASCII.") from error + solids = [] + current_solid = None + current_normal = None + current_vertices = None + loop_open = False + for line_number, line in enumerate(lines, start=1): + parts = line.split() + keyword = parts[0].lower() + try: + if keyword == "solid": + if current_solid is not None: + raise STLParseError("Nested ASCII STL solids are not supported.") + current_solid = STLSolid(" ".join(parts[1:]) or "solid") + solids.append(current_solid) + elif keyword == "facet" and len(parts) == 5 and parts[1].lower() == "normal": + if current_solid is None or current_normal is not None: + raise STLParseError("Invalid ASCII STL facet declaration.") + current_normal = [float(value) for value in parts[2:5]] + current_vertices = [] + elif keyword == "outer" and parts[1:] == ["loop"]: + if current_vertices is None or loop_open: + raise STLParseError("Invalid ASCII STL loop declaration.") + loop_open = True + elif keyword == "vertex" and len(parts) == 4: + if current_vertices is None or not loop_open: + raise STLParseError("ASCII STL vertex occurs outside a facet.") + current_vertices.append([float(value) for value in parts[1:4]]) + elif keyword == "endloop": + if current_vertices is None or not loop_open or len(current_vertices) != 3: + raise STLParseError("ASCII STL loops require exactly three vertices.") + loop_open = False + elif keyword == "endfacet": + if current_solid is None or current_normal is None or current_vertices is None or loop_open: + raise STLParseError("Invalid ASCII STL facet termination.") + current_solid.facets.append(STLFacet(current_normal, current_vertices)) + current_normal = None + current_vertices = None + elif keyword == "endsolid": + if current_solid is None or current_normal is not None: + raise STLParseError("Invalid ASCII STL solid termination.") + current_solid = None + else: + raise STLParseError(f"Unsupported ASCII STL statement on line {line_number}.") + except (IndexError, ValueError) as error: + if isinstance(error, STLParseError): + raise + raise STLParseError(f"Invalid ASCII STL statement on line {line_number}.") from error + if current_solid is not None or current_normal is not None or current_vertices is not None or loop_open: + raise STLParseError("ASCII STL data is incomplete.") + if not solids: + raise STLParseError("ASCII STL contains no solids.") + return STLDocument(format="ascii", solids=solids) + + +class STLParser: + """Parse STL source bytes into a document.""" + + def __init__(self, source: bytes, encoding: str = "utf-8") -> None: + self.source = source + self.encoding = encoding + + def parse(self) -> STLDocument: + """Parse the complete STL document. + + Returns + ------- + STLDocument + Parsed STL document. + + """ + document = _parse_binary(self.source) if _is_binary(self.source) else _parse_ascii(self.source, self.encoding) + document.validate() + return document diff --git a/src/compas/files/stl_reader.py b/src/compas/files/stl_reader.py new file mode 100644 index 000000000000..e412c96247e0 --- /dev/null +++ b/src/compas/files/stl_reader.py @@ -0,0 +1,23 @@ +"""Source acquisition for STL files.""" + +from compas import _iotools + + +class STLReader: + """Read bytes from an STL source.""" + + def __init__(self, source: _iotools.IOSource, encoding: str = "utf-8") -> None: + self.source = source + self.encoding = encoding + + def read(self) -> bytes: + """Read the source. + + Returns + ------- + bytes + Complete STL source data. + + """ + return _iotools.read_bytes(self.source, encoding=self.encoding) + diff --git a/src/compas/files/stl_types.py b/src/compas/files/stl_types.py new file mode 100644 index 000000000000..19aa34bb1380 --- /dev/null +++ b/src/compas/files/stl_types.py @@ -0,0 +1,6 @@ +"""Shared STL domain type declarations.""" + +from typing import Literal + +STLFormat = Literal["ascii", "binary"] + diff --git a/src/compas/files/stl_writer.py b/src/compas/files/stl_writer.py new file mode 100644 index 000000000000..1dfa50ba54d6 --- /dev/null +++ b/src/compas/files/stl_writer.py @@ -0,0 +1,101 @@ +"""Writer for structured ASCII and binary STL documents. + +Notes +----- +A future public `stl_to_bytes` function could expose serialization separately +from target writing, following the XML API. + +""" + +import struct +from typing import Optional + +from compas import _iotools + +from .stl_document import STLDocument +from .stl_document import STLFacet +from .stl_types import STLFormat + + +def _number(value: float, precision: Optional[int]) -> str: + if precision is None: + return str(float(value)) + number = f"{value:.{precision}f}".rstrip("0").rstrip(".") + return "0" if number in ("", "-0") else number + + +def _ascii_body(document: STLDocument, precision: Optional[int], encoding: str) -> bytes: + lines = [] + for solid in document.solids: + lines.append(f"solid {solid.name}") + for facet in solid.facets: + lines.append("facet normal " + " ".join(_number(value, precision) for value in facet.normal)) + lines.append(" outer loop") + lines.extend( + " vertex " + " ".join(_number(value, precision) for value in vertex) + for vertex in facet.vertices + ) + lines.append(" endloop") + lines.append("endfacet") + lines.append(f"endsolid {solid.name}") + return ("\n".join(lines) + "\n").encode(encoding) + + +def _binary_facet(facet: STLFacet) -> bytes: + values = [*facet.normal, *facet.vertices[0], *facet.vertices[1], *facet.vertices[2], facet.attribute] + return struct.pack("<12fH", *values) + + +def _binary_body(document: STLDocument) -> bytes: + facets = [facet for solid in document.solids for facet in solid.facets] + if len(facets) > 4294967295: + raise ValueError("Binary STL supports at most 4294967295 facets.") + default_header = document.solids[0].name.encode("ascii", errors="replace") if document.solids else b"" + header = (document.header or default_header)[:80].ljust(80, b"\0") + return header + struct.pack(" bytes: + if format == "ascii": + return _ascii_body(document, precision, encoding) + return _binary_body(document) + + +def _output_format(document_format: STLFormat, requested_format: Optional[STLFormat]) -> STLFormat: + if requested_format is None: + return document_format + return requested_format + + +class STLWriter: + """Write a structured STL document.""" + + def __init__( + self, + target: _iotools.IOTarget, + format: Optional[STLFormat] = None, + precision: Optional[int] = None, + encoding: str = "utf-8", + ) -> None: + self.target: _iotools.IOTarget = target + self.format: Optional[STLFormat] = format + self.precision = precision + self.encoding = encoding + + def write(self, document: STLDocument) -> None: + """Write an STL document. + + Parameters + ---------- + document + Document to write. + + Returns + ------- + None + + """ + document.validate() + output_format = _output_format(document.format, self.format) + data = _body(document, output_format, self.precision, self.encoding) + _iotools.write_bytes(self.target, data, encoding=self.encoding) diff --git a/src/compas/files/xml.py b/src/compas/files/xml.py index aa8fc472be35..74d5be204c67 100644 --- a/src/compas/files/xml.py +++ b/src/compas/files/xml.py @@ -1,328 +1,122 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +"""Read and write XML with the standard library ElementTree API.""" -import sys import xml.etree.ElementTree as ET +from copy import deepcopy +from os import PathLike +from typing import BinaryIO +from typing import Optional +from typing import TextIO +from typing import Union -import compas +from compas import _iotools -if not compas.IPY: - if sys.version_info[0] >= 3 and sys.version_info[1] >= 8: - from compas.files._xml import xml_cpython as xml_impl - else: - from compas.files._xml import xml_pre_38 as xml_impl -else: - from compas.files._xml import xml_cli as xml_impl +XMLSource = Union[str, PathLike[str], TextIO, BinaryIO] +XMLTarget = Union[str, PathLike[str], TextIO, BinaryIO] -prettify_string = xml_impl.prettify_string +def read_xml(source: XMLSource, parser: Optional[ET.XMLParser] = None) -> ET.Element: + """Read an XML document. -class XML(object): - """Class for working with XML files. - - This class simplifies reading XML files and strings - across different Python implementations. - - Attributes + Parameters ---------- - reader : :class:`compas.files.XMLReader`, read-only - Reader used to process the XML file or string. - writer : :class:`XMLWriter`, read-only - Writer used to process the XML object to a file or string. - filepath : str - The path to the XML file. - root : :class:`xml.etree.ElementTree.Element` - Root element of the XML tree. + source + Path, URL, text stream, or binary stream containing XML data. + parser + Custom ElementTree parser. - Examples - -------- - >>> from compas.files import XML - >>> xml = XML.from_string("
Test
") - >>> xml.root.tag - 'Main' + Returns + ------- + Element + Root element of the XML document. """ + with _iotools.open_file(source, "rb") as stream: + return ET.parse(stream, parser=parser).getroot() - def __init__(self, filepath=None): - self.filepath = filepath - self._is_parsed = False - self._reader = None - self._writer = None - self._root = None - - @property - def reader(self): - if not self._reader: - self.read() - return self._reader - - @property - def writer(self): - if not self._writer: - self._writer = XMLWriter(self) - return self._writer - - @property - def root(self): - if self._root is None: - self._root = self.reader.root - return self._root - - @root.setter - def root(self, value): - self._root = value - - def read(self): - """Read XML from a file path or file-like object, - stored in the attribute ``filepath``. - - Returns - ------- - None - - """ - self._reader = XMLReader.from_file(self.filepath) - - def write(self, prettify=False): - """Writes the string representation of this XML instance, - including all sub-elements, to the file path in the - associated XML object. - - Parameters - ---------- - prettify : bool, optional - If True, prettify the string representation by adding whitespace and indentation. - - Returns - ------- - None - - """ - self.writer.write(prettify) - - def to_file(self, prettify=False): - """Writes the string representation of this XML instance, - including all sub-elements, to the file path in the - associated XML object. - - Parameters - ---------- - prettify : bool, optional - If True, prettify the string representation by adding whitespace and indentation. - - Returns - ------- - None - - """ - self.write(prettify) - - @classmethod - def from_file(cls, source): - """Read XML from a file path or file-like object. - - Parameters - ---------- - source : str | file - File path or file-like object. - - Returns - ------- - :class:`compas.files.XML` - - """ - xml = cls(source) - xml._reader = XMLReader.from_file(source) - return xml - - @classmethod - def from_string(cls, text): - """Read XML from a string. - - Parameters - ---------- - text : str - XML string. - - Returns - ------- - :class:`compas.files.XML` - - """ - xml = cls() - xml._reader = XMLReader.from_string(text) - return xml - - def to_string(self, encoding="utf-8", prettify=False): - """Generate a string representation of this XML instance, - including all sub-elements. - - Parameters - ---------- - encoding : str, optional - Output encoding. - prettify : bool, optional - If True, prettify the string representation by adding whitespace and indentation. - - Returns - ------- - str - String representation of the XML. - """ - return self.writer.to_string(encoding=encoding, prettify=prettify) - - -class XMLReader(object): - """Reads XML files and strings. +def parse_xml(text: Union[str, bytes], parser: Optional[ET.XMLParser] = None) -> ET.Element: + """Parse an XML string. Parameters ---------- - root : :class:`xml.etree.ElementTree.Element` - Root XML element - - """ - - def __init__(self, root): - self.root = root - - @classmethod - def from_file(cls, source, tree_parser=None): - """Construct a reader from a source file. - - Parameters - ---------- - source : path string | file-like object | URL string - A path, a file-like object or a URL pointing to a file. - tree_parser : :class:`ET.XMLParser`, optional - A custom tree parser. - - Returns - ------- - :class:`compas.files.XMLReader` + text + XML text or bytes. + parser + Custom ElementTree parser. - """ - return cls(xml_impl.xml_from_file(source, tree_parser)) + Returns + ------- + Element + Root element of the XML document. - @classmethod - def from_string(cls, text, tree_parser=None): - """Construct a reader from a source text. - - Parameters - ---------- - text : str - A string of text containing the XML source code. - tree_parser : :class:`ET.XMLParser`, optional - A custom tree parser. - - Returns - ------- - :class:`compas.files.XMLReader` - - """ - return cls(xml_impl.xml_from_string(text, tree_parser)) + """ + return ET.fromstring(text, parser=parser) -class XMLWriter(object): - """Writes an XML file from XML object. +def xml_to_string( + root: ET.Element, + encoding: str = "unicode", + pretty: bool = False, + xml_declaration: Optional[bool] = None, +) -> Union[str, bytes]: + """Serialize an XML element tree. Parameters ---------- - xml : :class:`compas.files.XML` - The XML tree to write. + root + Root element to serialize. + encoding + Output encoding. Use `unicode` to return a string. + pretty + If True, indent the output. + xml_declaration + Controls inclusion of the XML declaration. + + Returns + ------- + str | bytes + Serialized XML data. The return type depends on `encoding`. """ + element = deepcopy(root) if pretty else root + if pretty: + ET.indent(element, space=" ") + return ET.tostring(element, encoding=encoding, xml_declaration=xml_declaration) - def __init__(self, xml): - self.xml = xml - - def write(self, prettify=False): - """Write the meshes to the file. - - Parameters - ---------- - prettify : bool, optional - Prettify the xml text format. - - Returns - ------- - None - """ - string = self.to_string(prettify=prettify) - with open(self.xml.filepath, "wb") as f: - f.write(string) - - def to_string(self, encoding="utf-8", prettify=False): - """Convert the XML element tree to a string. - - Parameters - ---------- - encoding : str, optional - The encoding to use for the conversion. - prettify : bool, optional - If True, prettify the string representation by adding whitespace and indentation. - - Returns - ------- - str - - """ - rough_string = ET.tostring(self.xml.root, encoding=encoding, method="xml") - if not prettify: - return rough_string - return xml_impl.prettify_string(rough_string) - - -class XMLElement(object): - """Class representing an XML element in the tree. +def write_xml( + target: XMLTarget, + root: ET.Element, + encoding: str = "utf-8", + pretty: bool = False, + xml_declaration: Optional[bool] = None, +) -> None: + """Write an XML element tree. Parameters ---------- - tag : str - The type of XML tag. - attributes : dict[str, Any], optional - The attributes of the tag as name-value pairs. - elements : list[:class:`compas.files.XMLElement`], optional - A list of elements contained by the current element. - text : str, optional - The text contained by the element. + target + Path or writable text or binary stream. + root + Root element to write. + encoding + Output encoding. + pretty + If True, indent the output. + xml_declaration + Controls inclusion of the XML declaration. + + Returns + ------- + None """ - - def __init__(self, tag, attributes=None, elements=None, text=None): - self.tag = tag - self.attributes = attributes or {} - self.elements = elements or [] - self.text = text - - def get_root(self): - """Get the root element. - - Returns - ------- - :class:`ET.Element` - - """ - root = ET.Element(self.tag, self.attributes) - root.text = self.text - return root - - def add_children(self, element): - """Add children to an element. - - Parameters - ---------- - element : :class:`ET.Element` - The parent element. - - Returns - ------- - None - - """ - for child in self.elements: - subelement = ET.SubElement(element, child.tag, child.attributes) - subelement.text = child.text - child.add_children(subelement) + data = xml_to_string(root, encoding=encoding, pretty=pretty, xml_declaration=xml_declaration) + with _iotools.open_file(target, "wb") as stream: + try: + stream.write(data) + except TypeError: + if isinstance(data, bytes): + stream.write(data.decode(encoding)) + else: + stream.write(data.encode(encoding)) diff --git a/src/compas/geometry/__init__.py b/src/compas/geometry/__init__.py index d8d46a1591c2..0dcddf089d21 100644 --- a/src/compas/geometry/__init__.py +++ b/src/compas/geometry/__init__.py @@ -2,97 +2,14 @@ This package defines all functionality for working with geometry in COMPAS. It provides classes representing geometric primitives, transformations, (NURBS) curves and surfaces, shapes, general polygons and polyhedrons, boundary representations (B-reps), and a number of geometry processing algorithms. -""" -from __future__ import absolute_import -import compas +""" +# ruff: noqa: F401 # ============================================================================= # Core # ============================================================================= -from ._core._algebra import ( - add_vectors, - add_vectors_xy, - allclose, - argmax, - argmin, - close, - cross_vectors, - cross_vectors_xy, - dehomogenize_vectors, - divide_vectors, - divide_vectors_xy, - dot_vectors, - dot_vectors_xy, - homogenize_vectors, - length_vector, - length_vector_sqrd, - length_vector_sqrd_xy, - length_vector_xy, - multiply_matrices, - multiply_matrix_vector, - multiply_vectors, - multiply_vectors_xy, - norm_vector, - norm_vectors, - normalize_vector, - normalize_vector_xy, - normalize_vectors, - normalize_vectors_xy, - orthonormalize_vectors, - power_vector, - power_vectors, - scale_vector, - scale_vector_xy, - scale_vectors, - scale_vectors_xy, - square_vector, - square_vectors, - subtract_vectors, - subtract_vectors_xy, - sum_vectors, - transpose_matrix, - vector_average, - vector_component, - vector_component_xy, - vector_standard_deviation, - vector_variance, - axis_and_angle_from_matrix, - axis_angle_from_quaternion, - axis_angle_vector_from_matrix, - basis_vectors_from_matrix, - compose_matrix, - decompose_matrix, - euler_angles_from_matrix, - euler_angles_from_quaternion, - identity_matrix, - is_matrix_square, - matrix_determinant, - matrix_from_axis_and_angle, - matrix_from_axis_angle_vector, - matrix_from_basis_vectors, - matrix_from_change_of_basis, - matrix_from_euler_angles, - matrix_from_frame, - matrix_from_frame_to_frame, - matrix_from_orthogonal_projection, - matrix_from_parallel_projection, - matrix_from_perspective_entries, - matrix_from_perspective_projection, - matrix_from_quaternion, - matrix_from_scale_factors, - matrix_from_shear, - matrix_from_shear_entries, - matrix_from_translation, - matrix_inverse, - matrix_minor, - quaternion_from_axis_angle, - quaternion_from_euler_angles, - quaternion_from_matrix, - translation_from_matrix, -) - from ._core.angles import ( angle_planes, angle_points, @@ -154,14 +71,6 @@ normal_triangle, normal_triangle_xy, ) -from ._core.quaternions import ( - quaternion_canonize, - quaternion_conjugate, - quaternion_is_unit, - quaternion_multiply, - quaternion_norm, - quaternion_unitize, -) from ._core.size import ( area_polygon, area_polygon_xy, @@ -202,17 +111,17 @@ world_to_local_coordinates, ) -if not compas.IPY: - from ._core.transformations_numpy import ( - dehomogenize_and_unflatten_frames_numpy, - dehomogenize_numpy, - homogenize_and_flatten_frames_numpy, - homogenize_numpy, - local_to_world_coordinates_numpy, - transform_points_numpy, - transform_vectors_numpy, - world_to_local_coordinates_numpy, - ) +from ._core.transformations_numpy import ( + dehomogenize_and_unflatten_frames_numpy, + dehomogenize_numpy, + homogenize_and_flatten_frames_numpy, + homogenize_numpy, + local_to_world_coordinates_numpy, + transform_frames_numpy, + transform_points_numpy, + transform_vectors_numpy, + world_to_local_coordinates_numpy, +) from ._core.predicates_2 import ( is_ccw_xy, @@ -334,23 +243,22 @@ ) from .trimesh_slicing import trimesh_slice -if not compas.IPY: - from .pca_numpy import pca_numpy - from .bbox_numpy import ( - oriented_bounding_box_numpy, - oriented_bounding_box_xy_numpy, - ) - from .bestfit_numpy import ( - bestfit_line_numpy, - bestfit_plane_numpy, - bestfit_frame_numpy, - bestfit_circle_numpy, - bestfit_sphere_numpy, - ) - from .hull_numpy import convex_hull_numpy, convex_hull_xy_numpy - from .icp_numpy import icp_numpy - from .trimesh_gradient_numpy import trimesh_gradient_numpy - from .trimesh_descent_numpy import trimesh_descent_numpy +from .pca_numpy import pca_numpy +from .bbox_numpy import ( + oriented_bounding_box_numpy, + oriented_bounding_box_xy_numpy, +) +from .bestfit_numpy import ( + bestfit_line_numpy, + bestfit_plane_numpy, + bestfit_frame_numpy, + bestfit_circle_numpy, + bestfit_sphere_numpy, +) +from .hull_numpy import convex_hull_numpy, convex_hull_xy_numpy +from .icp_numpy import icp_numpy +from .trimesh_gradient_numpy import trimesh_gradient_numpy +from .trimesh_descent_numpy import trimesh_descent_numpy # ============================================================================= # Class APIs @@ -370,14 +278,12 @@ from .quaternion import Quaternion from .frame import Frame from .plane import Plane - -# not sure what to do with line and polyline -# the required changes are drastic +from .line import Line +from .polyline import Polyline from .pointcloud import Pointcloud +from .intersection import Intersection, IntersectionResult, intersection from .curves.curve import Curve -from .curves.line import Line -from .curves.polyline import Polyline from .curves.circle import Circle from .curves.ellipse import Ellipse from .curves.parabola import Parabola @@ -422,353 +328,3 @@ from .brep.face import BrepFace, SurfaceType from .brep.vertex import BrepVertex from .brep.trim import BrepTrim, BrepTrimIsoStatus - - -__all__ = [ - "Arc", - "Bezier", - "Box", - "Brep", - "BrepEdge", - "BrepError", - "BrepFace", - "BrepInvalidError", - "BrepLoop", - "BrepOrientation", - "BrepTrim", - "BrepTrimIsoStatus", - "BrepTrimmingError", - "BrepType", - "BrepFilletError", - "BrepVertex", - "Capsule", - "Circle", - "Cone", - "ConicalSurface", - "Curve", - "CurveType", - "Cylinder", - "CylindricalSurface", - "Ellipse", - "Frame", - "Geometry", - "Hyperbola", - "KDTree", - "Line", - "NurbsCurve", - "NurbsSurface", - "Parabola", - "PlanarSurface", - "Plane", - "Point", - "Pointcloud", - "Polygon", - "Polyhedron", - "Polyline", - "Projection", - "Quaternion", - "Reflection", - "Rotation", - "Scale", - "Shape", - "Shear", - "Sphere", - "SphericalSurface", - "Surface", - "SurfaceType", - "ToroidalSurface", - "Torus", - "Transformation", - "Translation", - "Vector", - "add_vectors", - "add_vectors_xy", - "allclose", - "angle_planes", - "angle_points", - "angle_points_xy", - "angle_vectors", - "angle_vectors_signed", - "angle_vectors_projected", - "angle_vectors_xy", - "angles_points", - "angles_points_xy", - "angles_vectors", - "angles_vectors_xy", - "area_polygon", - "area_polygon_xy", - "area_triangle", - "area_triangle_xy", - "argmax", - "argmin", - "axis_and_angle_from_matrix", - "axis_angle_from_quaternion", - "axis_angle_vector_from_matrix", - "barycentric_coordinates", - "basis_vectors_from_matrix", - "bestfit_plane", - "boolean_difference_mesh_mesh", - "boolean_difference_polygon_polygon", - "boolean_intersection_mesh_mesh", - "boolean_intersection_polygon_polygon", - "boolean_symmetric_difference_polygon_polygon", - "boolean_union_mesh_mesh", - "boolean_union_polygon_polygon", - "bounding_box", - "bounding_box_xy", - "centroid_points", - "centroid_points_weighted", - "centroid_points_xy", - "centroid_polygon", - "centroid_polygon_edges", - "centroid_polygon_edges_xy", - "centroid_polygon_vertices", - "centroid_polygon_vertices_xy", - "centroid_polygon_xy", - "centroid_polyhedron", - "close", - "closest_line_to_point", - "closest_point_in_cloud", - "closest_point_in_cloud_xy", - "closest_point_on_line", - "closest_point_on_line_xy", - "closest_point_on_plane", - "closest_point_on_polygon_xy", - "closest_point_on_polyline", - "closest_point_on_polyline_xy", - "closest_point_on_segment", - "closest_point_on_segment_xy", - "compose_matrix", - "compute_basisfuncs", - "compute_basisfuncsderivs", - "conforming_delaunay_triangulation", - "constrained_delaunay_triangulation", - "construct_knotvector", - "convex_hull", - "convex_hull_xy", - "cross_vectors", - "cross_vectors_xy", - "decompose_matrix", - "dehomogenize_vectors", - "delaunay_triangulation", - "discrete_coons_patch", - "distance_line_line", - "distance_point_line", - "distance_point_line_sqrd", - "distance_point_line_sqrd_xy", - "distance_point_line_xy", - "distance_point_plane", - "distance_point_plane_signed", - "distance_point_point", - "distance_point_point_sqrd", - "distance_point_point_sqrd_xy", - "distance_point_point_xy", - "divide_vectors", - "divide_vectors_xy", - "dot_vectors", - "dot_vectors_xy", - "earclip_polygon", - "euler_angles_from_matrix", - "euler_angles_from_quaternion", - "find_span", - "homogenize_vectors", - "identity_matrix", - "intersection_circle_circle_xy", - "intersection_ellipse_line_xy", - "intersection_line_box_xy", - "intersection_line_line", - "intersection_line_line_xy", - "intersection_line_plane", - "intersection_line_segment", - "intersection_line_segment_xy", - "intersection_line_triangle", - "intersection_mesh_mesh", - "intersection_plane_circle", - "intersection_plane_plane", - "intersection_plane_plane_plane", - "intersection_polyline_box_xy", - "intersection_polyline_plane", - "intersection_ray_mesh", - "intersection_segment_plane", - "intersection_segment_polyline", - "intersection_segment_polyline_xy", - "intersection_segment_segment", - "intersection_segment_segment_xy", - "intersection_sphere_line", - "intersection_sphere_sphere", - "is_ccw_xy", - "is_colinear", - "is_colinear_line_line", - "is_colinear_xy", - "is_coplanar", - "is_matrix_square", - "is_parallel_line_line", - "is_parallel_vector_vector", - "is_point_behind_plane", - "is_point_in_circle", - "is_point_in_circle_xy", - "is_point_in_convex_polygon_xy", - "is_point_in_polygon_xy", - "is_point_in_polyhedron", - "is_point_in_triangle", - "is_point_in_triangle_xy", - "is_point_infrontof_plane", - "is_point_on_line", - "is_point_on_line_xy", - "is_point_on_plane", - "is_point_on_polyline", - "is_point_on_polyline_xy", - "is_point_on_segment", - "is_point_on_segment_xy", - "is_polygon_convex", - "is_polygon_convex_xy", - "is_polygon_in_polygon_xy", - "knots_and_mults_to_knotvector", - "knotvector_to_knots_and_mults", - "length_vector", - "length_vector_sqrd", - "length_vector_sqrd_xy", - "length_vector_xy", - "local_axes", - "local_to_world_coordinates", - "matrix_determinant", - "matrix_from_axis_and_angle", - "matrix_from_axis_angle_vector", - "matrix_from_basis_vectors", - "matrix_from_change_of_basis", - "matrix_from_euler_angles", - "matrix_from_frame", - "matrix_from_frame_to_frame", - "matrix_from_orthogonal_projection", - "matrix_from_parallel_projection", - "matrix_from_perspective_entries", - "matrix_from_perspective_projection", - "matrix_from_quaternion", - "matrix_from_scale_factors", - "matrix_from_shear", - "matrix_from_shear_entries", - "matrix_from_translation", - "matrix_inverse", - "matrix_minor", - "midpoint_line", - "midpoint_line_xy", - "midpoint_point_point", - "midpoint_point_point_xy", - "mirror_point_plane", - "mirror_points_line", - "mirror_points_line_xy", - "mirror_points_plane", - "mirror_points_point", - "mirror_points_point_xy", - "mirror_vector_vector", - "multiply_matrices", - "multiply_matrix_vector", - "multiply_vectors", - "multiply_vectors_xy", - "norm_vector", - "norm_vectors", - "normal_polygon", - "normal_triangle", - "normal_triangle_xy", - "normalize_vector", - "normalize_vector_xy", - "normalize_vectors", - "normalize_vectors_xy", - "offset_line", - "offset_polygon", - "offset_polyline", - "orient_points", - "oriented_bounding_box", - "orthonormalize_axes", - "orthonormalize_vectors", - "pca_numpy", - "power_vector", - "power_vectors", - "project_point_line", - "project_point_line_xy", - "project_point_plane", - "project_points_line", - "project_points_line_xy", - "project_points_plane", - "quadmesh_planarize", - "quaternion_canonize", - "quaternion_conjugate", - "quaternion_from_axis_angle", - "quaternion_from_euler_angles", - "quaternion_from_matrix", - "quaternion_is_unit", - "quaternion_multiply", - "quaternion_norm", - "quaternion_unitize", - "reflect_line_plane", - "reflect_line_triangle", - "rotate_points", - "rotate_points_xy", - "scale_points", - "scale_points_xy", - "scale_vector", - "scale_vector_xy", - "scale_vectors", - "scale_vectors_xy", - "sort_points", - "sort_points_xy", - "square_vector", - "square_vectors", - "subtract_vectors", - "subtract_vectors_xy", - "sum_vectors", - "tangent_points_to_circle_xy", - "transform_frames", - "transform_points", - "transform_vectors", - "translate_points", - "translate_points_xy", - "translation_from_matrix", - "transpose_matrix", - "trimesh_gaussian_curvature", - "trimesh_geodistance", - "trimesh_harmonic", - "trimesh_isolines", - "trimesh_lscm", - "trimesh_massmatrix", - "trimesh_mean_curvature", - "trimesh_principal_curvature", - "trimesh_remesh", - "trimesh_remesh_along_isoline", - "trimesh_remesh_constrained", - "trimesh_slice", - "tween_points", - "tween_points_distance", - "vector_average", - "vector_component", - "vector_component_xy", - "vector_standard_deviation", - "vector_variance", - "volume_polyhedron", - "world_to_local_coordinates", -] - -if not compas.IPY: - __all__ += [ - "bestfit_circle_numpy", - "bestfit_frame_numpy", - "bestfit_line_numpy", - "bestfit_plane_numpy", - "bestfit_sphere_numpy", - "closest_points_in_cloud_numpy", - "convex_hull_numpy", - "convex_hull_xy_numpy", - "dehomogenize_and_unflatten_frames_numpy", - "dehomogenize_numpy", - "homogenize_and_flatten_frames_numpy", - "homogenize_numpy", - "icp_numpy", - "local_to_world_coordinates_numpy", - "oriented_bounding_box_numpy", - "oriented_bounding_box_xy_numpy", - "transform_points_numpy", - "transform_vectors_numpy", - "trimesh_descent_numpy", - "trimesh_gradient_numpy", - "world_to_local_coordinates_numpy", - ] diff --git a/src/compas/geometry/_core/_algebra.py b/src/compas/geometry/_core/_algebra.py deleted file mode 100644 index 902bd29bf724..000000000000 --- a/src/compas/geometry/_core/_algebra.py +++ /dev/null @@ -1,2813 +0,0 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from copy import deepcopy -from math import acos -from math import asin -from math import atan2 -from math import cos -from math import fabs -from math import pi -from math import sin -from math import sqrt -from math import tan - -from compas.tolerance import TOL - -_SPEC2TUPLE = { - "sxyz": (0, 0, 0, 0), - "sxyx": (0, 0, 1, 0), - "sxzy": (0, 1, 0, 0), - "sxzx": (0, 1, 1, 0), - "syzx": (1, 0, 0, 0), - "syzy": (1, 0, 1, 0), - "syxz": (1, 1, 0, 0), - "syxy": (1, 1, 1, 0), - "szxy": (2, 0, 0, 0), - "szxz": (2, 0, 1, 0), - "szyx": (2, 1, 0, 0), - "szyz": (2, 1, 1, 0), - "rzyx": (0, 0, 0, 1), - "rxyx": (0, 0, 1, 1), - "ryzx": (0, 1, 0, 1), - "rxzx": (0, 1, 1, 1), - "rxzy": (1, 0, 0, 1), - "ryzy": (1, 0, 1, 1), - "rzxy": (1, 1, 0, 1), - "ryxy": (1, 1, 1, 1), - "ryxz": (2, 0, 0, 1), - "rzxz": (2, 0, 1, 1), - "rxyz": (2, 1, 0, 1), - "rzyz": (2, 1, 1, 1), -} -"""used for Euler angles: to map rotation type and axes to tuples of inner axis, parity, repetition, frame""" - -_NEXT_SPEC = [1, 2, 0, 1] - - -def vector_average(vector): - """Average of a vector. - - Parameters - ---------- - vector : [float, float, float] | :class:`compas.geometry.Vector` - List of values. - - Returns - ------- - float - The mean value. - """ - return sum(vector) / float(len(vector)) - - -def vector_variance(vector): - """Variance of a vector. - - Parameters - ---------- - vector : [float, float, float] | :class:`compas.geometry.Vector` - List of values. - - Returns - ------- - float - The variance value. - """ - m = vector_average(vector) - return (sum([(i - m) ** 2 for i in vector]) / float(len(vector))) ** 0.5 - - -def vector_standard_deviation(vector): - """Standard deviation of a vector. - - Parameters - ---------- - vector : [float, float, float] | :class:`compas.geometry.Vector` - List of values. - - Returns - ------- - float - The standard deviation value. - """ - return vector_variance(vector) ** 0.5 - - -def argmax(values): - """Returns the index of the first maximum value within an array. - - Parameters - ---------- - values : sequence[float] - A list of values. - - Returns - ------- - int - The index of the first maximum value within an array. - - Notes - ----- - NumPy's *argmax* function [1]_ is different, it returns an array of indices. - - References - ---------- - .. [1] https://numpy.org/doc/stable/reference/generated/numpy.argmax.html - - Examples - -------- - >>> argmax([2, 4, 4, 3]) - 1 - - """ - return max(range(len(values)), key=lambda i: values[i]) # type: ignore - - -def argmin(values): - """Returns the index of the first minimum value within an array. - - Parameters - ---------- - values : sequence[float] - A list of values. - - Returns - ------- - int - The index of the first minimum value within an array. - - Notes - ----- - NumPy's *argmin* function [1]_ is different, it returns an array of indices. - - References - ---------- - .. [1] https://numpy.org/doc/stable/reference/generated/numpy.argmin.html - - Examples - -------- - >>> argmin([4, 2, 2, 3]) - 1 - - """ - return min(range(len(values)), key=lambda i: values[i]) - - -# ============================================================================== -# these return something of smaller dimension/length/... -# something_(of)vector/s -# ============================================================================== - - -def sum_vectors(vectors, axis=0): - """Calculate the sum of a series of vectors along the specified axis. - - Parameters - ---------- - vectors : sequence[[float, float, float] | :class:`compas.geometry.Vector`] - A list of vectors. - axis : int, optional - If ``axis == 0``, the sum is taken per column. - If ``axis == 1``, the sum is taken per row. - - Returns - ------- - list[float] - The length of the list is ``len(vectors[0])``, if ``axis == 0``. - The length is ``len(vectors)``, otherwise. - - Examples - -------- - >>> vectors = [[1.0, 2.0, 3.0], [1.0, 2.0, 3.0], [1.0, 2.0, 3.0]] - >>> sum_vectors(vectors) - [3.0, 6.0, 9.0] - >>> sum_vectors(vectors, axis=1) - [6.0, 6.0, 6.0] - - """ - if axis == 0: - vectors = zip(*vectors) - return [sum(vector) for vector in vectors] - - -def norm_vector(vector): - """Calculate the length of a vector. - - Parameters - ---------- - vector : [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the vector. - - Returns - ------- - float - The L2 norm, or *length* of the vector. - - Examples - -------- - >>> norm_vector([2.0, 0.0, 0.0]) - 2.0 - - >>> norm_vector([1.0, 1.0, 0.0]) == sqrt(2.0) - True - - """ - return sqrt(sum(axis**2 for axis in vector)) - - -def norm_vectors(vectors): - """ - Calculate the norm of each vector in a list of vectors. - - Parameters - ---------- - vectors : sequence[[float, float, float] | :class:`compas.geometry.Vector`] - A list of vectors - - Returns - ------- - list[float] - A list with the lengths of all vectors. - - Examples - -------- - >>> norm_vectors([[1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [3.0, 0.0, 0.0]]) - [1.0, 2.0, 3.0] - - """ - return [norm_vector(vector) for vector in vectors] - - -def length_vector(vector): - """Calculate the length of the vector. - - Parameters - ---------- - vector : [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the vector. - - Returns - ------- - float - The length of the vector. - - Examples - -------- - >>> length_vector([2.0, 0.0, 0.0]) - 2.0 - - >>> length_vector([1.0, 1.0, 0.0]) == sqrt(2.0) - True - - """ - return sqrt(length_vector_sqrd(vector)) - - -def length_vector_xy(vector): - """Compute the length of a vector, assuming it lies in the XY plane. - - Parameters - ---------- - vector : [float, float, float] | :class:`compas.geometry.Vector` - XY(Z) components of the vector. - - Returns - ------- - float - The length of the XY component of the vector. - - Examples - -------- - >>> length_vector_xy([2.0, 0.0]) - 2.0 - - >>> length_vector_xy([2.0, 0.0, 0.0]) - 2.0 - - >>> length_vector_xy([2.0, 0.0, 2.0]) - 2.0 - - """ - return sqrt(length_vector_sqrd_xy(vector)) - - -def length_vector_sqrd(vector): - """Compute the squared length of a vector. - - Parameters - ---------- - vector : [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the vector. - - Returns - ------- - float - The squared length. - - Examples - -------- - >>> length_vector_sqrd([1.0, 1.0, 0.0]) - 2.0 - - """ - return vector[0] ** 2 + vector[1] ** 2 + vector[2] ** 2 - - -def length_vector_sqrd_xy(vector): - """Compute the squared length of a vector, assuming it lies in the XY plane. - - Parameters - ---------- - vector : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` - XY(Z) components of the vector. - - Returns - ------- - float - The squared length. - - Examples - -------- - >>> length_vector_sqrd_xy([1.0, 1.0]) - 2.0 - - >>> length_vector_sqrd_xy([1.0, 1.0, 0.0]) - 2.0 - - >>> length_vector_sqrd_xy([1.0, 1.0, 1.0]) - 2.0 - - """ - return vector[0] ** 2 + vector[1] ** 2 - - -# ============================================================================== -# these perform an operation on a vector and return a modified vector -# -> elementwise operations on 1 vector -# should this not bet ...ed_vector -# ... or else modify the vector in-place -# ============================================================================== - - -def scale_vector(vector, factor): - """Scale a vector by a given factor. - - Parameters - ---------- - vector : [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the vector. - factor : float - The scaling factor. - - Returns - ------- - [float, float, float] - The scaled vector. - - Examples - -------- - >>> scale_vector([1.0, 2.0, 3.0], 2.0) - [2.0, 4.0, 6.0] - - >>> v = [2.0, 0.0, 0.0] - >>> scale_vector(v, 1 / length_vector(v)) - [1.0, 0.0, 0.0] - - """ - return [axis * factor for axis in vector] - - -def scale_vector_xy(vector, factor): - """Scale a vector by a given factor, assuming it lies in the XY plane. - - Parameters - ---------- - vector : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` - XY(Z) components of the vector. - scale : float - Scale factor. - - Returns - ------- - [float, float, 0.0] - The scaled vector in the XY-plane. - - Examples - -------- - >>> scale_vector_xy([1.0, 2.0, 3.0], 2.0) - [2.0, 4.0, 0.0] - - """ - return [vector[0] * factor, vector[1] * factor, 0.0] - - -def scale_vectors(vectors, factor): - """Scale multiple vectors by a given factor. - - Parameters - ---------- - vectors : sequence[[float, float, float] | :class:`compas.geometry.Vector`] - A list of vectors. - factor : float - The scaling factor. - - Returns - ------- - list[[float, float, float]] - The scaled vectors. - - Examples - -------- - >>> - - """ - return [scale_vector(vector, factor) for vector in vectors] - - -def scale_vectors_xy(vectors, factor): - """Scale multiple vectors by a given factor, assuming they lie in the XY plane. - - Parameters - ---------- - vectors : sequence[[float, float] or [float, float, float] | :class:`compas.geometry.Vector`] - A list of vectors. - factor : float - The scaling factor. - - Returns - ------- - list[[float, float, 0.0]] - The scaled vectors in the XY plane. - - Examples - -------- - >>> - - """ - return [scale_vector_xy(vector, factor) for vector in vectors] - - -def normalize_vector(vector): - """Normalise a given vector. - - Parameters - ---------- - vector : [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the vector. - - Returns - ------- - [float, float, float] - The normalized vector. - - Examples - -------- - >>> - - """ - length = length_vector(vector) - if not length: - return vector - return [vector[0] / length, vector[1] / length, vector[2] / length] - - -def normalize_vector_xy(vector): - """Normalize a vector, assuming it lies in the XY-plane. - - Parameters - ---------- - vector : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` - XY(Z) components of the vector. - - Returns - ------- - [float, float, 0.0] - The normalized vector in the XY-plane. - - Examples - -------- - >>> - - """ - length = length_vector_xy(vector) - if not length: - return vector - return [vector[0] / length, vector[1] / length, 0.0] - - -def normalize_vectors(vectors): - """Normalise multiple vectors. - - Parameters - ---------- - vectors : sequence[[float, float, float] | :class:`compas.geometry.Vector`] - A list of vectors. - - Returns - ------- - list[[float, float, float]] - The normalized vectors. - - Examples - -------- - >>> - - """ - return [normalize_vector(vector) for vector in vectors] - - -def normalize_vectors_xy(vectors): - """Normalise multiple vectors, assuming they lie in the XY plane. - - Parameters - ---------- - vectors : sequence[[float, float] or [float, float, float] | :class:`compas.geometry.Vector`] - A list of vectors. - - Returns - ------- - list[[float, float, 0.0]] - The normalized vectors in the XY plane. - - Examples - -------- - >>> - - """ - return [normalize_vector_xy(vector) for vector in vectors] - - -def power_vector(vector, power): - """Raise a vector to the given power. - - Parameters - ---------- - vector : [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the vector. - power : int, float - The power to which to raise the vector. - - Returns - ------- - [float, float, float] - The raised vector. - - Examples - -------- - >>> - - """ - return [axis**power for axis in vector] - - -def power_vectors(vectors, power): - """Raise a list of vectors to the given power. - - Parameters - ---------- - vectors : sequence[[float, float, float] | :class:`compas.geometry.Vector`] - A list of vectors. - power : int, float - The power to which to raise the vectors. - - Returns - ------- - list[[float, float, float]] - The raised vectors. - - Examples - -------- - >>> - - """ - return [power_vector(vector, power) for vector in vectors] - - -def square_vector(vector): - """Raise a vector to the power 2. - - Parameters - ---------- - vector : [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the vector. - - Returns - ------- - [float, float, float] - The squared vector. - - Examples - -------- - >>> - - """ - return power_vector(vector, 2) - - -def square_vectors(vectors): - """Raise a multiple vectors to the power 2. - - Parameters - ---------- - vectors : sequence[[float, float, float] | :class:`compas.geometry.Vector`] - A list of vectors. - - Returns - ------- - [float, float, float]] - The squared vectors. - - Examples - -------- - >>> - - """ - return [square_vectors(vector) for vector in vectors] - - -# ============================================================================== -# these perform an operation with corresponding elements of the (2) input vectors as operands -# and return a vector with the results -# -> elementwise operations on two vectors -# ============================================================================== - - -def add_vectors(u, v): - """Add two vectors. - - Parameters - ---------- - u : [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the first vector. - v : [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the second vector. - - Returns - ------- - [float, float, float] - The resulting vector. - - """ - return [a + b for (a, b) in zip(u, v)] - - -def add_vectors_xy(u, v): - """Add two vectors, assuming they lie in the XY-plane. - - Parameters - ---------- - u : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` - XY(Z) components of the first vector. - v : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` - XY(Z) components of the second vector. - - Returns - ------- - [float, float, 0.0] - Resulting vector in the XY-plane. - - Examples - -------- - >>> - - """ - return [u[0] + v[0], u[1] + v[1], 0.0] - - -def subtract_vectors(u, v): - """Subtract one vector from another. - - Parameters - ---------- - u : [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the first vector. - v : [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the second vector. - - Returns - ------- - [float, float, float] - The resulting vector. - - Examples - -------- - >>> - - """ - return [a - b for (a, b) in zip(u, v)] - - -def subtract_vectors_xy(u, v): - """Subtract one vector from another, assuming they lie in the XY plane. - - Parameters - ---------- - u : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` - The XY(Z) components of the first vector. - v : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` - The XY(Z) components of the second vector. - - Returns - ------- - [float, float, 0.0] - Resulting vector in the XY-plane. - - Examples - -------- - >>> - - """ - return [u[0] - v[0], u[1] - v[1], 0.0] - - -def multiply_vectors(u, v): - """Element-wise multiplication of two vectors. - - Parameters - ---------- - u : [float, float, float] | :class:`compas.geometry.Vector` - The XYZ components of the first vector. - v : l[float, float, float] | :class:`compas.geometry.Vector` - The XYZ components of the second vector. - - Returns - ------- - [float, float, float] - Resulting vector. - - Examples - -------- - >>> - - """ - return [a * b for (a, b) in zip(u, v)] - - -def multiply_vectors_xy(u, v): - """Element-wise multiplication of two vectors assumed to lie in the XY plane. - - Parameters - ---------- - u : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` - The XY(Z) components of the first vector. - v : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` - The XY(Z) components of the second vector. - - Returns - ------- - [float, float, 0.0] - Resulting vector in the XY plane. - - Examples - -------- - >>> - - """ - return [u[0] * v[0], u[1] * v[1], 0.0] - - -def divide_vectors(u, v): - """Element-wise division of two vectors. - - Parameters - ---------- - u : [float, float, float] | :class:`compas.geometry.Vector` - The XYZ components of the first vector. - v : [float, float, float] | :class:`compas.geometry.Vector` - The XYZ components of the second vector. - - Returns - ------- - [float, float, float] - Resulting vector. - - Examples - -------- - >>> - - """ - return [a / b for (a, b) in zip(u, v)] - - -def divide_vectors_xy(u, v): - """Element-wise division of two vectors assumed to lie in the XY plane. - - Parameters - ---------- - u : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` - The XY(Z) components of the first vector. - v : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` - The XY(Z) components of the second vector. - - Returns - ------- - [float, float, 0.0] - Resulting vector in the XY plane. - - Examples - -------- - >>> - - """ - return [u[0] / v[0], u[1] / v[1], 0.0] - - -# ============================================================================== -# ... -# ============================================================================== - - -def cross_vectors(u, v): - r"""Compute the cross product of two vectors. - - Parameters - ---------- - u : [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the first vector. - v : [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the second vector. - - Returns - ------- - [float, float, float] - The cross product of the two vectors. - - Notes - ----- - The xyz components of the cross product of two vectors :math:`\mathbf{u}` - and :math:`\mathbf{v}` can be computed as the *minors* of the following matrix: - - .. math:: - :nowrap: - - \begin{bmatrix} - x & y & z \\ - u_{x} & u_{y} & u_{z} \\ - v_{x} & v_{y} & v_{z} - \end{bmatrix} - - Therefore, the cross product can be written as: - - .. math:: - :nowrap: - - \begin{eqnarray} - \mathbf{u} \times \mathbf{v} - & = - \begin{bmatrix} - u_{y} * v_{z} - u_{z} * v_{y} \\ - u_{z} * v_{x} - u_{x} * v_{z} \\ - u_{x} * v_{y} - u_{y} * v_{x} - \end{bmatrix} - \end{eqnarray} - - Examples - -------- - >>> cross_vectors([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]) - [0.0, 0.0, 1.0] - - """ - return [ - u[1] * v[2] - u[2] * v[1], - u[2] * v[0] - u[0] * v[2], - u[0] * v[1] - u[1] * v[0], - ] - - -def cross_vectors_xy(u, v): - """Compute the cross product of two vectors, assuming they lie in the XY-plane. - - Parameters - ---------- - u : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` - XY(Z) coordinates of the first vector. - v : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` - XY(Z) coordinates of the second vector. - - Returns - ------- - [float, float, float] - The cross product of the two vectors. - This vector will be perpendicular to the XY plane. - - Examples - -------- - >>> cross_vectors_xy([1.0, 0.0], [0.0, 1.0]) - [0.0, 0.0, 1.0] - - >>> cross_vectors_xy([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]) - [0.0, 0.0, 1.0] - - >>> cross_vectors_xy([1.0, 0.0, 1.0], [0.0, 1.0, 1.0]) - [0.0, 0.0, 1.0] - - """ - return [0.0, 0.0, u[0] * v[1] - u[1] * v[0]] - - -def dot_vectors(u, v): - """Compute the dot product of two vectors. - - Parameters - ---------- - u : [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the first vector. - v : [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the second vector. - - Returns - ------- - float - The dot product of the two vectors. - - Examples - -------- - >>> dot_vectors([1.0, 0, 0], [2.0, 0, 0]) - 2.0 - - """ - return sum(a * b for a, b in zip(u, v)) - - -def dot_vectors_xy(u, v): - """Compute the dot product of two vectors, assuming they lie in the XY-plane. - - Parameters - ---------- - u : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` - XY(Z) coordinates of the first vector. - v : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` - XY(Z) coordinates of the second vector. - - Returns - ------- - float - The dot product of the XY components of the two vectors. - - Examples - -------- - >>> dot_vectors_xy([1.0, 0], [2.0, 0]) - 2.0 - - >>> dot_vectors_xy([1.0, 0, 0], [2.0, 0, 0]) - 2.0 - - >>> dot_vectors_xy([1.0, 0, 1], [2.0, 0, 1]) - 2.0 - - """ - return u[0] * v[0] + u[1] * v[1] - - -def vector_component(u, v): - """Compute the component of u in the direction of v. - - Parameters - ---------- - u : [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the vector. - v : [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the direction. - - Returns - ------- - [float, float, float] - The component of u in the direction of v. - - Notes - ----- - This is similar to computing direction cosines, or to the projection of - a vector onto another vector. See the respective Wikipedia pages ([1]_, [2]_) - for more info. - - References - ---------- - .. [1] *Direction cosine*. Available at https://en.wikipedia.org/wiki/Direction_cosine. - .. [2] *Vector projection*. Available at https://en.wikipedia.org/wiki/Vector_projection. - - Examples - -------- - >>> vector_component([1.0, 2.0, 3.0], [1.0, 0.0, 0.0]) - [1.0, 0.0, 0.0] - - """ - l2 = length_vector_sqrd(v) - if not l2: - return [0, 0, 0] - x = dot_vectors(u, v) / l2 - return scale_vector(v, x) - - -def vector_component_xy(u, v): - """Compute the component of u in the direction of v, assuming they lie in the XY-plane. - - Parameters - ---------- - u : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the vector. - v : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` - XYZ components of the direction. - - Returns - ------- - [float, float, 0.0] - The component of u in the XY plane, in the direction of v. - - Notes - ----- - This is similar to computing direction cosines, or to the projection of - a vector onto another vector. See the respective Wikipedia pages ([1]_, [2]_) - for more info. - - References - ---------- - .. [1] *Direction cosine*. Available at https://en.wikipedia.org/wiki/Direction_cosine. - .. [2] *Vector projection*. Available at https://en.wikipedia.org/wiki/Vector_projection. - - Examples - -------- - >>> vector_component_xy([1, 2, 0], [1, 0, 0]) - [1.0, 0.0, 0.0] - - """ - l2 = length_vector_sqrd_xy(v) - if not l2: - return [0, 0, 0] - x = dot_vectors_xy(u, v) / l2 - return scale_vector_xy(v, x) - - -# ============================================================================== -# linalg -# ============================================================================== - - -def homogenize_vectors(vectors, w=1.0): - """Homogenise a list of vectors. - - Parameters - ---------- - vectors : sequence[[float, float, float] | :class:`compas.geometry.Vector`] - A list of vectors. - w : float, optional - Homogenisation parameter. - - Returns - ------- - list[[float, float, float]] - Homogenised vectors. - - Notes - ----- - Vectors described by XYZ components are homogenised by appending a homogenisation - parameter to the components, and by dividing each component by that parameter. - Homogenisatioon of vectors is often used in relation to transformations. - - Examples - -------- - >>> vectors = [[1.0, 0.0, 0.0]] - >>> homogenize_vectors(vectors) - [[1.0, 0.0, 0.0, 1.0]] - - """ - return [[x / w, y / w, z / w, w] for x, y, z in vectors] - - -def dehomogenize_vectors(vectors): - """Dehomogenise a list of vectors. - - Parameters - ---------- - vectors : sequence[[float, float, float] | :class:`compas.geometry.Vector`] - A list of vectors. - - Returns - ------- - list[float, float, float] - Dehomogenised vectors. - - Examples - -------- - >>> - - """ - return [[x * w, y * w, z * w] for x, y, z, w in vectors] - - -def orthonormalize_vectors(vectors): - """Orthonormalize a set of vectors. - - Parameters - ---------- - vectors : sequence[[float, float, float] | :class:`compas.geometry.Vector`] - The set of vectors to othonormalize. - - Returns - ------- - list[[float, float, float]] - An othonormal basis for the input vectors. - - Notes - ----- - This creates a basis for the range (column space) of the matrix A.T, - with A = vectors. - - Orthonormalisation is according to the Gram-Schmidt process. - - Examples - -------- - >>> orthonormalize_vectors([[1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) - [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] - - """ - basis = [] - for v in vectors: - if basis: - e = subtract_vectors(v, sum_vectors([vector_component(v, b) for b in basis])) - else: - e = v - if any(axis > 1e-10 for axis in e): - basis.append(normalize_vector(e)) - return basis - - -# ============================================================================= -# general matrices -# ============================================================================= - - -def transpose_matrix(M): - """Transpose a matrix. - - Parameters - ---------- - M : list[list[float]] | :class:`compas.geometry.Transformation` - The matrix to be transposed. - - Returns - ------- - list[list[float]] - The result matrix. - - """ - return list(map(list, zip(*list(M)))) - - -def multiply_matrices(A, B): - r"""Mutliply a matrix with a matrix. - - Parameters - ---------- - A : list[list[float]] | :class:`compas.geometry.Transformation` - The first matrix. - B : list[list[float]] | :class:`compas.geometry.Transformation` - The second matrix. - - Returns - ------- - list[list[float]] - The result matrix. - - Raises - ------ - Exception - If the shapes of the matrices are not compatible. - If the row length of B is inconsistent. - - Notes - ----- - This is a pure Python version of the following linear algebra procedure: - - .. math:: - - \mathbf{A} \cdot \mathbf{B} = \mathbf{C} - - with :math:`\mathbf{A}` [m x n], :math:`\mathbf{B}` [n x o], and :math:`\mathbf{C}` [m x o]. - - Examples - -------- - >>> A = [[2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 2.0]] - >>> B = [[2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 2.0]] - >>> multiply_matrices(A, B) - [[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]] - - """ - A = list(A) - B = list(B) - n = len(B) # number of rows in B - o = len(B[0]) # number of cols in B - if not all(len(row) == o for row in B): - raise Exception("Row length in matrix B is inconsistent.") - if not all([len(row) == n for row in A]): - raise Exception("Matrix shapes are not compatible.") - B = list(zip(*list(B))) - return [[dot_vectors(row, col) for col in B] for row in A] - - -def multiply_matrix_vector(A, b): - r"""Multiply a matrix with a vector. - - Parameters - ---------- - A : list[list[float]] | :class:`compas.geometry.Transformation` - The matrix. - b : [float, float, float] | :class:`compas.geometry.Vector` - The vector. - - Returns - ------- - [float, float, float] - The resulting vector. - - Raises - ------ - Exception - If not all rows of the matrix have the same length as the vector. - - Notes - ----- - This is a Python version of the following linear algebra procedure: - - .. math:: - - \mathbf{A} \cdot \mathbf{x} = \mathbf{b} - - with :math:`\mathbf{A}` a *m* by *n* matrix, :math:`\mathbf{x}` a vector of - length *n*, and :math:`\mathbf{b}` a vector of length *m*. - - Examples - -------- - >>> matrix = [[2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 2.0]] - >>> vector = [1.0, 2.0, 3.0] - >>> multiply_matrix_vector(matrix, vector) - [2.0, 4.0, 6.0] - - """ - n = len(b) - if not all([len(row) == n for row in A]): - raise Exception("Matrix shape is not compatible with vector length.") - return [dot_vectors(row, b) for row in A] - - -def is_matrix_square(M): - """Verify that a matrix is square. - - Parameters - ---------- - M : list[list[float]] - The matrix. - - Returns - ------- - bool - True if the length of every row is equal to the number of rows. - False otherwise. - - See Also - -------- - is_matrix_symmetric - - Examples - -------- - >>> M = identity_matrix(4) - >>> is_matrix_square(M) - True - - """ - number_of_rows = len(M) - for row in M: - if len(row) != number_of_rows: - return False - return True - - -def matrix_minor(M, i, j): - """Construct the minor corresponding to an element of a matrix. - - Parameters - ---------- - M : list[list[float]] - The matrix. - i : int - Row index of the minor. - j : int - Column index of the minor. - - Returns - ------- - list[list[float]] - The minor. - - See Also - -------- - matrix_determinant - matrix_inverse - - """ - return [row[:j] + row[j + 1 :] for row in (M[:i] + M[i + 1 :])] - - -def matrix_determinant(M, check=True): - """Calculates the determinant of a square matrix M. - - Parameters - ---------- - M : list[list[float]] - A square matrix of any dimension. - check : bool - If True, checks if the matrix is square. - - Raises - ------ - ValueError - If the matrix is not square. - - Returns - ------- - float - The determinant. - - See Also - -------- - matrix_minor - matrix_inverse - - Examples - -------- - >>> M = identity_matrix(4) - >>> matrix_determinant(M) - 1.0 - - """ - dim = len(M) - - if check: - if not is_matrix_square(M): - raise ValueError("Not a square matrix") - - if dim == 2: - return M[0][0] * M[1][1] - M[0][1] * M[1][0] - - D = 0 - for c in range(dim): - D += (-1) ** c * M[0][c] * matrix_determinant(matrix_minor(M, 0, c), check=False) - return D - - -def matrix_inverse(M): - """Calculates the inverse of a square matrix M. - - Parameters - ---------- - M : list[list[float]] - A square matrix of any dimension. - - Returns - ------- - list[list[float]] - The inverted matrix. - - Raises - ------ - ValueError - If the matrix is not squared - ValueError - If the matrix is singular. - ValueError - If the matrix is not invertible. - - See Also - -------- - matrix_minor - matrix_determinant - - Examples - -------- - >>> from compas.geometry import Frame - >>> f = Frame([1, 1, 1], [0.68, 0.68, 0.27], [-0.67, 0.73, -0.15]) - >>> T = matrix_from_frame(f) - >>> I = multiply_matrices(T, matrix_inverse(T)) - >>> I2 = identity_matrix(4) - >>> allclose(I[0], I2[0]) - True - >>> allclose(I[1], I2[1]) - True - >>> allclose(I[2], I2[2]) - True - >>> allclose(I[3], I2[3]) - True - - """ - D = matrix_determinant(M) - - if D == 0: - ValueError("The matrix is singular.") - - if len(M) == 2: - return [[M[1][1] / D, -1 * M[0][1] / D], [-1 * M[1][0] / D, M[0][0] / D]] - - cofactors = [] - for r in range(len(M)): - cofactor_row = [] - for c in range(len(M)): - cofactor_row.append((-1) ** (r + c) * matrix_determinant(matrix_minor(M, r, c))) - cofactors.append(cofactor_row) - - cofactors = transpose_matrix(cofactors) - - for r in range(len(cofactors)): - for c in range(len(cofactors)): - cofactors[r][c] = cofactors[r][c] / D - - return cofactors - - -# ============================================================================= -# 4x4 matrices -# ============================================================================= - - -def decompose_matrix(M): - """Calculates the components of rotation, translation, scale, shear, and - perspective of a given transformation matrix M. [1]_ - - Parameters - ---------- - M : list[list[float]] - The square matrix of any dimension. - - Raises - ------ - ValueError - If matrix is singular or degenerative. - - Returns - ------- - scale : [float, float, float] - The 3 scale factors in x-, y-, and z-direction. - shear : [float, float, float] - The 3 shear factors for x-y, x-z, and y-z axes. - angles : [float, float, float] - The rotation specified through the 3 Euler angles about static x, y, z axes. - translation : [float, float, float] - The 3 values of translation. - perspective : [float, float, float, float] - The 4 perspective entries of the matrix. - - See Also - -------- - compose_matrix - - Examples - -------- - >>> trans1 = [1, 2, 3] - >>> angle1 = [-2.142, 1.141, -0.142] - >>> scale1 = [0.123, 2, 0.5] - >>> T = matrix_from_translation(trans1) - >>> R = matrix_from_euler_angles(angle1) - >>> S = matrix_from_scale_factors(scale1) - >>> M = multiply_matrices(multiply_matrices(T, R), S) - >>> # M = compose_matrix(scale1, None, angle1, trans1, None) - >>> scale2, shear2, angle2, trans2, persp2 = decompose_matrix(M) - >>> allclose(scale1, scale2) - True - >>> allclose(angle1, angle2) - True - >>> allclose(trans1, trans2) - True - - References - ---------- - .. [1] Slabaugh, 1999. *Computing Euler angles from a rotation matrix*. - Available at: http://www.gregslabaugh.net/publications/euler.pdf - - """ - detM = matrix_determinant(M) # raises ValueError if matrix is not squared - if detM == 0: - ValueError("The matrix is singular.") - - Mt = transpose_matrix(M) - if TOL.is_zero(Mt[3][3]): - raise ValueError("The element [3,3] of the matrix is zero.") - - for i in range(4): - for j in range(4): - Mt[i][j] /= Mt[3][3] - - # copy Mt[:3, :3] into row - row = [ - [0, 0, 0], - [0, 0, 0], - [0, 0, 0], - ] - for i in range(3): - for j in range(3): - row[i][j] = Mt[i][j] - - # translation - 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] - angles = [0.0, 0.0, 0.0] - - scale[0] = norm_vector(row[0]) - for i in range(3): - row[0][i] /= scale[0] # type: ignore - - shear[0] = dot_vectors(row[0], row[1]) - for i in range(3): - row[1][i] -= row[0][i] * shear[0] - - scale[1] = norm_vector(row[1]) - for i in range(3): - row[1][i] /= scale[1] # type: ignore - - shear[1] = dot_vectors(row[0], row[2]) - for i in range(3): - row[2][i] -= row[0][i] * shear[1] - - # why is the order different here? - # it certainly influences the result - - shear[2] = dot_vectors(row[1], row[2]) - for i in range(3): - row[2][i] -= row[0][i] * shear[2] - - scale[2] = norm_vector(row[2]) - for i in range(3): - row[2][i] /= scale[2] # type: ignore - - shear[0] /= scale[1] - shear[1] /= scale[2] - shear[2] /= scale[2] - - if dot_vectors(row[0], cross_vectors(row[1], row[2])) < 0: - scale = [-x for x in scale] - row = [[-x for x in y] for y in row] - - # angles - if row[0][2] != -1.0 and row[0][2] != 1.0: - beta1 = asin(-row[0][2]) - # beta2 = pi - beta1 - alpha1 = atan2(row[1][2] / cos(beta1), row[2][2] / cos(beta1)) - # alpha2 = atan2(row[1][2] / cos(beta2), row[2][2] / cos(beta2)) - gamma1 = atan2(row[0][1] / cos(beta1), row[0][0] / cos(beta1)) - # gamma2 = atan2(row[0][1] / cos(beta2), row[0][0] / cos(beta2)) - angles = [alpha1, beta1, gamma1] - - else: - gamma = 0.0 - if row[0][2] == -1.0: - beta = pi / 2.0 - alpha = gamma + atan2(row[1][0], row[2][0]) - else: # row[0][2] == 1 - beta = -pi / 2.0 - alpha = -gamma + atan2(-row[1][0], -row[2][0]) - angles = [alpha, beta, gamma] - - # perspective - 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 - Ptinv = matrix_inverse(transpose_matrix(P)) - perspective = multiply_matrix_vector(Ptinv, [Mt[0][3], Mt[1][3], Mt[2][3], Mt[3][3]]) - else: - perspective = [0.0, 0.0, 0.0, 1.0] - - return scale, shear, angles, translation, perspective - - -def compose_matrix(scale=None, shear=None, angles=None, translation=None, perspective=None): - """Calculates a matrix from the components of scale, shear, euler_angles, translation and perspective. - - Parameters - ---------- - scale : [float, float, float] - The 3 scale factors in x-, y-, and z-direction. - shear : [float, float, float] - The 3 shear factors for x-y, x-z, and y-z axes. - angles : [float, float, float] - The rotation specified through the 3 Euler angles about static x, y, z axes. - translation : [float, float, float] - The 3 values of translation. - perspective : [float, float, float, float] - The 4 perspective entries of the matrix. - - Returns - ------- - list[list[float]] - The 4x4 matrix that combines the provided transformation components. - - See Also - -------- - decompose_matrix - - Examples - -------- - >>> trans1 = [1, 2, 3] - >>> angle1 = [-2.142, 1.141, -0.142] - >>> scale1 = [0.123, 2, 0.5] - >>> M = compose_matrix(scale1, None, angle1, trans1, None) - >>> scale2, shear2, angle2, trans2, persp2 = decompose_matrix(M) - >>> allclose(scale1, scale2) - True - >>> allclose(angle1, angle2) - True - >>> allclose(trans1, trans2) - True - - """ - M = [[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) - if translation is not None: - T = matrix_from_translation(translation) - M = multiply_matrices(M, T) - if angles is not None: - R = matrix_from_euler_angles(angles, static=True, axes="xyz") - M = multiply_matrices(M, R) - if shear is not None: - H = matrix_from_shear_entries(shear) - M = multiply_matrices(M, H) - if scale is not None: - S = matrix_from_scale_factors(scale) - M = multiply_matrices(M, S) - for i in range(4): - for j in range(4): - M[i][j] /= M[3][3] # type: ignore - return M - - -def identity_matrix(dim): - """Construct an identity matrix. - - Parameters - ---------- - dim : int - The number of rows and/or columns of the matrix. - - Returns - ------- - list of list - A list of `dim` lists, with each list containing `dim` elements. - The items on the "diagonal" are one. - All other items are zero. - - See Also - -------- - matrix_from_frame - matrix_from_frame_to_frame - matrix_from_euler_angles - matrix_from_axis_and_angle - matrix_from_basis_vectors - matrix_from_translation - matrix_from_scale_factors - matrix_from_shear_entries - matrix_from_perspective_entries - - Examples - -------- - >>> identity_matrix(4) - [[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)] - - -def matrix_from_frame(frame): - """Computes a change of basis transformation from world XY to the frame. - - Parameters - ---------- - frame : :class:`compas.geometry.Frame` - A frame describing the targeted Cartesian coordinate system - - Returns - ------- - list[list[float]] - A 4x4 transformation matrix representing the transformation from - world coordinates to frame coordinates. - - Examples - -------- - >>> from compas.geometry import Frame - >>> f = Frame([1, 1, 1], [0.68, 0.68, 0.27], [-0.67, 0.73, -0.15]) - >>> T = matrix_from_frame(f) - - """ - M = identity_matrix(4) - M[0][0], M[1][0], M[2][0] = frame.xaxis - M[0][1], M[1][1], M[2][1] = frame.yaxis - M[0][2], M[1][2], M[2][2] = frame.zaxis - M[0][3], M[1][3], M[2][3] = frame.point - return M - - -def matrix_from_frame_to_frame(frame_from, frame_to): - """Computes a transformation between two frames. - - This transformation allows to transform geometry from one Cartesian - coordinate system defined by `frame_from` to another Cartesian - coordinate system defined by `frame_to`. - - Parameters - ---------- - frame_from : :class:`compas.geometry.Frame` - A frame defining the original Cartesian coordinate system - frame_to : :class:`compas.geometry.Frame` - A frame defining the targeted Cartesian coordinate system - - Returns - ------- - list[list[float]] - A 4x4 transformation matrix representing the transformation - from one frame to another. - - Examples - -------- - >>> from compas.geometry import Frame - >>> f1 = Frame([2, 2, 2], [0.12, 0.58, 0.81], [-0.80, 0.53, -0.26]) - >>> f2 = Frame([1, 1, 1], [0.68, 0.68, 0.27], [-0.67, 0.73, -0.15]) - >>> T = matrix_from_frame_to_frame(f1, f2) - """ - T1 = matrix_from_frame(frame_from) - T2 = matrix_from_frame(frame_to) - return multiply_matrices(T2, matrix_inverse(T1)) - - -def matrix_from_change_of_basis(frame_from, frame_to): - """Computes a change of basis transformation between two frames. - - A basis change is essentially a remapping of geometry from one - coordinate system to another. - - Parameters - ---------- - frame_from : :class:`compas.geometry.Frame` - A frame defining the original Cartesian coordinate system - frame_to : :class:`compas.geometry.Frame` - A frame defining the targeted Cartesian coordinate system - - Returns - ------- - list[list[float]] - A 4x4 transformation matrix representing a change of basis. - - Examples - -------- - >>> from compas.geometry import Point, Frame - >>> f1 = Frame([2, 2, 2], [0.12, 0.58, 0.81], [-0.80, 0.53, -0.26]) - >>> f2 = Frame([1, 1, 1], [0.68, 0.68, 0.27], [-0.67, 0.73, -0.15]) - >>> T = matrix_from_change_of_basis(f1, f2) - - """ - T1 = matrix_from_frame(frame_from) - T2 = matrix_from_frame(frame_to) - return multiply_matrices(matrix_inverse(T2), T1) - - -def matrix_from_euler_angles(euler_angles, static=True, axes="xyz"): - """Calculates a rotation matrix from Euler angles. - - In 3D space any orientation can be achieved by composing three elemental - rotations, rotations about the axes (x, y, z) of a coordinate system. A - triple of Euler angles can be interpreted in 24 ways, which depends on if - the rotations are applied to a static (extrinsic) or rotating (intrinsic) - frame and the order of axes. - - Parameters - ---------- - euler_angles : [float, float, float] - Three numbers that represent the angles of rotations about the defined axes. - static : bool, optional - If True the rotations are applied to a static frame. - If False, to a rotational. - axes : Literal['xyz', 'yzx', 'zxy'], optional - A 3 character string specifying order of the axes. - - Returns - ------- - list[list[float]] - A 4x4 transformation matrix representing a rotation. - - Examples - -------- - >>> ea1 = 1.4, 0.5, 2.3 - >>> R = matrix_from_euler_angles(ea1) - >>> ea2 = euler_angles_from_matrix(R) - >>> allclose(ea1, ea2) - True - - """ - global _SPEC2TUPLE - global _NEXT_SPEC - - ai, aj, ak = euler_angles - - if static: - firstaxis, parity, repetition, frame = _SPEC2TUPLE["s" + axes] - else: - firstaxis, parity, repetition, frame = _SPEC2TUPLE["r" + axes] - - i = firstaxis - j = _NEXT_SPEC[i + parity] - k = _NEXT_SPEC[i - parity + 1] - - 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) - cc, cs = ci * ck, ci * sk - sc, ss = si * ck, si * sk - - M = [[1.0 if x == y else 0.0 for x in range(4)] for y in range(4)] - - if repetition: - M[i][i] = cj - M[i][j] = sj * si - M[i][k] = sj * ci - M[j][i] = sj * sk - M[j][j] = -cj * ss + cc - M[j][k] = -cj * cs - sc - M[k][i] = -sj * ck - M[k][j] = cj * sc + cs - M[k][k] = cj * cc - ss - else: - M[i][i] = cj * ck - M[i][j] = sj * sc - cs - M[i][k] = sj * cc + ss - M[j][i] = cj * sk - M[j][j] = sj * ss + cc - M[j][k] = sj * cs - sc - M[k][i] = -sj - M[k][j] = cj * si - M[k][k] = cj * ci - - return M - - -def euler_angles_from_matrix(M, static=True, axes="xyz"): - """Returns Euler angles from the rotation matrix M according to specified - axis sequence and type of rotation. - - Parameters - ---------- - M : list[list[float]] - The 3x3 or 4x4 matrix in row-major order. - static : bool, optional - If True the rotations are applied to a static frame. - If False, to a rotational. - axes : str, optional - A 3 character string specifying order of the axes. - - Returns - ------- - list[float] - The 3 Euler angles. - - Examples - -------- - >>> ea1 = 1.4, 0.5, 2.3 - >>> R = matrix_from_euler_angles(ea1) - >>> ea2 = euler_angles_from_matrix(R) - >>> allclose(ea1, ea2) - True - - """ - global _SPEC2TUPLE - global _NEXT_SPEC - - if static: - firstaxis, parity, repetition, frame = _SPEC2TUPLE["s" + axes] - else: - firstaxis, parity, repetition, frame = _SPEC2TUPLE["r" + axes] - - i = firstaxis - j = _NEXT_SPEC[i + parity] - k = _NEXT_SPEC[i - parity + 1] - - if repetition: - sy = sqrt(M[i][j] * M[i][j] + M[i][k] * M[i][k]) - if TOL.is_positive(sy): - ax = atan2(M[i][j], M[i][k]) - ay = atan2(sy, M[i][i]) - az = atan2(M[j][i], -M[k][i]) - else: - ax = atan2(-M[j][k], M[j][j]) - ay = atan2(sy, M[i][i]) - az = 0.0 - else: - cy = sqrt(M[i][i] * M[i][i] + M[j][i] * M[j][i]) - if TOL.is_positive(cy): - ax = atan2(M[k][j], M[k][k]) - ay = atan2(-M[k][i], cy) - az = atan2(M[j][i], M[i][i]) - else: - ax = atan2(-M[j][k], M[j][j]) - ay = atan2(-M[k][i], cy) - az = 0.0 - - if parity: - ax, ay, az = -ax, -ay, -az - if frame: - ax, az = az, ax - - return [ax, ay, az] - - -def matrix_from_axis_and_angle(axis, angle, point=None): - """Calculates a rotation matrix from an rotation axis, an angle and an optional - point of rotation. - - Parameters - ---------- - axis : [float, float, float] - Three numbers that represent the axis of rotation. - angle : float - The rotation angle in radians. - point : [float, float, float] | :class:`compas.geometry.Point`, optional - A point to perform a rotation around an origin other than [0, 0, 0]. - - Returns - ------- - list[list[float]] - A 4x4 transformation matrix representing a rotation. - - Notes - ----- - The rotation is based on the right hand rule, i.e. anti-clockwise if the - axis of rotation points towards the observer. - - Examples - -------- - >>> axis1 = normalize_vector([-0.043, -0.254, 0.617]) - >>> angle1 = 0.1 - >>> R = matrix_from_axis_and_angle(axis1, angle1) - >>> axis2, angle2 = axis_and_angle_from_matrix(R) - >>> allclose(axis1, axis2) - True - >>> allclose([angle1], [angle2]) - True - - """ - if not point: - point = [0.0, 0.0, 0.0] - - axis = list(axis) - if length_vector(axis): - axis = normalize_vector(axis) - - sina = sin(angle) - cosa = cos(angle) - - R = [[cosa, 0.0, 0.0], [0.0, cosa, 0.0], [0.0, 0.0, cosa]] - - outer_product = [[axis[i] * axis[j] * (1.0 - cosa) for i in range(3)] for j in range(3)] - R = [[R[i][j] + outer_product[i][j] for i in range(3)] for j in range(3)] - - axis = scale_vector(axis, sina) - m = [[0.0, -axis[2], axis[1]], [axis[2], 0.0, -axis[0]], [-axis[1], axis[0], 0.0]] - - M = identity_matrix(4) - - for i in range(3): - for j in range(3): - R[i][j] += m[i][j] - M[i][j] = R[i][j] - - # rotation about axis, angle AND point includes also translation - t = subtract_vectors(point, multiply_matrix_vector(R, point)) - M[0][3] = t[0] - M[1][3] = t[1] - M[2][3] = t[2] - - return M - - -def matrix_from_axis_angle_vector(axis_angle_vector, point=[0, 0, 0]): - """Calculates a rotation matrix from an axis-angle vector. - - Parameters - ---------- - axis_angle_vector : [float, float, float] - Three numbers that represent the axis of rotation and angle of rotation - through the vector's magnitude. - point : [float, float, float] | :class:`compas.geometry.Point`, optional - A point to perform a rotation around an origin other than [0, 0, 0]. - - Returns - ------- - list[list[float]] - The 4x4 transformation matrix representing a rotation. - - Examples - -------- - >>> aav1 = [-0.043, -0.254, 0.617] - >>> R = matrix_from_axis_angle_vector(aav1) - >>> aav2 = axis_angle_vector_from_matrix(R) - >>> allclose(aav1, aav2) - True - - """ - axis = list(axis_angle_vector) - angle = length_vector(axis_angle_vector) - return matrix_from_axis_and_angle(axis, angle, point) - - -def axis_and_angle_from_matrix(M): - """Returns the axis and the angle of the rotation matrix M. - - Parameters - ---------- - M : list[list[float]] - The 4-by-4 transformation matrix. - - Returns - ------- - [float, float, float] - The rotation axis. - float - The rotation angle in radians. - - """ - eps = 0.01 # margin to allow for rounding errors - eps2 = 0.1 # margin to distinguish between 0 and 180 degrees - - if all(fabs(M[i][j] - M[j][i]) < eps for i, j in [(0, 1), (0, 2), (1, 2)]): - if all(fabs(M[i][j] - M[j][i]) < eps2 for i, j in [(0, 1), (0, 2), (1, 2)]) and fabs(M[0][0] + M[1][1] + M[2][2] - 3) < eps2: - return [0, 0, 0], 0 - - angle = pi - xx = (M[0][0] + 1) / 2 - yy = (M[1][1] + 1) / 2 - zz = (M[2][2] + 1) / 2 - xy = (M[0][1] + M[1][0]) / 4 - xz = (M[0][2] + M[2][0]) / 4 - yz = (M[1][2] + M[2][1]) / 4 - root_half = sqrt(0.5) - if (xx > yy) and (xx > zz): - if xx < eps: - axis = [0, root_half, root_half] - else: - x = sqrt(xx) - axis = [x, xy / x, xz / x] - elif yy > zz: - if yy < eps: - axis = [root_half, 0, root_half] - else: - y = sqrt(yy) - axis = [xy / y, y, yz / y] - else: - if zz < eps: - axis = [root_half, root_half, 0] - else: - z = sqrt(zz) - axis = [xz / z, yz / z, z] - - return axis, angle - - s = sqrt((M[2][1] - M[1][2]) * (M[2][1] - M[1][2]) + (M[0][2] - M[2][0]) * (M[0][2] - M[2][0]) + (M[1][0] - M[0][1]) * (M[1][0] - M[0][1])) - - # should this also be an eps? - if fabs(s) < 0.001: - s = 1 - - angle = acos((M[0][0] + M[1][1] + M[2][2] - 1) / 2) - - x = (M[2][1] - M[1][2]) / s - y = (M[0][2] - M[2][0]) / s - z = (M[1][0] - M[0][1]) / s - - return [x, y, z], angle - - -def axis_angle_vector_from_matrix(M): - """Returns the axis-angle vector of the rotation matrix M. - - Parameters - ---------- - M : list[list[float]] - The 4-by-4 transformation matrix. - - Returns - ------- - [float, float, float] - The axis-angle vector. - - """ - axis, angle = axis_and_angle_from_matrix(M) - return scale_vector(axis, angle) - - -def matrix_from_quaternion(quaternion): - """Calculates a rotation matrix from quaternion coefficients. - - Parameters - ---------- - quaternion : [float, float, float, float] - Four numbers that represents the four coefficient values of a quaternion. - - Returns - ------- - list[list[float]] - The 4x4 transformation matrix representing a rotation. - - Raises - ------ - ValueError - If quaternion is invalid. - - Examples - -------- - >>> q1 = [0.945, -0.021, -0.125, 0.303] - >>> R = matrix_from_quaternion(q1) - >>> q2 = quaternion_from_matrix(R) - >>> allclose(q1, q2, tol=1e-03) - True - - """ - q = quaternion - n = q[0] ** 2 + q[1] ** 2 + q[2] ** 2 + q[3] ** 2 # dot product - - # perhaps this should not be hard-coded? - eps = 1.0e-15 - - if n < eps: - raise ValueError("Invalid quaternion, dot product must be != 0.") - - q = [v * sqrt(2.0 / n) for v in q] - q = [[q[i] * q[j] for i in range(4)] for j in range(4)] # outer_product - - rotation = [ - [1.0 - q[2][2] - q[3][3], q[1][2] - q[3][0], q[1][3] + q[2][0], 0.0], - [q[1][2] + q[3][0], 1.0 - q[1][1] - q[3][3], q[2][3] - q[1][0], 0.0], - [q[1][3] - q[2][0], q[2][3] + q[1][0], 1.0 - q[1][1] - q[2][2], 0.0], - [0.0, 0.0, 0.0, 1.0], - ] - return rotation - - -def quaternion_from_matrix(M): - """Returns the 4 quaternion coefficients from a rotation matrix. - - Parameters - ---------- - M : list[list[float]] - The coefficients of the rotation matrix, row per row. - - Returns - ------- - [float, float, float, float] - The quaternion coefficients. - - Examples - -------- - >>> q1 = [0.945, -0.021, -0.125, 0.303] - >>> R = matrix_from_quaternion(q1) - >>> q2 = quaternion_from_matrix(R) - >>> allclose(q1, q2, tol=1e-03) - True - - """ - qw, qx, qy, qz = 0, 0, 0, 0 - trace = M[0][0] + M[1][1] + M[2][2] - - if trace > 0.0: - s = 0.5 / sqrt(trace + 1.0) - qw = 0.25 / s - qx = (M[2][1] - M[1][2]) * s - qy = (M[0][2] - M[2][0]) * s - qz = (M[1][0] - M[0][1]) * s - - elif (M[0][0] > M[1][1]) and (M[0][0] > M[2][2]): - s = 2.0 * sqrt(1.0 + M[0][0] - M[1][1] - M[2][2]) - qw = (M[2][1] - M[1][2]) / s - qx = 0.25 * s - qy = (M[0][1] + M[1][0]) / s - qz = (M[0][2] + M[2][0]) / s - - elif M[1][1] > M[2][2]: - s = 2.0 * sqrt(1.0 + M[1][1] - M[0][0] - M[2][2]) - qw = (M[0][2] - M[2][0]) / s - qx = (M[0][1] + M[1][0]) / s - qy = 0.25 * s - qz = (M[1][2] + M[2][1]) / s - else: - s = 2.0 * sqrt(1.0 + M[2][2] - M[0][0] - M[1][1]) - qw = (M[1][0] - M[0][1]) / s - qx = (M[0][2] + M[2][0]) / s - qy = (M[1][2] + M[2][1]) / s - qz = 0.25 * s - - return [qw, qx, qy, qz] - - -def matrix_from_basis_vectors(xaxis, yaxis): - """Creates a rotation matrix from basis vectors (= orthonormal vectors). - - Parameters - ---------- - xaxis : [float, float, float] | :class:`compas.geometry.Vector` - The x-axis of the frame. - yaxis : [float, float, float] | :class:`compas.geometry.Vector` - The y-axis of the frame. - - Returns - ------- - list[list[float]] - A 4x4 transformation matrix representing a rotation. - - Notes - ----- - .. code-block:: none - - [ x0 y0 z0 0 ] - [ x1 y1 z1 0 ] - [ x2 y2 z2 0 ] - [ 0 0 0 1 ] - - Examples - -------- - >>> xaxis = [0.68, 0.68, 0.27] - >>> yaxis = [-0.67, 0.73, -0.15] - >>> R = matrix_from_basis_vectors(xaxis, yaxis) - - """ - xaxis = normalize_vector(list(xaxis)) - yaxis = normalize_vector(list(yaxis)) - zaxis = cross_vectors(xaxis, yaxis) - yaxis = cross_vectors(zaxis, xaxis) - - R = identity_matrix(4) - R[0][0], R[1][0], R[2][0] = xaxis - R[0][1], R[1][1], R[2][1] = yaxis - R[0][2], R[1][2], R[2][2] = zaxis - return R - - -def basis_vectors_from_matrix(R): - """Returns the basis vectors from the rotation matrix R. - - Parameters - ---------- - R : list[list[float]] - A 4-by-4 transformation matrix, or a 3-by-3 rotation matrix. - - Returns - ------- - [float, float, float] - The first basis vector of the rotation. - [float, float, float] - The second basis vector of the rotation. - - Raises - ------ - ValueError - If rotation matrix is invalid. - - Examples - -------- - >>> from compas.geometry import Frame - >>> f = Frame([0, 0, 0], [0.68, 0.68, 0.27], [-0.67, 0.73, -0.15]) - >>> R = matrix_from_frame(f) - >>> xaxis, yaxis = basis_vectors_from_matrix(R) - - """ - xaxis = [R[0][0], R[1][0], R[2][0]] - yaxis = [R[0][1], R[1][1], R[2][1]] - zaxis = [R[0][2], R[1][2], R[2][2]] - - if not allclose(zaxis, cross_vectors(xaxis, yaxis)): - raise ValueError("Matrix is invalid rotation matrix.") - - return xaxis, yaxis - - -def matrix_from_translation(translation): - """Returns a 4x4 translation matrix in row-major order. - - Parameters - ---------- - translation : [float, float, float] - The x, y and z components of the translation. - - Returns - ------- - list[list[float]] - The 4x4 transformation matrix representing a translation. - - Notes - ----- - .. code-block:: none - - [ . . . 0 ] - [ . . . 1 ] - [ . . . 2 ] - [ . . . . ] - - Examples - -------- - >>> T = matrix_from_translation([1, 2, 3]) - - """ - M = identity_matrix(4) - M[0][3] = float(translation[0]) - M[1][3] = float(translation[1]) - M[2][3] = float(translation[2]) - - return M - - -def translation_from_matrix(M): - """Returns the 3 values of translation from the matrix M. - - Parameters - ---------- - M : list[list[float]] - A 4-by-4 transformation matrix. - - Returns - ------- - [float, float, float] - The translation vector. - - """ - return [M[0][3], M[1][3], M[2][3]] - - -def matrix_from_orthogonal_projection(plane): - """Returns an orthogonal projection matrix to project onto a plane. - - Parameters - ---------- - plane : [point, normal] - The plane to project onto. - - Returns - ------- - list[list[float]] - The 4x4 transformation matrix representing an orthogonal projection. - - Examples - -------- - >>> point = [0, 0, 0] - >>> normal = [0, 0, 1] - >>> plane = (point, normal) - >>> P = matrix_from_orthogonal_projection(plane) - - """ - point, normal = plane - T = identity_matrix(4) - normal = normalize_vector(normal) - - for j in range(3): - for i in range(3): - T[i][j] -= normal[i] * normal[j] # outer_product - - T[0][3], T[1][3], T[2][3] = scale_vector(normal, dot_vectors(point, normal)) - return T - - -def matrix_from_parallel_projection(plane, direction): - """Returns an parallel projection matrix to project onto a plane. - - Parameters - ---------- - plane : [point, normal] - The plane to project onto. - direction : [float, float, float] | :class:`compas.geometry.Vector` - Direction of the projection. - - Returns - ------- - list[list[float]] - A 4-by-4 transformation matrix. - - Examples - -------- - >>> point = [0, 0, 0] - >>> normal = [0, 0, 1] - >>> plane = (point, normal) - >>> direction = [1, 1, 1] - >>> P = matrix_from_parallel_projection(plane, direction) - - """ - point, normal = plane - T = identity_matrix(4) - normal = normalize_vector(normal) - - scale = dot_vectors(direction, normal) - for j in range(3): - for i in range(3): - T[i][j] -= direction[i] * normal[j] / scale - - T[0][3], T[1][3], T[2][3] = scale_vector(direction, dot_vectors(point, normal) / scale) - return T - - -def matrix_from_perspective_projection(plane, center_of_projection): - """Returns a perspective projection matrix to project onto a plane along lines that emanate from a single point, called the center of projection. - - Parameters - ---------- - plane : [point, normal] - The plane to project onto. - center_of_projection : [float, float, float] | :class:`compas.geometry.Point` - The camera view point. - - Returns - ------- - list[list[float]] - A 4-by-4 transformation matrix. - - Examples - -------- - >>> point = [0, 0, 0] - >>> normal = [0, 0, 1] - >>> plane = (point, normal) - >>> center_of_projection = [1, 1, 0] - >>> P = matrix_from_perspective_projection(plane, center_of_projection) - - """ - point, normal = plane - T = identity_matrix(4) - normal = normalize_vector(normal) - - T[0][0] = T[1][1] = T[2][2] = dot_vectors(subtract_vectors(center_of_projection, point), normal) - - for j in range(3): - for i in range(3): - T[i][j] -= center_of_projection[i] * normal[j] - - T[0][3], T[1][3], T[2][3] = scale_vector(center_of_projection, dot_vectors(point, normal)) - - for i in range(3): - T[3][i] -= normal[i] - - T[3][3] = dot_vectors(center_of_projection, normal) - - return T - - -def matrix_from_perspective_entries(perspective): - """Returns a matrix from perspective entries. - - Parameters - ---------- - values : [float, float, float, float] - The 4 perspective entries of a matrix. - - Returns - ------- - list[list[float]] - A 4-by-4 transformation matrix. - - Notes - ----- - .. code-block:: none - - [ . . . . ] - [ . . . . ] - [ . . . . ] - [ 0 1 2 3 ] - - """ - M = identity_matrix(4) - M[3][0] = float(perspective[0]) - M[3][1] = float(perspective[1]) - M[3][2] = float(perspective[2]) - M[3][3] = float(perspective[3]) - return M - - -def matrix_from_shear_entries(shear_entries): - """Returns a shear matrix from the 3 factors for x-y, x-z, and y-z axes. - - Parameters - ---------- - shear_entries : [float, float, float] - The 3 shear factors for x-y, x-z, and y-z axes. - - Returns - ------- - list[list[float]] - A 4-by-4 transformation matrix. - - Notes - ----- - .. code-block:: none - - [ . 0 1 . ] - [ . . 2 . ] - [ . . . . ] - [ . . . . ] - - Examples - -------- - >>> Sh = matrix_from_shear_entries([1, 2, 3]) - - """ - M = identity_matrix(4) - M[0][1] = float(shear_entries[0]) - M[0][2] = float(shear_entries[1]) - M[1][2] = float(shear_entries[2]) - return M - - -def matrix_from_shear(angle, direction, point, normal): - """Constructs a shear matrix by an angle along the direction vector on the - shear plane (defined by point and normal). - - Parameters - ---------- - angle : float - The angle in radians. - direction : [float, float, float] | :class:`compas.geometry.Vector` - The direction vector as list of 3 numbers. - It must be orthogonal to the normal vector. - point : [float, float, float] | :class:`compas.geometry.Point` - The point of the shear plane as list of 3 numbers. - normal : [float, float, float] | :class:`compas.geometry.Vector` - The normal of the shear plane as list of 3 numbers. - - Returns - ------- - list[list[float]] - A 4-by-4 transformation matrix. - - Raises - ------ - ValueError - If direction and normal are not orthogonal. - - Notes - ----- - A point P is transformed by the shear matrix into P" such that - the vector P-P" is parallel to the direction vector and its extent is - given by the angle of P-P'-P", where P' is the orthogonal projection - of P onto the shear plane (defined by point and normal). - - Examples - -------- - >>> angle = 0.1 - >>> direction = [0.1, 0.2, 0.3] - >>> point = [4, 3, 1] - >>> normal = cross_vectors(direction, [1, 0.3, -0.1]) - >>> S = matrix_from_shear(angle, direction, point, normal) - - """ - normal = normalize_vector(normal) - direction = normalize_vector(direction) - - if not TOL.is_zero(dot_vectors(normal, direction)): - raise ValueError("Direction and normal vectors are not orthogonal") - - angle = tan(angle) - M = identity_matrix(4) - - for j in range(3): - for i in range(3): - M[i][j] += angle * direction[i] * normal[j] - - M[0][3], M[1][3], M[2][3] = scale_vector(direction, -angle * dot_vectors(point, normal)) - - return M - - -def matrix_from_scale_factors(scale_factors): - """Returns a 4x4 scaling transformation. - - Parameters - ---------- - scale_factors : [float, float, float] - Three numbers defining the scaling factors in x, y, and z respectively. - - Returns - ------- - list[list[float]] - A 4-by-4 transformation matrix. - - Notes - ----- - .. code-block:: python - - [ 0 . . . ] - [ . 1 . . ] - [ . . 2 . ] - [ . . . . ] - - Examples - -------- - >>> Sc = matrix_from_scale_factors([1, 2, 3]) - - """ - M = identity_matrix(4) - M[0][0] = float(scale_factors[0]) - M[1][1] = float(scale_factors[1]) - M[2][2] = float(scale_factors[2]) - - return M - - -def quaternion_from_euler_angles(e, static=True, axes="xyz"): - """Returns a quaternion from Euler angles. - - Parameters - ---------- - euler_angles : [float, float, float] - Three numbers that represent the angles of rotations about the specified axes. - static : bool, optional - If True, the rotations are applied to a static frame. - If False, the rotations are applied to a rotational frame. - axes : str, optional - A three-character string specifying the order of the axes. - - Returns - ------- - [float, float, float, float] - Quaternion as a list of four real values ``[w, x, y, z]``. - - """ - m = matrix_from_euler_angles(e, static, axes) - q = quaternion_from_matrix(m) - return q - - -def euler_angles_from_quaternion(q, static=True, axes="xyz"): - """Returns Euler angles from a quaternion. - - Parameters - ---------- - quaternion : [float, float, float, float] - Quaternion as a list of four real values ``[w, x, y, z]``. - static : bool, optional - If True, the rotations are applied to a static frame. - If False, the rotations are applied to a rotational frame. - axes : str, optional - A three-character string specifying the order of the axes. - - Returns - ------- - [float, float, float] - Euler angles as a list of three real values ``[a, b, c]``. - - """ - m = matrix_from_quaternion(q) - e = euler_angles_from_matrix(m, static, axes) - return e - - -def quaternion_from_axis_angle(axis, angle): - """Returns a quaternion describing a rotation around the given axis by the given angle. - - Parameters - ---------- - axis : [float, float, float] | :class:`compas.geometry.Vector` - XYZ coordinates of the rotation axis vector. - angle : float - Angle of rotation in radians. - - Returns - ------- - [float, float, float, float] - Quaternion as a list of four real values ``[qw, qx, qy, qz]``. - - Examples - -------- - >>> axis = [1.0, 0.0, 0.0] - >>> angle = math.pi / 2 - >>> q = quaternion_from_axis_angle(axis, angle) - >>> allclose(q, [math.sqrt(2) / 2, math.sqrt(2) / 2, 0, 0]) - True - - """ - m = matrix_from_axis_and_angle(axis, angle, None) - q = quaternion_from_matrix(m) - return q - - -def axis_angle_from_quaternion(q): - """Returns an axis and an angle of rotation from the given quaternion. - - Parameters - ---------- - q : [float, float, float, float] - Quaternion as a list of four real values ``[qw, qx, qy, qz]``. - - Returns - ------- - axis : [float, float, float] - XYZ coordinates of the rotation axis vector. - angle : float - Angle of rotation in radians. - - Examples - -------- - >>> q = [1.0, 1.0, 0.0, 0.0] - >>> axis, angle = axis_angle_from_quaternion(q) - >>> allclose(axis, [1.0, 0.0, 0.0]) - True - >>> allclose([angle], [math.pi / 2], 1e-6) - True - - """ - m = matrix_from_quaternion(q) - axis, angle = axis_and_angle_from_matrix(m) - return axis, angle - - -# ============================================================================= -# ============================================================================= -# ============================================================================= -# ============================================================================= -# ============================================================================= -# ============================================================================= -# ============================================================================= -# Deprecated -# ============================================================================= -# ============================================================================= -# ============================================================================= -# ============================================================================= -# ============================================================================= -# ============================================================================= -# ============================================================================= - - -def close(value1, value2, tol=1e-05): - """Returns True if two values are equal within a tolerance. - - Parameters - ---------- - value1 : float or int - value2 : float or int - tol : float, optional - The absolute tolerance for comparing values. - Default is :attr:`TOL.absolute`. - - Returns - ------- - bool - True if the values are closer than the tolerance. - False otherwise. - - Warnings - -------- - .. deprecated:: 2.0 - Will be removed in 2.1 - Use :func:`TOL.is_close` instead. - - The tolerance value used by this function is an absolute tolerance. - It is more accurate to use a combination of absolute and relative tolerance. - Therefor, use :func:`TOL.is_close` instead. - - """ - return TOL.is_close(value1, value2, rtol=0.0, atol=tol) - - -def allclose(l1, l2, tol=None): - """Returns True if two lists are element-wise equal within a tolerance. - - Parameters - ---------- - l1 : sequence[float] - The first list of values. - l2 : sequence[float] - The second list of values. - tol : float, optional - The absolute tolerance for comparing values. - Default is :attr:`TOL.absolute`. - - Returns - ------- - bool - True if all corresponding values of the two lists are closer than the tolerance. - False otherwise. - - Warnings - -------- - .. deprecated:: 2.0 - Will be removed in 2.1 - Use :func:`TOL.is_close` instead. - - The tolerance value used by this function is an absolute tolerance. - It is more accurate to use a combination of absolute and relative tolerance. - Therefor, use :func:`TOL.is_allclose` instead. - - Notes - ----- - The function is similar to NumPy's *allclose* function [1]_. - - References - ---------- - .. [1] https://docs.scipy.org/doc/numpy/reference/generated/numpy.allclose.html - - """ - return TOL.is_allclose(l1, l2, atol=tol) diff --git a/src/compas/geometry/_core/angles.py b/src/compas/geometry/_core/angles.py index c10835d08c9f..4a619219ed40 100644 --- a/src/compas/geometry/_core/angles.py +++ b/src/compas/geometry/_core/angles.py @@ -1,41 +1,39 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import acos from math import degrees from math import pi - +from typing import Optional +from typing import Sequence + +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 +from compas.linalg.vectors import length_vector +from compas.linalg.vectors import length_vector_xy +from compas.linalg.vectors import subtract_vectors +from compas.linalg.vectors import subtract_vectors_xy from compas.tolerance import TOL -from ._algebra import cross_vectors -from ._algebra import dot_vectors -from ._algebra import dot_vectors_xy -from ._algebra import length_vector -from ._algebra import length_vector_xy -from ._algebra import subtract_vectors -from ._algebra import subtract_vectors_xy - -def angle_vectors(u, v, deg=False, tol=None): +def angle_vectors(u: CoordinateType, v: CoordinateType, deg: bool = False, tol: Optional[float] = None) -> float: """Compute the smallest angle between two vectors. Parameters ---------- - u : [float, float, float] | :class:`compas.geometry.Vector` + u XYZ components of the first vector. - v : [float, float, float] | :class:`compas.geometry.Vector` + v XYZ components of the second vector. - deg : bool, optional + deg If True, returns the angle in degrees. - tol : float, optional + tol The tolerance for comparing values to zero. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- float - The smallest angle in radians (in degrees if ``deg == True``). + The smallest angle in radians (in degrees if `deg == True`). The angle is always positive. Examples @@ -75,24 +73,30 @@ def angle_vectors(u, v, deg=False, tol=None): return angle -def angle_vectors_signed(u, v, normal, deg=False, tol=None): +def angle_vectors_signed( + u: CoordinateType, + v: CoordinateType, + normal: CoordinateType, + deg: bool = False, + tol: Optional[float] = None, +) -> float: """Computes the signed angle between two vectors. Returns the smallest angle between 2 vectors, with the sign of the angle based on the direction of the normal vector according to the right hand rule of rotation. Parameters ---------- - u : [float, float, float] | :class:`compas.geometry.Vector` + u XYZ components of the first vector. - v : [float, float, float] | :class:`compas.geometry.Vector` + v XYZ components of the second vector. - normal : [float, float, float] | :class:`compas.geometry.Vector` + normal XYZ components of the plane's normal spanned by u and v. - deg : bool, optional + deg If True, returns the angle in degrees. - tol : float, optional + tol The tolerance for comparing values to zero. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns @@ -120,7 +124,13 @@ def angle_vectors_signed(u, v, normal, deg=False, tol=None): return angle -def angle_vectors_projected(u, v, normal, deg=False, tol=None): +def angle_vectors_projected( + u: Sequence[float], + v: Sequence[float], + normal: Sequence[float], + deg: bool = False, + tol: Optional[float] = None, +) -> float: """Computes the signed angle between two vectors. Retuns the angle between 2 vectors projected onto a plane defined by a normal vector. @@ -128,17 +138,17 @@ def angle_vectors_projected(u, v, normal, deg=False, tol=None): Parameters ---------- - u : [float, float, float] | :class:`compas.geometry.Vector` + u XYZ components of the first vector. - v : [float, float, float] | :class:`compas.geometry.Vector` + v XYZ components of the second vector. - normal : [float, float, float] | :class:`compas.geometry.Vector` + normal XYZ components of the plane's normal spanned by u and v. - deg : bool, optional + deg If True, returns the angle in degrees. - tol : float, optional + tol The tolerance for comparing values to zero. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- @@ -158,31 +168,27 @@ def angle_vectors_projected(u, v, normal, deg=False, tol=None): return angle_vectors_signed(u_cross, v_cross, normal, deg, tol) -def angle_vectors_xy(u, v, deg=False, tol=None): +def angle_vectors_xy(u: Sequence[float], v: Sequence[float], deg: bool = False, tol: Optional[float] = None) -> float: """Compute the smallest angle between the XY components of two vectors. Parameters ---------- - u : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` + u The first 2D or 3D vector (Z will be ignored). - v : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` + v The second 2D or 3D vector (Z will be ignored). - deg : bool, optional + deg If True, returns the angle in degrees. - tol : float, optional + tol The tolerance for comparing values to zero. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- float - The smallest angle in radians (in degrees if ``deg == True``). + The smallest angle in radians (in degrees if `deg == True`). The angle is always positive. - Examples - -------- - >>> - """ L = length_vector_xy(u) * length_vector_xy(v) if TOL.is_zero(L, tol): @@ -194,34 +200,34 @@ def angle_vectors_xy(u, v, deg=False, tol=None): return acos(a) -def angle_points(a, b, c, deg=False): +def angle_points(a: Sequence[float], b: Sequence[float], c: Sequence[float], deg: bool = False) -> float: r"""Compute the smallest angle between the vectors defined by three points. Parameters ---------- - a : [float, float, float] | :class:`compas.geometry.Point` + a XYZ coordinates. - b : [float, float, float] | :class:`compas.geometry.Point` + b XYZ coordinates. - c : [float, float, float] | :class:`compas.geometry.Point` + c XYZ coordinates. - deg : bool, optional + deg If True, returns the angle in degrees. Returns ------- float - The smallest angle in radians (in degrees if ``deg == True``). + The smallest angle in radians (in degrees if `deg == True`). The angle is always positive. Notes ----- The vectors are defined in the following way - .. math:: - + $$ \mathbf{u} = \mathbf{b} - \mathbf{a} \\ \mathbf{v} = \mathbf{c} - \mathbf{a} + $$ Z components may be provided, but are simply ignored. @@ -231,34 +237,34 @@ def angle_points(a, b, c, deg=False): return angle_vectors(u, v, deg) -def angle_points_xy(a, b, c, deg=False): +def angle_points_xy(a: Sequence[float], b: Sequence[float], c: Sequence[float], deg: bool = False) -> float: r"""Compute the smallest angle between the vectors defined by the XY components of three points. Parameters ---------- - a : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + a XY(Z) coordinates of a 2D or 3D point (Z will be ignored). - b : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + b XY(Z) coordinates of a 2D or 3D point (Z will be ignored). - c : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + c XY(Z) coordinates of a 2D or 3D point (Z will be ignored). - deg : bool, optional + deg If True, returns the angle in degrees. Returns ------- float - The smallest angle in radians (in degrees if ``deg == True``). + The smallest angle in radians (in degrees if `deg == True`). The angle is always positive. Notes ----- The vectors are defined in the following way - .. math:: - + $$ \mathbf{u} = \mathbf{b} - \mathbf{a} \\ \mathbf{v} = \mathbf{c} - \mathbf{a} + $$ Z components may be provided, but are simply ignored. @@ -268,28 +274,22 @@ def angle_points_xy(a, b, c, deg=False): return angle_vectors_xy(u, v, deg) -def angles_vectors(u, v, deg=False): +def angles_vectors(u: CoordinateType, v: CoordinateType, deg: bool = False) -> tuple[float, float]: """Compute the the 2 angles formed by a pair of vectors. Parameters ---------- - u : [float, float, float] | :class:`compas.geometry.Vector` + u XYZ components of the first vector. - v : [float, float, float] | :class:`compas.geometry.Vector` + v XYZ components of the second vector. - deg : bool, optional + deg If True, returns the angle in degrees. Returns ------- - float - The smallest angle in radians, or in degrees if ``deg == True``. - float - The other angle. - - Examples - -------- - >>> + tuple[float, float] + The smallest angle and the other angle, in radians or degrees if `deg == True`. """ if deg: @@ -299,33 +299,27 @@ def angles_vectors(u, v, deg=False): return a, pi * 2 - a -def angles_vectors_xy(u, v, deg=False): +def angles_vectors_xy(u: Sequence[float], v: Sequence[float], deg: bool = False) -> tuple[float, float]: """Compute the angles between the XY components of two vectors. Parameters ---------- - u : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` + u XY(Z) coordinates of the first vector. - v : [float, float] or [float, float, float] | :class:`compas.geometry.Vector` + v XY(Z) coordinates of the second vector. - deg : bool, optional + deg If True, returns the angle in degrees. Returns ------- - float - The smallest angle in radians, or in degrees if ``deg == True``. - float - The other angle. + tuple[float, float] + The smallest angle and the other angle, in radians or degrees if `deg == True`. Notes ----- Z components may be provided, but are simply ignored. - Examples - -------- - >>> - """ if deg: a = angle_vectors_xy(u, v, deg) @@ -334,39 +328,33 @@ def angles_vectors_xy(u, v, deg=False): return a, pi * 2 - a -def angles_points(a, b, c, deg=False): +def angles_points(a: Sequence[float], b: Sequence[float], c: Sequence[float], deg: bool = False) -> tuple[float, float]: r"""Compute the two angles between two vectors defined by three points. Parameters ---------- - a : [float, float, float] | :class:`compas.geometry.Point` + a XYZ coordinates. - b : [float, float, float] | :class:`compas.geometry.Point` + b XYZ coordinates. - c : [float, float, float] | :class:`compas.geometry.Point` + c XYZ coordinates. - deg : bool, optional + deg If True, returns the angle in degrees. Returns ------- - float - The smallest angle in radians, or in degrees if ``deg == True``. - float - The other angle. + tuple[float, float] + The smallest angle and the other angle, in radians or degrees if `deg == True`. Notes ----- The vectors are defined in the following way - .. math:: - + $$ \mathbf{u} = \mathbf{b} - \mathbf{a} \\ \mathbf{v} = \mathbf{c} - \mathbf{a} - - Examples - -------- - >>> + $$ """ u = subtract_vectors(b, a) @@ -374,66 +362,62 @@ def angles_points(a, b, c, deg=False): return angles_vectors(u, v, deg) -def angles_points_xy(a, b, c, deg=False): +def angles_points_xy(a: Sequence[float], b: Sequence[float], c: Sequence[float], deg: bool = False) -> tuple[float, float]: r"""Compute the two angles between the two vectors defined by the XY components of three points. Parameters ---------- - a : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + a XY(Z) coordinates. - b : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + b XY(Z) coordinates. - c : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + c XY(Z) coordinates. - deg : bool, optional + deg If True, returns the angle in degrees. Returns ------- - float - The smallest angle in radians, or in degrees if ``deg == True``. - float - The other angle. + tuple[float, float] + The smallest angle and the other angle, in radians or degrees if `deg == True`. Notes ----- The vectors are defined in the following way - .. math:: - + $$ \mathbf{u} = \mathbf{b} - \mathbf{a} \\ \mathbf{v} = \mathbf{c} - \mathbf{a} + $$ Z components may be provided, but are simply ignored. - Examples - -------- - >>> - """ u = subtract_vectors_xy(b, a) v = subtract_vectors_xy(c, a) return angles_vectors_xy(u, v, deg) -def angle_planes(a, b, deg=False): +def angle_planes( + a: tuple[Sequence[float], Sequence[float]], + b: tuple[Sequence[float], Sequence[float]], + deg: bool = False, +) -> float: """Compute the smallest angle between the two normal vectors of two planes. Parameters ---------- - a : [point, vector] + a The first plane. - b : [point, vector] + b The second plane. - deg : bool, optional + deg If True, returns the angle in degrees. Returns ------- float - The smallest angle in radians, or in degrees if ``deg == True``. - float - The other angle. + The smallest angle in radians, or in degrees if `deg == True`. Examples -------- @@ -441,5 +425,6 @@ def angle_planes(a, b, deg=False): >>> plane_b = [0.0, 0.0, 0.0], [1.0, 0.0, 0.0] >>> angle_planes(plane_a, plane_b, True) 90.0 + """ return angle_vectors(a[1], b[1], deg) diff --git a/src/compas/geometry/_core/centroids.py b/src/compas/geometry/_core/centroids.py index 41cf250ce203..0e98de6c8aba 100644 --- a/src/compas/geometry/_core/centroids.py +++ b/src/compas/geometry/_core/centroids.py @@ -1,113 +1,113 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import fabs +from typing import Sequence +from compas._typing import CoordinatesType from compas.itertools import pairwise - -from ._algebra import add_vectors -from ._algebra import cross_vectors -from ._algebra import cross_vectors_xy -from ._algebra import dot_vectors -from ._algebra import length_vector -from ._algebra import length_vector_xy -from ._algebra import scale_vector -from ._algebra import subtract_vectors -from ._algebra import subtract_vectors_xy -from ._algebra import sum_vectors - - -def midpoint_point_point(a, b): +from compas.linalg.vectors import add_vectors +from compas.linalg.vectors import cross_vectors +from compas.linalg.vectors import cross_vectors_xy +from compas.linalg.vectors import dot_vectors +from compas.linalg.vectors import length_vector +from compas.linalg.vectors import length_vector_xy +from compas.linalg.vectors import scale_vector +from compas.linalg.vectors import subtract_vectors +from compas.linalg.vectors import subtract_vectors_xy +from compas.linalg.vectors import sum_vectors + + +def midpoint_point_point(a: Sequence[float], b: Sequence[float]) -> list[float]: """Compute the midpoint of two points. Parameters ---------- - a : [float, float, float] | :class:`compas.geometry.Point` + a XYZ coordinates of the first point. - b : [float, float, float] | :class:`compas.geometry.Point` + b XYZ coordinates of the second point. Returns ------- - [float, float, float] + list[float] XYZ coordinates of the midpoint. """ 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, b): +def midpoint_point_point_xy(a: Sequence[float], b: Sequence[float]) -> list[float]: """Compute the midpoint of two points lying in the XY-plane. Parameters ---------- - a : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + a XY(Z) coordinates of the first 2D or 3D point (Z will be ignored). - b : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + b XY(Z) coordinates of the second 2D or 3D point (Z will be ignored). Returns ------- - [float, float, 0.0] + list[float] XYZ coordinates of the midpoint in the XY plane. + """ return [0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1]), 0.0] -def midpoint_line(line): +def midpoint_line(line: Sequence[Sequence[float]]) -> list[float]: """Compute the midpoint of a line defined by two points. Parameters ---------- - line : [point, point] | :class:`compas.geometry.Line` + line XYZ coordinates of the first point, and XYZ coordinates of the second point. Returns ------- - [float, float, float] + list[float] XYZ coordinates of the midpoint. Examples -------- >>> midpoint_line(([0.0, 0.0, 0.0], [1.0, 0.0, 1.0])) [0.5, 0.0, 0.5] + """ return midpoint_point_point(*line) -def midpoint_line_xy(line): +def midpoint_line_xy(line: Sequence[Sequence[float]]) -> list[float]: """Compute the midpoint of a line defined by two points. Parameters ---------- - line : [point, point] | :class:`compas.geometry.Line` + line XYZ coordinates of the first point, and XYZ coordinates of the second point. Returns ------- - [float, float, 0.0] + list[float] XYZ coordinates of the midpoint in the XY plane. Examples -------- >>> midpoint_line_xy(([0.0, 0.0, 0.0], [1.0, 0.0, 1.0])) [0.5, 0.0, 0.0] + """ return midpoint_point_point_xy(*line) -def centroid_points(points): +def centroid_points(points: CoordinatesType) -> list[float]: """Compute the centroid of a set of points. Parameters ---------- - points : sequence[[float, float, float] | :class:`compas.geometry.Point`] + points A sequence of XYZ coordinates. Returns ------- - [float, float, float] + list[float] XYZ coordinates of the centroid. Warnings @@ -120,43 +120,45 @@ def centroid_points(points): >>> points = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]] >>> centroid_points(points) [0.5, 0.5, 0.0] + """ p = len(points) x, y, z = zip(*points) return [sum(x) / p, sum(y) / p, sum(z) / p] -def centroid_points_weighted(points, weights): +def centroid_points_weighted(points: Sequence[Sequence[float]], weights: Sequence[float]) -> list[float]: """Compute the weighted centroid of a set of points. The weights can be any between minus and plus infinity. Parameters ---------- - points : sequence[[float, float, float] | :class:`compas.geometry.Point`] + points A list of point coordinates. - weights : sequence[float] + weights A list of weight floats. Returns ------- - [float, float, float] + list[float] The coordinates of the weighted centroid. + """ vectors = [scale_vector(point, weight) for point, weight in zip(points, weights)] vector = scale_vector(sum_vectors(vectors), 1.0 / sum(weights)) return vector -def centroid_points_xy(points): +def centroid_points_xy(points: Sequence[Sequence[float]]) -> list[float]: """Compute the centroid of a set of points lying in the XY-plane. Parameters ---------- - points : sequence[[float, float] or [float, float, float] | :class:`compas.geometry.Point`] + points A sequence of points represented by their XY(Z) coordinates. Returns ------- - [float, float, 0.0] + list[float] XYZ coordinates of the centroid in the XY plane. Warnings @@ -169,23 +171,24 @@ def centroid_points_xy(points): >>> points = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]] >>> centroid_points_xy(points) [0.5, 0.5, 0.0] + """ p = len(points) x, y = list(zip(*points))[:2] return [sum(x) / p, sum(y) / p, 0.0] -def centroid_polygon(polygon): +def centroid_polygon(polygon: Sequence[Sequence[float]]) -> Sequence[float]: r"""Compute the centroid of the surface of a polygon. Parameters ---------- - polygon : sequence[[float, float, float] | :class:`compas.geometry.Point`] + polygon A sequence of polygon point coordinates. Returns ------- - [float, float, float] + Sequence[float] The XYZ coordinates of the centroid. Raises @@ -213,11 +216,17 @@ def centroid_polygon(polygon): the individual triangles, weighted by the corresponding triangle area in proportion to the total surface area. - .. math:: + $$ + c_x = \frac{1}{A} \sum_{i=1}^{N} A_i \cdot c_{x,i} + $$ - c_x = \frac{1}{A} \sum_{i=1}^{N} A_i \cdot c_{x,i} - c_y = \frac{1}{A} \sum_{i=1}^{N} A_i \cdot c_{y,i} - c_z = \frac{1}{A} \sum_{i=1}^{N} A_i \cdot c_{z,i} + $$ + c_y = \frac{1}{A} \sum_{i=1}^{N} A_i \cdot c_{y,i} + $$ + + $$ + c_z = \frac{1}{A} \sum_{i=1}^{N} A_i \cdot c_{z,i} + $$ Examples -------- @@ -275,18 +284,18 @@ def centroid_polygon(polygon): return [cx / A2, cy / A2, cz / A2] -def centroid_polygon_xy(polygon): +def centroid_polygon_xy(polygon: Sequence[Sequence[float]]) -> list[float]: r"""Compute the centroid of the surface of a polygon projected to the XY plane. Parameters ---------- - polygon : sequence[[float, float] or [float, float, float] | :class:`compas.geometry.Point`] + polygon A sequence of polygon point XY(Z) coordinates. The Z coordinates are ignored. Returns ------- - [float, float, 0.0] + list[float] The XYZ coordinates of the centroid in the XY plane. Raises @@ -311,11 +320,17 @@ def centroid_polygon_xy(polygon): the individual triangles, weighted by the corresponding triangle area in proportion to the total surface area. - .. math:: + $$ + c_x = \frac{1}{A} \sum_{i=1}^{N} A_i \cdot c_{x,i} + $$ + + $$ + c_y = \frac{1}{A} \sum_{i=1}^{N} A_i \cdot c_{y,i} + $$ - c_x = \frac{1}{A} \sum_{i=1}^{N} A_i \cdot c_{x,i} - c_y = \frac{1}{A} \sum_{i=1}^{N} A_i \cdot c_{y,i} - c_z = 0 + $$ + c_z = 0 + $$ Examples -------- @@ -368,52 +383,52 @@ def centroid_polygon_xy(polygon): return [cx / A2, cy / A2, 0.0] -def centroid_polygon_vertices(polygon): +def centroid_polygon_vertices(polygon: Sequence[Sequence[float]]) -> list[float]: """Compute the centroid of the vertices of a polygon. Parameters ---------- - polygon : sequence[[float, float, float] | :class:`compas.geometry.Point`] + polygon A sequence of polygon point coordinates. Returns ------- - [float, float, float] + list[float] The XYZ coordinates of the centroid. """ return centroid_points(polygon) -def centroid_polygon_vertices_xy(polygon): +def centroid_polygon_vertices_xy(polygon: Sequence[Sequence[float]]) -> list[float]: """Compute the centroid of the vertices of a polygon projected to the XY plane. Parameters ---------- - polygon : sequence[[float, float] or [float, float, float] | :class:`compas.geometry.Point`] + polygon A sequence of polygon point coordinates. The Z coordinates will be ignored. Returns ------- - [float, float, 0.0] + list[float] The XYZ coordinates of the centroid in the XY plane. """ return centroid_points_xy(polygon) -def centroid_polygon_edges(polygon): +def centroid_polygon_edges(polygon: Sequence[Sequence[float]]) -> list[float]: """Compute the centroid of the edges of a polygon. Parameters ---------- - polygon : sequence[[float, float, float] | :class:`compas.geometry.Point`] + polygon A sequence of polygon point coordinates. Returns ------- - [float, float, float] + list[float] The XYZ coordinates of the centroid. Notes @@ -421,6 +436,7 @@ def centroid_polygon_edges(polygon): The centroid of the edges is the centroid of the midpoints of the edges, with each midpoint weighted by the length of the corresponding edge proportional to the total length of the boundary. + """ L = 0 cx = 0 @@ -438,18 +454,18 @@ def centroid_polygon_edges(polygon): return [cx / L, cy / L, cz / L] -def centroid_polygon_edges_xy(polygon): - """Compute the centroid of the edges of a polygon prohected to the XY plane. +def centroid_polygon_edges_xy(polygon: Sequence[Sequence[float]]) -> list[float]: + """Compute the centroid of the edges of a polygon projected to the XY plane. Parameters ---------- - polygon : sequence[[float, float] or [float, float, float] | :class:`compas.geometry.Point`] + polygon A sequence of polygon point coordinates. The Z coordinates will be ignored. Returns ------- - [float, float, 0.0] + list[float] The XYZ coordinates of the centroid in the XY plane. Notes @@ -457,6 +473,7 @@ def centroid_polygon_edges_xy(polygon): The centroid of the edges is the centroid of the midpoints of the edges, with each midpoint weighted by the length of the corresponding edge proportional to the total length of the boundary. + """ L = 0 cx = 0 @@ -472,18 +489,18 @@ def centroid_polygon_edges_xy(polygon): return [cx / L, cy / L, 0.0] -def centroid_polyhedron(polyhedron): +def centroid_polyhedron(polyhedron: tuple[list[Sequence[float]], Sequence[list[int]]]) -> list[float]: """Compute the center of mass of a polyhedron. Parameters ---------- - polyhedron : tuple[sequence[[float, float, float] | :class:`compas.geometry.Point`], sequence[sequence[int]]] + polyhedron The coordinates of the vertices, and the indices of the vertices forming the faces. Returns ------- - [float, float, float] + list[float] XYZ coordinates of the center of mass. Warnings @@ -498,6 +515,7 @@ def centroid_polyhedron(polyhedron): >>> p = Polyhedron.from_platonicsolid(6) >>> centroid_polyhedron(p) [0.0, 0.0, 0.0] + """ vertices, faces = polyhedron diff --git a/src/compas/geometry/_core/distance.py b/src/compas/geometry/_core/distance.py index 6fa1a1e9bc84..2e9c0820e164 100644 --- a/src/compas/geometry/_core/distance.py +++ b/src/compas/geometry/_core/distance.py @@ -1,44 +1,52 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import fabs from math import sqrt - +from typing import TYPE_CHECKING +from typing import Any +from typing import Literal +from typing import Optional +from typing import Sequence +from typing import Union +from typing import overload + +if TYPE_CHECKING: + 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 +from compas.linalg.vectors import cross_vectors +from compas.linalg.vectors import cross_vectors_xy +from compas.linalg.vectors import dot_vectors +from compas.linalg.vectors import length_vector +from compas.linalg.vectors import length_vector_sqrd +from compas.linalg.vectors import length_vector_sqrd_xy +from compas.linalg.vectors import length_vector_xy +from compas.linalg.vectors import normalize_vector +from compas.linalg.vectors import scale_vector +from compas.linalg.vectors import subtract_vectors +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.tolerance import TOL -from ._algebra import add_vectors -from ._algebra import add_vectors_xy -from ._algebra import cross_vectors -from ._algebra import cross_vectors_xy -from ._algebra import dot_vectors -from ._algebra import length_vector -from ._algebra import length_vector_sqrd -from ._algebra import length_vector_sqrd_xy -from ._algebra import length_vector_xy -from ._algebra import normalize_vector -from ._algebra import scale_vector -from ._algebra import subtract_vectors -from ._algebra import subtract_vectors_xy -from ._algebra import vector_component -from ._algebra import vector_component_xy - - -def distance_point_point(a, b): - """Compute the distance bewteen a and b. + +def distance_point_point(a: CoordinateType, b: CoordinateType) -> float: + """Compute the distance between two points. Parameters ---------- - a : [float, float, float] | :class:`compas.geometry.Point` + a XYZ coordinates of point a. - b : [float, float, float] | :class:`compas.geometry.Point` + b XYZ coordinates of point b. Returns ------- float - Distance bewteen a and b. + Distance between `a` and `b`. Examples -------- @@ -47,21 +55,21 @@ def distance_point_point(a, b): See Also -------- - distance_point_point_xy + [`distance_point_point_xy`][compas.geometry.distance_point_point_xy] """ ab = subtract_vectors(b, a) return length_vector(ab) -def distance_point_point_xy(a, b): +def distance_point_point_xy(a: Sequence[float], b: Sequence[float]) -> float: """Compute the distance between points a and b, assuming they lie in the XY plane. Parameters ---------- - a : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + a XY(Z) coordinates of a 2D or 3D point (Z will be ignored). - b : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + b XY(Z) coordinates of a 2D or 3D point (Z will be ignored). Returns @@ -85,20 +93,20 @@ def distance_point_point_xy(a, b): return length_vector_xy(ab) -def distance_point_point_sqrd(a, b): - """Compute the squared distance bewteen points a and b. +def distance_point_point_sqrd(a: CoordinateType, b: CoordinateType) -> float: + """Compute the squared distance between points `a` and `b`. Parameters ---------- - a : [float, float, float] | :class:`compas.geometry.Point` + a XYZ coordinates of point a. - b : [float, float, float] | :class:`compas.geometry.Point` + b XYZ coordinates of point b. Returns ------- float - Squared distance bewteen a and b. + Squared distance between `a` and `b`. Examples -------- @@ -107,21 +115,21 @@ def distance_point_point_sqrd(a, b): See Also -------- - distance_point_point_sqrd_xy + [`distance_point_point_sqrd_xy`][compas.geometry.distance_point_point_sqrd_xy] """ ab = subtract_vectors(b, a) return length_vector_sqrd(ab) -def distance_point_point_sqrd_xy(a, b): +def distance_point_point_sqrd_xy(a: Sequence[float], b: Sequence[float]) -> float: """Compute the squared distance between points a and b lying in the XY plane. Parameters ---------- - a : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + a XY(Z) coordinates of the first point. - b : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + b XY(Z) coordinates of the second point. Returns @@ -145,14 +153,14 @@ def distance_point_point_sqrd_xy(a, b): return length_vector_sqrd_xy(ab) -def distance_point_line(point, line): +def distance_point_line(point: CoordinateType, line: CoordinatesType) -> float: """Compute the distance between a point and a line. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point Point location. - line : [point, point] | :class:`compas.geometry.Line` + line Line defined by two points. Returns @@ -164,16 +172,11 @@ def distance_point_line(point, line): ----- This implementation computes the *right angle distance* from a point P to a line defined by points A and B as twice the area of the triangle ABP divided - by the length of AB [1]_. + by the length of AB.[^distance-point-line] References ---------- - .. [1] Wikipedia. *Distance from a point to a line*. - Available at: https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line - - Examples - -------- - >>> + [^distance-point-line]: Wikipedia, [Distance from a point to a line](https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line). """ a, b = line @@ -185,14 +188,14 @@ def distance_point_line(point, line): return length / length_ab -def distance_point_line_xy(point, line): +def distance_point_line_xy(point: CoordinateType, line: CoordinatesType) -> float: """Compute the distance between a point and a line, assuming they lie in the XY-plane. Parameters ---------- - point : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + point XY(Z) coordinates of the point. - line : [point, point] | :class:`compas.geometry.Line` + line Line defined by two points. Returns @@ -204,12 +207,11 @@ def distance_point_line_xy(point, line): ----- This implementation computes the orthogonal distance from a point P to a line defined by points A and B as twice the area of the triangle ABP divided - by the length of AB [1]_. + by the length of AB.[^distance-point-line-xy] References ---------- - .. [1] Wikipedia. *Distance from a point to a line*. - Available at: https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line. + [^distance-point-line-xy]: Wikipedia, [Distance from a point to a line](https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line). """ a, b = line @@ -221,14 +223,14 @@ def distance_point_line_xy(point, line): return length / length_ab -def distance_point_line_sqrd(point, line): +def distance_point_line_sqrd(point: Sequence[float], line: Sequence[Sequence[float]]) -> float: """Compute the squared distance between a point and a line. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XYZ coordinates of the point. - line : [point, point] | :class:`compas.geometry.Line` + line Line defined by two points. Returns @@ -238,12 +240,11 @@ def distance_point_line_sqrd(point, line): Notes ----- - For more info, see [1]_. + For more information, see the reference below.[^distance-point-line-squared] References ---------- - .. [1] Wikipedia. *Distance from a point to a line*. - Available at: https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line. + [^distance-point-line-squared]: Wikipedia, [Distance from a point to a line](https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line). """ a, b = line @@ -255,14 +256,14 @@ def distance_point_line_sqrd(point, line): return length / length_ab -def distance_point_line_sqrd_xy(point, line): +def distance_point_line_sqrd_xy(point: Sequence[float], line: Sequence[Sequence[float]]) -> float: """Compute the squared distance between a point and a line lying in the XY-plane. Parameters ---------- - point : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + point XY(Z) coordinates of a 2D or 3D point (Z will be ignored). - line : [point, point] | :class:`compas.geometry.Line` + line Line defined by two points. Returns @@ -274,12 +275,11 @@ def distance_point_line_sqrd_xy(point, line): ----- This implementation computes the orthogonal squared distance from a point P to a line defined by points A and B as twice the area of the triangle ABP divided - by the length of AB [1]_. + by the length of AB.[^distance-point-line-squared-xy] References ---------- - .. [1] Wikipedia. *Distance from a point to a line*. - Available at: https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line. + [^distance-point-line-squared-xy]: Wikipedia, [Distance from a point to a line](https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line). """ a, b = line @@ -291,14 +291,14 @@ def distance_point_line_sqrd_xy(point, line): return length / length_ab -def distance_point_plane(point, plane): +def distance_point_plane(point: CoordinateType, plane: CoordinatesType) -> float: r"""Compute the distance from a point to a plane defined by origin point and normal. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point Point coordinates. - plane : [point, vector] + plane A point and a vector defining a plane. Returns @@ -309,51 +309,45 @@ def distance_point_plane(point, plane): Notes ----- The distance from a point to a plane can be computed from the coefficients - of the equation of the plane and the coordinates of the point [1]_. + of the equation of the plane and the coordinates of the point.[^distance-point-plane] The equation of a plane is - .. math:: - - Ax + By + Cz + D = 0 + $$ + Ax + By + Cz + D = 0 + $$ where - .. math:: - :nowrap: - - \begin{align} - D &= - Ax_0 - Bx_0 - Cz_0 \\ - Q &= (x_0, y_0, z_0) \\ - N &= (A, B, C) - \end{align} + $$ + \begin{aligned} + D &= -Ax_0 - By_0 - Cz_0 \\ + Q &= (x_0, y_0, z_0) \\ + N &= (A, B, C) + \end{aligned} + $$ - with :math:`Q` a point on the plane, and :math:`N` the normal vector at - that point. The distance of any point :math:`P` to a plane is the - absolute value of the dot product of the vector from :math:`Q` to :math:`P` - and the normal at :math:`Q`. + with $Q$ a point on the plane, and $N$ the normal vector at + that point. The distance of any point $P$ to a plane is the + absolute value of the dot product of the vector from $Q$ to $P$ + and the normal at $Q$. References ---------- - .. [1] Nykamp, D. *Distance from point to plane*. - Available at: http://mathinsight.org/distance_point_plane. - - Examples - -------- - >>> + [^distance-point-plane]: D. Nykamp, [Distance from point to plane](https://mathinsight.org/distance_point_plane). """ return fabs(distance_point_plane_signed(point, plane)) -def distance_point_plane_signed(point, plane): +def distance_point_plane_signed(point: CoordinateType, plane: CoordinatesType) -> float: r"""Compute the signed distance from a point to a plane defined by origin point and normal. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point Point coordinates. - plane : [point, vector] + plane A point and a vector defining a plane. Returns @@ -364,38 +358,32 @@ def distance_point_plane_signed(point, plane): Notes ----- The distance from a point to a plane can be computed from the coefficients - of the equation of the plane and the coordinates of the point [1]_. + of the equation of the plane and the coordinates of the point.[^distance-point-plane-signed] The equation of a plane is - .. math:: - - Ax + By + Cz + D = 0 + $$ + Ax + By + Cz + D = 0 + $$ where - .. math:: - :nowrap: - - \begin{align} - D &= - Ax_0 - Bx_0 - Cz_0 \\ - Q &= (x_0, y_0, z_0) \\ - N &= (A, B, C) - \end{align} + $$ + \begin{aligned} + D &= -Ax_0 - By_0 - Cz_0 \\ + Q &= (x_0, y_0, z_0) \\ + N &= (A, B, C) + \end{aligned} + $$ - with :math:`Q` a point on the plane, and :math:`N` the normal vector at - that point. The distance of any point :math:`P` to a plane is the - value of the dot product of the vector from :math:`Q` to :math:`P` - and the normal at :math:`Q`. + with $Q$ a point on the plane, and $N$ the normal vector at + that point. The distance of any point $P$ to a plane is the + value of the dot product of the vector from $Q$ to $P$ + and the normal at $Q$. References ---------- - .. [1] Nykamp, D. *Distance from point to plane*. - Available at: http://mathinsight.org/distance_point_plane. - - Examples - -------- - >>> + [^distance-point-plane-signed]: D. Nykamp, [Distance from point to plane](https://mathinsight.org/distance_point_plane). """ base, normal = plane @@ -403,18 +391,18 @@ def distance_point_plane_signed(point, plane): return dot_vectors(vector, normal) -def distance_line_line(l1, l2, tol=None): +def distance_line_line(l1: Sequence[Sequence[float]], l2: Sequence[Sequence[float]], tol: Optional[float] = None) -> float: r"""Compute the shortest distance between two lines. Parameters ---------- - l1 : [point, point] | :class:`compas.geometry.Line` + l1 Two points defining a line. - l2 : [point, point] | :class:`compas.geometry.Line` + l2 Two points defining a line. - tol : float, optional + tol, optional The tolerance for comparing values to zero. - Default is :attr:`TOL.absolute`. + Default is [`TOL.absolute`][compas.tolerance.Tolerance.absolute]. Returns ------- @@ -424,22 +412,16 @@ def distance_line_line(l1, l2, tol=None): Notes ----- The distance is the absolute value of the dot product of a unit vector that - is perpendicular to the two lines, and the vector between two points on the lines ([1]_, [2]_). + is perpendicular to the two lines, and the vector between two points on the lines.[^line-line-distance][^skew-lines-distance] - If each of the lines is defined by two points (:math:`l_1 = (\mathbf{x_1}, \mathbf{x_2})`, - :math:`l_2 = (\mathbf{x_3}, \mathbf{x_4})`), then the unit vector that is + If each of the lines is defined by two points ($l_1 = (\mathbf{x_1}, \mathbf{x_2})$, + $l_2 = (\mathbf{x_3}, \mathbf{x_4})$), then the unit vector that is perpendicular to both lines is... References ---------- - .. [1] Weisstein, E.W. *Line-line Distance*. - Available at: http://mathworld.wolfram.com/Line-LineDistance.html. - .. [2] Wikipedia. *Skew lines Distance*. - Available at: https://en.wikipedia.org/wiki/Skew_lines#Distance. - - Examples - -------- - >>> + [^line-line-distance]: E. W. Weisstein, [Line-Line Distance](https://mathworld.wolfram.com/Line-LineDistance.html). + [^skew-lines-distance]: Wikipedia, [Skew lines: Distance](https://en.wikipedia.org/wiki/Skew_lines#Distance). """ a, b = l1 @@ -460,19 +442,19 @@ def distance_line_line(l1, l2, tol=None): # ============================================================================== -def sort_points(point, cloud): +def sort_points(point: Sequence[float], cloud: Sequence[Sequence[float]]) -> list[tuple[float, Sequence[float], int]]: """Sorts points of a pointcloud based on their distance from a given point. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point The XYZ coordinates of the base point. - cloud : sequence[[float, float, float] | :class:`compas.geometry.Point`] + cloud A sequence locations in three-dimensional space. Returns ------- - list[[float, [float, float, float], int]] + list[tuple[float, Sequence[float], int]] A list containing the points of the cloud sorted by their squared distance to the base points. Each item in the list contains the squared distance to the base point, the XYZ coordinates of the point in the cloud, and the index of the point in the original cloud. @@ -481,29 +463,25 @@ def sort_points(point, cloud): ----- Check kdTree class for an optimized implementation (MR). - Examples - -------- - >>> - """ minsq = [distance_point_point_sqrd(p, point) for p in cloud] return sorted(zip(minsq, cloud, range(len(cloud))), key=lambda x: x[0]) -def sort_points_xy(point, cloud): +def sort_points_xy(point: Sequence[float], cloud: Sequence[Sequence[float]]) -> list[tuple[float, Sequence[float], int]]: """Sorts points of a pointcloud based on their distance from a given point, assuming all points lie in the XY plane. Parameters ---------- - point : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + point XY(Z) coordinates of a point. - cloud : sequence[[float, float] or [float, float, float] | :class:`compas.geometry.Point`] + cloud A list of points represented by their XY(Z) coordinates. Returns ------- - list[[float, [float, float, 0.0], int]] + list[tuple[float, Sequence[float], int]] A list containing the points of the cloud sorted by their squared distance to the base points. Each item in the list contains the squared distance to the base point, the XYZ coordinates of the point in the cloud in the XY plane, and the index of the point in the original cloud. @@ -512,75 +490,100 @@ def sort_points_xy(point, cloud): ----- Check kdTree class for an optimized implementation (MR). - Examples - -------- - >>> - """ minsq = [distance_point_point_sqrd_xy(p, point) for p in cloud] return sorted(zip(minsq, cloud, range(len(cloud))), key=lambda x: x[0]) -def closest_point_in_cloud(point, cloud): +def closest_point_in_cloud(point: Sequence[float], cloud: Sequence[Sequence[float]]) -> tuple[float, Sequence[float], int]: """Calculates the closest point in a pointcloud. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XYZ coordinates of the base point. - cloud : sequence[[float, float, float] | :class:`compas.geometry.Point`] + cloud A sequence locations in three-dimensional space. Returns ------- - float - The distance to the closest point. - [float, float, float] - XYZ coordinates of the closest point. - int - The index of the closest point in the original list. + tuple[float, Sequence[float], int] + The distance to the closest point, its coordinates, and its index in the original list. Notes ----- Check kdTree class for an optimized implementation. - Examples - -------- - >>> - """ data = sort_points(point, cloud) d, xyz, index = data[0] return sqrt(d), xyz, index -def closest_points_in_cloud_numpy(points, cloud, threshold=10**7, distances=True, num_nbrs=1): +@overload +def closest_points_in_cloud_numpy( + points: "ArrayLike", + cloud: "ArrayLike", + threshold: int = 10**7, + distances: Literal[True] = True, + num_nbrs: int = 1, +) -> "tuple[NDArray[Any], NDArray[Any]]": ... + + +@overload +def closest_points_in_cloud_numpy( + points: "ArrayLike", + cloud: "ArrayLike", + threshold: int, + distances: Literal[False], + num_nbrs: int = 1, +) -> "NDArray[Any]": ... + + +@overload +def closest_points_in_cloud_numpy( + points: "ArrayLike", + cloud: "ArrayLike", + threshold: int = 10**7, + *, + distances: Literal[False], + num_nbrs: int = 1, +) -> "NDArray[Any]": ... + + +def closest_points_in_cloud_numpy( + points: "ArrayLike", + cloud: "ArrayLike", + threshold: int = 10**7, + distances: bool = True, + num_nbrs: int = 1, +) -> "Union[NDArray[Any], tuple[NDArray[Any], NDArray[Any]]]": """Find the closest points in a point cloud to a set of sample points. Parameters ---------- - points : array_like [n x 3] + points The sample points. - cloud : array_like [n x 3] + cloud The cloud points to compare to. - threshold : float, optional - Only points within this distance are checked. - distances : bool, optional + threshold, optional + Size threshold at which SciPy switches from vectorised operations to a Python loop. + distances, optional If True, return the distance matrix in addition to the indices of the closest points. - num_nbrs : int, optional + num_nbrs, optional The number of nearest neighbors to include in the result. Returns ------- - list or tuple[list, array] + numpy.typing.NDArray[Any] | tuple[numpy.typing.NDArray[Any], numpy.typing.NDArray[Any]] If `distances` is False, indices of the closest points in the cloud per point in points. If `distances` is True, indices of the closest points in the cloud per point in points and distances between points and closest points in cloud (n x n). Notes ----- - Items in cloud further from items in points than threshold return zero - distance and will affect the indices returned if not set suitably high. + The `threshold` parameter controls the implementation strategy of + `scipy.spatial.distance_matrix`; it does not filter points by distance. Examples -------- @@ -609,24 +612,20 @@ def closest_points_in_cloud_numpy(points, cloud, threshold=10**7, distances=True return indices -def closest_point_in_cloud_xy(point, cloud): +def closest_point_in_cloud_xy(point: Sequence[float], cloud: Sequence[Sequence[float]]) -> tuple[float, Sequence[float], int]: """Calculates the closest point in a list of points in the XY-plane. Parameters ---------- - point : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + point XY(Z) coordinates of a the base point. - cloud : sequence[[float, float] or [float, float, float] | :class:`compas.geometry.Point`] + cloud A list of points forming the cloud, with each point represented by its XY(Z) coordinates. Returns ------- - float - The distance to the closest point. - [float, float, 0.0] - The XYZ coordinates of the closest point in the XY plane. - int - The index of the closest point in the cloud. + tuple[float, Sequence[float], int] + The distance to the closest point, its coordinates, and its index in the cloud. Notes ----- @@ -638,28 +637,24 @@ def closest_point_in_cloud_xy(point, cloud): return sqrt(d), xyz, index -def closest_point_on_line(point, line): +def closest_point_on_line(point: CoordinateType, line: CoordinatesType) -> list[float]: """Computes closest point on line to a given point. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XYZ coordinates. - line : [point, point] | :class:`compas.geometry.Line` + line Two points defining the line. Returns ------- - [float, float, float] + list[float] XYZ coordinates of closest point. - Examples - -------- - >>> - See Also -------- - :func:`basic.transformations.project_point_line` + [`project_point_line`][compas.geometry.project_point_line] """ a, b = line @@ -669,19 +664,19 @@ def closest_point_on_line(point, line): return add_vectors(a, c) -def closest_point_on_line_xy(point, line): +def closest_point_on_line_xy(point: Sequence[float], line: Sequence[Sequence[float]]) -> list[float]: """Compute closest point on line (continuous) to a given point lying in the XY-plane. Parameters ---------- - point : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + point XY(Z) coordinates of a point. - line : [point, point] | :class:`compas.geometry.Line` + line Two XY(Z) points defining a line. Returns ------- - [float, float, 0.0] + list[float] XYZ coordinates of the closest point in the XY plane. """ @@ -692,25 +687,21 @@ def closest_point_on_line_xy(point, line): return add_vectors_xy(a, c) -def closest_point_on_segment(point, segment): +def closest_point_on_segment(point: CoordinateType, segment: CoordinatesType) -> CoordinateType: """Computes closest point on line segment (p1, p2) to test point. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XYZ coordinates. - segment : [point, point] | :class:`compas.geometry.Line` + segment Two points defining the segment. Returns ------- - [float, float, float] + Sequence[float] XYZ coordinates of closest point. - Examples - -------- - >>> - """ a, b = segment p = closest_point_on_line(point, segment) @@ -724,19 +715,19 @@ def closest_point_on_segment(point, segment): return p -def closest_point_on_segment_xy(point, segment): +def closest_point_on_segment_xy(point: Sequence[float], segment: Sequence[Sequence[float]]) -> list[float]: """Compute closest point on a line segment to a given point lying in the XY-plane. Parameters ---------- - point : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + point XY(Z) coordinates of a point. - segment : [point, point] | :class:`compas.geometry.Line` + segment Two 2D or 3D points defining the line segment (Z components will be ignored). Returns ------- - [float, float, 0.0] + list[float] XYZ coordinates of closest point in the XY plane. """ @@ -752,20 +743,20 @@ def closest_point_on_segment_xy(point, segment): return p -def closest_point_on_polyline(point, polyline): +def closest_point_on_polyline(point: Sequence[float], polyline: Sequence[Sequence[float]]) -> Sequence[float]: """Find the closest point on a polyline to a given point. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XYZ coordinates of a 2D or 3D point (Z will be ignored). - polyline : sequence[point] | :class:`compas.geometry.Polyline` + polyline A sequence of XYZ coordinates representing the locations of the corners of a polyline. The vertices are assumed to be in order. Returns ------- - [float, float, float] + Sequence[float] XYZ coordinates of closest point. """ @@ -777,22 +768,22 @@ def closest_point_on_polyline(point, polyline): return closest_point_in_cloud(point, cloud)[1] -def closest_point_on_polyline_xy(point, polyline): +def closest_point_on_polyline_xy(point: Sequence[float], polyline: Sequence[Sequence[float]]) -> Sequence[float]: """Compute closest point on a polyline to a given point, assuming they both lie in the XY-plane. Parameters ---------- - point : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + point XY(Z) coordinates of a 2D or 3D point (Z will be ignored). - polyline : sequence[point] | :class:`compas.geometry.Polyline` + polyline A sequence of XY(Z) coordinates of 2D or 3D points (Z will be ignored) representing the locations of the corners of a polyline. The vertices are assumed to be in order. Returns ------- - [float, float, 0.0] + Sequence[float] XYZ coordinates of closest point in the XY plane. """ @@ -804,14 +795,14 @@ def closest_point_on_polyline_xy(point, polyline): return closest_point_in_cloud_xy(point, cloud)[1] -def closest_point_on_polygon_xy(point, polygon): +def closest_point_on_polygon_xy(point: Sequence[float], polygon: Sequence[Sequence[float]]) -> Sequence[float]: """Compute closest point on a polygon to a given point lying in the XY-plane. Parameters ---------- - point : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + point XY(Z) coordinates of a 2D or 3D point (Z will be ignored). - polygon : sequence[point] | :class:`compas.geometry.Polygon` + polygon A sequence of XY(Z) coordinates of 2D or 3D points (Z will be ignored) representing the locations of the corners of a polygon. The vertices are assumed to be in order. The polygon is assumed to be closed: @@ -819,7 +810,7 @@ def closest_point_on_polygon_xy(point, polygon): Returns ------- - [float, float, 0.0] + Sequence[float] XYZ coordinates of closest point in the XY plane. """ @@ -831,29 +822,28 @@ def closest_point_on_polygon_xy(point, polygon): return closest_point_in_cloud_xy(point, points)[1] -def closest_point_on_plane(point, plane): +def closest_point_on_plane(point: Sequence[float], plane: Sequence[Sequence[float]]) -> list[float]: """Compute closest point on a plane to a given point. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XYZ coordinates of point. - plane : [point, vector] + plane The base point and normal defining the plane. Returns ------- - [float, float, float] + list[float] XYZ coordinates of the closest point. Notes ----- - For more info, see [1]_. + For more information, see the reference below.[^closest-point-plane] References ---------- - .. [1] Wikipedia. *Distance from a point to a plane*. - Available at: https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_plane + [^closest-point-plane]: Wikipedia, [Distance from a point to a plane](https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_plane). Examples -------- @@ -872,20 +862,21 @@ def closest_point_on_plane(point, plane): return [x1 - k * a, y1 - k * b, z1 - k * c] -def closest_line_to_point(point, lines): +def closest_line_to_point(point: Sequence[float], lines: Sequence[Sequence[Sequence[float]]]) -> Sequence[Sequence[float]]: """Compute closest line to a point from a list of lines. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XYZ coordinates of point. - lines : sequence[[point, point] | :class:`compas.geometry.Line`]. + lines The lines to be checked for distance. Returns ------- - tuple[point, point] + Sequence[Sequence[float]] The closest line. + """ cloud = [] diff --git a/src/compas/geometry/_core/normals.py b/src/compas/geometry/_core/normals.py index 6c62f9d7894a..d89d32ea30d5 100644 --- a/src/compas/geometry/_core/normals.py +++ b/src/compas/geometry/_core/normals.py @@ -1,29 +1,29 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from ._algebra import cross_vectors -from ._algebra import cross_vectors_xy -from ._algebra import length_vector -from ._algebra import normalize_vector -from ._algebra import subtract_vectors -from ._algebra import subtract_vectors_xy +from typing 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 .centroids import centroid_points -def normal_polygon(polygon, unitized=True): +def normal_polygon(polygon: CoordinatesType, unitized: bool = True) -> list[float]: """Compute the normal of a polygon defined by a sequence of points. Parameters ---------- - polygon : sequence[point] | :class:`compas.geometry.Polygon` + polygon A list of polygon point coordinates. - unitized : bool, optional + unitized If True, unitize the normal vector. Returns ------- - [float, float, float] + list[float] The normal vector. Raises @@ -33,8 +33,8 @@ def normal_polygon(polygon, unitized=True): See Also -------- - normal_triangle - normal_triangle_xy + [`normal_triangle`][compas.geometry.normal_triangle] + [`normal_triangle_xy`][compas.geometry.normal_triangle_xy] Notes ----- @@ -71,19 +71,19 @@ def normal_polygon(polygon, unitized=True): return normalize_vector([nx, ny, nz]) -def normal_triangle(triangle, unitized=True): +def normal_triangle(triangle: Sequence[Sequence[float]], unitized: bool = True) -> list[float]: """Compute the normal vector of a triangle. Parameters ---------- - triangle : [point, point, point] | :class:`compas.geometry.Polygon` + triangle A list of triangle point coordinates. - unitized : bool, optional + unitized If True, unitize the normal vector. Returns ------- - [float, float, float] + list[float] The normal vector. Raises @@ -93,8 +93,8 @@ def normal_triangle(triangle, unitized=True): See Also -------- - normal_polygon - normal_triangle_xy + [`normal_polygon`][compas.geometry.normal_polygon] + [`normal_triangle_xy`][compas.geometry.normal_triangle_xy] """ if len(triangle) != 3: @@ -110,20 +110,20 @@ def normal_triangle(triangle, unitized=True): return [lvec * n[0], lvec * n[1], lvec * n[2]] -def normal_triangle_xy(triangle, unitized=True): +def normal_triangle_xy(triangle: Sequence[Sequence[float]], unitized: bool = True) -> list[float]: """Compute the normal vector of a triangle assumed to lie in the XY plane. Parameters ---------- - triangle : [point, point, point] | :class:`compas.geometry.Polygon` + triangle A list of triangle point coordinates. Z-coordinates are ignored. - unitized : bool, optional + unitized If True, unitize the normal vector. Returns ------- - [float, float, float] + list[float] The normal vector, which is a vector perpendicular to the XY plane. Raises @@ -133,8 +133,8 @@ def normal_triangle_xy(triangle, unitized=True): See Also -------- - normal_polygon - normal_triangle + [`normal_polygon`][compas.geometry.normal_polygon] + [`normal_triangle`][compas.geometry.normal_triangle] """ if len(triangle) != 3: diff --git a/src/compas/geometry/_core/nurbs.py b/src/compas/geometry/_core/nurbs.py index 7f850eb513a9..e666960abd42 100644 --- a/src/compas/geometry/_core/nurbs.py +++ b/src/compas/geometry/_core/nurbs.py @@ -1,20 +1,21 @@ from itertools import groupby +from typing import Sequence -def construct_knotvector(degree, pointcount): +def construct_knotvector(degree: int, pointcount: int) -> list[float]: """Construct a nonperiodic (clamped), uniform knot vector for a curve with given degree and number of control points. This function will generate a knotvector of the form - ``[0] * (order) + [i / d for i in range(1, d)] + [1] * (order)``, with ``order = degree + 1`` and ``d = pointcount - degree``. - Therefore the length of the knotvector will be ``pointcount + degree + 1``. + `[0] * (order) + [i / d for i in range(1, d)] + [1] * (order)`, with `order = degree + 1` and `d = pointcount - degree`. + Therefore the length of the knotvector will be `pointcount + degree + 1`. - For example, if degree is 3 and the number of control points is 7, the knot vector will be ``[0, 0, 0, 0, 1/4, 2/4, 3/4, 1, 1, 1, 1]``. + For example, if degree is 3 and the number of control points is 7, the knot vector will be `[0, 0, 0, 0, 1/4, 2/4, 3/4, 1, 1, 1, 1]`. Parameters ---------- - degree : int + degree Degree of the curve. - pointcount : int + pointcount The number of control points of the curve. Returns @@ -29,15 +30,15 @@ def construct_knotvector(degree, pointcount): See Also -------- - knotvector_to_knots_and_mults - knots_and_mults_to_knotvector - find_span - compute_basisfuncs - compute_basisfuncsderivs + [`knotvector_to_knots_and_mults`][compas.geometry.knotvector_to_knots_and_mults] + [`knots_and_mults_to_knotvector`][compas.geometry.knots_and_mults_to_knotvector] + [`find_span`][compas.geometry.find_span] + [`compute_basisfuncs`][compas.geometry.compute_basisfuncs] + [`compute_basisfuncsderivs`][compas.geometry.compute_basisfuncsderivs] References ---------- - The NURBS Book. Chapter 2. Page 66. + - *The NURBS Book*. Chapter 2. Page 66. """ order = degree + 1 @@ -49,12 +50,12 @@ def construct_knotvector(degree, pointcount): return [0] * (order) + [i / d for i in range(1, d)] + [1] * (order) -def knotvector_to_knots_and_mults(knotvector): +def knotvector_to_knots_and_mults(knotvector: Sequence[float]) -> tuple[list[float], list[int]]: """Convert a knot vector to a list of knots and multiplicities. Parameters ---------- - knotvector : list[int | float] + knotvector Knot vector. Returns @@ -64,19 +65,19 @@ def knotvector_to_knots_and_mults(knotvector): See Also -------- - construct_knotvector - knots_and_mults_to_knotvector - find_span - compute_basisfuncs - compute_basisfuncsderivs + [`construct_knotvector`][compas.geometry.construct_knotvector] + [`knots_and_mults_to_knotvector`][compas.geometry.knots_and_mults_to_knotvector] + [`find_span`][compas.geometry.find_span] + [`compute_basisfuncs`][compas.geometry.compute_basisfuncs] + [`compute_basisfuncsderivs`][compas.geometry.compute_basisfuncsderivs] Notes ----- The "standard" representation of a knot vector is a list of the form - ``[0] * (degree + 1) + [i / d for i in range(1, d)] + [1] * (degree + 1)``, with ``d = pointcount - degree``. + `[0] * (degree + 1) + [i / d for i in range(1, d)] + [1] * (degree + 1)`, with `d = pointcount - degree`. This representation is used, for example, in the NURBS Book and OpenCASCADe. - Rhino uses a knot vector of the form ``[0] * (degree) + [i / d for i in range(1, d)] + [1] * (degree)``. + Rhino uses a knot vector of the form `[0] * (degree) + [i / d for i in range(1, d)] + [1] * (degree)`. """ knots = [] @@ -89,14 +90,14 @@ def knotvector_to_knots_and_mults(knotvector): return knots, mults -def knots_and_mults_to_knotvector(knots, mults): +def knots_and_mults_to_knotvector(knots: Sequence[float], mults: Sequence[int]) -> list[float]: """Convert a list of knots and multiplicities to a knot vector. Parameters ---------- - knots : list[int | float] + knots Knots. - mults : list[int] + mults Multiplicities. Returns @@ -106,19 +107,19 @@ def knots_and_mults_to_knotvector(knots, mults): See Also -------- - construct_knotvector - knotvector_to_knots_and_mults - find_span - compute_basisfuncs - compute_basisfuncsderivs + [`construct_knotvector`][compas.geometry.construct_knotvector] + [`knotvector_to_knots_and_mults`][compas.geometry.knotvector_to_knots_and_mults] + [`find_span`][compas.geometry.find_span] + [`compute_basisfuncs`][compas.geometry.compute_basisfuncs] + [`compute_basisfuncsderivs`][compas.geometry.compute_basisfuncsderivs] Notes ----- The "standard" representation of a knot vector is a list of the form - ``[0] * (degree + 1) + [i / d for i in range(1, d)] + [1] * (degree + 1)``, with ``d = pointcount - degree``. + `[0] * (degree + 1) + [i / d for i in range(1, d)] + [1] * (degree + 1)`, with `d = pointcount - degree`. This representation is used, for example, in the NURBS Book and OpenCASCADe. - Rhino uses a knot vector of the form ``[0] * (degree) + [i / d for i in range(1, d)] + [1] * (degree)``. + Rhino uses a knot vector of the form `[0] * (degree) + [i / d for i in range(1, d)] + [1] * (degree)`. """ knotvector = [] @@ -129,18 +130,18 @@ def knots_and_mults_to_knotvector(knots, mults): return knotvector -def find_span(n, degree, knotvector, u): +def find_span(n: int, degree: int, knotvector: Sequence[float], u: float) -> int: """Find the knot span index for a given knot value. Parameters ---------- - n : int + n Number of control points minus 1. - degree : int + degree Degree of the curve. - knotvector : list[int | float] + knotvector Knot vector of the curve. - u : float + u Parameter value. Returns @@ -155,15 +156,15 @@ def find_span(n, degree, knotvector, u): See Also -------- - construct_knotvector - knotvector_to_knots_and_mults - knots_and_mults_to_knotvector - compute_basisfuncs - compute_basisfuncsderivs + [`construct_knotvector`][compas.geometry.construct_knotvector] + [`knotvector_to_knots_and_mults`][compas.geometry.knotvector_to_knots_and_mults] + [`knots_and_mults_to_knotvector`][compas.geometry.knots_and_mults_to_knotvector] + [`compute_basisfuncs`][compas.geometry.compute_basisfuncs] + [`compute_basisfuncsderivs`][compas.geometry.compute_basisfuncsderivs] References ---------- - The NURBS Book. Chapter 2. Page 68. Algorithm A2.1. + - *The NURBS Book*. Chapter 2. Page 68. Algorithm A2.1. """ if u > knotvector[-1]: @@ -189,18 +190,18 @@ def find_span(n, degree, knotvector, u): return mid -def compute_basisfuncs(degree, knotvector, i, u): +def compute_basisfuncs(degree: int, knotvector: Sequence[float], i: int, u: float) -> list[float]: """Compute the nonzero basis functions for a given parameter value. Parameters ---------- - degree : int + degree Degree of the curve. - knotvector : list + knotvector Knot vector of the curve. - i : int + i Knot span index. - u : float + u Parameter value. Returns @@ -210,21 +211,22 @@ def compute_basisfuncs(degree, knotvector, i, u): See Also -------- - construct_knotvector - knotvector_to_knots_and_mults - knots_and_mults_to_knotvector - find_span - compute_basisfuncsderivs + [`construct_knotvector`][compas.geometry.construct_knotvector] + [`knotvector_to_knots_and_mults`][compas.geometry.knotvector_to_knots_and_mults] + [`knots_and_mults_to_knotvector`][compas.geometry.knots_and_mults_to_knotvector] + [`find_span`][compas.geometry.find_span] + [`compute_basisfuncsderivs`][compas.geometry.compute_basisfuncsderivs] Notes ----- - In any given knot span, :math:`\\[u_{j}, u_{j+1}\\)` at most degree + 1 of the :math:`N_{i,degree}` basis functions are nonzero, - namely the functions :math:`N_{j-degree,degree}, \\dots, N_{j,degree}`. + In any given knot span $[u_j, u_{j+1})$, at most degree + 1 of the + $N_{i, degree}$ basis functions are nonzero, namely the functions + $N_{j - degree, degree}, \\dots, N_{j, degree}$. References ---------- - The NURBS Book. Chapter 2. Page 56. - The NURBS Book. Chapter 2. Page 70. Algorithm A2.2. + - *The NURBS Book*. Chapter 2. Page 56. + - *The NURBS Book*. Chapter 2. Page 70. Algorithm A2.2. """ N = [0.0 for _ in range(degree + 1)] @@ -249,20 +251,20 @@ def compute_basisfuncs(degree, knotvector, i, u): return N -def compute_basisfuncsderivs(degree, knotvector, i, u, n): +def compute_basisfuncsderivs(degree: int, knotvector: Sequence[float], i: int, u: float, n: int) -> list[list[float]]: """Compute the derivatives of the basis functions for a given parameter value. Parameters ---------- - degree : int + degree Degree of the curve. - knotvector : list[int | float] + knotvector Knot vector of the curve. - i : int + i Knot span index. - u : float + u Parameter value. - n : int + n Number of derivatives to compute. Returns @@ -272,15 +274,15 @@ def compute_basisfuncsderivs(degree, knotvector, i, u, n): See Also -------- - construct_knotvector - knotvector_to_knots_and_mults - knots_and_mults_to_knotvector - find_span - compute_basisfuncs + [`construct_knotvector`][compas.geometry.construct_knotvector] + [`knotvector_to_knots_and_mults`][compas.geometry.knotvector_to_knots_and_mults] + [`knots_and_mults_to_knotvector`][compas.geometry.knots_and_mults_to_knotvector] + [`find_span`][compas.geometry.find_span] + [`compute_basisfuncs`][compas.geometry.compute_basisfuncs] References ---------- - The NURBS Book. Chapter 2. Page 72. Algorithm A2.3. + - *The NURBS Book*. Chapter 2. Page 72. Algorithm A2.3. """ # output diff --git a/src/compas/geometry/_core/predicates_2.py b/src/compas/geometry/_core/predicates_2.py index 339851d7c755..0dfb590c17c7 100644 --- a/src/compas/geometry/_core/predicates_2.py +++ b/src/compas/geometry/_core/predicates_2.py @@ -1,7 +1,9 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import Any +from typing import Optional +from typing import Sequence +from compas._typing import CoordinatesType +from compas._typing import CoordinateType 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 @@ -24,19 +26,19 @@ # ============================================================================= -def is_ccw_xy(a, b, c, colinear=False): +def is_ccw_xy(a: CoordinateType, b: CoordinateType, c: CoordinateType, colinear: bool = False) -> 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. Parameters ---------- - a : [float, float, float] | :class:`compas.geometry.Point` + a Base point defined by XY(Z) coordinates. - b : [float, float, float] | :class:`compas.geometry.Point` + b First end point defined by XY(Z) coordinates. - c : [float, float, float] | :class:`compas.geometry.Point` + c Second end point defined by XY(Z) coordinates. - colinear : bool, optional + colinear If True, colinear points will return a positive result. Returns @@ -47,14 +49,15 @@ def is_ccw_xy(a, b, c, colinear=False): See Also -------- - is_colinear_xy + [`is_colinear_xy`][compas.geometry.is_colinear_xy] + + Notes + ----- + This follows the orientation test described by Marsh.[^is-ccw-xy-marsh] References ---------- - For more info, see [1]_. - - .. [1] Marsh, C. *Computational Geometry in Python: From Theory to Application*. - Available at: https://www.toptal.com/python/computational-geometry-in-python-from-theory-to-implementation + [^is-ccw-xy-marsh]: Marsh, C. [*Computational Geometry in Python: From Theory to Application*](https://www.toptal.com/python/computational-geometry-in-python-from-theory-to-implementation). Examples -------- @@ -80,16 +83,16 @@ def is_ccw_xy(a, b, c, colinear=False): return ab_x * ac_y - ab_y * ac_x > 0 -def is_colinear_xy(a, b, c): +def is_colinear_xy(a: Sequence[float], b: Sequence[float], c: Sequence[float]) -> bool: """Determine if three points are colinear on the XY-plane. Parameters ---------- - a : [float, float, float] | :class:`compas.geometry.Point` + a Point 1 defined by XY(Z) coordinates. - b : [float, float, float] | :class:`compas.geometry.Point` + b Point 2 defined by XY(Z) coordinates. - c : [float, float, float] | :class:`compas.geometry.Point` + c Point 3 defined by XY(Z) coordinates. Returns @@ -100,7 +103,7 @@ def is_colinear_xy(a, b, c): See Also -------- - is_ccw_xy + [`is_ccw_xy`][compas.geometry.is_ccw_xy] """ ab_x = b[0] - a[0] @@ -144,16 +147,16 @@ def is_colinear_xy(a, b, c): # ============================================================================= -def is_polygon_convex_xy(polygon, colinear=False): +def is_polygon_convex_xy(polygon: Sequence[Sequence[float]], colinear: bool = False) -> bool: """Determine if the polygon is convex on the XY-plane. Parameters ---------- - polygon : sequence[point] | :class:`compas.geometry.Polygon` + polygon The XY(Z) coordinates of the corners of a polygon. The vertices are assumed to be in order. The polygon is assumed to be closed: the first and last vertex in the sequence should not be the same. - colinear : bool, optional + colinear Are points allowed to be colinear? Returns @@ -193,18 +196,18 @@ def is_polygon_convex_xy(polygon, colinear=False): # ============================================================================= -def is_point_on_line_xy(point, line, tol=None): +def is_point_on_line_xy(point: CoordinateType, line: CoordinatesType, tol: Optional[float] = None) -> bool: """Determine if a point lies on a line on the XY-plane. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XY(Z) coordinates of a point. - line : [point, point] | :class:`compas.geometry.Line` + line XY(Z) coordinates of two points defining a line. - tol : float, optional + tol The tolerance for comparing the distance between point and line to zero. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- @@ -214,25 +217,25 @@ def is_point_on_line_xy(point, line, tol=None): See Also -------- - is_point_on_segment_xy - is_point_on_polyline_xy + [`is_point_on_segment_xy`][compas.geometry.is_point_on_segment_xy] + [`is_point_on_polyline_xy`][compas.geometry.is_point_on_polyline_xy] """ return TOL.is_zero(distance_point_line_xy(point, line), tol) -def is_point_on_segment_xy(point, segment, tol=None): +def is_point_on_segment_xy(point: CoordinateType, segment: CoordinatesType, tol: Optional[float] = None) -> bool: """Determine if a point lies on a given line segment on the XY-plane. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XY(Z) coordinates of a point. - segment : [point, point] | :class:`compas.geometry.Line` + segment XY(Z) coordinates of two points defining a segment. - tol : float, optional + tol The tolerance for comparing the distance between point and segment to zero. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- @@ -242,8 +245,8 @@ def is_point_on_segment_xy(point, segment, tol=None): See Also -------- - is_point_on_line_xy - is_point_on_polyline_xy + [`is_point_on_line_xy`][compas.geometry.is_point_on_line_xy] + [`is_point_on_polyline_xy`][compas.geometry.is_point_on_polyline_xy] """ a, b = segment @@ -265,18 +268,18 @@ def is_point_on_segment_xy(point, segment, tol=None): return False -def is_point_on_polyline_xy(point, polyline, tol=None): +def is_point_on_polyline_xy(point: Sequence[float], polyline: Sequence[Sequence[float]], tol: Optional[float] = None) -> bool: """Determine if a point is on a polyline on the XY-plane. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XY(Z) coordinates. - polyline : sequence[point] | :class:`compas.geometry.Polyline` + polyline XY(Z) coordinates of the points of the polyline. - tol : float, optional + tol The tolerance for comparing the distance between point and polyline to zero. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- @@ -286,8 +289,8 @@ def is_point_on_polyline_xy(point, polyline, tol=None): See Also -------- - is_point_on_line_xy - is_point_on_segment_xy + [`is_point_on_line_xy`][compas.geometry.is_point_on_line_xy] + [`is_point_on_segment_xy`][compas.geometry.is_point_on_segment_xy] """ for i in range(len(polyline) - 1): @@ -318,16 +321,16 @@ def is_point_on_polyline_xy(point, polyline, tol=None): # ============================================================================= -def is_point_in_triangle_xy(point, triangle, colinear=False): +def is_point_in_triangle_xy(point: Sequence[float], triangle: Sequence[Sequence[float]], colinear: bool = False) -> bool: """Determine if a point is in the interior of a triangle lying on the XY-plane. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XY(Z) coordinates of a point. - triangle : [point, point, point] + triangle XY(Z) coordinates of the corners of the triangle. - colinear : bool, optional + colinear Allow points to be colinear. Returns @@ -338,9 +341,9 @@ def is_point_in_triangle_xy(point, triangle, colinear=False): See Also -------- - is_point_in_convex_polygon_xy - is_point_in_polygon_xy - is_point_in_circle_xy + [`is_point_in_convex_polygon_xy`][compas.geometry.is_point_in_convex_polygon_xy] + [`is_point_in_polygon_xy`][compas.geometry.is_point_in_polygon_xy] + [`is_point_in_circle_xy`][compas.geometry.is_point_in_circle_xy] """ a, b, c = triangle @@ -355,14 +358,14 @@ def is_point_in_triangle_xy(point, triangle, colinear=False): return True -def is_point_in_convex_polygon_xy(point, polygon): +def is_point_in_convex_polygon_xy(point: CoordinateType, polygon: CoordinatesType) -> bool: """Determine if a point is in the interior of a convex polygon lying on the XY-plane. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XY(Z) coordinates of a point (Z will be ignored). - polygon : sequence[point] | :class:`compas.geometry.Polygon` + polygon A sequence of XY(Z) coordinates of points representing the locations of the corners of a polygon (Z will be ignored). The vertices are assumed to be in order. The polygon is assumed to be closed: the first and last vertex in the sequence should not be the same. @@ -379,9 +382,9 @@ def is_point_in_convex_polygon_xy(point, polygon): See Also -------- - is_point_in_triangle_xy - is_point_in_polygon_xy - is_point_in_circle_xy + [`is_point_in_triangle_xy`][compas.geometry.is_point_in_triangle_xy] + [`is_point_in_polygon_xy`][compas.geometry.is_point_in_polygon_xy] + [`is_point_in_circle_xy`][compas.geometry.is_point_in_circle_xy] """ ccw = None @@ -396,14 +399,14 @@ def is_point_in_convex_polygon_xy(point, polygon): return True -def is_point_in_polygon_xy(point, polygon): +def is_point_in_polygon_xy(point: CoordinateType, polygon: CoordinatesType) -> bool: """Determine if a point is in the interior of a polygon lying on the XY-plane. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XY(Z) coordinates of a point (Z will be ignored). - polygon : sequence[point] | :class:`compas.geometry.Polygon` + polygon A sequence of XY(Z) coordinates of points representing the locations of the corners of a polygon (Z will be ignored). The vertices are assumed to be in order. The polygon is assumed to be closed. @@ -421,9 +424,9 @@ def is_point_in_polygon_xy(point, polygon): See Also -------- - is_point_in_triangle_xy - is_point_in_convex_polygon_xy - is_point_in_circle_xy + [`is_point_in_triangle_xy`][compas.geometry.is_point_in_triangle_xy] + [`is_point_in_convex_polygon_xy`][compas.geometry.is_point_in_convex_polygon_xy] + [`is_point_in_circle_xy`][compas.geometry.is_point_in_circle_xy] """ x, y = point[0], point[1] @@ -444,14 +447,14 @@ def is_point_in_polygon_xy(point, polygon): return inside -def is_point_in_circle_xy(point, circle): +def is_point_in_circle_xy(point: Sequence[float], circle: Sequence[Any]) -> bool: """Determine if a point lies in a circle lying on the XY-plane. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XY(Z) coordinates of a point (Z will be ignored). - circle : [[point, vector], float] + circle Center and radius of the circle on the XY plane. Returns @@ -462,9 +465,9 @@ def is_point_in_circle_xy(point, circle): See Also -------- - is_point_in_triangle_xy - is_point_in_convex_polygon_xy - is_point_in_polygon_xy + [`is_point_in_triangle_xy`][compas.geometry.is_point_in_triangle_xy] + [`is_point_in_convex_polygon_xy`][compas.geometry.is_point_in_convex_polygon_xy] + [`is_point_in_polygon_xy`][compas.geometry.is_point_in_polygon_xy] """ dis = distance_point_point_xy(point, circle[0][0]) @@ -473,16 +476,16 @@ def is_point_in_circle_xy(point, circle): return False -def is_polygon_in_polygon_xy(polygon1, polygon2): +def is_polygon_in_polygon_xy(polygon1: Sequence[Sequence[float]], polygon2: Sequence[Sequence[float]]) -> bool: """Determine if a polygon is in the interior of another polygon on the XY-plane. Parameters ---------- - polygon1 : sequence[point] | :class:`compas.geometry.Polygon` + polygon1 List of XY(Z) coordinates of points representing the locations of the corners of the exterior polygon (Z will be ignored). The vertices are assumed to be in order. The polygon is assumed to be closed: the first and last vertex in the sequence should not be the same. - polygon2 : sequence[point] | :class:`compas.geometry.Polygon` + polygon2 List of XY(Z) coordinates of points representing the locations of the corners of the interior polygon (Z will be ignored). The vertices are assumed to be in order. The polygon is assumed to be closed: the first and last vertex in the sequence should not be the same. @@ -529,16 +532,16 @@ def is_polygon_in_polygon_xy(polygon1, polygon2): # ============================================================================= -def is_intersection_line_line_xy(l1, l2, tol=None): +def is_intersection_line_line_xy(l1: Sequence[Sequence[float]], l2: Sequence[Sequence[float]], tol: Optional[float] = None) -> bool: """Verifies if two lines intersect on the XY-plane. Parameters ---------- - l1 : [point, point] | :class:`compas.geometry.Line` + l1 XY(Z) coordinates of two points defining a line. - l2 : [point, point] | :class:`compas.geometry.Line` + l2 XY(Z) coordinates of two points defining a line. - tol : float, optional + tol A tolerance for intersection verification. Returns @@ -551,15 +554,15 @@ def is_intersection_line_line_xy(l1, l2, tol=None): raise NotImplementedError -def is_intersection_segment_segment_xy(ab, cd): +def is_intersection_segment_segment_xy(ab: Sequence[Sequence[float]], cd: Sequence[Sequence[float]]) -> bool: """Determines if two segments, ab and cd, intersect. Parameters ---------- - ab : [point, point] | :class:`compas.geometry.Line` + ab Two points representing the start and end points of a segment. Z coordinates will be ignored. - cd : [point, point] | :class:`compas.geometry.Line` + cd Two points representing the start and end points of a segment. Z coordinates will be ignored. diff --git a/src/compas/geometry/_core/predicates_3.py b/src/compas/geometry/_core/predicates_3.py index 4e7eb851c5ed..693f310ed9d7 100644 --- a/src/compas/geometry/_core/predicates_3.py +++ b/src/compas/geometry/_core/predicates_3.py @@ -1,19 +1,22 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from itertools import chain +from typing import Any +from typing import Optional +from typing import Sequence +from compas._typing import CoordinatesType +from compas._typing import CoordinateType from compas.geometry import area_triangle from compas.geometry import centroid_points from compas.geometry import closest_point_on_segment -from compas.geometry import cross_vectors from compas.geometry import distance_point_line from compas.geometry import distance_point_plane from compas.geometry import distance_point_point -from compas.geometry import dot_vectors -from compas.geometry import length_vector from compas.geometry import normal_polygon -from compas.geometry import subtract_vectors from compas.itertools import window +from compas.linalg.vectors import cross_vectors +from compas.linalg.vectors import dot_vectors +from compas.linalg.vectors import length_vector +from compas.linalg.vectors import subtract_vectors from compas.tolerance import TOL # ============================================================================= @@ -33,20 +36,20 @@ # ============================================================================= -def is_colinear(a, b, c, tol=None): +def is_colinear(a: Sequence[float], b: Sequence[float], c: Sequence[float], tol: Optional[float] = None) -> bool: """Determine if three points are colinear. Parameters ---------- - a : [float, float, float] | :class:`compas.geometry.Point` + a Point 1. - b : [float, float, float] | :class:`compas.geometry.Point` + b Point 2. - c : [float, float, float] | :class:`compas.geometry.Point` + c Point 3. - tol : float, optional + tol Tolerance for comparing the area of the triangle formed by the three points to zero. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- @@ -56,25 +59,25 @@ def is_colinear(a, b, c, tol=None): See Also -------- - is_colinear_line_line - is_coplanar + [`is_colinear_line_line`][compas.geometry.is_colinear_line_line] + [`is_coplanar`][compas.geometry.is_coplanar] """ return TOL.is_zero(area_triangle([a, b, c]), tol) -def is_colinear_line_line(line1, line2, tol=None): +def is_colinear_line_line(line1: Sequence[Sequence[float]], line2: Sequence[Sequence[float]], tol: Optional[float] = None) -> bool: """Determine if two lines are colinear. Parameters ---------- - line1 : [point, point] | :class:`compas.geometry.Line` + line1 Line 1. - line2 : [point, point] | :class:`compas.geometry.Line` + line2 Line 2. - tol : float, optional + tol Tolerance for colinearity verification. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- @@ -84,8 +87,8 @@ def is_colinear_line_line(line1, line2, tol=None): See Also -------- - is_colinear - is_coplanar + [`is_colinear`][compas.geometry.is_colinear] + [`is_coplanar`][compas.geometry.is_coplanar] """ a, b = line1 @@ -93,14 +96,14 @@ def is_colinear_line_line(line1, line2, tol=None): return is_colinear(a, b, c, tol) and is_colinear(a, b, d, tol) -def is_coplanar(points, tol=None): +def is_coplanar(points: Sequence[Sequence[float]], tol: Optional[float] = None) -> bool: """Determine if the points are coplanar. Parameters ---------- - points : sequence[point] + points A sequence of point locations. - tol : float, optional + tol A tolerance for planarity validation. Returns @@ -111,8 +114,8 @@ def is_coplanar(points, tol=None): See Also -------- - is_colinear - is_colinear_line_line + [`is_colinear`][compas.geometry.is_colinear] + [`is_colinear_line_line`][compas.geometry.is_colinear_line_line] Notes ----- @@ -124,7 +127,8 @@ def is_coplanar(points, tol=None): if len(points) < 4: return True - temp = points[:] + # A Sequence may be immutable, so make a mutable copy before consuming points. + temp = list(points) while len(temp) >= 3: a = temp.pop(0) @@ -157,18 +161,18 @@ def is_coplanar(points, tol=None): # ============================================================================= -def is_parallel_vector_vector(u, v, tol=None): +def is_parallel_vector_vector(u: Sequence[float], v: Sequence[float], tol: Optional[float] = None) -> bool: """Determine if two vectors are parallel. Parameters ---------- - u : [float, float, float] | :class:`~compas.geometry.Vector` + u Vector 1. - v : [float, float, float] | :class:`~compas.geometry.Vector` + v Vector 2. - tol : float, optional + tol Tolerance for comparing the length of the cross product of the two vectors to zero. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- @@ -178,8 +182,8 @@ def is_parallel_vector_vector(u, v, tol=None): See Also -------- - is_parallel_line_line - is_parallel_plane_plane + [`is_parallel_line_line`][compas.geometry.is_parallel_line_line] + [`is_parallel_plane_plane`][compas.geometry.is_parallel_plane_plane] Notes ----- @@ -199,18 +203,18 @@ def is_parallel_vector_vector(u, v, tol=None): return TOL.is_zero(length_vector(cross_vectors(u, v)), tol) -def is_parallel_line_line(line1, line2, tol=None): +def is_parallel_line_line(line1: Sequence[Sequence[float]], line2: Sequence[Sequence[float]], tol: Optional[float] = None) -> bool: """Determine if two lines are parallel. Parameters ---------- - line1 : [point, point] | :class:`compas.geometry.Line` + line1 Line 1. - line2 : [point, point] | :class:`compas.geometry.Line` + line2 Line 2. - tol : float, optional + tol Tolerance for comparing the length of the cross product of the direction vectors of the two lines to zero. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- @@ -220,9 +224,9 @@ def is_parallel_line_line(line1, line2, tol=None): See Also -------- - is_parallel_vector_vector - is_parallel_plane_plane - is_perpendicular_line_line + [`is_parallel_vector_vector`][compas.geometry.is_parallel_vector_vector] + [`is_parallel_plane_plane`][compas.geometry.is_parallel_plane_plane] + [`is_perpendicular_line_line`][compas.geometry.is_perpendicular_line_line] """ a, b = line1 @@ -232,16 +236,16 @@ def is_parallel_line_line(line1, line2, tol=None): return is_parallel_vector_vector(ab, cd, tol) -def is_parallel_plane_plane(plane1, plane2, tol=None): +def is_parallel_plane_plane(plane1: Sequence[Sequence[float]], plane2: Sequence[Sequence[float]], tol: Optional[float] = None) -> bool: """Determine if two planes are parallel. Parameters ---------- - plane1 : [point, vector] + plane1 Plane 1. - plane2 : [point, vector] + plane2 Plane 2. - tol : float, optional + tol A tolerance for verifying parallelity of the plane normals. Returns @@ -252,26 +256,26 @@ def is_parallel_plane_plane(plane1, plane2, tol=None): See Also -------- - is_parallel_vector_vector - is_parallel_line_line - is_perpendicular_plane_plane + [`is_parallel_vector_vector`][compas.geometry.is_parallel_vector_vector] + [`is_parallel_line_line`][compas.geometry.is_parallel_line_line] + [`is_perpendicular_plane_plane`][compas.geometry.is_perpendicular_plane_plane] """ return is_parallel_vector_vector(plane1[1], plane2[1], tol) -def is_perpendicular_vector_vector(u, v, tol=None): +def is_perpendicular_vector_vector(u: Sequence[float], v: Sequence[float], tol: Optional[float] = None) -> bool: """Determine if two vectors are perpendicular. Parameters ---------- - u : [float, float, float] | :class:`~compas.geometry.Vector` + u Vector 1. - v : [float, float, float] | :class:`~compas.geometry.Vector` + v Vector 2. - tol : float, optional + tol Tolerance for comparing the dot product of the two vectors to zero. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- @@ -281,8 +285,8 @@ def is_perpendicular_vector_vector(u, v, tol=None): See Also -------- - is_perpendicular_line_line - is_perpendicular_plane_plane + [`is_perpendicular_line_line`][compas.geometry.is_perpendicular_line_line] + [`is_perpendicular_plane_plane`][compas.geometry.is_perpendicular_plane_plane] Notes ----- @@ -296,18 +300,18 @@ def is_perpendicular_vector_vector(u, v, tol=None): return TOL.is_zero(dot_vectors(u, v), tol) -def is_perpendicular_line_line(line1, line2, tol=None): +def is_perpendicular_line_line(line1: Sequence[Sequence[float]], line2: Sequence[Sequence[float]], tol: Optional[float] = None) -> bool: """Determine if two lines are perpendicular. Parameters ---------- - line1 : [point, point] | :class:`~compas.geometry.Line` + line1 Line 1. - line2 : [point, point] | :class:`~compas.geometry.Line` + line2 Line 2. - tol : float, optional + tol Tolerance for verifying the perpendicularity of the direction vectors of the two lines. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- @@ -317,8 +321,8 @@ def is_perpendicular_line_line(line1, line2, tol=None): See Also -------- - is_perpendicular_vector_vector - is_perpendicular_plane_plane + [`is_perpendicular_vector_vector`][compas.geometry.is_perpendicular_vector_vector] + [`is_perpendicular_plane_plane`][compas.geometry.is_perpendicular_plane_plane] """ a, b = line1 @@ -328,16 +332,16 @@ def is_perpendicular_line_line(line1, line2, tol=None): return is_perpendicular_vector_vector(ab, cd, tol) -def is_perpendicular_plane_plane(plane1, plane2, tol=None): +def is_perpendicular_plane_plane(plane1: Sequence[Sequence[float]], plane2: Sequence[Sequence[float]], tol: Optional[float] = None) -> bool: """Determine if two planes are perpendicular. Parameters ---------- - plane1 : [point, vector] + plane1 Plane 1. - plane2 : [point, vector] + plane2 Plane 2. - tol : float, optional + tol A tolerance for verifying perpendicularity of the plane normals. Returns @@ -348,9 +352,9 @@ def is_perpendicular_plane_plane(plane1, plane2, tol=None): See Also -------- - is_perpendicular_vector_vector - is_perpendicular_line_line - is_parallel_plane_plane + [`is_perpendicular_vector_vector`][compas.geometry.is_perpendicular_vector_vector] + [`is_perpendicular_line_line`][compas.geometry.is_perpendicular_line_line] + [`is_parallel_plane_plane`][compas.geometry.is_parallel_plane_plane] """ return is_perpendicular_vector_vector(plane1[1], plane2[1], tol) @@ -373,12 +377,12 @@ def is_perpendicular_plane_plane(plane1, plane2, tol=None): # ============================================================================= -def is_polygon_convex(polygon): +def is_polygon_convex(polygon: Sequence[Sequence[float]]) -> bool: """Determine if a polygon is convex. Parameters ---------- - polygon : sequence[point] | :class:`compas.geometry.Polygon` + polygon A polygon. Returns @@ -389,12 +393,12 @@ def is_polygon_convex(polygon): See Also -------- - is_polyhedron_convex + [`is_polyhedron_convex`][compas.geometry.is_polyhedron_convex] Notes ----- Use this function for *spatial* polygons. - If the polygon is in a horizontal plane, use :func:`is_polygon_convex_xy` instead. + If the polygon is in a horizontal plane, use `is_polygon_convex_xy` instead. Examples -------- @@ -409,7 +413,8 @@ def is_polygon_convex(polygon): oa = subtract_vectors(a, o) ob = subtract_vectors(b, o) n0 = cross_vectors(oa, ob) - for a, o, b in window(polygon + polygon[:2], 3): + # A Sequence does not necessarily support concatenation. + for a, o, b in window(chain(polygon, polygon[:2]), 3): oa = subtract_vectors(a, o) ob = subtract_vectors(b, o) n = cross_vectors(oa, ob) @@ -420,12 +425,12 @@ def is_polygon_convex(polygon): return True -def is_polyhedron_convex(polyhedron): +def is_polyhedron_convex(polyhedron: Sequence[Any]) -> bool: """Determine if a polyhedron is convex. Parameters ---------- - polyhedron : [sequence[point], sequence[sequence[int]]] + polyhedron A polyhedron defined by a sequence of points and a sequence of faces, with each face defined as a sequence of indices into the sequence of points. @@ -437,7 +442,7 @@ def is_polyhedron_convex(polyhedron): See Also -------- - is_polygon_convex + [`is_polygon_convex`][compas.geometry.is_polygon_convex] """ vertices, faces = polyhedron @@ -473,18 +478,18 @@ def is_polyhedron_convex(polyhedron): # ============================================================================= -def is_point_on_plane(point, plane, tol=None): +def is_point_on_plane(point: CoordinateType, plane: CoordinatesType, tol: Optional[float] = None) -> bool: """Determine if a point lies on a plane. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point A point. - plane : [point, vector] + plane A plane. - tol : float, optional + tol Tolerance for comparing the distance between the point and the plane to zero. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- @@ -513,18 +518,18 @@ def is_point_on_plane(point, plane, tol=None): # ============================================================================= -def is_point_on_line(point, line, tol=None): +def is_point_on_line(point: CoordinateType, line: CoordinatesType, tol: Optional[float] = None) -> bool: """Determine if a point lies on a line. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point A point. - line : [point, point] | :class:`compas.geometry.Line` + line A line. - tol : float, optional + tol Tolerance for comparing the distance between the point and the line to zero. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- @@ -536,18 +541,18 @@ def is_point_on_line(point, line, tol=None): return TOL.is_zero(distance_point_line(point, line), tol) -def is_point_on_segment(point, segment, tol=None): +def is_point_on_segment(point: CoordinateType, segment: CoordinatesType, tol: Optional[float] = None) -> bool: """Determine if a point lies on a given line segment. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point A point. - segment : [point, point] | :class:`compas.geometry.Line` + segment A line segment. - tol : float, optional + tol Tolerance for comparing the distance between the point and the line segment to zero. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- @@ -573,18 +578,18 @@ def is_point_on_segment(point, segment, tol=None): return False -def is_point_on_polyline(point, polyline, tol=None): +def is_point_on_polyline(point: CoordinateType, polyline: CoordinatesType, tol: Optional[float] = None) -> bool: """Determine if a point is on a polyline. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point A point. - polyline : sequence[point] | :class:`compas.geometry.Polyline` + polyline A polyline. - tol : float, optional + tol Tolerance for comparing the distance between the point and the polyline to zero. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- @@ -604,16 +609,16 @@ def is_point_on_polyline(point, polyline, tol=None): return False -def is_point_on_circle(point, circle, tol=None): +def is_point_on_circle(point: Sequence[float], circle: Sequence[Any], tol: Optional[float] = None) -> bool: """Determine if a point lies on a circle. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point A point. - circle : [plane, float] + circle A circle. - tol : float, optional + tol A tolerance for membership verification. Returns @@ -663,14 +668,14 @@ def is_point_on_circle(point, circle, tol=None): # ============================================================================= -def is_point_in_circle(point, circle, tol=None): +def is_point_in_circle(point: CoordinateType, circle: Sequence[Any], tol: Optional[float] = None) -> bool: """Determine if a point lies in a circle. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point A point. - circle : [plane, float] + circle A circle. Returns @@ -686,14 +691,14 @@ def is_point_in_circle(point, circle, tol=None): return False -def is_point_in_triangle(point, triangle, tol=None): +def is_point_in_triangle(point: CoordinateType, triangle: CoordinatesType, tol: Optional[float] = None) -> bool: """Determine if a point is in the interior of a triangle. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point A point. - triangle : [point, point, point] + triangle A triangle. Returns @@ -708,7 +713,7 @@ def is_point_in_triangle(point, triangle, tol=None): """ - def is_on_same_side(p1, p2, segment): + def is_on_same_side(p1: CoordinateType, p2: CoordinateType, segment: CoordinatesType) -> bool: a, b = segment v = subtract_vectors(b, a) c1 = cross_vectors(v, subtract_vectors(p1, a)) @@ -726,14 +731,14 @@ def is_on_same_side(p1, p2, segment): return False -def is_point_in_polygon(point, polygon, tol=None): +def is_point_in_polygon(point: Sequence[float], polygon: Sequence[Sequence[float]], tol: Optional[float] = None) -> bool: """Determine if a point is in the interior of a polygon. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point A point. - polygon : sequence[point] | :class:`compas.geometry.Polygon` + polygon A polygon. Returns @@ -763,14 +768,14 @@ def is_point_in_polygon(point, polygon, tol=None): # ============================================================================= -def is_point_in_sphere(point, sphere, tol=None): +def is_point_in_sphere(point: Sequence[float], sphere: Sequence[Any], tol: Optional[float] = None) -> bool: """Determine if a point lies in a sphere. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point A point. - sphere : [point, float] + sphere A sphere. Returns @@ -784,14 +789,14 @@ def is_point_in_sphere(point, sphere, tol=None): return TOL.is_positive(radius - distance_point_point(point, center), tol) -def is_point_in_aab(point, box, tol=None): +def is_point_in_aab(point: Sequence[float], box: Sequence[Sequence[float]], tol: Optional[float] = None) -> bool: """Determine if a point lies in an axis-aligned box. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point A point. - box : [[float, float, float], [float, float, float]] | [:class:`compas.geometry.Point`, :class:`compas.geometry.Point``] + box An axis-aligned box defined by the min/max corners. Returns @@ -805,14 +810,14 @@ def is_point_in_aab(point, box, tol=None): return all(TOL.is_between(point[i], minval=a[i], maxval=b[i], atol=tol) for i in range(3)) -def is_point_in_polyhedron(point, polyhedron, tol=None): +def is_point_in_polyhedron(point: Sequence[float], polyhedron: Sequence[Any], tol: Optional[float] = None) -> bool: """Determine if the point lies inside the given polyhedron. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point The test point. - polyhedron : [sequence[point], sequence[sequence[int]]] + polyhedron The polyhedron defined by a sequence of points and a sequence of faces, with each face defined as a sequence of indices into the sequence of points. @@ -829,16 +834,16 @@ def is_point_in_polyhedron(point, polyhedron, tol=None): return all(is_point_behind_plane(point, plane, tol=tol) for plane in planes) -def is_point_infrontof_plane(point, plane, tol=None): +def is_point_infrontof_plane(point: Sequence[float], plane: Sequence[Sequence[float]], tol: Optional[float] = None) -> bool: """Determine if a point lies in front of a plane. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point A point. - plane : [point, vector] + plane A plane. - tol : float, optional + tol A tolerance for membership verification. Returns @@ -851,16 +856,16 @@ def is_point_infrontof_plane(point, plane, tol=None): return TOL.is_positive(dot_vectors(subtract_vectors(point, plane[0]), plane[1]), tol) -def is_point_behind_plane(point, plane, tol=None): +def is_point_behind_plane(point: CoordinateType, plane: CoordinatesType, tol: Optional[float] = None) -> bool: """Determine if a point lies behind a plane. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point A point. - plane : [point, normal] + plane A plane. - tol : float, optional + tol A tolerance for membership verification. Returns @@ -895,9 +900,9 @@ def is_point_behind_plane(point, plane, tol=None): # Parameters # ---------- -# l1 : [point, point] | :class:`compas.geometry.Line` +# l1 : [point, point] | `compas.geometry.Line` # A line. -# l2 : [point, point] | :class:`compas.geometry.Line` +# l2 : [point, point] | `compas.geometry.Line` # A line. # tol : float, optional # A tolerance for intersection verification. @@ -930,9 +935,9 @@ def is_point_behind_plane(point, plane, tol=None): # Parameters # ---------- -# s1 : [point, point] | :class:`compas.geometry.Line` +# s1 : [point, point] | `compas.geometry.Line` # A line segment. -# s2 : [point, point] | :class:`compas.geometry.Line` +# s2 : [point, point] | `compas.geometry.Line` # A line segment. # tol : float, optional # A tolerance for intersection verification. @@ -952,7 +957,7 @@ def is_point_behind_plane(point, plane, tol=None): # Parameters # ---------- -# line : [point, point] | :class:`compas.geometry.Line` +# line : [point, point] | `compas.geometry.Line` # A line. # triangle : [point, point, point] # A triangle. @@ -1023,7 +1028,7 @@ def is_point_behind_plane(point, plane, tol=None): # Parameters # ---------- -# line : [point, point] | :class:`compas.geometry.Line` +# line : [point, point] | `compas.geometry.Line` # A line. # plane : [point, vector] # A plane. @@ -1054,7 +1059,7 @@ def is_point_behind_plane(point, plane, tol=None): # Parameters # ---------- -# segment : [point, point] | :class:`compas.geometry.Line` +# segment : [point, point] | `compas.geometry.Line` # A line segment. # plane : [point, vector] # A plane. diff --git a/src/compas/geometry/_core/quaternions.py b/src/compas/geometry/_core/quaternions.py deleted file mode 100644 index 8283bbac9523..000000000000 --- a/src/compas/geometry/_core/quaternions.py +++ /dev/null @@ -1,196 +0,0 @@ -import math - -from compas.tolerance import TOL - - -def quaternion_norm(q): - """Calculates the length (euclidean norm) of a quaternion. - - Parameters - ---------- - q : [float, float, float, float] | :class:`compas.geometry.Quaternion` - Quaternion or sequence of four floats ``[w, x, y, z]``. - - Returns - ------- - float - The length (euclidean norm) of a quaternion. - - See Also - -------- - quaternion_is_unit - quaternion_unitize - quaternion_multiply - quaternion_canonize - quaternion_conjugate - - References - ---------- - * Quaternion Norm: http://mathworld.wolfram.com/QuaternionNorm.html - - """ - return math.sqrt(sum([x * x for x in q])) - - -def quaternion_unitize(q): - """Makes a quaternion unit-length. - - Parameters - ---------- - q : [float, float, float, float] | :class:`compas.geometry.Quaternion` - Quaternion or sequence of four floats ``[w, x, y, z]``. - - Returns - ------- - [float, float, float, float] - Quaternion of length 1 as a list of four real values ``[nw, nx, ny, nz]``. - - See Also - -------- - quaternion_is_unit - quaternion_norm - quaternion_multiply - quaternion_canonize - quaternion_conjugate - - """ - n = quaternion_norm(q) - - if TOL.is_zero(n): - raise ValueError("The given quaternion has zero length.") - - return [x / n for x in q] - - -def quaternion_is_unit(q, tol=None): - """Checks if a quaternion is unit-length. - - Parameters - ---------- - q : [float, float, float, float] | :class:`compas.geometry.Quaternion` - Quaternion or sequence of four floats ``[w, x, y, z]``. - tol : float, optional - The tolerance for comparing the quaternion norm to 1. - Default is :attr:`TOL.absolute`. - - Returns - ------- - bool - True if the quaternion is unit-length, - and False if otherwise. - - See Also - -------- - quaternion_unitize - quaternion_norm - quaternion_multiply - quaternion_canonize - quaternion_conjugate - - """ - n = quaternion_norm(q) - return TOL.is_close(n, 1.0, rtol=0.0, atol=tol) - - -def quaternion_multiply(r, q): - """Multiplies two quaternions. - - Parameters - ---------- - r : [float, float, float, float] | :class:`compas.geometry.Quaternion` - Quaternion or sequence of four floats ``[w, x, y, z]``. - q : [float, float, float, float] | :class:`compas.geometry.Quaternion` - Quaternion or sequence of four floats ``[w, x, y, z]``. - - Returns - ------- - [float, float, float, float] - Quaternion :math:`p = rq` as a list of four real values ``[pw, px, py, pz]``. - - See Also - -------- - quaternion_is_unit - quaternion_norm - quaternion_unitize - quaternion_canonize - quaternion_conjugate - - Notes - ----- - Multiplication of two quaternions :math:`p = rq` can be interpreted as applying rotation :math:`r` to an orientation :math:`q`, - provided that both :math:`r` and :math:`q` are unit-length. - The result is also unit-length. - Multiplication of quaternions is not commutative! - - References - ---------- - * Quaternion: http://mathworld.wolfram.com/Quaternion.html - - """ - rw, rx, ry, rz = r - qw, qx, qy, qz = q - pw = rw * qw - rx * qx - ry * qy - rz * qz - px = rw * qx + rx * qw + ry * qz - rz * qy - py = rw * qy - rx * qz + ry * qw + rz * qx - pz = rw * qz + rx * qy - ry * qx + rz * qw - return [pw, px, py, pz] - - -def quaternion_canonize(q): - """Converts a quaternion into a canonic form if needed. - - Parameters - ---------- - q : [float, float, float, float] | :class:`compas.geometry.Quaternion` - Quaternion or sequence of four floats ``[w, x, y, z]``. - - Returns - ------- - [float, float, float, float] - Quaternion in a canonic form as a list of four real values ``[cw, cx, cy, cz]``. - - See Also - -------- - quaternion_is_unit - quaternion_norm - quaternion_unitize - quaternion_multiply - quaternion_conjugate - - Notes - ----- - Canonic form means the scalar component is a non-negative number. - - """ - if q[0] < 0.0: - return [-x for x in q] - return q[:] - - -def quaternion_conjugate(q): - """Conjugate of a quaternion. - - Parameters - ---------- - q : [float, float, float, float] | :class:`compas.geometry.Quaternion` - Quaternion or sequence of four floats ``[w, x, y, z]``. - - Returns - ------- - [float, float, float, float] - Conjugate quaternion as a list of four real values ``[cw, cx, cy, cz]``. - - See Also - -------- - quaternion_is_unit - quaternion_norm - quaternion_unitize - quaternion_multiply - quaternion_canonize - - References - ---------- - * Quaternion Conjugate: http://mathworld.wolfram.com/QuaternionConjugate.html - - """ - return [q[0], -q[1], -q[2], -q[3]] diff --git a/src/compas/geometry/_core/size.py b/src/compas/geometry/_core/size.py index b7370590383f..065530f8bca6 100644 --- a/src/compas/geometry/_core/size.py +++ b/src/compas/geometry/_core/size.py @@ -1,29 +1,26 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import fabs +from typing import Sequence from compas.itertools import pairwise +from compas.linalg.vectors import cross_vectors +from compas.linalg.vectors import cross_vectors_xy +from compas.linalg.vectors import dot_vectors +from compas.linalg.vectors import length_vector +from compas.linalg.vectors import subtract_vectors +from compas.linalg.vectors import subtract_vectors_xy -from ._algebra import cross_vectors -from ._algebra import cross_vectors_xy -from ._algebra import dot_vectors -from ._algebra import length_vector -from ._algebra import subtract_vectors -from ._algebra import subtract_vectors_xy from .centroids import centroid_points from .centroids import centroid_points_xy from .normals import normal_triangle from .normals import normal_triangle_xy -def area_triangle(triangle): +def area_triangle(triangle: Sequence[Sequence[float]]) -> float: """Compute the area of a triangle defined by three points. Parameters ---------- - triangle : [point, point, point] | :class:`compas.geometry.Polygon` + triangle XYZ coordinates of the corners of the triangle. Returns @@ -33,20 +30,20 @@ def area_triangle(triangle): See Also -------- - area_triangle_xy - area_polygon - area_polygon_xy + [`area_triangle_xy`][compas.geometry.area_triangle_xy] + [`area_polygon`][compas.geometry.area_polygon] + [`area_polygon_xy`][compas.geometry.area_polygon_xy] """ return 0.5 * length_vector(normal_triangle(triangle, False)) -def area_triangle_xy(triangle): +def area_triangle_xy(triangle: Sequence[Sequence[float]]) -> float: """Compute the area of a triangle defined by three points lying in the XY-plane. Parameters ---------- - triangle : [point, point, point] | :class:`compas.geometry.Polygon` + triangle XY(Z) coordinates of the corners of the triangle. Returns @@ -56,20 +53,20 @@ def area_triangle_xy(triangle): See Also -------- - area_triangle - area_polygon - area_polygon_xy + [`area_triangle`][compas.geometry.area_triangle] + [`area_polygon`][compas.geometry.area_polygon] + [`area_polygon_xy`][compas.geometry.area_polygon_xy] """ return 0.5 * length_vector(normal_triangle_xy(triangle, False)) -def area_polygon(polygon): +def area_polygon(polygon: Sequence[Sequence[float]]) -> float: """Compute the area of a polygon. Parameters ---------- - polygon : sequence[point] | :class:`compas.geometry.Polygon` + polygon The XYZ coordinates of the vertices/corners of the polygon. The vertices are assumed to be in order. The polygon is assumed to be closed: @@ -82,8 +79,8 @@ def area_polygon(polygon): See Also -------- - area_polygon_xy - area_triangle + [`area_polygon_xy`][compas.geometry.area_polygon_xy] + [`area_triangle`][compas.geometry.area_triangle] """ o = centroid_points(polygon) @@ -105,12 +102,12 @@ def area_polygon(polygon): return abs(area) -def area_polygon_xy(polygon): +def area_polygon_xy(polygon: Sequence[Sequence[float]]) -> float: """Compute the area of a polygon lying in the XY-plane. Parameters ---------- - polygon : sequence[point] | :class:`compas.geometry.Polygon` + polygon A sequence of XY(Z) coordinates of 2D or 3D points representing the locations of the corners of a polygon. The vertices are assumed to be in order. The polygon is assumed to be closed: @@ -123,8 +120,8 @@ def area_polygon_xy(polygon): See Also -------- - area_polygon - area_triangle_xy + [`area_polygon`][compas.geometry.area_polygon] + [`area_triangle_xy`][compas.geometry.area_triangle_xy] """ o = centroid_points_xy(polygon) @@ -138,12 +135,12 @@ def area_polygon_xy(polygon): return fabs(a) -def volume_polyhedron(polyhedron): +def volume_polyhedron(polyhedron: tuple[list[list[float]], Sequence[list[int]]]) -> float: r"""Compute the volume of a polyhedron represented by a closed mesh. Parameters ---------- - polyhedron : tuple[sequence[[float, float, float] | :class:`compas.geometry.Point`], sequence[sequence[int]]] + polyhedron The vertices and faces of the polyhedron. Returns @@ -161,22 +158,20 @@ def volume_polyhedron(polyhedron): This implementation is based on the divergence theorem, the fact that the *area vector* is constant for each face, and the fact that the area of each face can be computed as half the length of the cross product of two adjacent - edge vectors [1]_. - - .. math:: - :nowrap: + edge vectors.[^volume-polyhedron-nurnberg] - \begin{align} + $$ + \begin{aligned} V = \int_{P} 1 &= \frac{1}{3} \int_{\partial P} \mathbf{x} \cdot \mathbf{n} \\ &= \frac{1}{3} \sum_{i=0}^{N-1} \int{A_{i}} a_{i} \cdot n_{i} \\ &= \frac{1}{6} \sum_{i=0}^{N-1} a_{i} \cdot \hat n_{i} - \end{align} + \end{aligned} + $$ References ---------- - .. [1] Nurnberg, R. *Calculating the area and centroid of a polygon in 2d*. - Available at: http://wwwf.imperial.ac.uk/~rn/centroid.pdf + [^volume-polyhedron-nurnberg]: Nurnberg, R. [*Calculating the Area and Centroid of a Polygon in 2D*](http://wwwf.imperial.ac.uk/~rn/centroid.pdf). """ xyz, faces = polyhedron diff --git a/src/compas/geometry/_core/tangent.py b/src/compas/geometry/_core/tangent.py index 9e45db1f06f0..cf1ff8078e93 100644 --- a/src/compas/geometry/_core/tangent.py +++ b/src/compas/geometry/_core/tangent.py @@ -1,24 +1,23 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import sqrt +from typing import Sequence -def tangent_points_to_circle_xy(circle, point): +def tangent_points_to_circle_xy( + circle: tuple[tuple[Sequence[float], Sequence[float]], float], point: Sequence[float] +) -> tuple[tuple[float, float, int], tuple[float, float, int]]: """Calculates the tangent points on a circle in the XY plane. Parameters ---------- - circle : [plane, float] + circle Plane and radius of the circle. - point : [float, float] or [float, float, float] | :class:`compas.geometry.Point` + point XY(Z) coordinates of a point in the xy plane. Returns ------- - tuple[[float, float, 0.0], [float, float, 0.0]] - the tangent points on the circle. + tuple[tuple[float, float, int], tuple[float, float, int]] + The tangent points on the circle. Examples -------- diff --git a/src/compas/geometry/_core/transformations.py b/src/compas/geometry/_core/transformations.py index 0e0080e0ddb1..afc00dd8eb89 100644 --- a/src/compas/geometry/_core/transformations.py +++ b/src/compas/geometry/_core/transformations.py @@ -1,59 +1,61 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import math +from typing import Iterable +from typing import Optional +from typing import Sequence + +from compas._typing import CoordinatesType +from compas.linalg.matrices import multiply_matrices +from compas.linalg.matrices import multiply_matrix_vector +from compas.linalg.matrices import transpose_matrix +from compas.linalg.transformations import matrix_from_axis_and_angle +from compas.linalg.transformations import matrix_from_change_of_basis +from compas.linalg.transformations import matrix_from_scale_factors +from compas.linalg.vectors import add_vectors +from compas.linalg.vectors import add_vectors_xy +from compas.linalg.vectors import cross_vectors +from compas.linalg.vectors import dot_vectors +from compas.linalg.vectors import norm_vector +from compas.linalg.vectors import normalize_vector +from compas.linalg.vectors import scale_vector +from compas.linalg.vectors import scale_vector_xy +from compas.linalg.vectors import subtract_vectors +from compas.linalg.vectors import subtract_vectors_xy +from compas.linalg.vectors import vector_component +from compas.linalg.vectors import vector_component_xy -from ._algebra import add_vectors -from ._algebra import add_vectors_xy -from ._algebra import cross_vectors -from ._algebra import dot_vectors -from ._algebra import matrix_from_axis_and_angle -from ._algebra import matrix_from_change_of_basis -from ._algebra import matrix_from_scale_factors -from ._algebra import multiply_matrices -from ._algebra import multiply_matrix_vector -from ._algebra import norm_vector -from ._algebra import normalize_vector -from ._algebra import scale_vector -from ._algebra import scale_vector_xy -from ._algebra import subtract_vectors -from ._algebra import subtract_vectors_xy -from ._algebra import transpose_matrix -from ._algebra import vector_component -from ._algebra import vector_component_xy from .angles import angle_vectors from .distance import closest_point_on_line from .distance import closest_point_on_line_xy from .distance import closest_point_on_plane +_WORLD_XY = ([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]) + # this function will not always work # it is also a duplicate of stuff found in matrices and frame -def local_axes(a, b, c): - u = b - a - v = c - a +def local_axes(a: Sequence[float], b: Sequence[float], c: Sequence[float]) -> tuple[list[float], list[float], list[float]]: + # Raw coordinate sequences do not necessarily support vector subtraction. + u = subtract_vectors(b, a) + v = subtract_vectors(c, a) w = cross_vectors(u, v) v = cross_vectors(w, u) return normalize_vector(u), normalize_vector(v), normalize_vector(w) -def orthonormalize_axes(xaxis, yaxis): +def orthonormalize_axes(xaxis: Sequence[float], yaxis: Sequence[float]) -> tuple[Sequence[float], Sequence[float]]: """Corrects xaxis and yaxis to be unit vectors and orthonormal. Parameters ---------- - xaxis: [float, float, float] | :class:`compas.geometry.Vector` + xaxis The first axis. - yaxis: [float, float, float] | :class:`compas.geometry.Vector` + yaxis The second axis. Returns ------- - [float, float, float] - The corrected x axis. - [float, float, float] - The corrected y axis. + tuple[Sequence[float], Sequence[float]] + The corrected x and y axes, in that order. Raises ------ @@ -81,20 +83,20 @@ def orthonormalize_axes(xaxis, yaxis): return xaxis, yaxis -def homogenize(xyz, w=1.0): +def homogenize(xyz: Iterable[Iterable[float]], w: float = 1.0) -> list[list[float]]: """Homogenise a list of vectors. Parameters ---------- - xyz : sequence[[float, float, float] | :class:`compas.geometry.Point`] | sequence[[float, float, float] | :class:`compas.geometry.Vector`] + xyz A list of points or vectors. - w : float, optional + w Homogenisation parameter. - Use ``1.0`` for points, and ``0.0`` for vectors. + Use `1.0` for points, and `0.0` for vectors. Returns ------- - list[[float, float, float, `w`]] + list[list[float]] Homogenised data. Notes @@ -113,37 +115,35 @@ def homogenize(xyz, w=1.0): 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): +def dehomogenize(xyzw: Sequence[Sequence[float]]) -> list[list[float]]: """Dehomogenise a list of vectors. Parameters ---------- - xyzw : sequence[[float, float, float, `w`]] + xyzw A list of vectors. Returns ------- - list[[float, float, float]] + list[list[float]] Dehomogenised vectors. - Examples - -------- - >>> - """ return [[x / w, y / w, z / w] if w else [x, y, z] for x, y, z, w in xyzw] -def homogenize_and_flatten_frames(frames): +def homogenize_and_flatten_frames( + frames: Sequence[Sequence[Sequence[float]]], +) -> list[list[float]]: """Homogenize a list of frames and flatten the 3D list into a 2D list. Parameters ---------- - frames : sequence[[point, vector, vector]] + frames Returns ------- - list[[float, float, float, `w`]] + list[list[float]] A list with 3 entries per frame: a homogenized point, and two homogenized vectors. Examples @@ -155,23 +155,25 @@ def homogenize_and_flatten_frames(frames): """ - def homogenize_frame(frame): + def homogenize_frame(frame: Sequence[Sequence[float]]) -> list[list[float]]: 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): +def dehomogenize_and_unflatten_frames( + points_and_vectors: Sequence[Sequence[float]], +) -> list[list[list[float]]]: """Dehomogenize a list of vectors and unflatten the 2D list into a 3D list. Parameters ---------- - points_and_vectors : sequence[[float, float, float, `w`]] + points_and_vectors List of homogenized frames with 3 entries per frame: a homogenized point, and two homogenized vectors. Returns ------- - list[[point, vector, vector]] + list[list[list[float]]] The dehmogenized frame data. Examples @@ -190,19 +192,19 @@ def dehomogenize_and_unflatten_frames(points_and_vectors): # ============================================================================== -def transform_points(points, T): +def transform_points(points: Iterable[Iterable[float]], T: Iterable[Iterable[float]]) -> list[list[float]]: """Transform multiple points with one transformation matrix. Parameters ---------- - points : sequence[[float, float, float] | :class:`compas.geometry.Point`] + points A list of points to be transformed. - T : list[list[float]] | :class:`compas.geometry.Transformation` + T The transformation to apply. Returns ------- - list[[float, float, float]] + list[list[float]] Transformed points. Examples @@ -215,19 +217,19 @@ def transform_points(points, T): return dehomogenize(multiply_matrices(homogenize(points, w=1.0), transpose_matrix(T))) -def transform_vectors(vectors, T): +def transform_vectors(vectors: CoordinatesType, T: CoordinatesType) -> list[list[float]]: """Transform multiple vectors with one transformation matrix. Parameters ---------- - vectors : sequence[[float, float, float] | :class:`compas.geometry.Vector`] + vectors A list of vectors to be transformed. - T : list[list[float]] | :class:`compas.geometry.Transformation` + T The transformation to apply. Returns ------- - list[[float, float, float]] + list[list[float]] Transformed vectors. Examples @@ -240,19 +242,19 @@ def transform_vectors(vectors, T): return dehomogenize(multiply_matrices(homogenize(vectors, w=0.0), transpose_matrix(T))) -def transform_frames(frames, T): +def transform_frames(frames: Sequence[Sequence[Sequence[float]]], T: Sequence[Sequence[float]]) -> list[list[list[float]]]: """Transform multiple frames with one transformation matrix. Parameters ---------- - frames : sequence[[point, vector, vector]] + frames A list of frames to be transformed. - T : list[list[float]] | :class:`compas.geometry.Transformation` + T The transformation to apply on the frames. Returns ------- - list[[point, vector, vector]] + list[list[list[float]]] Transformed frames. Examples @@ -267,19 +269,19 @@ def transform_frames(frames, T): return dehomogenize_and_unflatten_frames(multiply_matrices(points_and_vectors, transpose_matrix(T))) -def world_to_local_coordinates(frame, xyz): +def world_to_local_coordinates(frame: Sequence[Sequence[float]], xyz: Sequence[Sequence[float]]) -> list[list[float]]: """Convert global coordinates to local coordinates. Parameters ---------- - frame : [point, vector, vector] + frame The local coordinate system. - xyz : array-like[[float, float, float] | :class:`compas.geometry.Point`] + xyz The global coordinates of the points to convert. Returns ------- - list[[float, float, float]] + list[list[float]] The coordinates of the given points in the local coordinate system. Examples @@ -291,25 +293,23 @@ def world_to_local_coordinates(frame, xyz): Point(x=3.726, y=4.088, z=1.550) """ - from compas.geometry import Frame # noqa: F811 - - T = matrix_from_change_of_basis(Frame.worldXY(), frame) + T = matrix_from_change_of_basis(_WORLD_XY, frame) return transform_points(xyz, T) -def local_to_world_coordinates(frame, xyz): +def local_to_world_coordinates(frame: Sequence[Sequence[float]], xyz: Sequence[Sequence[float]]) -> list[list[float]]: """Convert local coordinates to global coordinates. Parameters ---------- - frame : [point, vector, vector] + frame The local coordinate system. - xyz : array-like[[float, float, float] | :class:`compas.geometry.Point`] + xyz The global coordinates of the points to convert. Returns ------- - list[[float, float, float]] + list[list[float]] The coordinates of the given points in the local coordinate system. Examples @@ -321,9 +321,7 @@ def local_to_world_coordinates(frame, xyz): Point(x=2.000, y=3.000, z=5.000) """ - from compas.geometry import Frame # noqa: F811 - - T = matrix_from_change_of_basis(frame, Frame.worldXY()) + T = matrix_from_change_of_basis(frame, _WORLD_XY) return transform_points(xyz, T) @@ -332,38 +330,38 @@ def local_to_world_coordinates(frame, xyz): # ============================================================================== -def translate_points(points, vector): +def translate_points(points: Sequence[Sequence[float]], vector: Sequence[float]) -> list[list[float]]: """Translate points. Parameters ---------- - points : sequence[[float, float, float] | :class:`compas.geometry.Point`] + points A list of points. - vector : [float, float, float] | :class:`compas.geometry.Vector` + vector A translation vector. Returns ------- - list[[float, float, float]] + list[list[float]] The translated points. """ return [add_vectors(point, vector) for point in points] -def translate_points_xy(points, vector): +def translate_points_xy(points: Sequence[Sequence[float]], vector: Sequence[float]) -> list[list[float]]: """Translate points and in the XY plane. Parameters ---------- - points : sequence[[float, float, float] | :class:`compas.geometry.Point`] + points A list of points. - vector : [float, float, float] | :class:`compas.geometry.Vector` + vector A translation vector. Returns ------- - list[[float, float, float]] + list[list[float]] The translated points in the XY plane (Z=0). """ @@ -375,19 +373,19 @@ def translate_points_xy(points, vector): # ============================================================================== -def scale_points(points, scale): +def scale_points(points: Sequence[Sequence[float]], scale: float) -> list[list[float]]: """Scale points. Parameters ---------- - points : sequence[[float, float, float] | :class:`compas.geometry.Point`] + points A list of points. - scale : float + scale A scaling factor. Returns ------- - list[[float, float, float]] + list[list[float]] The scaled points. """ @@ -395,19 +393,19 @@ def scale_points(points, scale): return transform_points(points, T) -def scale_points_xy(points, scale): +def scale_points_xy(points: Sequence[Sequence[float]], scale: float) -> list[list[float]]: """Scale points in the XY plane. Parameters ---------- - points : sequence[[float, float, float] | :class:`compas.geometry.Point`] + points A list of points. - scale : float + scale A scaling factor. Returns ------- - list[[float, float, float]] + list[list[float]] The scaled points in the XY plane (Z=0). """ @@ -420,35 +418,39 @@ def scale_points_xy(points, scale): # ============================================================================== -def rotate_points(points, angle, axis=None, origin=None): +def rotate_points( + points: Sequence[Sequence[float]], + angle: float, + axis: Optional[Sequence[float]] = None, + origin: Optional[Sequence[float]] = None, +) -> list[list[float]]: """Rotates points around an arbitrary axis in 3D. Parameters ---------- - points : sequence[[float, float, float] | :class:`compas.geometry.Point`] + points A list of points. - angle : float + angle The angle of rotation in radians. - axis : [float, float, float] | :class:`compas.geometry.Vector`, optional + axis The rotation axis. - Default is ``[0.0, 0.0, 1.0]`` - origin : [float, float, float] | :class:`compas.geometry.Point`, optional + Default is `[0.0, 0.0, 1.0]` + origin The origin of the rotation axis. - Default is ``[0.0, 0.0, 0.0]``. + Default is `[0.0, 0.0, 0.0]`. Returns ------- - list[[float, float, float]] + list[list[float]] The rotated points Notes ----- - For more info, see [1]_. + This uses a standard rotation matrix.[^rotate-points-rotation-matrix] References ---------- - .. [1] Wikipedia. *Rotation matrix*. - Available at: https://en.wikipedia.org/wiki/Rotation_matrix. + [^rotate-points-rotation-matrix]: [Rotation matrix](https://en.wikipedia.org/wiki/Rotation_matrix) """ if axis is None: @@ -461,22 +463,22 @@ def rotate_points(points, angle, axis=None, origin=None): return points -def rotate_points_xy(points, angle, origin=None): +def rotate_points_xy(points: Sequence[Sequence[float]], angle: float, origin: Optional[Sequence[float]] = None) -> list[list[float]]: """Rotates points in the XY plane around the Z axis at a specific origin. Parameters ---------- - points : sequence[[float, float, float] | :class:`compas.geometry.Point`] + points A list of points. - angle : float + angle The angle of rotation in radians. - origin : [float, float, float] | :class:`compas.geometry.Point`, optional + origin The origin of the rotation axis. - Default is ``[0.0, 0.0, 0.0]``. + Default is `[0.0, 0.0, 0.0]`. Returns ------- - list[[float, float, 0.0]] + list[list[float]] The rotated points in the XY plane (Z=0). """ @@ -500,123 +502,122 @@ def rotate_points_xy(points, angle, origin=None): # ============================================================================== -def mirror_vector_vector(v1, v2): +def mirror_vector_vector(v1: Sequence[float], v2: Sequence[float]) -> list[float]: """Mirrors vector about vector. Parameters ---------- - v1 : [float, float, float] | :class:`compas.geometry.Vector` + v1 The vector. - v2 : [float, float, float] | :class:`compas.geometry.Vector` + v2 The normalized vector as mirror axis Returns ------- - [float, float, float] + list[float] The mirrored vector. Notes ----- - For more info, see [1]_. + This follows the standard vector reflection formula.[^mirror-vector-vector-formula] References ---------- - .. [1] Math Stack Exchange. *How to get a reflection vector?* - Available at: https://math.stackexchange.com/questions/13261/how-to-get-a-reflection-vector. + [^mirror-vector-vector-formula]: [How to get a reflection vector?](https://math.stackexchange.com/questions/13261/how-to-get-a-reflection-vector) """ return subtract_vectors(v1, scale_vector(v2, 2 * dot_vectors(v1, v2))) -def mirror_point_point(point, mirror): +def mirror_point_point(point: Sequence[float], mirror: Sequence[float]) -> list[float]: """Mirror a point about a point. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XYZ coordinates of the point to mirror. - mirror : [float, float, float] | :class:`compas.geometry.Point` + mirror XYZ coordinates of the mirror point. Returns ------- - [float, float, float] + list[float] The mirrored point. """ return add_vectors(mirror, subtract_vectors(mirror, point)) -def mirror_point_point_xy(point, mirror): +def mirror_point_point_xy(point: Sequence[float], mirror: Sequence[float]) -> list[float]: """Mirror a point about a point. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XY(Z) coordinates of the point to mirror. - mirror : [float, float, float] | :class:`compas.geometry.Point` + mirror XY(Z) coordinates of the mirror point. Returns ------- - [float, float, float] + list[float] The mirrored point, with Z=0. """ return add_vectors_xy(mirror, subtract_vectors_xy(mirror, point)) -def mirror_points_point(points, mirror): +def mirror_points_point(points: Sequence[Sequence[float]], mirror: Sequence[float]) -> list[list[float]]: """Mirror multiple points about a point. Parameters ---------- - points : sequence[[float, float, float] | :class:`compas.geometry.Point`] + points List of points. - mirror : [float, float, float] | :class:`compas.geometry.Point` + mirror The mirror point. Returns ------- - list[[float, float, float]] + list[list[float]] The mirrored points, with Z=0. """ return [mirror_point_point(point, mirror) for point in points] -def mirror_points_point_xy(points, mirror): +def mirror_points_point_xy(points: Sequence[Sequence[float]], mirror: Sequence[float]) -> list[list[float]]: """Mirror multiple points about a point. Parameters ---------- - points : sequence[[float, float, float] | :class:`compas.geometry.Point`] + points List of points with XY(Z) coordinates. - mirror : [float, float, float] | :class:`compas.geometry.Point` + mirror The XY(Z) coordinates of the mirror point. Returns ------- - list[[float, float, float]] + list[list[float]] The mirrored points, with Z=0. """ return [mirror_point_point_xy(point, mirror) for point in points] -def mirror_point_line(point, line): +def mirror_point_line(point: Sequence[float], line: Sequence[Sequence[float]]) -> list[float]: """Mirror a point about a line. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XYZ coordinates of the point to mirror. - line : [point, point] | :class:`compas.geometry.Line` + line Two points defining the mirror line. Returns ------- - [float, float, float] + list[float] The mirrored point. """ @@ -624,20 +625,20 @@ def mirror_point_line(point, line): return add_vectors(closest, subtract_vectors(closest, point)) -def mirror_point_line_xy(point, line): +def mirror_point_line_xy(point: Sequence[float], line: Sequence[Sequence[float]]) -> list[float]: """Mirror a point about a line. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XY(Z) coordinates of the point to mirror. - line : [point, point] | :class:`compas.geometry.Line` + line Two points defining the line. XY(Z) coordinates of the two points defining the mirror line. Returns ------- - [float, float, float] + list[float] The mirrored point, with Z=0. """ @@ -645,57 +646,57 @@ def mirror_point_line_xy(point, line): return add_vectors_xy(closest, subtract_vectors_xy(closest, point)) -def mirror_points_line(points, line): +def mirror_points_line(points: Sequence[Sequence[float]], line: Sequence[Sequence[float]]) -> list[list[float]]: """Mirror a point about a line. Parameters ---------- - points : sequence[[float, float, float] | :class:`compas.geometry.Point`] + points List of points to mirror. - line : [point, point] | :class:`compas.geometry.Line` + line Two points defining the mirror line. Returns ------- - list[[float, float, float]] + list[list[float]] The mirrored points. """ return [mirror_point_line(point, line) for point in points] -def mirror_points_line_xy(points, line): +def mirror_points_line_xy(points: Sequence[Sequence[float]], line: Sequence[Sequence[float]]) -> list[list[float]]: """Mirror a point about a line. Parameters ---------- - points : sequence[[float, float, float] | :class:`compas.geometry.Point`] + points List of points to mirror. - line : [point, point] | :class:`compas.geometry.Line` + line Two points defining the mirror line. Returns ------- - list[[float, float, float]] + list[list[float]] The mirrored points. """ return [mirror_point_line_xy(point, line) for point in points] -def mirror_point_plane(point, plane): +def mirror_point_plane(point: Sequence[float], plane: Sequence[Sequence[float]]) -> list[float]: """Mirror a point about a plane. Parameters ---------- - point : list[float] + point XYZ coordinates of mirror point. - plane : [point, vector] + plane Base point and normal defining the mirror plane. Returns ------- - [float, float, float] + list[float] XYZ coordinates of the mirrored point. """ @@ -703,19 +704,19 @@ def mirror_point_plane(point, plane): return add_vectors(closest, subtract_vectors(closest, point)) -def mirror_points_plane(points, plane): +def mirror_points_plane(points: Sequence[Sequence[float]], plane: Sequence[Sequence[float]]) -> list[list[float]]: """Mirror a point about a plane. Parameters ---------- - points : sequence[[float, float, float] | :class:`compas.geometry.Point`] + points List of points to mirror. - plane : [point, vector] + plane Base point and normal defining the mirror plane. Returns ------- - list[[float, float, float]] + list[list[float]] The mirrored points. """ @@ -729,31 +730,30 @@ def mirror_points_plane(points, plane): # ============================================================================== -def project_point_plane(point, plane): +def project_point_plane(point: Sequence[float], plane: Sequence[Sequence[float]]) -> list[float]: """Project a point onto a plane. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XYZ coordinates of the point. - plane : [point, vector] + plane Base point and normal vector defining the projection plane. Returns ------- - [float, float, float] + list[float] XYZ coordinates of the projected point. Notes ----- The projection is in the direction perpendicular to the plane. The projected point is thus the closest point on the plane to the original - point [1]_. + point.[^project-point-plane-formula] References ---------- - .. [1] Math Stack Exchange. *Project a point in 3D on a given plane*. - Available at: https://math.stackexchange.com/questions/444968/project-a-point-in-3d-on-a-given-plane. + [^project-point-plane-formula]: [Project a point in 3D on a given plane](https://math.stackexchange.com/questions/444968/project-a-point-in-3d-on-a-given-plane) Examples -------- @@ -771,52 +771,51 @@ def project_point_plane(point, plane): return subtract_vectors(point, snormal) -def project_points_plane(points, plane): +def project_points_plane(points: Sequence[Sequence[float]], plane: Sequence[Sequence[float]]) -> list[list[float]]: """Project multiple points onto a plane. Parameters ---------- - points : sequence[[float, float, float] | :class:`compas.geometry.Point`] + points List of points. - plane : [point, vector] + plane Base point and normal vector defining the projection plane. Returns ------- - list[[float, float, float]] + list[list[float]] The projected points. See Also -------- - project_point_plane + [`project_point_plane`][compas.geometry.project_point_plane] """ return [project_point_plane(point, plane) for point in points] -def project_point_line(point, line): +def project_point_line(point: Sequence[float], line: Sequence[Sequence[float]]) -> list[float]: """Project a point onto a line. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point XYZ coordinates of the point. - line : [point, point] | :class:`compas.geometry.Line` + line Two points defining the projection line. Returns ------- - [float, float, float] + list[float] XYZ coordinates of the projected point. Notes ----- - For more info, see [1]_. + This uses orthogonal projection onto a line.[^project-point-line-formula] References ---------- - .. [1] Wiki Books. *Linear Algebra/Orthogonal Projection Onto a Line*. - Available at: https://en.wikibooks.org/wiki/Linear_Algebra/Orthogonal_Projection_Onto_a_Line. + [^project-point-line-formula]: [Linear Algebra/Orthogonal Projection Onto a Line](https://en.wikibooks.org/wiki/Linear_Algebra/Orthogonal_Projection_Onto_a_Line) """ a, b = line @@ -827,29 +826,28 @@ def project_point_line(point, line): return add_vectors(a, c) -def project_point_line_xy(point, line): +def project_point_line_xy(point: Sequence[float], line: Sequence[Sequence[float]]) -> list[float]: """Project a point onto a line in the XY plane. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` - XY(Z) coordinates of the point. - line : [point, point] | :class:`compas.geometry.Line` + points + XY(Z) coordinates of the points. + line Two points defining the projection line. Returns ------- - [float, float, float] - XYZ coordinates of the projected point, with Z=0. + list[list[float]] + XYZ coordinates of the projected points, with Z=0. Notes ----- - For more info, see [1]_. + This uses orthogonal projection onto a line.[^project-point-line-xy-formula] References ---------- - .. [1] Wiki Books. *Linear Algebra/Orthogonal Projection Onto a Line*. - Available at: https://en.wikibooks.org/wiki/Linear_Algebra/Orthogonal_Projection_Onto_a_Line. + [^project-point-line-xy-formula]: [Linear Algebra/Orthogonal Projection Onto a Line](https://en.wikibooks.org/wiki/Linear_Algebra/Orthogonal_Projection_Onto_a_Line) """ a, b = line @@ -859,57 +857,55 @@ def project_point_line_xy(point, line): return add_vectors_xy(a, c) -def project_points_line(points, line): +def project_points_line(points: Sequence[Sequence[float]], line: Sequence[Sequence[float]]) -> list[list[float]]: """Project points onto a line. Parameters ---------- - points : sequence[[float, float, float] | :class:`compas.geometry.Point`] + points XYZ coordinates of the points. - line : [point, point] | :class:`compas.geometry.Line` + line Two points defining the projection line. Returns ------- - list[[float, float, float]] + list[list[float]] XYZ coordinates of the projected points. Notes ----- - For more info, see [1]_. + This uses orthogonal projection onto a line.[^project-points-line-formula] References ---------- - .. [1] Wiki Books. *Linear Algebra/Orthogonal Projection Onto a Line*. - Available at: https://en.wikibooks.org/wiki/Linear_Algebra/Orthogonal_Projection_Onto_a_Line. + [^project-points-line-formula]: [Linear Algebra/Orthogonal Projection Onto a Line](https://en.wikibooks.org/wiki/Linear_Algebra/Orthogonal_Projection_Onto_a_Line) """ return [project_point_line(point, line) for point in points] -def project_points_line_xy(points, line): +def project_points_line_xy(points: Sequence[Sequence[float]], line: Sequence[Sequence[float]]) -> list[list[float]]: """Project points onto a line in the XY plane. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` - XY(Z) coordinates of the point. - line : [point, point] | :class:`compas.geometry.Line` + points + XY(Z) coordinates of the points. + line Two points defining the projection line. Returns ------- - [float, float, float] - XYZ coordinates of the projected point, with Z=0. + list[list[float]] + XYZ coordinates of the projected points, with Z=0. Notes ----- - For more info, see [1]_. + This uses orthogonal projection onto a line.[^project-points-line-xy-formula] References ---------- - .. [1] Wiki Books. *Linear Algebra/Orthogonal Projection Onto a Line*. - Available at: https://en.wikibooks.org/wiki/Linear_Algebra/Orthogonal_Projection_Onto_a_Line. + [^project-points-line-xy-formula]: [Linear Algebra/Orthogonal Projection Onto a Line](https://en.wikibooks.org/wiki/Linear_Algebra/Orthogonal_Projection_Onto_a_Line) """ return [project_point_line_xy(point, line) for point in points] @@ -920,22 +916,26 @@ def project_points_line_xy(points, line): # ============================================================================== -def reflect_line_plane(line, plane, tol=None): +def reflect_line_plane( + line: Sequence[Sequence[float]], + plane: Sequence[Sequence[float]], + tol: Optional[float] = None, +) -> Optional[tuple[list[float], list[float]]]: """Bounce a line of a reflection plane. Parameters ---------- - line : [point, point] | :class:`compas.geometry.Line` + line Two points defining the line. - plane : [point, vector] + plane Base point and normal vector of the plane. - tol : float, optional + tol A tolerance for finding the intersection between the line and the plane. - Default is :func:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- - tuple[[float, float, float], [float, float, float]] + Optional[tuple[list[float], list[float]]] The reflected line defined by the intersection point of the line and plane and the mirrored start point of the line with respect to a line perpendicular to the plane through the intersection. @@ -973,22 +973,26 @@ def reflect_line_plane(line, plane, tol=None): return x, mirror_point_line(a, mirror) -def reflect_line_triangle(line, triangle, tol=None): +def reflect_line_triangle( + line: Sequence[Sequence[float]], + triangle: Sequence[Sequence[float]], + tol: Optional[float] = None, +) -> Optional[tuple[list[float], list[float]]]: """Bounce a line of a reflection triangle. Parameters ---------- - line : [point, point] | :class:`compas.geometry.Line` + line Two points defining the line. - triangle : [point, point, point] + triangle The triangle vertices. - tol : float, optional + tol A tolerance value for finding the intersection between the line and the triangle. - Default is :func:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- - tuple[[float, float, float], [float, float, float]] + Optional[tuple[list[float], list[float]]] The reflected line defined by the intersection point of the line and triangle and the mirrored start point of the line with respect to a line perpendicular to the triangle through the intersection. @@ -1037,21 +1041,25 @@ def reflect_line_triangle(line, triangle, tol=None): # ============================================================================== -def orient_points(points, reference_plane, target_plane): +def orient_points( + points: Sequence[Sequence[float]], + reference_plane: Sequence[Sequence[float]], + target_plane: Sequence[Sequence[float]], +) -> list[list[float]]: """Orient points from one plane to another. Parameters ---------- - points : sequence[[float, float, float] | :class:`compas.geometry.Point`] + points XYZ coordinates of the points. - reference_plane : [point, vector] + reference_plane Base point and normal defining a reference plane. - target_plane : [point, vector] + target_plane Base point and normal defining a target plane. Returns ------- - list[[float, float, float]] + list[list[float]] 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 7fc1802754ef..87bea8e7bc46 100644 --- a/src/compas/geometry/_core/transformations_numpy.py +++ b/src/compas/geometry/_core/transformations_numpy.py @@ -1,31 +1,36 @@ +from typing import Any +from typing import Sequence + from numpy import asarray from numpy import hstack from numpy import ones from numpy import tile from numpy import vectorize -from scipy.linalg import solve # type: ignore +from numpy.typing import ArrayLike +from numpy.typing import NDArray +from scipy.linalg import solve -from ._algebra import cross_vectors +from compas.linalg.vectors import cross_vectors -def transform_points_numpy(points, T): +def transform_points_numpy(points: ArrayLike, T: ArrayLike) -> NDArray[Any]: """Transform multiple points with one Transformation using numpy. Parameters ---------- - points : sequence[[float, float, float] | :class:`compas.geometry.Point`] + points A list of points to be transformed. - T : :class:`compas.geometry.Transformation` | list[list[float]] + T The transformation to apply. Returns ------- - (N, 3) ndarray - The transformed points. + NDArray[Any] + The transformed points as an array with shape `(N, 3)`. Examples -------- - >>> from compas.geometry import matrix_from_axis_and_angle + >>> from compas.linalg import matrix_from_axis_and_angle >>> points = [[1, 0, 0], [1, 2, 4], [4, 7, 1]] >>> T = matrix_from_axis_and_angle([0, 2, 0], math.radians(45), point=[4, 5, 6]) >>> points_transformed = transform_points_numpy(points, T) @@ -36,24 +41,24 @@ def transform_points_numpy(points, T): return dehomogenize_numpy(points.dot(T.T)) -def transform_vectors_numpy(vectors, T): +def transform_vectors_numpy(vectors: ArrayLike, T: ArrayLike) -> NDArray[Any]: """Transform multiple vectors with one Transformation using numpy. Parameters ---------- - vectors : sequence[[float, float, float] | :class:`compas.geometry.Vector`] + vectors A list of vectors to be transformed. - T : :class:`compas.geometry.Transformation` + T The transformation to apply. Returns ------- - (N, 3) ndarray - The transformed vectors. + NDArray[Any] + The transformed vectors as an array with shape `(N, 3)`. Examples -------- - >>> from compas.geometry import matrix_from_axis_and_angle + >>> from compas.linalg import matrix_from_axis_and_angle >>> vectors = [[1, 0, 0], [1, 2, 4], [4, 7, 1]] >>> T = matrix_from_axis_and_angle([0, 2, 0], math.radians(45), point=[4, 5, 6]) >>> vectors_transformed = transform_vectors_numpy(vectors, T) @@ -64,20 +69,20 @@ def transform_vectors_numpy(vectors, T): return dehomogenize_numpy(vectors.dot(T.T)) -def transform_frames_numpy(frames, T): - """Transform multiple frames with one Transformation usig numpy. +def transform_frames_numpy(frames: Sequence[Sequence[Sequence[float]]], T: ArrayLike) -> NDArray[Any]: + """Transform multiple frames with one transformation using NumPy. Parameters ---------- - frames : sequence[[point, vector, vector]] + frames A list of frames to be transformed. - T : :class:`compas.geometry.Transformation` + T The transformation to apply on the frames. Returns ------- - (N, 3, 3) ndarray - The transformed frames. + NDArray[Any] + The transformed frames as an array with shape `(N, 3, 3)`. Examples -------- @@ -92,20 +97,21 @@ def transform_frames_numpy(frames, T): return dehomogenize_and_unflatten_frames_numpy(points_and_vectors.dot(T.T)) -def world_to_local_coordinates_numpy(frame, xyz): +def world_to_local_coordinates_numpy(frame: Sequence[Sequence[float]], xyz: ArrayLike) -> NDArray[Any]: """Convert global coordinates to local coordinates. Parameters ---------- - frame : [point, vector, vector] + frame The local coordinate system. - xyz : array-like[[float, float, float] | :class:`compas.geometry.Point`] + xyz The global coordinates of the points to convert. Returns ------- - (N, 3) ndarray - The coordinates of the given points in the local coordinate system. + NDArray[Any] + The coordinates of the given points in the local coordinate system, + as an array with shape `(N, 3)`. Examples -------- @@ -125,20 +131,20 @@ def world_to_local_coordinates_numpy(frame, xyz): return rst.T -def local_to_world_coordinates_numpy(frame, rst): +def local_to_world_coordinates_numpy(frame: Sequence[Sequence[float]], rst: ArrayLike) -> NDArray[Any]: """Convert local coordinates to global (world) coordinates. Parameters ---------- - frame : [point, vector, vector] + frame The local coordinate system. - rst : array-like[[float, float, float] | :class:`compas.geometry.Point`] + rst The coordinates of the points wrt the local coordinate system. Returns ------- - (N, 3) ndarray - The world coordinates of the given points. + NDArray[Any] + The world coordinates of the given points as an array with shape `(N, 3)`. Notes ----- @@ -168,20 +174,21 @@ def local_to_world_coordinates_numpy(frame, rst): # ============================================================================== -def homogenize_numpy(data, w=1.0): - """Dehomogenizes points or vectors. +def homogenize_numpy(data: ArrayLike, w: float = 1.0) -> NDArray[Any]: + """Homogenize points or vectors. Parameters ---------- - data : array_like[[float, float, float] | :class:`compas.geometry.Point`] | array_like[[float, float, float] | :class:`compas.geometry.Vector`] + data The input data. - w : float, optional + w The homogenization factor. - Use ``1.0`` for points, and ``0.0`` for vectors. + Use `1.0` for points, and `0.0` for vectors. Returns ------- - (N, 4) ndarray + NDArray[Any] + The homogenized data as an array with shape `(N, 4)`. Examples -------- @@ -196,17 +203,18 @@ def homogenize_numpy(data, w=1.0): return data -def dehomogenize_numpy(data): +def dehomogenize_numpy(data: ArrayLike) -> NDArray[Any]: """Dehomogenizes points or vectors. Parameters ---------- - data : array_like[[float, float, float, float]] + data The data to dehomogenize. Returns ------- - (N, 3) ndarray + NDArray[Any] + The dehomogenized data as an array with shape `(N, 3)`. Examples -------- @@ -217,7 +225,7 @@ def dehomogenize_numpy(data): """ - def func(a): + def func(a: float) -> float: return a if a else 1.0 func = vectorize(func) @@ -226,18 +234,20 @@ def func(a): return data[:, :-1] / func(data[:, -1]).reshape((-1, 1)) -def homogenize_and_flatten_frames_numpy(frames): +def homogenize_and_flatten_frames_numpy( + frames: Sequence[Sequence[Sequence[float]]], +) -> NDArray[Any]: """Homogenize a list of frames and flatten the 3D list into a 2D list using numpy. Parameters ---------- - frames : array_like[[point, vector, vector]] + frames The input frames. Returns ------- - (N x 3, 4) ndarray - An array of points and vectors. + NDArray[Any] + The points and vectors as an array with shape `(N * 3, 4)`. Examples -------- @@ -249,23 +259,23 @@ def homogenize_and_flatten_frames_numpy(frames): """ n = len(frames) - frames = asarray(frames).reshape(n * 3, 3) + frames_array = asarray(frames).reshape(n * 3, 3) extend = tile(asarray([1, 0, 0]).reshape(3, 1), (n, 1)) - return hstack((frames, extend)) + return hstack((frames_array, extend)) -def dehomogenize_and_unflatten_frames_numpy(points_and_vectors): +def dehomogenize_and_unflatten_frames_numpy(points_and_vectors: ArrayLike) -> NDArray[Any]: """Dehomogenize a list of vectors and unflatten the 2D list into a 3D list. Parameters ---------- - points_and_vectors : array_like[[float, float, float, float]] + points_and_vectors Homogenized points and vectors. Returns ------- - (N / 3, 3, 3) ndarray - The frames. + NDArray[Any] + The frames as an array with shape `(N / 3, 3, 3)`. Examples -------- diff --git a/src/compas/geometry/_typing.py b/src/compas/geometry/_typing.py new file mode 100644 index 000000000000..def43fabc23d --- /dev/null +++ b/src/compas/geometry/_typing.py @@ -0,0 +1,28 @@ +from typing import TYPE_CHECKING +from typing import Sequence +from typing import Union + +from compas._typing import CoordinatesType +from compas._typing import CoordinateType + +if TYPE_CHECKING: + from compas.geometry import Line + from compas.geometry import Plane + from compas.geometry import Polygon + from compas.geometry import Polyline + from compas.geometry import Quaternion + from compas.geometry import Transformation + + +LineType = Union["Line", Sequence[CoordinateType]] +PlaneType = Union["Plane", Sequence[CoordinateType]] +SphereType = tuple[CoordinateType, float] +CircleType = tuple[PlaneType, float] +MeshType = tuple[CoordinatesType, Sequence[Sequence[int]]] +RayType = tuple[CoordinateType, CoordinateType] +RayMeshHit = tuple[int, float, float, float] +PolygonType = Union["Polygon", Sequence[CoordinateType]] +PolylineType = Union["Polyline", Sequence[CoordinateType]] +QuaternionType = Union["Quaternion", Sequence[float]] +TriangleType = Union["Polygon", Sequence[CoordinateType]] +TransformationType = Union["Transformation", Sequence[Sequence[float]]] diff --git a/src/compas/geometry/bbox.py b/src/compas/geometry/bbox.py index 232f821176e5..b7af2ce3f02e 100644 --- a/src/compas/geometry/bbox.py +++ b/src/compas/geometry/bbox.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from itertools import islice from compas.plugins import pluggable diff --git a/src/compas/geometry/bbox_numpy.py b/src/compas/geometry/bbox_numpy.py index c3704b24fe24..9fd8e847dd23 100644 --- a/src/compas/geometry/bbox_numpy.py +++ b/src/compas/geometry/bbox_numpy.py @@ -10,11 +10,11 @@ from numpy import zeros from scipy.spatial import ConvexHull -from compas.geometry import length_vector from compas.geometry import local_axes from compas.geometry import local_to_world_coordinates_numpy from compas.geometry import pca_numpy from compas.geometry import world_to_local_coordinates_numpy +from compas.linalg.vectors import length_vector from compas.tolerance import TOL from .bbox import bounding_box @@ -69,7 +69,7 @@ def oriented_bounding_box_numpy(points, tol=None): Compute the volume of the oriented bounding box. - >>> from compas.geometry import length_vector, subtract_vectors, close + >>> from compas.linalg import length_vector, subtract_vectors, close >>> bbox = oriented_bounding_box_numpy(points) >>> a = length_vector(subtract_vectors(bbox[1], bbox[0])) >>> b = length_vector(subtract_vectors(bbox[3], bbox[0])) diff --git a/src/compas/geometry/bestfit.py b/src/compas/geometry/bestfit.py index ba4a02c538ca..ed56f56295f6 100644 --- a/src/compas/geometry/bestfit.py +++ b/src/compas/geometry/bestfit.py @@ -1,10 +1,6 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas.geometry import centroid_points -from compas.geometry import normalize_vector -from compas.geometry import subtract_vectors +from compas.linalg.vectors import normalize_vector +from compas.linalg.vectors import subtract_vectors def bestfit_plane(points): diff --git a/src/compas/geometry/booleans.py b/src/compas/geometry/booleans.py index 5ed22c70402c..83a5f2091db1 100644 --- a/src/compas/geometry/booleans.py +++ b/src/compas/geometry/booleans.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas.plugins import PluginNotInstalledError from compas.plugins import pluggable diff --git a/src/compas/geometry/brep/brep.py b/src/compas/geometry/brep/brep.py index dd5c7174867e..3d961975661f 100644 --- a/src/compas/geometry/brep/brep.py +++ b/src/compas/geometry/brep/brep.py @@ -444,6 +444,7 @@ def from_native(cls, native_brep): Returns ------- :class:`compas.geometry.Brep` + """ return from_native(native_brep) diff --git a/src/compas/geometry/brep/vertex.py b/src/compas/geometry/brep/vertex.py index 659902d6d714..a7df691b668c 100644 --- a/src/compas/geometry/brep/vertex.py +++ b/src/compas/geometry/brep/vertex.py @@ -42,5 +42,6 @@ def from_point(cls, point): ------- :class:`compas.geometry.BrepVertex` The created vertex + """ raise NotImplementedError diff --git a/src/compas/geometry/curves/arc.py b/src/compas/geometry/curves/arc.py index b98dac9f1f71..2982009335ce 100644 --- a/src/compas/geometry/curves/arc.py +++ b/src/compas/geometry/curves/arc.py @@ -1,10 +1,10 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import cos from math import pi from math import sin +from typing import Any +from typing import Optional + +from typing_extensions import Self from compas.geometry import Circle from compas.geometry import Frame @@ -18,119 +18,47 @@ class Arc(Curve): - """An arc is a segment of a circle, and is defined by a coordinate system, radius, and start and end angles. - - The centre of the underlying circle is at the origin of the coordinate system. - The start and end angles are measured from the positive X axis towards the positive Y axis. + """A circular arc defined by a frame, radius, and two angles. - The parametrisation of the arc is normalised with respect to the arc angle. - The value ``t=0.0`` corresponds to the start angle of the arc. - The value ``t=1.0`` corresponds to the end angle of the arc. - The value ``t=0.5`` corresponds to the angle halfway between start and end. - - Transformations of the arc are performed by transforming the local coordinate system. + The center of the underlying circle is the origin of the frame. Angles are + measured from the positive x-axis towards the positive y-axis. Parameters ---------- - radius : float - Radius of the arc's circle. - start_angle : float - The angle in radians of the start of this Arc. - end_angle : float - The angle in radians of the end of this Arc. - frame : :class:`compas.geometry.Frame`, optional - Local coordinate system of the arc. - Defaults to the world XY plane. - name : str, optional + radius + The radius of the underlying circle. + start_angle + The start angle in radians, in the range `[0, 2 * pi]`. + end_angle + The end angle in radians, in the range `[0, 2 * pi]`. + frame + The local coordinate frame. Default is the world XY frame. + name The name of the arc. - Attributes - ---------- - frame : :class:`compas.geometry.Frame` - The coordinate frame of the arc. - transformation : :class:`Transformation`, read-only - The transformation from the local coordinate system of the arc (:attr:`frame`) to the world coordinate system. - radius : float - The radius of the circle. - start_angle : float - The start angle of the arc. - end_angle : float - The end angle of the arc. - circle : :class:`compas.geometry.Circle`, read-only - The underlying circle. - plane : :class:`compas.geometry.Plane`, read-only - The plane of the arc. - diameter : float, read-only - The diameter of the underlying circle. - length : float, read-only - The length of the arc. - angle : float, read-only - The sweep angle in radians between start angle and end angle. - circumference : float, read-only - The circumference of the underlying circle. - is_closed : bool, read-only - False. - is_periodic : bool, read-only - False. - See Also -------- - :class:`compas.geometry.Circle` + [`Circle`][compas.geometry.Circle] + + Notes + ----- + The parameter domain is `[0, 1]`: `0.0` is the start angle, `1.0` is the + end angle, and `0.5` is the angle halfway between them. Examples -------- - Construct a semicircular arc in the XY plane, with radius 1.0, and compute its length. - >>> from math import pi >>> from compas.geometry import Arc - >>> arc = Arc(radius=1.0, start_angle=0.0, end_angle=pi) - >>> arc.length == 1.0 * pi + >>> arc = Arc(1.0, 0.0, pi) + >>> arc.length == pi True - - Construct a quarter arc in the 3rd quadrant of a frame - aligned with the world ZX plane at a distance of 1.0 from the world origin along the world Y axis. - - >>> from math import pi - >>> from compas.geometry import Frame - >>> from compas.geometry import Arc - >>> frame = Frame([0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]) - >>> arc = Arc(radius=1.0, start_angle=pi, end_angle=pi * 1.5, frame=frame) - >>> arc.length == 1.0 * pi * 0.5 - True - - Visualize the arc using the viewer. - - >>> from compas_viewer import Viewer # doctest: +SKIP - >>> viewer = Viewer() # doctest: +SKIP - >>> viewer.scene.add(arc.to_polyline(n=20)) # doctest: +SKIP - >>> viewer.scene.add(arc.frame) # doctest: +SKIP - >>> viewer.show() # doctest: +SKIP - - Visualize only part of the arc. - - >>> from compas_viewer import Viewer # doctest: +SKIP - >>> viewer = Viewer() # doctest: +SKIP - >>> viewer.scene.add(arc.to_polyline(n=20, domain=(0.25, 0.75))) # doctest: +SKIP - >>> viewer.scene.add(arc.frame) # doctest: +SKIP - >>> viewer.show() # doctest: +SKIP + >>> arc.point_at(0.5) + Point(x=0.000, y=1.000, z=0.000) """ - DATASCHEMA = { - "value": { - "type": "object", - "properties": { - "radius": {"type": "number", "minimum": 0}, - "start_angle": {"type": "number", "minimum": 0, "optional": True}, - "end_angle": {"type": "number", "minimum": 0}, - "frame": Frame.DATASCHEMA, - }, - "required": ["frame", "radius", "start_angle", "end_angle"], - } - } - @property - def __data__(self): + def __data__(self) -> dict[str, Any]: return { "radius": self.radius, "start_angle": self.start_angle, @@ -139,7 +67,7 @@ def __data__(self): } @classmethod - def __from_data__(cls, data): + def __from_data__(cls, data: dict[str, Any]) -> Self: return cls( radius=data["radius"], start_angle=data["start_angle"], @@ -147,223 +75,236 @@ def __from_data__(cls, data): frame=Frame.__from_data__(data["frame"]), ) - def __init__(self, radius, start_angle, end_angle, frame=None, name=None): - super(Arc, self).__init__(frame=frame, name=name) - self._radius = None - self._start_angle = None - self._end_angle = None + def __init__( + self, + radius: float, + start_angle: float, + end_angle: float, + frame: Optional[Frame] = None, + name: Optional[str] = None, + ) -> None: + super().__init__(frame=frame, name=name) + self._radius: Optional[float] = None + self._start_angle: Optional[float] = None + self._end_angle: Optional[float] = None self.radius = radius self.start_angle = start_angle self.end_angle = end_angle - def __repr__(self): - return "{0}(radius={1}, start_angle={2}, end_angle={3}, frame={4!r})".format( - type(self).__name__, - self.radius, - self.start_angle, - self.end_angle, - self.frame, - ) + def __repr__(self) -> str: + return "{0}(radius={1}, start_angle={2}, end_angle={3}, frame={4!r})".format(type(self).__name__, self.radius, self.start_angle, self.end_angle, self.frame) - def __eq__(self, other): - try: - return self.radius == other.radius and self.start_angle == other.start and self.end_angle == other.end and self.frame == other.frame - except Exception: + def __eq__(self, other: object) -> bool: + if not isinstance(other, Arc): return False - - # ============================================================================= - # Properties - # ============================================================================= + return self.radius == other.radius and self.start_angle == other.start_angle and self.end_angle == other.end_angle and self.frame == other.frame @property - def radius(self): + def radius(self) -> float: + """The radius of the underlying circle. + + Notes + ----- + The radius must be positive. + + Examples + -------- + >>> Arc(1.0, 0.0, pi).radius + 1.0 + + """ if self._radius is None: raise ValueError("Radius is not set.") return self._radius @radius.setter - def radius(self, value): - if value < 0.0: - raise ValueError("Radius must be greater than or equal to zero.") + def radius(self, value: float) -> None: + if value <= 0.0: + raise ValueError("Radius must be positive.") self._radius = value @property - def start_angle(self): + def start_angle(self) -> float: + """The start angle in radians. + + Examples + -------- + >>> Arc(1.0, 0.5, 1.0).start_angle + 0.5 + + """ if self._start_angle is None: self._start_angle = 0.0 return self._start_angle @start_angle.setter - def start_angle(self, value): + def start_angle(self, value: float) -> None: if value < 0.0 or value > PI2: raise ValueError("Start angle must satisfy 0 <= angle <= 2 * pi.") self._start_angle = value @property - def end_angle(self): + def end_angle(self) -> float: + """The end angle in radians. + + Examples + -------- + >>> Arc(1.0, 0.0, pi).end_angle == pi + True + + """ if self._end_angle is None: raise ValueError("End angle not set.") return self._end_angle @end_angle.setter - def end_angle(self, value): + def end_angle(self, value: float) -> None: if value < 0.0 or value > PI2: raise ValueError("End angle must satisfy 0 <= angle <= 2 * pi.") self._end_angle = value @property - def circle(self): + def circle(self) -> Circle: + """The underlying circle as an independent object.""" return Circle(radius=self.radius, frame=self.frame) @property - def center(self): + def center(self) -> Point: + """The center of the arc. + + Notes + ----- + The returned point belongs to the arc frame. Mutating it changes the arc. + + """ return self.frame.point @property - def length(self): - return self.radius * self.angle + def length(self) -> float: + """The nonnegative length of the arc.""" + return self.radius * abs(self.angle) @property - def angle(self): + def angle(self) -> float: + """The signed sweep angle in radians.""" return self.end_angle - self.start_angle @property - def diameter(self): + def diameter(self) -> float: + """The diameter of the underlying circle.""" return 2.0 * self.radius @property - def circumference(self): + def circumference(self) -> float: + """The circumference of the underlying circle.""" return self.diameter * pi @property - def is_circle(self): + def is_circle(self) -> bool: + """Whether the arc covers a complete circle.""" return TOL.is_close(abs(self.angle), PI2) @property - def is_closed(self): - return False + def is_closed(self) -> bool: + """Whether the arc covers a complete circle.""" + return self.is_circle @property - def is_periodic(self): - return False - - def _verify(self): - if self.angle < 0.0 or self.angle > PI2: - raise ValueError("Sweep angle must satisfy 0 < angle < 2 * Pi. Currently:{}".format(self.angle)) - - # ============================================================================= - # Constructors - # ============================================================================= + def is_periodic(self) -> bool: + """Whether the arc covers a complete circle.""" + return self.is_circle @classmethod - def from_circle(cls, circle, start_angle, end_angle): - """Creates an Arc from a circle and start and end angles. + def from_circle(cls, circle: Circle, start_angle: float, end_angle: float) -> Self: + """Construct an arc from a circle and two angles. Parameters ---------- - circle : :class:`compas.geometry.Circle` - The frame and radius of this circle will be used to create an Arc. - start_angle : float + circle + The circle providing the frame and radius. + start_angle The start angle in radians. - end_angle : float + end_angle The end angle in radians. Returns ------- - :class:`compas.geometry.Arc` - - """ - return cls( - frame=circle.frame, - radius=circle.radius, - start_angle=start_angle, - end_angle=end_angle, - ) + Self + The constructed arc. - # ============================================================================= - # Conversions - # ============================================================================= + Notes + ----- + The arc receives an independent copy of the circle frame. - # ============================================================================= - # Transformations - # ============================================================================= + Examples + -------- + >>> circle = Circle(radius=2.0) + >>> arc = Arc.from_circle(circle, 0.0, pi) + >>> arc.radius, arc.length + (2.0, 6.283185307179586) - # ============================================================================= - # Methods - # ============================================================================= + """ + return cls(circle.radius, start_angle, end_angle, frame=circle.frame) - def point_at(self, t, world=True): - """Returns the point at the specified parameter. + def point_at(self, t: float, world: bool = True) -> Point: + """Compute the point at a normalized parameter. Parameters ---------- - t : float - The parameter at which to evaluate the arc. - world : bool, optional - If ``True``, the point is returned in world coordinates. + t + The parameter in `[0, 1]`. + world + Return world coordinates if `True`, otherwise local coordinates. Returns ------- - :class:`compas.geometry.Point` + Point + The point at the parameter. Raises ------ ValueError - If the parameter is not in the domain of the curve ``[0, 1]``. + If `t` is outside `[0, 1]`. - See Also + Examples -------- - :meth:`normal_at`, :meth:`tangent_at` - - Notes - ----- - The parametrisation of the arc is normalised with respect to the polar angle domain. - The value ``t=0.0`` corresponds to the start angle of the arc. - The value ``t=1.0`` corresponds to the end angle of the arc. - The value ``t=0.5`` corresponds to the angle halfway between start and end. + >>> Arc(1.0, 0.0, pi).point_at(0.5) + Point(x=0.000, y=1.000, z=0.000) """ if t < 0.0 or t > 1.0: raise ValueError("Parameter t should be between 0.0 and 1.0") - angle = self.start_angle + t * self.angle x = self.radius * cos(angle) y = self.radius * sin(angle) - if not world: return Point(x, y, 0.0) - return self.frame.point + self.frame.xaxis * x + self.frame.yaxis * y - def normal_at(self, t, world=True): - """Construct a normal vector to the arc at a specific parameter. + def normal_at(self, t: float, world: bool = True) -> Vector: + """Compute the inward unit normal at a normalized parameter. Parameters ---------- - t : float - The parameter at which to evaluate the arc. - world : bool, optional - If ``True``, the normal is returned in world coordinates. + t + The parameter in `[0, 1]`. + world + Return world coordinates if `True`, otherwise local coordinates. Returns ------- - :class:`compas.geometry.Vector` + Vector + The inward unit normal. Raises ------ ValueError - If the parameter is not in the domain of the curve ``[0, 1]``. + If `t` is outside `[0, 1]`. - See Also + Examples -------- - :meth:`point_at`, :meth:`tangent_at` - - Notes - ----- - The parametrisation of the arc is normalised with respect to the polar angle domain. - The value ``t=0.0`` corresponds to the start angle of the arc. - The value ``t=1.0`` corresponds to the end angle of the arc. - The value ``t=0.5`` corresponds to the angle halfway between start and end. + >>> Arc(1.0, 0.0, pi).normal_at(0.0) + Vector(x=-1.000, y=0.000, z=0.000) """ if not world: @@ -371,52 +312,58 @@ def normal_at(self, t, world=True): normal = Vector(-point.x, -point.y, 0.0) normal.unitize() return normal - normal = self.frame.point - self.point_at(t) normal.unitize() return normal - def tangent_at(self, t, world=True): - """Construct a tangent on the circle at a specific parameter. + def tangent_at(self, t: float, world: bool = True) -> Vector: + """Compute the unit tangent at a normalized parameter. Parameters ---------- - t : float - The parameter at which to evaluate the arc. - world : bool, optional - If ``True``, the tangent is returned in world coordinates. + t + The parameter in `[0, 1]`. + world + Return world coordinates if `True`, otherwise local coordinates. Returns ------- - :class:`compas.geometry.Vector` - The tangent on the circle at the specified parameter. + Vector + The unit tangent in increasing parameter direction. Raises ------ ValueError - If the parameter is not in the domain of the curve ``[0, 1]``. + If `t` is outside `[0, 1]`. - See Also + Examples -------- - :meth:`point_at`, :meth:`normal_at`, :meth:`binormal_at` - - Notes - ----- - The parametrisation of the arc is normalised with respect to the polar angle domain. - The value ``t=0.0`` corresponds to the start angle of the arc. - The value ``t=1.0`` corresponds to the end angle of the arc. - The value ``t=0.5`` corresponds to the angle halfway between start and end. + >>> Arc(2.0, 0.0, pi).tangent_at(0.0) + Vector(x=0.000, y=1.000, z=0.000) + >>> Arc(2.0, pi, 0.0).tangent_at(0.0) + Vector(x=0.000, y=1.000, z=0.000) """ if t < 0.0 or t > 1.0: raise ValueError("Parameter t should be between 0.0 and 1.0") - angle = self.start_angle + t * self.angle - - x = -self.radius * sin(angle) - y = +self.radius * cos(angle) - + direction = -1.0 if self.angle < 0.0 else 1.0 + x = -direction * sin(angle) + y = direction * cos(angle) if not world: return Vector(x, y, 0.0) - return self.frame.xaxis * x + self.frame.yaxis * y + + def reverse(self) -> None: + """Reverse the parametrisation of the arc. + + Examples + -------- + >>> arc = Arc(1.0, 0.0, pi) + >>> start, end = arc.point_at(0.0), arc.point_at(1.0) + >>> arc.reverse() + >>> arc.point_at(0.0) == end and arc.point_at(1.0) == start + True + + """ + self.start_angle, self.end_angle = self.end_angle, self.start_angle diff --git a/src/compas/geometry/curves/bezier.py b/src/compas/geometry/curves/bezier.py index e8c4fde34adc..401fa2cc6556 100644 --- a/src/compas/geometry/curves/bezier.py +++ b/src/compas/geometry/curves/bezier.py @@ -1,25 +1,26 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - +from math import comb from math import factorial +from typing import Any +from typing import Optional +from typing import Sequence +from compas._typing import CoordinateType from compas.geometry import Frame from compas.geometry import Point from compas.geometry import Vector +from .._typing import TransformationType from .curve import Curve -def binomial_coefficient(n, k): - """Returns the binomial coefficient of the :math:`x^k` term in the - polynomial expansion of the binomial power :math:`(1 + x)^n`. +def binomial_coefficient(n: int, k: int) -> int: + """Return the binomial coefficient of the $x^k$ term in $(1 + x)^n$. Parameters ---------- - n : int + n The number of terms. - k : int + k The index of the coefficient. Returns @@ -34,21 +35,21 @@ def binomial_coefficient(n, k): Pascal's triangle. """ - return int(factorial(n) / float(factorial(k) * factorial(n - k))) + return comb(n, k) -def bernstein_polynomial(n, k, t): - """The k:sup:`th` of ``n + 1`` Bernstein basis polynomials of degree ``n``. +def bernstein_polynomial(n: int, k: int, t: float) -> float: + """Compute the $k$-th Bernstein basis polynomial of degree $n$. A weighted linear combination of these basis polynomials is called a Bernstein polynomial. Parameters ---------- - n : int + n The degree of the polynomial. - k : int + k The number of the basis polynomial. - t : float + t The variable. Returns @@ -58,8 +59,8 @@ def bernstein_polynomial(n, k, t): See Also -------- - :func:`compas.geometry.bernstein_derivative` - :func:`compas.geometry.binomial_coefficient` + [`bernstein_derivative`][compas.geometry.bernstein_derivative] and + [`binomial_coefficient`][compas.geometry.binomial_coefficient] Notes ----- @@ -68,9 +69,7 @@ def bernstein_polynomial(n, k, t): References ---------- - More info at [1]_. - - .. [1] https://en.wikipedia.org/wiki/Bernstein_polynomial + - [Bernstein polynomial](https://en.wikipedia.org/wiki/Bernstein_polynomial) Examples -------- @@ -85,18 +84,18 @@ def bernstein_polynomial(n, k, t): return binomial_coefficient(n, k) * t**k * (1 - t) ** (n - k) -def bernstein_derivative(n, k, t, p=1): - """The p:sup:`th` derivative of the k:sup:`th` of ``n + 1`` Bernstein basis polynomials of degree ``n``. +def bernstein_derivative(n: int, k: int, t: float, p: int = 1) -> float: + """Compute a derivative of a Bernstein basis polynomial. Parameters ---------- - n : int + n The degree of the polynomial. - k : int + k The number of the basis polynomial. - t : float + t The variable. - p : int, optional + p The order of the derivative. Returns @@ -106,10 +105,15 @@ def bernstein_derivative(n, k, t, p=1): See Also -------- - :func:`compas.geometry.bernstein_polynomial` + [`bernstein_polynomial`][compas.geometry.bernstein_polynomial] """ - c = 0 + if p < 0: + raise ValueError("The derivative order cannot be negative.") + if p > n: + return 0.0 + + c = 0.0 for i in range(max(0, k + p - n), min(k, p) + 1): c += (-1) ** (i + p) * binomial_coefficient(p, i) * bernstein_polynomial(n - p, k - i, t) c = factorial(n) / factorial(n - p) * c @@ -119,29 +123,19 @@ def bernstein_derivative(n, k, t, p=1): class Bezier(Curve): """A Bezier curve is defined by control points and a degree. - A Bezier curve of degree ``n`` is a linear combination of ``n + 1`` Bernstein - basis polynomials of degree ``n``. + A Bezier curve of degree $n$ is a linear combination of $n + 1$ Bernstein + basis polynomials of degree $n$. Parameters ---------- - points : sequence[point] + points A sequence of control points, represented by their location in 3D space. - name : str, optional + name The name of the curve. - Attributes - ---------- - points : list[:class:`compas.geometry.Point`] - The control points. - degree : int, read-only - The degree of the curve. - frame : :class:`compas.geometry.Frame`, read-only - The frame of the curve. - This is always the world coordinate system. - See Also -------- - :class:`compas.geometry.NubrsCurve` + [`NurbsCurve`][compas.geometry.NurbsCurve] Examples -------- @@ -162,53 +156,114 @@ class Bezier(Curve): """ - DATASCHEMA = { - "type": "object", - "properties": { - "points": {"type": "array", "minItems": 2, "items": Point.DATASCHEMA}, - }, - "required": ["points"], - } - @property - def __data__(self): + def __data__(self) -> dict[str, Any]: return {"points": [point.__data__ for point in self.points]} - def __init__(self, points, name=None): - super(Bezier, self).__init__(name=name) - self._points = [] + def __init__(self, points: Sequence[CoordinateType], name: Optional[str] = None) -> None: + super().__init__(name=name) + self._points: list[Point] = [] self.points = points - def __repr__(self): + def __repr__(self) -> str: return "{0}(points={1!r})".format(type(self).__name__, self.points) + def __eq__(self, other: object) -> bool: + if not isinstance(other, Bezier): + return False + return self.points == other.points + # ========================================================================== # Properties # ========================================================================== @property - def frame(self): - if not self._frame: + def frame(self) -> Frame: + """The world XY frame. + + Notes + ----- + Bezier control points are stored in world coordinates; therefore the + frame is read-only. + + """ + if self._frame is None: self._frame = Frame.worldXY() return self._frame @frame.setter - def frame(self, frame): - raise Exception("Setting the coordinate frame of a Bezier curve is not supported.") + def frame(self, frame: Optional[Frame]) -> None: + raise AttributeError("The frame of a Bezier curve is read-only.") @property - def points(self): + def points(self) -> list[Point]: + """The control points of the curve. + + Notes + ----- + Assigning coordinate sequences creates independent `Point` objects. + At least two control points are required. + + """ return self._points @points.setter - def points(self, points): - if points: - self._points = [Point(*point) for point in points] + def points(self, points: Sequence[CoordinateType]) -> 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] @property - def degree(self): + def degree(self) -> int: + """The polynomial degree of the curve.""" return len(self.points) - 1 + @property + def length(self) -> float: + """The approximate arc length of the curve. + + Notes + ----- + The length is computed by adaptive de Casteljau subdivision until the + difference between each control-polygon length and its chord length is + negligible relative to the size of that subdivision. + + Examples + -------- + >>> Bezier([[0.0, 0.0, 0.0], [3.0, 4.0, 0.0]]).length + 5.0 + + """ + + def arc_length(points: list[Point], depth: int = 0) -> float: + polygon_length = sum(a.distance_to_point(b) for a, b in zip(points, points[1:])) + chord_length = points[0].distance_to_point(points[-1]) + tolerance = 1e-9 * max(1.0, polygon_length) + if polygon_length - chord_length <= tolerance or depth == 32: + return 0.5 * (polygon_length + chord_length) + + level = points + left = [level[0]] + right = [level[-1]] + while len(level) > 1: + level = [a + (b - a) * 0.5 for a, b in zip(level, level[1:])] + left.append(level[0]) + right.append(level[-1]) + right.reverse() + return arc_length(left, depth + 1) + arc_length(right, depth + 1) + + return arc_length(self.points) + + @property + def is_closed(self) -> bool: + """Whether the start and end points coincide.""" + return self.points[0] == self.points[-1] + + @property + def is_periodic(self) -> bool: + """Whether the curve is periodic, which is always `False`.""" + return False + # ========================================================================== # Constructors # ========================================================================== @@ -217,43 +272,43 @@ def degree(self): # Transformations # ========================================================================== - def transform(self, T): + def transform(self, transformation: TransformationType) -> None: """Transform this curve. Parameters ---------- - T : :class:`compas.geometry.Transformation` + transformation The transformation. - Returns - ------- - None - """ for point in self.points: - point.transform(T) + point.transform(transformation) # ========================================================================== # Methods # ========================================================================== - def point_at(self, t): + def point_at(self, t: float, world: bool = True) -> Point: """Compute the point on the curve at the given parameter. Parameters ---------- - t : float + t The value of the curve parameter. Must be between 0 and 1. + world + Included for consistency with the curve interface. Bezier control + points are stored in world coordinates, so both values are equivalent. Returns ------- - :class:`compas.geometry.Point` - the corresponding point on the curve. + Point + The corresponding point on the curve. See Also -------- - :meth:`compas.geometry.Bezier.tangent_at`, :meth:`compas.geometry.Bezier.normal_at` + [`Bezier.tangent_at`][compas.geometry.Bezier.tangent_at] and + [`Bezier.normal_at`][compas.geometry.Bezier.normal_at] Examples -------- @@ -264,6 +319,8 @@ def point_at(self, t): Point(x=1.000, y=0.000, z=0.000) """ + if t < 0.0 or t > 1.0: + raise ValueError("The parameter must be in the domain [0, 1].") n = self.degree point = Point(0, 0, 0) for i, p in enumerate(self.points): @@ -271,22 +328,26 @@ def point_at(self, t): point += p * b return point - def tangent_at(self, t): + def tangent_at(self, t: float, world: bool = True) -> Vector: """Compute the tangent vector to the curve at the point at the given parameter. Parameters ---------- - t : float + t The value of the curve parameter. Must be between 0 and 1. + world + Included for consistency with the curve interface. Bezier control + points are stored in world coordinates, so both values are equivalent. Returns ------- - :class:`compas.geometry.Vector` + Vector The corresponding tangent vector. See Also -------- - :meth:`compas.geometry.Bezier.point_at`, :meth:`compas.geometry.Bezier.normal_at` + [`Bezier.point_at`][compas.geometry.Bezier.point_at] and + [`Bezier.normal_at`][compas.geometry.Bezier.normal_at] Examples -------- @@ -295,6 +356,8 @@ def tangent_at(self, t): Vector(x=1.000, y=0.000, z=0.000) """ + if t < 0.0 or t > 1.0: + raise ValueError("The parameter must be in the domain [0, 1].") n = self.degree vector = Vector(0, 0, 0) for i, point in enumerate(self.points): @@ -302,22 +365,26 @@ def tangent_at(self, t): vector.unitize() return vector - def normal_at(self, t): + def normal_at(self, t: float, world: bool = True) -> Optional[Vector]: """Compute the normal vector to the curve at the point at the given parameter. Parameters ---------- - t : float + t The value of the curve parameter. Must be between 0 and 1. + world + Included for consistency with the curve interface. Bezier control + points are stored in world coordinates, so both values are equivalent. Returns ------- - :class:`compas.geometry.Vector` - The corresponding normal vector. + Optional[Vector] + The corresponding normal vector, or `None` if the curvature is zero. See Also -------- - :meth:`compas.geometry.Bezier.point_at`, :meth:`compas.geometry.Bezier.tangent_at` + [`Bezier.point_at`][compas.geometry.Bezier.point_at] and + [`Bezier.tangent_at`][compas.geometry.Bezier.tangent_at] Examples -------- diff --git a/src/compas/geometry/curves/circle.py b/src/compas/geometry/curves/circle.py index 60e8e60b9414..5802d1ce7bcf 100644 --- a/src/compas/geometry/curves/circle.py +++ b/src/compas/geometry/curves/circle.py @@ -1,11 +1,17 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - +from math import atan2 from math import cos from math import pi from math import sin +from typing import Any +from typing import Literal +from typing import Optional +from typing import Sequence +from typing import Union +from typing import overload + +from typing_extensions import Self +from compas._typing import CoordinateType from compas.geometry import Frame from compas.geometry import Plane from compas.geometry import Point @@ -19,49 +25,26 @@ class Circle(Conic): """A circle is a curve defined by a coordinate system and a radius. - The centre of the circle is at the origin of the coordinate system. + The center of the circle is at the origin of the coordinate system. The z-axis of the coordinate system defines the normal of the circle plane. The parameter domain is normalized with respect to the polar angle. - A parameter value of ``t = 0`` corresponds to the point on the circle at angle ``0``. - A parameter value of ``t = 1`` corresponds to the point on the circle at angle ``2 * pi``. + A parameter value of `t = 0` corresponds to the point at angle `0`. + A parameter value of `t = 1` corresponds to the point at angle `2 * pi`. Moving along the circle in the parameter direction corresponds to moving counter-clockwise around the origin of the local coordinate system. Parameters ---------- - radius : float + radius The radius of the circle. - frame : :class:`compas.geometry.Frame`, optional + frame The coordinate frame of the circle. - The default value is ``None``, in which case the world coordinate system is used. - name : str, optional + If `None`, the world XY frame is used. + name The name of the circle. - Attributes - ---------- - frame : :class:`compas.geometry.Frame` - The coordinate frame of the circle. - transformation : :class:`Transformation`, read-only - The transformation from the local coordinate system of the circle (:attr:`frame`) to the world coordinate system. - center : :class:`compas.geometry.Point` - The center of the circle. - radius : float - The radius of the circle. - diameter : float, read-only - The diameter of the circle. - area : float, read-only - The area of the circle. - circumference : float, read-only - The circumference of the circle. - eccentricity : float, read-only - The eccentricity of the circle is zero. - is_closed : bool, read-only - True. - is_periodic : bool, read-only - True. - See Also -------- - :class:`compas.geometry.Ellipse`, :class:`compas.geometry.Arc` + [`Ellipse`][compas.geometry.Ellipse] and [`Arc`][compas.geometry.Arc] Examples -------- @@ -78,101 +61,106 @@ class Circle(Conic): >>> plane = Plane(line.end, line.direction) >>> circle = Circle.from_plane_and_radius(plane, 5) >>> circle = Circle(radius=5, frame=Frame.from_plane(plane)) - - Visualise the line, circle, and frame of the circle with the COMPAS viewer. - - >>> from compas_viewer import Viewer # doctest: +SKIP - >>> viewer = Viewer() # doctest: +SKIP - >>> viewer.scene.add(line) # doctest: +SKIP - >>> viewer.scene.add(circle) # doctest: +SKIP - >>> viewer.scene.add(circle.frame) # doctest: +SKIP - >>> viewer.show() # doctest: +SKIP - """ - DATASCHEMA = { - "type": "object", - "properties": { - "radius": {"type": "number", "minimum": 0}, - "frame": Frame.DATASCHEMA, - }, - "required": ["radius", "frame"], - } - @property - def __data__(self): + def __data__(self) -> dict[str, Any]: return {"radius": self.radius, "frame": self.frame.__data__} @classmethod - def __from_data__(cls, data): + def __from_data__(cls, data: dict[str, Any]) -> Self: return cls(radius=data["radius"], frame=Frame.__from_data__(data["frame"])) - def __init__(self, radius, frame=None, name=None): - super(Circle, self).__init__(frame=frame, name=name) - self._radius = None + def __init__(self, radius: float, frame: Optional[Frame] = None, name: Optional[str] = None) -> None: + super().__init__(frame=frame, name=name) + self._radius: Optional[float] = None self.radius = radius - def __repr__(self): + def __repr__(self) -> str: return "{0}(radius={1!r}, frame={2!r})".format( type(self).__name__, self.radius, self.frame, ) - def __eq__(self, other): - try: - other_frame = other.frame - other_radius = other.radius - except Exception: + def __eq__(self, other: object) -> bool: + if not isinstance(other, Circle): return False - return self.frame == other_frame and self.radius == other_radius + return self.frame == other.frame and self.radius == other.radius # ========================================================================== # Properties # ========================================================================== @property - def center(self): + def center(self) -> Point: + """The center of the circle. + + Notes + ----- + Assigning a point or three coordinates updates the frame origin and + creates an independent point. + + Examples + -------- + >>> circle = Circle(1.0) + >>> circle.center = [1.0, 2.0, 3.0] + >>> circle.center + Point(x=1.000, y=2.000, z=3.000) + + """ return self.frame.point @center.setter - def center(self, point): + def center(self, point: CoordinateType) -> None: self.frame.point = point @property - def radius(self): + def radius(self) -> float: + """The positive radius of the circle.""" if self._radius is None: raise ValueError("The radius of the circle has not been set yet.") return self._radius @radius.setter - def radius(self, radius): - if radius < 0: - raise ValueError("The radius of a circle should be larger than or equal to zero.") + def radius(self, radius: float) -> None: + if radius <= 0: + raise ValueError("The radius of a circle must be positive.") self._radius = float(radius) @property - def diameter(self): + def diameter(self) -> float: + """The diameter of the circle.""" return 2 * self.radius @property - def area(self): + def area(self) -> float: + """The area enclosed by the circle.""" return pi * (self.radius**2) @property - def circumference(self): + def circumference(self) -> float: + """The circumference of the circle.""" return 2 * pi * self.radius @property - def eccentricity(self): - return 0 + def length(self) -> float: + """The length of the circle, equal to its circumference.""" + return self.circumference + + @property + def eccentricity(self) -> float: + """The eccentricity of the circle, which is always zero.""" + return 0.0 @property - def is_closed(self): + def is_closed(self) -> bool: + """Whether the circle is closed.""" return True @property - def is_periodic(self): + def is_periodic(self) -> bool: + """Whether the circle is periodic.""" return True # ========================================================================== @@ -180,48 +168,56 @@ def is_periodic(self): # ========================================================================== @classmethod - def from_point_and_radius(cls, point, radius): + def from_point_and_radius(cls, point: CoordinateType, radius: float) -> Self: """Construct a circle from a point and a radius. Parameters ---------- - point : :class:`compas.geometry.Point` + point The center of the circle. - radius : float + radius The radius of the circle. Returns ------- - :class:`compas.geometry.Circle` + Self The constructed circle. See Also -------- - :meth:`from_plane_and_radius`, :meth:`from_three_points`, :meth:`from_points` + [`Circle.from_plane_and_radius`][compas.geometry.Circle.from_plane_and_radius], + [`Circle.from_three_points`][compas.geometry.Circle.from_three_points] + + Examples + -------- + >>> circle = Circle.from_point_and_radius([1.0, 2.0, 3.0], 2.0) + >>> circle.center, circle.radius + (Point(x=1.000, y=2.000, z=3.000), 2.0) """ frame = Frame(point, [1, 0, 0], [0, 1, 0]) return cls(frame=frame, radius=radius) @classmethod - def from_plane_and_radius(cls, plane, radius): + def from_plane_and_radius(cls, plane: Plane, radius: float) -> Self: """Construct a circle from a plane and a radius. Parameters ---------- - plane : :class:`compas.geometry.Plane` + plane The plane of the circle. - radius : float + radius The radius of the circle. Returns ------- - :class:`compas.geometry.Circle` + Self The constructed circle. See Also -------- - :meth:`from_point_and_radius`, :meth:`from_three_points`, :meth:`from_points` + [`Circle.from_point_and_radius`][compas.geometry.Circle.from_point_and_radius], + [`Circle.from_three_points`][compas.geometry.Circle.from_three_points] Examples -------- @@ -235,33 +231,39 @@ def from_plane_and_radius(cls, plane, radius): return cls(frame=frame, radius=radius) @classmethod - def from_three_points(cls, a, b, c): + def from_three_points(cls, a: CoordinateType, b: CoordinateType, c: CoordinateType) -> Self: """Construct a circle from three points. Parameters ---------- - a : :class:`compas.geometry.Point` + a The first point. - b : :class:`compas.geometry.Point` + b The second point. - c : :class:`compas.geometry.Point` + c The third point. Returns ------- - :class:`compas.geometry.Circle` + Self The constructed circle. See Also -------- - :meth:`from_point_and_radius`, :meth:`from_plane_and_radius`, :meth:`from_points` + [`Circle.from_point_and_radius`][compas.geometry.Circle.from_point_and_radius] + + Examples + -------- + >>> circle = Circle.from_three_points([1, 0, 0], [0, 1, 0], [-1, 0, 0]) + >>> circle.center, circle.radius + (Point(x=0.000, y=0.000, z=0.000), 1.0) """ from compas.geometry import Plane - a = Point(*a) - b = Point(*b) - c = Point(*c) + a = Point(a[0], a[1], a[2]) + b = Point(b[0], b[1], b[2]) + c = Point(c[0], c[1], c[2]) ab = b - a cb = b - c @@ -288,17 +290,17 @@ def from_three_points(cls, a, b, c): return cls.from_plane_and_radius(plane, radius) @classmethod - def from_points(cls, points): + def from_points(cls, points: Sequence[CoordinateType]) -> Self: """Construct a circle from a list of at least three points. Parameters ---------- - points : list of :class:`compas.geometry.Point` + points A list of three points defining the circle. Returns ------- - :class:`compas.geometry.Circle` + Self The constructed circle. Raises @@ -308,12 +310,18 @@ def from_points(cls, points): See Also -------- - :meth:`from_point_and_radius`, :meth:`from_plane_and_radius`, :meth:`from_three_points` + [`Circle.from_three_points`][compas.geometry.Circle.from_three_points] Notes ----- If more than three points are provided, - the constructed cicrle is the one that best fits the points in the least squares sense. + the constructed circle is the one that best fits the points in the least-squares sense. + + Examples + -------- + >>> points = [[1, 0, 0], [0, 1, 0], [-1, 0, 0]] + >>> Circle.from_points(points).radius + 1.0 """ if len(points) < 3: @@ -334,31 +342,37 @@ def from_points(cls, points): # Methods # ============================================================================= - def point_at(self, t, world=True): + def point_at(self, t: float, world: bool = True) -> Point: """Construct a point on the circle at a specific parameter. Parameters ---------- - t : float + t The parameter of the point. The parameter is expected to be normalized, - and will be mapped to the corresponding angle in the interval ``[0, 2 * pi]``. - world : bool, optional - If ``True``, the point is returned in world coordinates. + and is mapped to the angle interval `[0, 2 * pi]`. + world + If `True`, the point is returned in world coordinates. Returns ------- - :class:`compas.geometry.Point` + Point The point on the circle at the specified parameter. See Also -------- - :meth:`normal_at`, :meth:`tangent_at`, :meth:`binormal_at` + [`Circle.normal_at`][compas.geometry.Circle.normal_at] and + [`Circle.tangent_at`][compas.geometry.Circle.tangent_at] Notes ----- The location of the point is expressed with respect to the world coordinate system. + Examples + -------- + >>> Circle(2.0).point_at(0.25) + Point(x=0.000, y=2.000, z=0.000) + """ t = t * PI2 x = self.radius * cos(t) @@ -368,64 +382,78 @@ def point_at(self, t, world=True): point.transform(self.transformation) return point - def normal_at(self, t, world=True): + def normal_at(self, t: float, world: bool = True) -> Vector: """Construct a normal on the circle at a specific parameter. Parameters ---------- - t : float + t The parameter of the normal vector. The parameter is expected to be normalized, - and will be mapped to the corresponding angle in the interval ``[0, 2 * pi]``. - world : bool, optional - If ``True``, the normal is returned in world coordinates. + and is mapped to the angle interval `[0, 2 * pi]`. + world + If `True`, the normal is returned in world coordinates. Returns ------- - :class:`compas.geometry.Vector` + Vector The normal on the circle at the specified parameter. See Also -------- - :meth:`point_at`, :meth:`tangent_at`, :meth:`binormal_at` + [`Circle.point_at`][compas.geometry.Circle.point_at] and + [`Circle.tangent_at`][compas.geometry.Circle.tangent_at] Notes ----- The orientation of the vector is expressed with respect to the world coordinate system. + Examples + -------- + >>> Circle(2.0).normal_at(0.0) + Vector(x=-1.000, y=0.000, z=0.000) + """ if world: normal = self.center - self.point_at(t, world=True) normal.unitize() return normal point = self.point_at(t, world=False) - return Vector(-point.x, -point.y, 0) + normal = Vector(-point.x, -point.y, 0) + normal.unitize() + return normal - def tangent_at(self, t, world=True): + def tangent_at(self, t: float, world: bool = True) -> Vector: """Construct a tangent on the circle at a specific parameter. Parameters ---------- - t : float + t The parameter of the tangent vector. The parameter is expected to be normalized, - and will be mapped to the corresponding angle in the interval ``[0, 2 * pi]``. - world : bool, optional - If ``True``, the tangent is returned in world coordinates. + and is mapped to the angle interval `[0, 2 * pi]`. + world + If `True`, the tangent is returned in world coordinates. Returns ------- - :class:`compas.geometry.Vector` + Vector The tangent on the circle at the specified parameter. See Also -------- - :meth:`point_at`, :meth:`normal_at`, :meth:`binormal_at` + [`Circle.point_at`][compas.geometry.Circle.point_at] and + [`Circle.normal_at`][compas.geometry.Circle.normal_at] Notes ----- The orientation of the vector is expressed with respect to the world coordinate system. + Examples + -------- + >>> Circle(2.0).tangent_at(0.0) + Vector(x=0.000, y=1.000, z=0.000) + """ t = t * PI2 x = -self.radius * sin(t) @@ -436,48 +464,65 @@ def tangent_at(self, t, world=True): vector.transform(self.transformation) return vector - def closest_point(self, point, return_parameter=False): + @overload + def closest_point(self, point: CoordinateType, 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: CoordinateType, return_parameter: bool = False) -> Union[Point, tuple[Point, float]]: """Compute the closest point on the circle to a given point. Parameters ---------- - point : :class:`compas.geometry.Point` + point A point. - return_parameter : bool, optional + return_parameter Return the parameter of the closest point as well. Returns ------- - :class:`compas.geometry.Point` - The closest point on the circle. + Point + The closest point if `return_parameter` is `False`. + tuple[Point, float] + The closest point and its normalized parameter if `return_parameter` is `True`. Notes ----- The location of the point is expressed with respect to the world coordinate system. + If the input projects onto the center, the point at parameter `0.0` is returned. - """ - from compas.geometry import Vector + Examples + -------- + >>> circle = Circle(1.0) + >>> circle.closest_point([2.0, 2.0, 0.0]) + Point(x=0.707, y=0.707, z=0.000) + >>> circle.closest_point([0.0, 2.0, 0.0], return_parameter=True) + (Point(x=0.000, y=1.000, z=0.000), 0.25) - projected = self.plane.closest_point(point) - vector = Vector.from_start_end(self.center, projected) + """ + local = self.frame.to_local_coordinates(point) + vector = Vector(local.x, local.y, 0.0) + if not vector.length: + vector = Vector(self.radius, 0.0, 0.0) vector.unitize() vector *= self.radius - + closest = self.frame.to_world_coordinates(Point(vector.x, vector.y, 0.0)) if return_parameter: - raise NotImplementedError - - return self.center + vector + parameter = atan2(vector.y, vector.x) / PI2 + return closest, parameter % 1.0 + return closest - def contains_point(self, point, tol=1e-6, dmax=1e-6): + def contains_point(self, point: CoordinateType, tol: float = 1e-6, dmax: float = 1e-6) -> bool: """Verify that the circle contains a given point. Parameters ---------- - point : :class:`compas.geometry.Point` + point The point. - tol : float, optional + tol The tolerance for the verification. - dmax : float, optional + dmax The maximum allowed distance between the plane of the circle and the point. Returns @@ -488,13 +533,19 @@ def contains_point(self, point, tol=1e-6, dmax=1e-6): Notes ----- - By default, the verification will fail if the point is not exactly in the plane of the circle. - To allow for a certain tolerance, use the ``dmax`` parameter. - Like with apparent intersections, using a ``dmax`` higher than zero, allows for "apparent containment" checks + `dmax` controls the allowed distance from the circle plane. + + Examples + -------- + >>> circle = Circle(1.0) + >>> circle.contains_point([1.0, 0.0, 0.0]) + True + >>> circle.contains_point([0.0, 0.0, 0.0]) + False """ point = self.frame.to_local_coordinates(point) - x, y, z = point.x, point.y, point.z # type: ignore + x, y, z = point.x, point.y, point.z if abs(z) > dmax: return False - return x**2 + y**2 <= (self.radius + tol) ** 2 + return abs((x**2 + y**2) ** 0.5 - self.radius) <= tol diff --git a/src/compas/geometry/curves/conic.py b/src/compas/geometry/curves/conic.py index 93e6b6043545..0b8c26d0fc92 100644 --- a/src/compas/geometry/curves/conic.py +++ b/src/compas/geometry/curves/conic.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from .curve import Curve diff --git a/src/compas/geometry/curves/curve.py b/src/compas/geometry/curves/curve.py index af869d823159..143e8c40ac70 100644 --- a/src/compas/geometry/curves/curve.py +++ b/src/compas/geometry/curves/curve.py @@ -1,7 +1,15 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - +from math import isfinite +from typing import TYPE_CHECKING +from typing import Literal +from typing import Optional +from typing import TypeVar +from typing import Union +from typing import overload + +from typing_extensions import Self + +from compas._typing import CoordinateType +from compas._typing import FilePath from compas.geometry import Frame from compas.geometry import Geometry from compas.geometry import Plane @@ -10,9 +18,19 @@ from compas.plugins import PluginNotInstalledError from compas.plugins import pluggable +from .._typing import TransformationType + +if TYPE_CHECKING: + from compas.geometry import Point + from compas.geometry import Polygon + from compas.geometry import Polyline + from compas.geometry import Vector + +CurveType = TypeVar("CurveType", bound="Curve") + @pluggable(category="factories") -def curve_from_native(cls, *args, **kwargs): +def curve_from_native(cls: type[CurveType], *args: object, **kwargs: object) -> CurveType: raise PluginNotInstalledError @@ -21,36 +39,17 @@ class Curve(Geometry): Parameters ---------- - frame : :class:`compas.geometry.Frame`, optional + frame The local coordinate system of the curve. Default is the world coordinate system. - name : str, optional + name The name of the curve. - Attributes - ---------- - frame : :class:`compas.geometry.Frame` - The frame of the curve. - transformation : :class:`compas.geometry.Transformation`, read-only - The transformation from the local coordinate system of the curve (:attr:`frame`) to the world coordinate system. - plane : :class:`compas.geometry.Plane`, read-only - The plane of the curve. - dimension : int, read-only - The spatial dimension of the curve. - In most cases this will be 3. - For curves embedded on a surface, this is 2. - domain : tuple[float, float], read-only - The domain of the parameter space of the curve is the interval ``[0.0, 1.0]``. - is_closed : bool, read-only - True if the curve is closed. - is_periodic : bool, read-only - True if the curve is periodic. - See Also -------- - :class:`compas.geometry.Arc`, :class:`compas.geometry.Circle`, - :class:`compas.geometry.Ellipse`, :class:`compas.geometry.Line`, - :class:`compas.geometry.NurbsCurve`, :class:`compas.geometry.Polyline` + [`Arc`][compas.geometry.Arc], [`Circle`][compas.geometry.Circle], + [`Ellipse`][compas.geometry.Ellipse] and + [`NurbsCurve`][compas.geometry.NurbsCurve] are concrete curve types. Notes ----- @@ -59,25 +58,23 @@ class Curve(Geometry): If a backend is available, it will be used to construct the curve and provide its functionality. This backend is referred to as the "plugin" implementation of the curve. - To activate the plugin mechanism, the backend should provide an implementation of the :func:`new_curve` function, - and of any other function that can be implemented through the functionality available in the backend. + To activate the plugin mechanism, a backend should provide an implementation + of `curve_from_native` and any other supported backend operation. """ - def __new__(cls, *args, **kwargs): + def __new__(cls, *args: object, **kwargs: object) -> Self: if cls is Curve: raise TypeError("Making an instance of `Curve` using `Curve()` is not allowed. Please use one of the factory methods instead (`Curve.from_...`)") return object.__new__(cls) - def __init__(self, frame=None, name=None): - super(Curve, self).__init__(name=name) - self._frame = None - self._transformation = None - self._domain = None - if frame: + def __init__(self, frame: Optional[Frame] = None, name: Optional[str] = None) -> None: + super().__init__(name=name) + self._frame: Optional[Frame] = None + if frame is not None: self.frame = frame - def __repr__(self): + def __repr__(self) -> str: return "{0}(frame={1!r}, domain={2})".format( type(self).__name__, self.frame, @@ -89,43 +86,108 @@ def __repr__(self): # ============================================================================== @property - def frame(self): + def frame(self) -> Frame: + """The local coordinate frame of the curve. + + Notes + ----- + If no frame is assigned, the world XY frame is created on first access. + Assigning a frame creates an independent `Frame`. + Assigning `None` restores the default world XY frame. + + Examples + -------- + >>> from compas.geometry import Circle + >>> source = Frame.worldYZ() + >>> curve = Circle(radius=1, frame=source) + >>> curve.frame == source and curve.frame is not source + True + >>> curve.frame = None + >>> curve.frame == Frame.worldXY() + True + + """ if not self._frame: self._frame = Frame.worldXY() return self._frame @frame.setter - def frame(self, frame): - if not frame: + def frame(self, frame: Optional[Frame]) -> None: + if frame is None: self._frame = None else: - self._frame = Frame(frame[0], frame[1], frame[2]) - self._transformation = None + if not isinstance(frame, Frame): + raise TypeError("The frame must be a Frame object or None.") + self._frame = Frame(frame.point, frame.xaxis, frame.yaxis) @property - def transformation(self): - if not self._transformation: - self._transformation = Transformation.from_frame_to_frame(Frame.worldXY(), self.frame) - return self._transformation + def transformation(self) -> Transformation: + """The transformation from world XY to the curve frame. + + Examples + -------- + >>> from compas.geometry import Circle + >>> curve = Circle(radius=1, frame=Frame.worldYZ()) + >>> curve.frame.point = [1, 2, 3] + >>> curve.transformation.translation_vector == [1, 2, 3] + True + + """ + return Transformation.from_frame_to_frame(Frame.worldXY(), self.frame) @property - def plane(self): + def plane(self) -> Plane: + """The plane defined by the curve frame. + + Examples + -------- + >>> from compas.geometry import Circle + >>> curve = Circle(radius=1, frame=Frame.worldXY()) + >>> curve.plane == Plane.worldXY() + True + + """ return Plane(self.frame.point, self.frame.zaxis) @property - def dimension(self): + def dimension(self) -> int: + """The spatial dimension of the curve. + + Examples + -------- + >>> from compas.geometry import Circle + >>> Circle(radius=1).dimension + 3 + + """ return 3 @property - def domain(self): + def domain(self) -> tuple[float, float]: + """The parameter domain of the curve. + + Examples + -------- + >>> from compas.geometry import Circle + >>> Circle(radius=1).domain + (0.0, 1.0) + + """ return 0.0, 1.0 @property - def is_closed(self): + def length(self) -> float: + """The length of the curve.""" + raise NotImplementedError + + @property + def is_closed(self) -> bool: + """Whether the curve is closed.""" raise NotImplementedError @property - def is_periodic(self): + def is_periodic(self) -> bool: + """Whether the curve is periodic.""" raise NotImplementedError # ============================================================================== @@ -133,7 +195,7 @@ def is_periodic(self): # ============================================================================== @classmethod - def from_native(cls, curve): + def from_native(cls, curve: object) -> Self: """Construct a parametric curve from a native curve geometry. Parameters @@ -143,40 +205,42 @@ def from_native(cls, curve): Returns ------- - :class:`compas.geometry.Curve` + Self A COMPAS curve. """ return curve_from_native(cls, curve) @classmethod - def from_obj(cls, filepath): + def from_obj(cls, filepath: FilePath) -> Self: """Load a curve from an OBJ file. Parameters ---------- - filepath : str + filepath The path to the file. Returns ------- - :class:`compas.geometry.Curve` + Self + The loaded curve. """ raise NotImplementedError @classmethod - def from_step(cls, filepath): + def from_step(cls, filepath: FilePath) -> Self: """Load a curve from a STP file. Parameters ---------- - filepath : str + filepath The path to the file. Returns ------- - :class:`compas.geometry.Curve` + Self + The loaded curve. """ raise NotImplementedError @@ -185,75 +249,85 @@ def from_step(cls, filepath): # Conversions # ============================================================================== - def to_step(self, filepath, schema="AP203"): + def to_step(self, filepath: FilePath, schema: str = "AP203") -> None: """Write the curve geometry to a STP file. Parameters ---------- - filepath : str + filepath The path of the output file. - schema : str, optional - The STEP schema to use. Default is ``"AP203"``. - - Returns - ------- - None + schema + The STEP schema to use. Default is `"AP203"`. """ raise NotImplementedError - def to_obj(self, filepath): + def to_obj(self, filepath: FilePath) -> None: """Write the curve geometry to an OBJ file. Parameters ---------- - filepath : str + filepath The path of the output file. - Returns - ------- - None - """ raise NotImplementedError - def to_points(self, n=10, domain=None): + def to_points(self, n: int = 10, domain: Optional[tuple[float, float]] = None) -> list["Point"]: """Convert the curve to a list of points. Parameters ---------- - n : int, optional + n The number of points in the list. - Default is ``10``. - domain : tuple, optional + Default is `10`. + domain Subset of the domain to use for the discretisation. - Default is ``None``, in which case the entire curve domain is used. + Default is `None`, in which case the entire curve domain is used. + The domain endpoints must be finite. Returns ------- - list[:class:`compas.geometry.Point`] + list[Point] + The sampled points. + + Raises + ------ + ValueError + If fewer than two points are requested or either domain endpoint is + not finite. """ - domain = domain or self.domain + domain = self.domain if domain is None else domain start, end = domain + if not isfinite(start) or not isfinite(end): + raise ValueError("Curve discretization requires a finite domain.") points = [self.point_at(t) for t in linspace(start, end, n)] return points - def to_polyline(self, n=128, domain=None): + def to_polyline(self, n: int = 128, domain: Optional[tuple[float, float]] = None) -> "Polyline": """Convert the curve to a polyline. Parameters ---------- - n : int, optional + n The number of line segments in the polyline. - Default is ``16``. - domain : tuple, optional + Default is `128`. + domain Subset of the domain to use for the discretisation. - Default is ``None``, in which case the entire curve domain is used. + Default is `None`, in which case the entire curve domain is used. + The domain endpoints must be finite. Returns ------- - :class:`compas.geometry.Polyline` + Polyline + The discretized curve. + + Raises + ------ + ValueError + If fewer than one line segment is requested or either domain + endpoint is not finite. """ from compas.geometry import Polyline @@ -261,18 +335,19 @@ def to_polyline(self, n=128, domain=None): points = self.to_points(n=n + 1, domain=domain) return Polyline(points) - def to_polygon(self, n=16): + def to_polygon(self, n: int = 16) -> "Polygon": """Convert the curve to a polygon. Parameters ---------- - n : int, optional + n The number of sides of the polygon. - Default is ``16``. + Default is `16`. Returns ------- - :class:`compas.geometry.Polygon` + Polygon + The discretized closed curve. Raises ------ @@ -292,45 +367,42 @@ def to_polygon(self, n=16): # Transformations # ============================================================================== - def transform(self, T): + def transform(self, transformation: TransformationType) -> None: """Transform the local coordinate system of the curve. Parameters ---------- - T : :class:`compas.geometry.Transformation` | list[list[float]] + transformation The transformation. - Returns - ------- - None - The (local coordinate system of the) curve is modified in-place. - Notes ----- Transformations of frames are limited to rotations and translations. All other transformations have no effect. - See :meth:`~compas.geometry.Frame.transform` for more info. + See [`Frame.transform`][compas.geometry.Frame.transform] for more information. """ - self.frame.transform(T) - self._transformation = None + self.frame.transform(transformation) # ============================================================================== # Methods # ============================================================================== - def point_at(self, t): + def point_at(self, t: float, world: bool = True) -> "Point": """Compute a point of the curve at a parameter. Parameters ---------- - t : float + t The value of the curve parameter. Must be between 0 and 1. + world + If `True`, return the point in world coordinates. Otherwise, + return it in coordinates local to the curve frame. Returns ------- - :class:`compas.geometry.Point` - the corresponding point on the curve. + Point + The corresponding point on the curve. Raises ------ @@ -339,23 +411,29 @@ def point_at(self, t): See Also -------- - :meth:`normal_at`, :meth:`tangent_at`, :meth:`binormal_at`, :meth:`frame_at`, :meth:`curvature_at` + [`Curve.normal_at`][compas.geometry.Curve.normal_at], + [`Curve.tangent_at`][compas.geometry.Curve.tangent_at], + [`Curve.frame_at`][compas.geometry.Curve.frame_at], and + [`Curve.curvature_at`][compas.geometry.Curve.curvature_at]. """ raise NotImplementedError - def normal_at(self, t): + def normal_at(self, t: float, world: bool = True) -> Optional["Vector"]: """Compute the normal of the curve at a parameter. Parameters ---------- - t : float + t The value of the curve parameter. + world + If `True`, return the vector in world coordinates. Otherwise, + return it in coordinates local to the curve frame. Returns ------- - :class:`compas.geometry.Vector` - The corresponding normal vector. + Optional[Vector] + The corresponding normal vector, or `None` if it is undefined. Raises ------ @@ -364,22 +442,28 @@ def normal_at(self, t): See Also -------- - :meth:`point_at`, :meth:`tangent_at`, :meth:`binormal_at`, :meth:`frame_at`, :meth:`curvature_at` + [`Curve.point_at`][compas.geometry.Curve.point_at], + [`Curve.tangent_at`][compas.geometry.Curve.tangent_at], + [`Curve.frame_at`][compas.geometry.Curve.frame_at], and + [`Curve.curvature_at`][compas.geometry.Curve.curvature_at]. """ raise NotImplementedError - def tangent_at(self, t): + def tangent_at(self, t: float, world: bool = True) -> "Vector": """Compute the tangent vector of the curve at a parameter. Parameters ---------- - t : float + t The value of the curve parameter. + world + If `True`, return the vector in world coordinates. Otherwise, + return it in coordinates local to the curve frame. Returns ------- - :class:`compas.geometry.Vector` + Vector The corresponding tangent vector. Raises @@ -389,22 +473,25 @@ def tangent_at(self, t): See Also -------- - :meth:`point_at`, :meth:`normal_at`, :meth:`binormal_at`, :meth:`frame_at`, :meth:`curvature_at` + [`Curve.point_at`][compas.geometry.Curve.point_at], + [`Curve.normal_at`][compas.geometry.Curve.normal_at], + [`Curve.frame_at`][compas.geometry.Curve.frame_at], and + [`Curve.curvature_at`][compas.geometry.Curve.curvature_at]. """ raise NotImplementedError - def frame_at(self, t): + def frame_at(self, t: float) -> Frame: """Compute the local frame of the curve at a parameter. Parameters ---------- - t : float + t The value of the curve parameter. Returns ------- - :class:`compas.geometry.Frame` + Frame The corresponding local frame. Raises @@ -414,12 +501,18 @@ def frame_at(self, t): See Also -------- - :meth:`point_at`, :meth:`normal_at`, :meth:`tangent_at`, :meth:`binormal_at`, :meth:`curvature_at` + [`Curve.point_at`][compas.geometry.Curve.point_at], + [`Curve.normal_at`][compas.geometry.Curve.normal_at], + [`Curve.tangent_at`][compas.geometry.Curve.tangent_at], and + [`Curve.curvature_at`][compas.geometry.Curve.curvature_at]. """ - return Frame(self.point_at(t), self.tangent_at(t), self.normal_at(t)) + normal = self.normal_at(t) + if normal is None: + raise ValueError("The curve has no defined normal at the parameter.") + return Frame(self.point_at(t), self.tangent_at(t), normal) - def curvature_at(self, t): + def curvature_at(self, t: float) -> "Vector": """Compute the curvature vector of the curve at a parameter. This is a vector pointing from the point on the curve at the specified parameter, @@ -429,12 +522,12 @@ def curvature_at(self, t): Parameters ---------- - t : float + t The value of the curve parameter. Returns ------- - :class:`compas.geometry.Vector` + Vector The corresponding curvature vector. Raises @@ -444,7 +537,10 @@ def curvature_at(self, t): See Also -------- - :meth:`point_at`, :meth:`normal_at`, :meth:`tangent_at`, :meth:`binormal_at`, :meth:`frame_at` + [`Curve.point_at`][compas.geometry.Curve.point_at], + [`Curve.normal_at`][compas.geometry.Curve.normal_at], + [`Curve.tangent_at`][compas.geometry.Curve.tangent_at], and + [`Curve.frame_at`][compas.geometry.Curve.frame_at]. """ raise NotImplementedError @@ -453,139 +549,119 @@ def curvature_at(self, t): # Methods continued # ============================================================================== - def reverse(self): + def reverse(self) -> None: """Reverse the parametrisation of the curve. - Returns - ------- - None - See Also -------- - :meth:`reversed` + [`Curve.reversed`][compas.geometry.Curve.reversed] """ raise NotImplementedError - def reversed(self): + def reversed(self) -> Self: """Reverse a copy of the curve. Returns ------- - :class:`compas.geometry.Curve` + Self + The reversed copy. See Also -------- - :meth:`reverse` + [`Curve.reverse`][compas.geometry.Curve.reverse] """ copy = self.copy() copy.reverse() return copy - def closest_point(self, point, return_parameter=False): + @overload + def closest_point(self, point: CoordinateType, 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: CoordinateType, return_parameter: bool = False) -> Union["Point", tuple["Point", float]]: """Compute the closest point on the curve to a given point. Parameters ---------- - point : :class:`compas.geometry.Point` + point The test point. - return_parameter : bool, optional - If True, the parameter corresponding to the closest point should be returned in addition to the point. + return_parameter + If `True`, also return the parameter corresponding to the closest point. Returns ------- - :class:`compas.geometry.Point` | tuple[:class:`compas.geometry.Point`, float] - If `return_parameter` is False (default), only the closest point is returned. - If `return_parameter` is True, the closest point and the corresponding parameter are returned. + Point + The closest point if `return_parameter` is `False`. + tuple[Point, float] + The closest point and its curve parameter if `return_parameter` is `True`. """ raise NotImplementedError - def divide_by_count(self, count, return_points=False): + @overload + def divide_by_count(self, count: int, return_points: Literal[False] = False) -> list[float]: ... + + @overload + def divide_by_count(self, count: int, return_points: Literal[True]) -> tuple[list[float], list["Point"]]: ... + + def divide_by_count(self, count: int, return_points: bool = False) -> Union[list[float], tuple[list[float], list["Point"]]]: """Compute the curve parameters that divide the curve into a specific number of equal length segments. Parameters ---------- - count : int + count The number of segments. - return_points : bool, optional - If True, return the list of division parameters, + return_points + If `True`, return the list of division parameters, and the points corresponding to those parameters. - If False, return only the list of parameters. + If `False`, return only the list of parameters. Returns ------- - list[float] | tuple[list[float], list[:class:`compas.geometry.Point`]] - If `return_points` is False, the parameters of the discretisation. - If `return_points` is True, a list of points in addition to the parameters of the discretisation. + list[float] + The division parameters if `return_points` is `False`. + tuple[list[float], list[Point]] + The division parameters and corresponding points if `return_points` is `True`. See Also -------- - :meth:`divide_by_length` - :meth:`split` + [`Curve.divide_by_length`][compas.geometry.Curve.divide_by_length] """ raise NotImplementedError - def divide_by_length(self, length, return_points=False): + @overload + def divide_by_length(self, length: float, return_points: Literal[False] = False) -> list[float]: ... + + @overload + def divide_by_length(self, length: float, return_points: Literal[True]) -> tuple[list[float], list["Point"]]: ... + + def divide_by_length(self, length: float, return_points: bool = False) -> Union[list[float], tuple[list[float], list["Point"]]]: """Compute the curve parameters that divide the curve into segments of specified length. Parameters ---------- - length : float + length The length of the segments. - return_points : bool, optional - If True, return the list of division parameters, + return_points + If `True`, return the list of division parameters, and the points corresponding to those parameters. - If False, return only the list of parameters. + If `False`, return only the list of parameters. Returns ------- - list[float] | tuple[list[float], list[:class:`compas.geometry.Point`]] - If `return_points` is False, the parameters of the discretisation. - If `return_points` is True, a list of points in addition to the parameters of the discretisation. + list[float] + The division parameters if `return_points` is `False`. + tuple[list[float], list[Point]] + The division parameters and corresponding points if `return_points` is `True`. See Also -------- - :meth:`divide_by_count` - :meth:`split` + [`Curve.divide_by_count`][compas.geometry.Curve.divide_by_count] """ raise NotImplementedError - - def aabb(self): - """Compute the axis-aligned bounding box of the curve. - - Returns - ------- - :class:`compas.geometry.Box` - - """ - raise NotImplementedError - - def length(self, tol=None): - """Compute the length of the curve. - - Parameters - ---------- - precision : float, optional - Required precision of the calculated length. - - """ - raise NotImplementedError - - def fair(self, tol=None): - raise NotImplementedError - - def offset(self): - raise NotImplementedError - - def smooth(self): - raise NotImplementedError - - def split(self): - raise NotImplementedError - - def trim(self): - raise NotImplementedError diff --git a/src/compas/geometry/curves/ellipse.py b/src/compas/geometry/curves/ellipse.py index 2f9fa9ed43be..8dbd5b02c2ae 100644 --- a/src/compas/geometry/curves/ellipse.py +++ b/src/compas/geometry/curves/ellipse.py @@ -1,18 +1,20 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import cos from math import pi from math import sin from math import sqrt +from typing import Any +from typing import Optional + +from typing_extensions 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 ..line import Line from .conic import Conic -from .line import Line PI2 = 2 * pi @@ -20,70 +22,28 @@ class Ellipse(Conic): """An ellipse is a curve defined by a coordinate system and a major and minor axis. - The centre of the ellipse is at the origin of the coordinate system. + The center of the ellipse is at the origin of the coordinate system. The major axis is parallel to the local x-axis. The minor axis is parallel to the local y-axis. - The parameter domain of an ellipse is ``[0, 2*pi]``. + The normalized parameter domain is `[0, 1]`. Moving along the ellipse in the parameter direction corresponds to moving counter-clockwise around the origin of the local coordinate system. Parameters ---------- - major : float + major The major of the ellipse. - minor : float + minor The minor of the ellipse. - frame : :class:`compas.geometry.Frame`, optional + frame The local coordinate system of the ellipse. - The default value is ``None``, in which case the ellipse is constructed in the XY plane of the world coordinate system. - name : str, optional + If `None`, the world XY frame is used. + name The name of the ellipse. - Attributes - ---------- - frame : :class:`compas.geometry.Frame` - The coordinate frame of the ellipse. - transformation : :class:`Transformation`, read-only - The transformation from the local coordinate system of the ellipse (:attr:`frame`) to the world coordinate system. - major : float - The major of the ellipse. - minor : float - The minor of the ellipse. - plane : :class:`compas.geometry.Plane`, read-only - The plane of the ellipse. - area : float, read-only - The area of the ellipse. - circumference : float, read-only - The length of the circumference of the ellipse. - semifocal : float, read-only - The semi-focal distance of the ellipse. - This is the distance from the center of the ellipse to the focus points. - focal : float, read-only - The distance between the two focus points. - eccentricity : float, read-only - The eccentricity of the ellipse. - This is the ratio between the semifocal length to the length of the semi-major axis. - focus1 : :class:`compas.geometry.Point`, read-only - The first focus point of the ellipse. - focus2 : :class:`compas.geometry.Point`, read-only - The second focus point of the ellipse. - directix1 : :class:`compas.geometry.Line`, read-only - The first directix of the ellipse. - The directix is perpendicular to the major axis - and passes through a point at a distance ``major **2 / semifocal`` along the positive xaxis from the center of the ellipse. - directix2 : :class:`compas.geometry.Line`, read-only - The second directix of the ellipse. - The directix is perpendicular to the major axis - and passes through a point at a distance ``major **2 / semifocal`` along the negative xaxis from the center of the ellipse. - is_closed : bool, read-only - True. - is_periodic : bool, read-only - True. - is_circle : bool, read-only - True if the ellipse is a circle. - See Also -------- - :class:`compas.geometry.Circle`, :class:`compas.geometry.Hyperbola`, :class:`compas.geometry.Parabola` + [`Circle`][compas.geometry.Circle], [`Hyperbola`][compas.geometry.Hyperbola], + and [`Parabola`][compas.geometry.Parabola] Examples -------- @@ -101,29 +61,10 @@ class Ellipse(Conic): >>> ellipse = Ellipse.from_plane_major_minor(plane, 3, 2) >>> ellipse = Ellipse(major=3, minor=2, frame=Frame.from_plane(plane)) - Visualise the line, ellipse, and frame of the ellipse with the COMPAS viewer. - - >>> from compas_viewer import Viewer # doctest: +SKIP - >>> viewer = Viewer() # doctest: +SKIP - >>> viewer.scene.add(line) # doctest: +SKIP - >>> viewer.scene.add(ellipse) # doctest: +SKIP - >>> viewer.scene.add(ellipse.frame) # doctest: +SKIP - >>> viewer.show() # doctest: +SKIP - """ - DATASCHEMA = { - "type": "object", - "properties": { - "major": {"type": "number", "minimum": 0}, - "minor": {"type": "number", "minimum": 0}, - "frame": Frame.DATASCHEMA, - }, - "required": ["major", "minor", "frame"], - } - @property - def __data__(self): + def __data__(self) -> dict[str, Any]: return { "major": self.major, "minor": self.minor, @@ -131,21 +72,27 @@ def __data__(self): } @classmethod - def __from_data__(cls, data): + def __from_data__(cls, data: dict[str, Any]) -> Self: return cls( major=data["major"], minor=data["minor"], frame=Frame.__from_data__(data["frame"]), ) - def __init__(self, major=1.0, minor=1.0, frame=None, name=None): - super(Ellipse, self).__init__(frame=frame, name=name) - self._major = None - self._minor = None + def __init__( + self, + major: float = 1.0, + minor: float = 1.0, + frame: Optional[Frame] = None, + name: Optional[str] = None, + ) -> None: + super().__init__(frame=frame, name=name) + self._major: Optional[float] = None + self._minor: Optional[float] = None self.major = major self.minor = minor - def __repr__(self): + def __repr__(self) -> str: return "{0}(major={1!r}, minor={2}, frame={3!r})".format( type(self).__name__, self.major, @@ -153,109 +100,151 @@ def __repr__(self): self.frame, ) - def __eq__(self, other): - try: - other_frame = other.frame - other_major = other.major - other_minor = other.minor - except Exception: + def __eq__(self, other: object) -> bool: + if not isinstance(other, Ellipse): return False - return self.major == other_major and self.minor == other_minor, self.frame == other_frame + return self.major == other.major and self.minor == other.minor and self.frame == other.frame # ========================================================================== # Properties # ========================================================================== @property - def center(self): + def center(self) -> Point: + """The center of the ellipse. + + Notes + ----- + Assigning a point or three coordinates updates the frame origin and + creates an independent point. + + """ return self.frame.point @center.setter - def center(self, point): + def center(self, point: CoordinateType) -> None: self.frame.point = point @property - def major(self): + def major(self) -> float: + """The positive semi-major axis length.""" if self._major is None: raise ValueError("Length of major axis is not set.") return self._major @major.setter - def major(self, major): - if major < 0: - raise ValueError("Major axis length cannot be negative.") + def major(self, major: float) -> None: + if major <= 0: + raise ValueError("Major axis length must be positive.") + if self._minor is not None and major < self._minor: + raise ValueError("Major axis length cannot be smaller than the minor axis length.") self._major = float(major) @property - def minor(self): + def minor(self) -> float: + """The positive semi-minor axis length.""" if self._minor is None: raise ValueError("Length of minor axis is not set.") return self._minor @minor.setter - def minor(self, minor): - if minor < 0: - raise ValueError("Minor axis length cannot be negative.") + def minor(self, minor: float) -> None: + if minor <= 0: + raise ValueError("Minor axis length must be positive.") + if self._major is not None and minor > self._major: + raise ValueError("Minor axis length cannot be larger than the major axis length.") self._minor = float(minor) @property - def semifocal(self): + def semifocal(self) -> float: + """The distance from the center to either focus.""" return sqrt(self.major**2 - self.minor**2) @property - def focal(self): + def focal(self) -> float: + """The distance between the two foci.""" return 2 * self.semifocal @property - def eccentricity(self): + def eccentricity(self) -> float: + """The eccentricity of the ellipse.""" return self.semifocal / self.major @property - def focus1(self): + def focus1(self) -> Point: + """The focus in the positive major-axis direction.""" return self.frame.point + self.frame.xaxis * +self.semifocal @property - def focus2(self): + def focus2(self) -> Point: + """The focus in the negative major-axis direction.""" return self.frame.point + self.frame.xaxis * -self.semifocal @property - def vertex1(self): + def vertex1(self) -> Point: + """The vertex in the positive major-axis direction.""" return self.frame.point + self.frame.xaxis * self.major @property - def vertex2(self): + def vertex2(self) -> Point: + """The vertex in the negative major-axis direction.""" return self.frame.point + self.frame.xaxis * -self.major @property - def directix1(self): + def directix1(self) -> Line: + """The directrix in the positive major-axis direction.""" d1 = self.major**2 / self.semifocal p1 = self.frame.point + self.frame.xaxis * +d1 return Line.from_point_and_vector(p1, self.frame.yaxis) @property - def directix2(self): + def directix2(self) -> Line: + """The directrix in the negative major-axis direction.""" d2 = self.major**2 / self.semifocal p2 = self.frame.point + self.frame.xaxis * -d2 return Line.from_point_and_vector(p2, self.frame.yaxis) @property - def area(self): + def area(self) -> float: + """The area enclosed by the ellipse.""" return pi * self.major * self.minor @property - def circumference(self): - raise NotImplementedError + def circumference(self) -> float: + """The approximate circumference of the ellipse. + + Notes + ----- + The circumference is computed with Ramanujan's second approximation, + using `h = ((a - b) / (a + b)) ** 2`. + + Examples + -------- + >>> round(Ellipse(1.0, 0.5).circumference, 6) + 4.844224 + + """ + h = ((self.major - self.minor) / (self.major + self.minor)) ** 2 + return pi * (self.major + self.minor) * (1.0 + 3.0 * h / (10.0 + sqrt(4.0 - 3.0 * h))) @property - def is_circle(self): + def length(self) -> float: + """The length of the ellipse, equal to its circumference.""" + return self.circumference + + @property + def is_circle(self) -> bool: + """Whether the major and minor axes are equal.""" return self.major == self.minor @property - def is_closed(self): + def is_closed(self) -> bool: + """Whether the ellipse is closed.""" return True @property - def is_periodic(self): + def is_periodic(self) -> bool: + """Whether the ellipse is periodic.""" return True # ========================================================================== @@ -263,45 +252,57 @@ def is_periodic(self): # ========================================================================== @classmethod - def from_point_major_minor(cls, point, major, minor): + def from_point_major_minor(cls, point: CoordinateType, major: float, minor: float) -> Self: """Construct a ellipse from a point and major and minor axis lengths. Parameters ---------- - point : :class:`compas.geometry.Point` + point The center point of the ellipse. - major : float + major The major axis length. - minor : float + minor The minor axis length. Returns ------- - :class:`Ellipse` + Self The constructed ellipse. + Examples + -------- + >>> ellipse = Ellipse.from_point_major_minor([1, 2, 3], 3, 2) + >>> ellipse.center + Point(x=1.000, y=2.000, z=3.000) + """ frame = Frame(point, [1, 0, 0], [0, 1, 0]) return cls(major=major, minor=minor, frame=frame) @classmethod - def from_plane_major_minor(cls, plane, major, minor): + def from_plane_major_minor(cls, plane: Plane, major: float, minor: float) -> Self: """Construct a ellipse from a point and major and minor axis lengths. Parameters ---------- - plane : :class:`compas.geometry.Plane` + plane The plane of the ellipse. - major : float + major The major axis length. - minor : float + minor The minor axis length. Returns ------- - :class:`Ellipse` + Self The constructed ellipse. + Examples + -------- + >>> plane = Plane.worldXY() + >>> Ellipse.from_plane_major_minor(plane, 3, 2).plane == plane + True + """ frame = Frame.from_plane(plane) return cls(major=major, minor=minor, frame=frame) @@ -310,29 +311,35 @@ def from_plane_major_minor(cls, plane, major, minor): # Methods # ========================================================================== - def point_at(self, t, world=True): + def point_at(self, t: float, world: bool = True) -> Point: """Compute the point at a specific parameter. Parameters ---------- - t : float + t The parameter value. - world : bool, optional - If ``True``, the point is returned in world coordinates. + world + If `True`, the point is returned in world coordinates. Returns ------- - :class:`compas.geometry.Point` + Point The point at the parameter. See Also -------- - :meth:`normal_at`, :meth:`tangent_at` + [`Ellipse.normal_at`][compas.geometry.Ellipse.normal_at] and + [`Ellipse.tangent_at`][compas.geometry.Ellipse.tangent_at] Notes ----- The location of the point is expressed with respect to the world coordinate system. + Examples + -------- + >>> Ellipse(3, 2).point_at(0.25) + Point(x=0.000, y=2.000, z=0.000) + """ t = t * PI2 x = self.major * cos(t) @@ -342,29 +349,35 @@ def point_at(self, t, world=True): point.transform(self.transformation) return point - def tangent_at(self, t, world=True): + def tangent_at(self, t: float, world: bool = True) -> Vector: """Compute the tangent at a specific parameter. Parameters ---------- - t : float + t The parameter value. - world : bool, optional - If ``True``, the tangent is returned in world coordinates. + world + If `True`, the tangent is returned in world coordinates. Returns ------- - :class:`compas.geometry.Vector` + Vector The tangent vector at the parameter. See Also -------- - :meth:`point_at`, :meth:`normal_at` + [`Ellipse.point_at`][compas.geometry.Ellipse.point_at] and + [`Ellipse.normal_at`][compas.geometry.Ellipse.normal_at] Notes ----- The orientation of the vector is expressed with respect to the world coordinate system. + Examples + -------- + >>> Ellipse(3, 2).tangent_at(0.0) + Vector(x=0.000, y=1.000, z=0.000) + """ normal = self.normal_at(t, world=False) zaxis = Vector(0, 0, 1) @@ -374,29 +387,35 @@ def tangent_at(self, t, world=True): tangent.transform(self.transformation) return tangent - def normal_at(self, t, world=True): + def normal_at(self, t: float, world: bool = True) -> Vector: """Compute the normal at a specific parameter. Parameters ---------- - t : float + t The parameter value. - world : bool, optional - If ``True``, the normal is returned in world coordinates. + world + If `True`, the normal is returned in world coordinates. Returns ------- - :class:`compas.geometry.Vector` + Vector The normal vector at the parameter. See Also -------- - :meth:`point_at`, :meth:`tangent_at` + [`Ellipse.point_at`][compas.geometry.Ellipse.point_at] and + [`Ellipse.tangent_at`][compas.geometry.Ellipse.tangent_at] Notes ----- The orientation of the vector is expressed with respect to the world coordinate system. + Examples + -------- + >>> Ellipse(3, 2).normal_at(0.0) + Vector(x=-1.000, y=0.000, z=0.000) + """ point = self.point_at(t, world=False) f1 = Point(+self.semifocal, 0, 0) diff --git a/src/compas/geometry/curves/hyperbola.py b/src/compas/geometry/curves/hyperbola.py index ef3c952a82fb..36dfc55300cc 100644 --- a/src/compas/geometry/curves/hyperbola.py +++ b/src/compas/geometry/curves/hyperbola.py @@ -1,246 +1,218 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from math import cos -from math import pi -from math import sin +from math import cosh +from math import inf +from math import sinh from math import sqrt +from typing import Any +from typing import Literal +from typing import Optional + +from typing_extensions import Self +from compas._typing import CoordinateType from compas.geometry import Frame from compas.geometry import Point +from compas.geometry import Vector +from ..line import Line from .conic import Conic -PI2 = 2 * pi - class Hyperbola(Conic): - r""" - A hyperbola is defined by a coordinate frame and a major and minor axis. - - It is implemented using the equation - - .. math:: - - \frac{x^2}{a^2} - \frac{y^2}{b^2} = 1 - - and with parametric form + r"""A single branch of a hyperbola. - .. math:: + In local coordinates the branch is parameterized by - x(t) &= a \times \sec(t) \\ - y(t) &= b \times \tan(t) + $$ + x(u) = s a \cosh(u), \qquad y(u) = b \sinh(u), + $$ - This means that the center of the hyperbola is at the center of the coordinate frame, - the vertices of the left and right branches are at (0, -a) and (0, +a) respectively, - the linear eccentricity is math::`\sqrt{a^2 + b^2}`, - and the eccentricity math::`\fraq{\sqrt{a^2 + b^2}}{a}`. + where $a$ and $b$ are the semi-major and semi-minor axis lengths and + $s$ is the branch sign. The parameter domain is the entire real line. Parameters ---------- - major : float - The major of the hyperbola. - minor : float - The minor of the hyperbola. - frame : :class:`compas.geometry.Frame`, optional - The local coordinate system of the hyperbola. - The default value is ``None``, in which case the hyperbola is constructed in the XY plane of the world coordinate system. - name : str, optional + major + The positive semi-major axis length. + minor + The positive semi-minor axis length. + branch + `1` for the positive-x branch or `-1` for the negative-x branch. + frame + The local coordinate frame. Default is the world XY frame. + name The name of the hyperbola. - Attributes - ---------- - frame : :class:`compas.geometry.Frame` - The coordinate frame of the hyperbola. - transformation : :class:`Transformation`, read-only - The transformation from the local coordinate system of the hyperbola (:attr:`frame`) to the world coordinate system. - major : float - The major radius of the hyperbola. - minor : float - The minor radius of the hyperbola. - plane : :class:`compas.geometry.Plane`, read-only - The plane of the hyperbola. - semifocal : float, read-only - The distance between the center and the focus points. - focal : float, read-only - The distance between the two focus points. - eccentricity : float, read-only - This is the ratio between the semifocal length to the length of the semi-major axis. - The eccentricity of a hyperbola is a number higher than 1. - vertex1 : :class:`compas.geometry.Point`, read-only - The first vertex of the hyperbola is on the positive x axis. - vertex2 : :class:`compas.geometry.Point`, read-only - The second vertex of the hyperbola is on the negative x axis. - focus1 : :class:`compas.geometry.Point`, read-only - The first focus of the hyperbola is on the positive x axis. - focus2 : :class:`compas.geometry.Point`, read-only - The second focus of the hyperbola is on the negative x axis. - asymptote1 : :class:`compas.geometry.Line`, read-only - The first asymptote of the hyperbola. - asymptote2 : :class:`compas.geometry.Line`, read-only - The second asymptote of the hyperbola. - is_closed : bool, read-only - False. - is_periodic : bool, read-only - False. - See Also -------- - :class:`compas.geometry.Circle`, :class:`compas.geometry.Ellipse`, :class:`compas.geometry.Parabola` + [`Circle`][compas.geometry.Circle], [`Ellipse`][compas.geometry.Ellipse], + and [`Parabola`][compas.geometry.Parabola] Examples -------- - Construct a hyperbola in the world XY plane. - >>> from compas.geometry import Frame, Hyperbola - >>> hyperbola = Hyperbola(major=3, minor=2, frame=Frame.worldXY()) - >>> hyperbola = Hyperbola(major=3, minor=2) - - Construct a hyperbola such that the Z axis of its frame aligns with a given line. - - >>> from compas.geometry import Line, Plane, Frame, Hyperbola - >>> line = Line([0, 0, 0], [1, 1, 1]) - >>> plane = Plane(line.end, line.direction) - >>> hyperbola = Hyperbola(major=3, minor=2, frame=Frame.from_plane(plane)) - - Visualise the line, hyperbola, and frame of the hyperbola with the COMPAS viewer. - - >>> from compas_viewer import Viewer # doctest: +SKIP - >>> viewer = Viewer() # doctest: +SKIP - >>> viewer.scene.add(line) # doctest: +SKIP - >>> viewer.scene.add(hyperbola) # doctest: +SKIP - >>> viewer.scene.add(hyperbola.frame) # doctest: +SKIP - >>> viewer.show() # doctest: +SKIP + >>> hyperbola = Hyperbola(major=3, minor=2, branch=1) + >>> hyperbola.point_at(0.0) + Point(x=3.000, y=0.000, z=0.000) """ - DATASCHEMA = { - "type": "object", - "properties": { - "major": {"type": "number", "minimum": 0}, - "minor": {"type": "number", "minimum": 0}, - "frame": Frame.DATASCHEMA, - }, - "required": ["major", "minor", "frame"], - } - @property - def __data__(self): + def __data__(self) -> dict[str, Any]: return { "major": self.major, "minor": self.minor, + "branch": self.branch, "frame": self.frame.__data__, } @classmethod - def __from_data__(cls, data): + def __from_data__(cls, data: dict[str, Any]) -> Self: return cls( major=data["major"], minor=data["minor"], + branch=data["branch"], frame=Frame.__from_data__(data["frame"]), ) - def __init__(self, major, minor, frame=None, name=None): - super(Hyperbola, self).__init__(frame=frame, name=name) - self._major = None - self._minor = None + def __init__( + self, + major: float, + minor: float, + branch: Literal[-1, 1] = 1, + frame: Optional[Frame] = None, + name: Optional[str] = None, + ) -> None: + super().__init__(frame=frame, name=name) + self._major: Optional[float] = None + self._minor: Optional[float] = None + self._branch: Literal[-1, 1] = 1 self.major = major self.minor = minor + self.branch = branch - def __repr__(self): - return "{0}(major={1}, minor={2}, frame={3!r})".format( + def __repr__(self) -> str: + return "{0}(major={1}, minor={2}, branch={3}, frame={4!r})".format( type(self).__name__, self.major, self.minor, + self.branch, self.frame, ) - def __eq__(self, other): - try: - return self.major == other.major and self.minor == other.minor, self.frame == other.frame - except AttributeError: + def __eq__(self, other: object) -> bool: + if not isinstance(other, Hyperbola): return False + return self.major == other.major and self.minor == other.minor and self.branch == other.branch and self.frame == other.frame # ========================================================================== # Properties # ========================================================================== @property - def center(self): + def center(self) -> Point: + """The center of the hyperbola.""" return self.frame.point @center.setter - def center(self, point): + def center(self, point: CoordinateType) -> None: self.frame.point = point @property - def major(self): + def major(self) -> float: + """The positive semi-major axis length.""" if self._major is None: raise ValueError("Length of major axis is not set.") return self._major @major.setter - def major(self, major): - if major < 0: - raise ValueError("Major axis length cannot be negative.") + def major(self, major: float) -> None: + if major <= 0: + raise ValueError("Major axis length must be positive.") self._major = float(major) @property - def minor(self): + def minor(self) -> float: + """The positive semi-minor axis length.""" if self._minor is None: raise ValueError("Length of minor axis is not set.") return self._minor @minor.setter - def minor(self, minor): - if minor < 0: - raise ValueError("Minor axis length cannot be negative.") + def minor(self, minor: float) -> None: + if minor <= 0: + raise ValueError("Minor axis length must be positive.") self._minor = float(minor) @property - def semifocal(self): + def branch(self) -> Literal[-1, 1]: + """The branch sign: `1` for positive x and `-1` for negative x.""" + return self._branch + + @branch.setter + def branch(self, branch: Literal[-1, 1]) -> None: + if branch not in (-1, 1): + raise ValueError("Branch must be either -1 or 1.") + self._branch = branch + + @property + def domain(self) -> tuple[float, float]: + """The unbounded real parameter domain.""" + return -inf, inf + + @property + def semifocal(self) -> float: + """The distance from the center to either focus.""" return sqrt(self.major**2 + self.minor**2) @property - def focal(self): + def focal(self) -> float: + """The distance between the two foci.""" return 2 * self.semifocal @property - def eccentricity(self): + def eccentricity(self) -> float: + """The eccentricity of the hyperbola.""" return self.semifocal / self.major @property - def focus1(self): + def focus1(self) -> Point: + """The focus on the positive x-axis.""" return self.frame.point + self.frame.xaxis * self.semifocal @property - def focus2(self): + def focus2(self) -> Point: + """The focus on the negative x-axis.""" return self.frame.point + self.frame.xaxis * -self.semifocal @property - def vertex1(self): + def vertex1(self) -> Point: + """The vertex on the positive x-axis.""" return self.frame.point + self.frame.xaxis * self.major @property - def vertex2(self): + def vertex2(self) -> Point: + """The vertex on the negative x-axis.""" return self.frame.point + self.frame.xaxis * -self.major @property - def asymptote1(self): - pass + def asymptote1(self) -> Line: + """The asymptote with positive local y direction.""" + return Line.from_point_and_vector(self.center, self.frame.xaxis * self.major + self.frame.yaxis * self.minor) @property - def asymptote2(self): - pass + def asymptote2(self) -> Line: + """The asymptote with negative local y direction.""" + return Line.from_point_and_vector(self.center, self.frame.xaxis * self.major - self.frame.yaxis * self.minor) @property - def is_closed(self): - return True + def is_closed(self) -> bool: + """Whether the branch is closed.""" + return False @property - def is_periodic(self): - return True + def is_periodic(self) -> bool: + """Whether the branch is periodic.""" + return False # ========================================================================== # Constructors @@ -250,87 +222,99 @@ def is_periodic(self): # Methods # ========================================================================== - def point_at(self, t, world=True): - """ - Point at the parameter. + def point_at(self, t: float, world: bool = True) -> Point: + """Compute the point at a parameter. Parameters ---------- - t : float - The curve parameter. - world : bool, optional - If ``True``, the point is transformed to the world coordinate system. + t + The real-valued hyperbolic parameter. + world + If `True`, return the point in world coordinates. Returns ------- - :class:`compas_future.geometry.Point` + Point + The point at the parameter. See Also -------- - :meth:`tangent_at`, :meth:`normal_at` + [`Hyperbola.tangent_at`][compas.geometry.Hyperbola.tangent_at] and + [`Hyperbola.normal_at`][compas.geometry.Hyperbola.normal_at] - Notes - ----- - The location of the point is expressed with respect to the world coordinate system. + Examples + -------- + >>> Hyperbola(2, 1).point_at(0.0) + Point(x=2.000, y=0.000, z=0.000) """ - t = t * PI2 - sec_t = 1 / cos(t) - x = self.major * sec_t - y = self.minor * sin(t) * sec_t + x = self.branch * self.major * cosh(t) + y = self.minor * sinh(t) point = Point(x, y, 0) if world: point.transform(self.transformation) return point - def tangent_at(self, t, world=True): - """ - Tangent vector at the parameter. + def tangent_at(self, t: float, world: bool = True) -> Vector: + """Compute the unit tangent at a parameter. Parameters ---------- - t : float - The curve parameter. - world : bool, optional - If ``True``, the tangent vector is transformed to the world coordinate system. + t + The real-valued hyperbolic parameter. + world + If `True`, return the vector in world coordinates. Returns ------- - :class:`compas_future.geometry.Vector` + Vector + The unit tangent at the parameter. See Also -------- - :meth:`point_at`, :meth:`normal_at` + [`Hyperbola.point_at`][compas.geometry.Hyperbola.point_at] and + [`Hyperbola.normal_at`][compas.geometry.Hyperbola.normal_at] - Notes - ----- - The orientation of the vector is expressed with respect to the world coordinate system. + Examples + -------- + >>> Hyperbola(2, 1).tangent_at(0.0) + Vector(x=0.000, y=1.000, z=0.000) """ - raise NotImplementedError + tangent = Vector(self.branch * self.major * sinh(t), self.minor * cosh(t), 0.0) + tangent.unitize() + if world: + tangent.transform(self.transformation) + return tangent - def normal_at(self, t, world=True): - """ - Normal at a specific normalized parameter. + def normal_at(self, t: float, world: bool = True) -> Vector: + """Compute the inward unit normal at a parameter. Parameters ---------- - t : float - The curve parameter. - world : bool, optional - If ``True``, the normal vector is transformed to the world coordinate system. + t + The real-valued hyperbolic parameter. + world + If `True`, return the vector in world coordinates. Returns ------- - :class:`compas_future.geometry.Vector` + Vector + The inward unit normal at the parameter. See Also -------- - :meth:`point_at`, :meth:`tangent_at` + [`Hyperbola.point_at`][compas.geometry.Hyperbola.point_at] and + [`Hyperbola.tangent_at`][compas.geometry.Hyperbola.tangent_at] - Notes - ----- - The orientation of the vector is expressed with respect to the world coordinate system. + Examples + -------- + >>> Hyperbola(2, 1).normal_at(0.0) + Vector(x=-1.000, y=0.000, z=0.000) """ - raise NotImplementedError + normal = Vector(-self.branch * self.minor * cosh(t), self.major * sinh(t), 0.0) + normal.unitize() + if world: + normal.transform(self.transformation) + return normal diff --git a/src/compas/geometry/curves/line.py b/src/compas/geometry/curves/line.py deleted file mode 100644 index b17799987db6..000000000000 --- a/src/compas/geometry/curves/line.py +++ /dev/null @@ -1,431 +0,0 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from compas.geometry import Frame -from compas.geometry import Point -from compas.geometry import Vector -from compas.geometry import add_vectors - -from .curve import Curve - - -class Line(Curve): - """A line is a curve defined by two points. - - The first point is the start point of the line. - The second point is the end point of the line. - The vector between the two points defines the direction of the line. - The length of the vector is the length of the line. - The direction vector is the unit vector of the vector between start and end. - The parameterisation of the line is such that the start point corresponds to ``t = 0`` and the end point to ``t = 1``. - - The coordinate system of a line is always the world coordinate system (WCS). - Transformation of a line is performed by transforming the start and end point. - - Parameters - ---------- - start : [float, float, float] | :class:`compas.geometry.Point` - The first point. - end : [float, float, float] | :class:`compas.geometry.Point` - The second point. - name : str, optional - The name of the line. - - Attributes - ---------- - start : :class:`compas.geometry.Point` - The start point of the line. - end : :class:`compas.geometry.Point` - The end point of the line. - vector : :class:`compas.geometry.Vector`, read-only - A vector pointing from start to end. - length : float, read-only - The length of the vector from start to end. - direction : :class:`compas.geometry.Vector`, read-only - A unit vector parallel to the line vector. - midpoint : :class:`compas.geometry.Point`, read-only - The midpoint between start and end. - frame : :class:`compas.geometry.Frame`, read-only - The frame of the line. - This is alsways the world XY frame. - transformation : :class:`compas.geometry.Transformation`, read-only - This is always the identity transformation. - - Examples - -------- - >>> line = Line([0, 0, 0], [1, 1, 1]) - >>> print(line.start) - Point(x=0.000, y=0.000, z=0.000) - >>> print(line.midpoint) - Point(x=0.500, y=0.500, z=0.500) - >>> line.length == line.vector.length - True - >>> print(line.direction) - Vector(x=0.577, y=0.577, z=0.577) - - """ - - # overwriting the __new__ method is necessary - # to avoid triggering the plugin mechanism of the base curve class - def __new__(cls, *args, **kwargs): - return object.__new__(cls) - - DATASCHEMA = { - "type": "object", - "properties": { - "start": Point.DATASCHEMA, - "end": Point.DATASCHEMA, - }, - "required": ["start", "end"], - } - - @property - def __data__(self): - return {"start": self.start.__data__, "end": self.end.__data__} - - def __init__(self, start, end, name=None): - super(Line, self).__init__(name=name) - self._point = None - self._vector = None - self._direction = None - self.start = start - self.end = end - - def __repr__(self): - return "{0}({1!r}, {2!r})".format( - type(self).__name__, - self.start, - self.end, - ) - - def __getitem__(self, key): - if key == 0: - return self.start - if key == 1: - return self.end - raise KeyError - - def __setitem__(self, key, value): - if key == 0: - self.start = value - elif key == 1: - self.end = value - else: - raise KeyError - - def __iter__(self): - return iter([self.start, self.end]) - - def __len__(self): - return 2 - - def __eq__(self, other): - try: - return self.start == other[0] and self.end == other[1] - except Exception: - return False - - # ========================================================================== - # properties - # ========================================================================== - - @property - def frame(self): - return Frame.worldXY() - - @frame.setter - def frame(self, frame): - raise AttributeError("Setting the coordinate frame of a line is not supported.") - - @property - def point(self): - if not self._point: - raise ValueError("The line has no base point.") - return self._point - - @point.setter - def point(self, point): - self._point = Point(*point) - - @property - def vector(self): - if not self._vector: - raise ValueError("The line has no direction vector.") - return self._vector - - @vector.setter - def vector(self, vector): - self._vector = Vector(*vector) - self._direction = None - - @property - def length(self): - return self.vector.length - - @property - def direction(self): - if not self._direction: - self._direction = self.vector.unitized() - return self._direction - - @property - def start(self): - return self.point - - @start.setter - def start(self, point): - self.point = point - - @property - def end(self): - return self.start + self.vector - - @end.setter - def end(self, point): - self._vector = Vector.from_start_end(self.start, point) - self._direction = None - - @property - def midpoint(self): - return self.point_at(0.5) - - # ========================================================================== - # Constructors - # ========================================================================== - - @classmethod - def from_point_and_vector(cls, point, vector): - """Construct a line from a point and a vector. - - Parameters - ---------- - point : :class:`compas.geometry.Point` - The start point of the line. - vector : :class:`compas.geometry.Vector` - The vector of the line. - - Returns - ------- - :class:`Line` - The constructed line. - - See Also - -------- - :meth:`Line.from_point_direction_length` - - Examples - -------- - >>> from compas.geometry import Point, Vector - >>> line = Line.from_point_and_vector(Point(0, 0, 0), Vector(1, 1, 1)) - >>> print(line.start) - Point(x=0.000, y=0.000, z=0.000) - >>> print(line.end) - Point(x=1.000, y=1.000, z=1.000) - - """ - return cls(point, add_vectors(point, vector)) - - @classmethod - def from_point_direction_length(cls, point, direction, length): - """Construct a line from a point, a direction and a length. - - Parameters - ---------- - point : :class:`compas.geometry.Point` - The start point of the line. - direction : :class:`compas.geometry.Vector` - The direction of the line. - length : float - The length of the line. - - Returns - ------- - :class:`Line` - The constructed line. - - See Also - -------- - :meth:`Line.from_point_and_vector` - - Examples - -------- - >>> from compas.geometry import Point, Vector - >>> line = Line.from_point_direction_length(Point(0, 0, 0), Vector(1, 1, 1), 1) - >>> print(line.start) - Point(x=0.000, y=0.000, z=0.000) - >>> print(line.end) - Point(x=0.577, y=0.577, z=0.577) - - """ - direction = Vector(*direction) - direction.unitize() - return cls(point, add_vectors(point, direction * length)) - - # ========================================================================== - # Transformations - # ========================================================================== - - def transform(self, T): - """Transform this line. - - Parameters - ---------- - T : :class:`compas.geometry.Transformation` - The transformation. - - Returns - ------- - None - - Examples - -------- - >>> from math import radians - >>> from compas.geometry import Rotation - >>> line = Line([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]) - >>> R = Rotation.from_axis_and_angle([0.0, 0.0, 1.0], radians(90)) - >>> line.transform(R) - >>> print(line.end) - Point(x=0.000, y=1.000, z=0.000) - - """ - self.point.transform(T) - self.vector.transform(T) - - # ========================================================================== - # Methods - # ========================================================================== - - def point_at(self, t): - """Construct a point along the line at a fractional position. - - Parameters - ---------- - t : float - The relative position along the line as a fraction of the length of the line. - 0.0 corresponds to the start point and 1.0 corresponds to the end point. - Numbers outside of this range are also valid and correspond to points beyond the start and end point. - - Returns - ------- - :class:`compas.geometry.Point` - The point at the specified position. - - See Also - -------- - :meth:`tangent_at` - - Examples - -------- - >>> line = Line([0, 0, 0], [1, 1, 1]) - >>> print(line.point_at(0.5)) - Point(x=0.500, y=0.500, z=0.500) - - """ - point = self.point + self.vector * t - return point - - def point_from_start(self, distance): - """Construct a point along the line at a distance from the start point. - - Parameters - ---------- - distance : float - The distance along the line from the start point towards the end point. - If the distance is negative, the point is constructed in the opposite direction of the end point. - If the distance is larger than the length of the line, the point is constructed beyond the end point. - - Returns - ------- - :class:`compas.geometry.Point` - The point at the specified distance. - - """ - point = self.point + self.direction * distance - return point - - def point_from_end(self, distance): - """Construct a point along the line at a distance from the end point. - - Parameters - ---------- - distance : float - The distance along the line from the end point towards the start point. - If the distance is negative, the point is constructed in the opposite direction of the start point. - If the distance is larger than the length of the line, the point is constructed beyond the start point. - - Returns - ------- - :class:`compas.geometry.Point` - The point at the specified distance. - - """ - point = self.end - self.direction * distance - return point - - def closest_point(self, point, return_parameter=False): - """Compute the closest point on the line to a given point. - - Parameters - ---------- - point : :class:`compas.geometry.Point` - The point. - return_parameter : bool, optional - Return the parameter of the closest point on the line. - Default is ``False``. - - Returns - ------- - :class:`compas.geometry.Point` - The closest point on the line. - float - The parameter of the closest point on the line. - Only if ``return_parameter`` is ``True``. - - """ - vector = point - self.start - t = vector.dot(self.vector) / self.length**2 - closest = self.start + self.vector * t - if return_parameter: - return closest, t - return closest - - def flip(self): - """Flip the direction of the line. - - Returns - ------- - None - - Examples - -------- - >>> line = Line([0, 0, 0], [1, 2, 3]) - >>> line - Line(Point(x=0.0, y=0.0, z=0.0), Point(x=1.0, y=2.0, z=3.0)) - >>> line.flip() - >>> line - Line(Point(x=1.0, y=2.0, z=3.0), Point(x=0.0, y=0.0, z=0.0)) - - """ - new_vector = self.vector.inverted() - self.start = self.end - self.vector = new_vector - - def flipped(self): - """Return a new line with the direction flipped. - - Returns - ------- - :class:`Line` - A new line. - - Examples - -------- - >>> line = Line([0, 0, 0], [1, 2, 3]) - >>> line - Line(Point(x=0.0, y=0.0, z=0.0), Point(x=1.0, y=2.0, z=3.0)) - >>> line.flipped() - Line(Point(x=1.0, y=2.0, z=3.0), Point(x=0.0, y=0.0, z=0.0)) - - """ - return Line(self.end, self.start) diff --git a/src/compas/geometry/curves/nurbs.py b/src/compas/geometry/curves/nurbs.py index cce9186974a3..702c0751cdca 100644 --- a/src/compas/geometry/curves/nurbs.py +++ b/src/compas/geometry/curves/nurbs.py @@ -1,39 +1,59 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import sqrt +from typing import TYPE_CHECKING +from typing import Any +from typing import Optional +from typing import Sequence +from typing import TypeVar + +from typing_extensions import Self -from compas.geometry import Frame -from compas.geometry import Point +from compas._typing import CoordinateType +from compas._typing import FilePath from compas.plugins import PluginNotInstalledError from compas.plugins import pluggable from .curve import Curve +if TYPE_CHECKING: + from compas.geometry import Arc + from compas.geometry import Circle + from compas.geometry import Ellipse + from compas.geometry import Line + from compas.geometry import Point + +NurbsCurveType = TypeVar("NurbsCurveType", bound="NurbsCurve") + @pluggable(category="factories") -def nurbscurve_from_interpolation(cls, *args, **kwargs): +def nurbscurve_from_interpolation(cls: type[NurbsCurveType], points: Sequence[CoordinateType], precision: float = 1e-3) -> NurbsCurveType: raise PluginNotInstalledError @pluggable(category="factories") -def nurbscurve_from_native(cls, *args, **kwargs): +def nurbscurve_from_native(cls: type[NurbsCurveType], curve: object) -> NurbsCurveType: raise PluginNotInstalledError @pluggable(category="factories") -def nurbscurve_from_parameters(cls, *args, **kwargs): +def nurbscurve_from_parameters( + cls: type[NurbsCurveType], + points: Sequence[CoordinateType], + weights: Sequence[float], + knots: Sequence[float], + multiplicities: Sequence[int], + degree: int, + is_periodic: bool = False, +) -> NurbsCurveType: raise PluginNotInstalledError @pluggable(category="factories") -def nurbscurve_from_points(cls, *args, **kwargs): +def nurbscurve_from_points(cls: type[NurbsCurveType], points: Sequence[CoordinateType], degree: int = 3, is_periodic: bool = False) -> NurbsCurveType: raise PluginNotInstalledError @pluggable(category="factories") -def nurbscurve_from_step(cls, *args, **kwargs): +def nurbscurve_from_step(cls: type[NurbsCurveType], filepath: FilePath) -> NurbsCurveType: raise PluginNotInstalledError @@ -42,50 +62,22 @@ class NurbsCurve(Curve): Parameters ---------- - name : str, optional + name The name of the curve. - Attributes - ---------- - points : list[:class:`compas.geometry.Point`], read-only - The control points. - weights : list[float], read-only - The weights of the control points. - knots : list[float], read-only - The knots, without multiplicity. - knotsequence : list[float], read-only - The complete knot vector. - multiplicity : list[int], read-only - The multiplicities of the knots. - continuity : int, read-only - The degree of continuity of the curve. - degree : int, read-only - The degree of the curve. - order : int, read-only - The order of the curve (degree + 1). + Notes + ----- + `NurbsCurve` defines the backend contract. Concrete implementations are + supplied through the plugin mechanism, for example by Rhino or OCC. """ - DATASCHEMA = { - "type": "object", - "properties": { - "points": {"type": "array", "minItems": 2, "items": Point.DATASCHEMA}, - "weights": {"type": "array", "items": {"type": "number"}}, - "knots": {"type": "array", "items": {"type": "number"}}, - "multiplicities": {"type": "array", "items": {"type": "integer"}}, - "degree": {"type": "integer", "exclusiveMinimum": 0}, - "is_periodic": {"type": "boolean"}, - }, - "additionalProperties": False, - "minProperties": 6, - } - @property - def __dtype__(self): + def __dtype__(self) -> str: return "compas.geometry/NurbsCurve" @property - def __data__(self): + def __data__(self) -> dict[str, Any]: return { "points": [point.__data__ for point in self.points], "weights": self.weights, @@ -96,7 +88,7 @@ def __data__(self): } @classmethod - def __from_data__(cls, data): + def __from_data__(cls, data: dict[str, Any]) -> Self: return cls.from_parameters( data["points"], # conversion is not needed because point data can be provided in raw form as well data["weights"], @@ -106,13 +98,13 @@ def __from_data__(cls, data): data["is_periodic"], ) - def __new__(cls, *args, **kwargs): + def __new__(cls, *args: object, **kwargs: object) -> Self: if cls is NurbsCurve: raise TypeError("Making an instance of `NurbsCurve` using `NurbsCurve()` is not allowed. Please use one of the factory methods instead (`NurbsCurve.from_...`)") return object.__new__(cls) - def __repr__(self): - return "{0}(points={1!r}, weigths={2}, knots={3}, multiplicities={4}, degree={5}, is_periodic={6})".format( + def __repr__(self) -> str: + return "{0}(points={1!r}, weights={2}, knots={3}, multiplicities={4}, degree={5}, is_periodic={6})".format( type(self).__name__, self.points, self.weights, @@ -127,39 +119,48 @@ def __repr__(self): # ============================================================================== @property - def points(self): + def points(self) -> list["Point"]: + """The control points.""" raise NotImplementedError @property - def weights(self): + def weights(self) -> list[float]: + """The control-point weights.""" raise NotImplementedError @property - def knots(self): + def knots(self) -> list[float]: + """The unique knots, without multiplicity.""" raise NotImplementedError @property - def multiplicities(self): + def multiplicities(self) -> list[int]: + """The multiplicity of each unique knot.""" raise NotImplementedError @property - def knotvector(self): - raise NotImplementedError + def knotvector(self) -> list[float]: + """The complete knot vector, including repeated knots.""" + return [knot for knot, multiplicity in zip(self.knots, self.multiplicities) for _ in range(multiplicity)] @property - def continuity(self): + def continuity(self) -> int: + """The continuity degree reported by the backend.""" raise NotImplementedError @property - def degree(self): + def degree(self) -> int: + """The polynomial degree.""" raise NotImplementedError @property - def order(self): + def order(self) -> int: + """The polynomial order, equal to `degree + 1`.""" return self.degree + 1 @property - def is_rational(self): + def is_rational(self) -> bool: + """Whether any control-point weight differs from one.""" raise NotImplementedError # ============================================================================== @@ -167,34 +168,48 @@ def is_rational(self): # ============================================================================== @classmethod - def from_arc(cls, arc): + def from_arc(cls, arc: "Arc") -> Self: """Construct a NURBS curve from an arc. Parameters ---------- - arc : :class:`compas.geometry.Arc` + arc + The arc to convert. Returns ------- - :class:`compas.geometry.NurbsCurve` + Self + The constructed NURBS curve. + + Examples + -------- + >>> from compas.geometry import Arc + >>> curve = NurbsCurve.from_arc(Arc(1.0, 0.0, 1.0)) # doctest: +SKIP """ raise NotImplementedError @classmethod - def from_circle(cls, circle): + def from_circle(cls, circle: "Circle") -> Self: """Construct a NURBS curve from a circle. Parameters ---------- - circle : :class:`compas.geometry.Circle` + circle + The circle to convert. Returns ------- - :class:`compas.geometry.NurbsCurve` + Self + The constructed NURBS curve. + + Examples + -------- + >>> from compas.geometry import Circle + >>> curve = NurbsCurve.from_circle(Circle(1.0)) # doctest: +SKIP """ - frame = Frame.from_plane(circle.plane) + frame = circle.frame w = 0.5 * sqrt(2) dx = frame.xaxis * circle.radius dy = frame.yaxis * circle.radius @@ -215,20 +230,26 @@ def from_circle(cls, circle): return cls.from_parameters(points=points, weights=weights, knots=knots, multiplicities=mults, degree=2) @classmethod - def from_ellipse(cls, ellipse): + def from_ellipse(cls, ellipse: "Ellipse") -> Self: """Construct a NURBS curve from an ellipse. Parameters ---------- - ellipse : :class:`compas.geometry.Ellipse` + ellipse + The ellipse to convert. Returns ------- - :class:`compas.geometry.NurbsCurve` + Self + The constructed NURBS curve. + + Examples + -------- + >>> from compas.geometry import Ellipse + >>> curve = NurbsCurve.from_ellipse(Ellipse(2.0, 1.0)) # doctest: +SKIP """ - frame = Frame.from_plane(ellipse.plane) - frame = Frame.worldXY() + frame = ellipse.frame w = 0.5 * sqrt(2) dx = frame.xaxis * ellipse.major dy = frame.yaxis * ellipse.minor @@ -249,34 +270,47 @@ def from_ellipse(cls, ellipse): return cls.from_parameters(points=points, weights=weights, knots=knots, multiplicities=mults, degree=2) @classmethod - def from_interpolation(cls, points, precision=1e-3): + def from_interpolation(cls, points: Sequence[CoordinateType], precision: float = 1e-3) -> Self: """Construct a NURBS curve by interpolating a set of points. Parameters ---------- - points : list[[float, float, float] | :class:`compas.geometry.Point`] + points A list of interpolation points. - precision : int, optional + precision The desired precision of the interpolation. Returns ------- - :class:`compas.geometry.NurbsCurve` + Self + The interpolated NURBS curve. + + Examples + -------- + >>> points = [[0, 0, 0], [1, 1, 0], [2, 0, 0]] + >>> curve = NurbsCurve.from_interpolation(points) # doctest: +SKIP """ - return nurbscurve_from_interpolation(cls, points, precision=1e-3) + return nurbscurve_from_interpolation(cls, points, precision=precision) @classmethod - def from_line(cls, line): + def from_line(cls, line: "Line") -> Self: """Construct a NURBS curve from a line. Parameters ---------- - line : :class:`compas.geometry.Line` + line + The line to convert. Returns ------- - :class:`compas.geometry.NurbsCurve` + Self + The constructed NURBS curve. + + Examples + -------- + >>> from compas.geometry import Line + >>> curve = NurbsCurve.from_line(Line([0, 0, 0], [1, 0, 0])) # doctest: +SKIP """ return cls.from_parameters( @@ -288,7 +322,7 @@ def from_line(cls, line): ) @classmethod - def from_native(cls, curve): + def from_native(cls, curve: object) -> Self: """Construct a NURBS curve from a CAD-native curve geometry. Parameters @@ -298,68 +332,104 @@ def from_native(cls, curve): Returns ------- - :class:`compas.geometry.NurbsCurve` + Self A COMPAS NURBS curve. + Examples + -------- + >>> curve = NurbsCurve.from_native(native_curve) # doctest: +SKIP + """ return nurbscurve_from_native(cls, curve) @classmethod - def from_parameters(cls, points, weights, knots, multiplicities, degree, is_periodic=False): + def from_parameters( + cls, + points: Sequence[CoordinateType], + weights: Sequence[float], + knots: Sequence[float], + multiplicities: Sequence[int], + degree: int, + is_periodic: bool = False, + ) -> Self: """Construct a NURBS curve from explicit curve parameters. Parameters ---------- - points : list[[float, float, float] | :class:`compas.geometry.Point`] + points The control points. - weights : list[float] + weights The weights of the control points. - knots : list[float] + knots The curve knots, without multiplicity. - multiplicities : list[int] + multiplicities Multiplicity of the knots. - degree : int + degree Degree of the curve. - is_periodic : bool, optional + is_periodic Flag indicating that the curve is periodic. Returns ------- - :class:`compas.geometry.NurbsCurve` + Self + The constructed NURBS curve. + + Examples + -------- + >>> curve = NurbsCurve.from_parameters( # doctest: +SKIP + ... points=[[0, 0, 0], [1, 0, 0]], + ... weights=[1.0, 1.0], + ... knots=[0.0, 1.0], + ... multiplicities=[2, 2], + ... degree=1, + ... ) """ - return nurbscurve_from_parameters(cls, points, weights, knots, multiplicities, degree, is_periodic=False) + return nurbscurve_from_parameters(cls, points, weights, knots, multiplicities, degree, is_periodic=is_periodic) @classmethod - def from_points(cls, points, degree=3): + def from_points(cls, points: Sequence[CoordinateType], degree: int = 3, is_periodic: bool = False) -> Self: """Construct a NURBS curve from control points. Parameters ---------- - points : list[[float, float, float] | :class:`compas.geometry.Point`] + points The control points. - degree : int, optional + degree The degree of the curve. + is_periodic + Whether the curve is periodic. Returns ------- - :class:`compas.geometry.NurbsCurve` + Self + The constructed NURBS curve. + + Examples + -------- + >>> curve = NurbsCurve.from_points([[0, 0, 0], [1, 0, 0]], degree=1) # doctest: +SKIP """ - return nurbscurve_from_points(cls, points, degree=degree) + return nurbscurve_from_points(cls, points, degree=degree, is_periodic=is_periodic) @classmethod - def from_step(cls, filepath): + def from_step(cls, filepath: FilePath) -> Self: """Load a NURBS curve from an STP file. Parameters ---------- - filepath : str + filepath The path to the file. Returns ------- - :class:`compas.geometry.NurbsCurve` + Self + The loaded NURBS curve. + + Examples + -------- + >>> curve = NurbsCurve.from_step("curve.step") # doctest: +SKIP + """ return nurbscurve_from_step(cls, filepath) @@ -371,15 +441,24 @@ def from_step(cls, filepath): # Methods # ============================================================================== - def copy(self): + def copy(self, cls: Optional[type[Self]] = None, copy_guid: bool = False) -> Self: # type: ignore[override] """Make an independent copy of the current curve. + Parameters + ---------- + cls + The NURBS curve type to construct. Default is `type(self)`. + copy_guid + If `True`, preserve the globally unique identifier. + Returns ------- - :class:`compas.geometry.NurbsCurve` + Self + The independent copy. """ - return NurbsCurve.from_parameters( + curve_type = cls or type(self) + curve = curve_type.from_parameters( self.points, self.weights, self.knots, @@ -387,18 +466,6 @@ def copy(self): self.degree, self.is_periodic, ) - - def insert_knot(self): - pass - - def refine_knot(self): - pass - - def remove_knot(self): - pass - - def elevate_degree(self): - pass - - def reduce_degree(self): - pass + if copy_guid: + curve._guid = self.guid + return curve diff --git a/src/compas/geometry/curves/parabola.py b/src/compas/geometry/curves/parabola.py index c6202c9d5864..e484d9a57380 100644 --- a/src/compas/geometry/curves/parabola.py +++ b/src/compas/geometry/curves/parabola.py @@ -1,255 +1,242 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from math import inf +from typing import Any +from typing import Optional + +from typing_extensions import Self from compas.geometry import Frame from compas.geometry import Point from compas.geometry import Vector +from ..line import Line from .conic import Conic -from .line import Line class Parabola(Conic): - """ - A parabola is defined by a plane and a major and minor axis. - The origin of the coordinate frame is the center of the parabola. + r"""A parabola defined by a frame and focal length. + + In local coordinates the parabola is parameterized by - The parabola in this implementation is based on the equation ``y = a * x**2``. - Therefore it will have the y axis of the coordinate frame as its axis of symmetry. + $$ + x(t) = t, \qquad y(t) = \frac{t^2}{4f}, + $$ + + where $f$ is the focal length. The vertex is the frame origin and the + positive local y-axis is the axis of symmetry. The parameter domain is the + entire real line. Parameters ---------- - focal : float - The focal length of the parabola. - frame : :class:`compas.geometry.Frame` - The coordinate frame of the parabola. - name : str, optional + focal + The positive focal length. + frame + The coordinate frame. Default is the world XY frame. + name The name of the parabola. - Attributes - ---------- - frame : :class:`compas.geometry.Frame` - The coordinate frame of the parabola. - transformation : :class:`Transformation`, read-only - The transformation from the local coordinate system of the parabola (:attr:`frame`) to the world coordinate system. - focal : float - The focal length of the parabola. - plane : :class:`compas.geometry.Plane`, read-only - The plane of the parabola. - latus : :class:`compas.geometry.Point`, read-only - The latus rectum of the parabola. - eccentricity : float, read-only - The eccentricity of a parabola is between 0 and 1. - focus : :class:`compas.geometry.Point`, read-only - The focus point of the parabola. - directix : :class:`compas.geometry.Line`, read-only - The directix is the line perpendicular to the y axis of the parabola frame - at a distance ``d = + major / eccentricity`` from the origin of the parabola frame. - is_closed : bool, read-only - False. - is_periodic : bool, read-only - False. - See Also -------- - :class:`compas.geometry.Ellipse`, :class:`compas.geometry.Hyperbola`, :class:`compas.geometry.Circle` + [`Ellipse`][compas.geometry.Ellipse], [`Hyperbola`][compas.geometry.Hyperbola], + and [`Circle`][compas.geometry.Circle] Examples -------- - Construct a parabola in the world XY plane. - - >>> from compas.geometry import Frame, Parabola - >>> parabola = Parabola(focal=3, frame=Frame.worldXY()) - >>> parabola = Parabola(focal=3) - - Construct a parabola such that the Z axis of its frame aligns with a given line. - - >>> from compas.geometry import Frame, Line, Plane, Parabola - >>> line = Line([0, 0, 0], [1, 1, 1]) - >>> plane = Plane(line.end, line.direction) - >>> frame = Frame.from_plane(plane) - >>> parabola = Parabola(focal=3, frame=frame) - - Visualize the parabola with the COMPAS viewer. - - >>> from compas_viewer import Viewer # doctest: +SKIP - >>> viewer = Viewer() # doctest: +SKIP - >>> viewer.scene.add(line) # doctest: +SKIP - >>> viewer.scene.add(parabola) # doctest: +SKIP - >>> viewer.scene.add(parabola.frame) # doctest: +SKIP - >>> viewer.show() # doctest: +SKIP + >>> from compas.geometry import Parabola + >>> parabola = Parabola(focal=1.0) + >>> parabola.point_at(2.0) + Point(x=2.000, y=1.000, z=0.000) """ - DATASCHEMA = { - "type": "object", - "properties": { - "focal": {"type": "number", "minimum": 0}, - "frame": Frame.DATASCHEMA, - }, - "required": ["focal", "frame"], - } - @property - def __data__(self): + def __data__(self) -> dict[str, Any]: return {"focal": self.focal, "frame": self.frame.__data__} @classmethod - def __from_data__(cls, data): - return cls( - focal=data["focal"], - frame=Frame.__from_data__(data["frame"]), - ) - - def __init__(self, focal, frame=None, name=None): - super(Parabola, self).__init__(frame=frame, name=name) - self._focal = None + def __from_data__(cls, data: dict[str, Any]) -> Self: + return cls(focal=data["focal"], frame=Frame.__from_data__(data["frame"])) + + def __init__(self, focal: float, frame: Optional[Frame] = None, name: Optional[str] = None) -> None: + super().__init__(frame=frame, name=name) + self._focal: Optional[float] = None self.focal = focal - def __repr__(self): - return "{0}(focal={1}, frame={2!r})".format( - type(self).__name__, - self.focal, - self.frame, - ) - - def __eq__(self, other): - try: - return self.focal == other.focal and self.frame == other.frame - except AttributeError: + def __repr__(self) -> str: + return "{0}(focal={1}, frame={2!r})".format(type(self).__name__, self.focal, self.frame) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Parabola): return False + return self.focal == other.focal and self.frame == other.frame # ========================================================================== - # properties + # Properties # ========================================================================== @property - def focal(self): + def focal(self) -> float: + """The positive focal length. + + Notes + ----- + Changing the focal length changes the curvature while preserving the + vertex and axis of symmetry. + + """ if self._focal is None: raise ValueError("The focal length of the parabola is not set.") return self._focal @focal.setter - def focal(self, focal): - self._focal = focal + def focal(self, focal: float) -> None: + if focal <= 0.0: + raise ValueError("The focal length must be positive.") + self._focal = float(focal) @property - def a(self): - return 1 / (4 * self.focal) + def a(self) -> float: + """The quadratic coefficient $a = 1 / (4f)$. + + Notes + ----- + Assigning a positive coefficient updates the focal length. + + """ + return 1.0 / (4.0 * self.focal) @a.setter - def a(self, a): - self.focal = 1 / (4 * a) + def a(self, a: float) -> None: + if a <= 0.0: + raise ValueError("The quadratic coefficient must be positive.") + self.focal = 1.0 / (4.0 * a) + + @property + def domain(self) -> tuple[float, float]: + """The unbounded real parameter domain.""" + return -inf, inf @property - def eccentricity(self): - return 1 + def eccentricity(self) -> float: + """The eccentricity, which is always one.""" + return 1.0 @property - def latus(self): - return 2 * self.focal + def latus(self) -> float: + """The length of the latus rectum.""" + return 4.0 * self.focal @property - def focus(self): + def focus(self) -> Point: + """The focus of the parabola.""" return self.frame.point + self.frame.yaxis * self.focal @property - def vertex(self): + def vertex(self) -> Point: + """The vertex of the parabola. + + Notes + ----- + The returned point belongs to the parabola frame. Mutating it changes + the parabola. + + """ return self.frame.point @property - def directix(self): - point = self.frame.point + self.frame.yaxis * -self.focal - return Line(point, point + self.frame.xaxis) + def directix(self) -> Line: + """The directrix of the parabola as an independent line.""" + point = self.frame.point - self.frame.yaxis * self.focal + return Line.from_point_and_vector(point, self.frame.xaxis) @property - def is_closed(self): + def is_closed(self) -> bool: + """Whether the parabola is closed.""" return False @property - def is_periodic(self): + def is_periodic(self) -> bool: + """Whether the parabola is periodic.""" return False - # ========================================================================== - # Constructors - # ========================================================================== - # ========================================================================== # Methods # ========================================================================== - def point_at(self, t, world=True): - """ - Point at the parameter. + def point_at(self, t: float, world: bool = True) -> Point: + """Compute the point at a parameter. Parameters ---------- - t : float - The curve parameter. - world : bool, optional - If ``True``, the point is returned in world coordinates. + t + The real-valued parameter. + world + If `True`, return the point in world coordinates. Returns ------- - :class:`compas_future.geometry.Point` + Point + The point at the parameter. + + Examples + -------- + >>> Parabola(1.0).point_at(-2.0) + Point(x=-2.000, y=1.000, z=0.000) """ - x = t - y = self.a * x**2 - z = 0 - point = Point(x, y, z) + point = Point(t, self.a * t**2, 0.0) if world: point.transform(self.transformation) return point - def tangent_at(self, t, world=True): - """ - Tangent vector at the parameter. + def tangent_at(self, t: float, world: bool = True) -> Vector: + """Compute the unit tangent at a parameter. Parameters ---------- - t : float - The curve parameter. - world : bool, optional - If ``True``, the tangent vector is returned in world coordinates. + t + The real-valued parameter. + world + If `True`, return the vector in world coordinates. Returns ------- - :class:`compas_future.geometry.Vector` + Vector + The unit tangent in increasing parameter direction. + + Examples + -------- + >>> Parabola(1.0).tangent_at(0.0) + Vector(x=1.000, y=0.000, z=0.000) """ - x0 = t - y0 = self.a * t**2 - x = 2 * t - y = 2 * self.a * x0 * x - y0 - tangent = Vector(x - x0, y - y0, 0) + tangent = Vector(1.0, 2.0 * self.a * t, 0.0) tangent.unitize() if world: tangent.transform(self.transformation) return tangent - def normal_at(self, t, world=True): - """ - Normal at a specific normalized parameter. + def normal_at(self, t: float, world: bool = True) -> Vector: + """Compute the inward unit normal at a parameter. Parameters ---------- - t : float - The curve parameter. - world : bool, optional - If ``True``, the normal vector is returned in world coordinates. + t + The real-valued parameter. + world + If `True`, return the vector in world coordinates. Returns ------- - :class:`compas_future.geometry.Vector` + Vector + The unit normal directed towards the concave side. + + Examples + -------- + >>> Parabola(1.0).normal_at(0.0) + Vector(x=0.000, y=1.000, z=0.000) """ - x0 = t - y0 = self.a * t**2 - x = 2 * t - y = 2 * self.a * x0 * x - y0 - normal = Vector(y0 - y, x - x0, 0) + normal = Vector(-2.0 * self.a * t, 1.0, 0.0) normal.unitize() if world: normal.transform(self.transformation) diff --git a/src/compas/geometry/frame.py b/src/compas/geometry/frame.py index bfb52f4a1ef0..355061950875 100644 --- a/src/compas/geometry/frame.py +++ b/src/compas/geometry/frame.py @@ -1,61 +1,56 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - +from typing import Iterator +from typing import MutableSequence +from typing import Optional +from typing import Sequence +from typing import TypeVar +from typing import Union +from typing import overload + +from typing_extensions import Self + +from compas._typing import Coordinates +from compas._typing import CoordinatesType +from compas._typing import CoordinateType from compas.geometry import Geometry from compas.geometry import Transformation -from compas.geometry import argmax -from compas.geometry import axis_angle_vector_from_matrix -from compas.geometry import basis_vectors_from_matrix -from compas.geometry import cross_vectors -from compas.geometry import decompose_matrix -from compas.geometry import euler_angles_from_matrix -from compas.geometry import matrix_from_axis_angle_vector -from compas.geometry import matrix_from_basis_vectors -from compas.geometry import matrix_from_euler_angles -from compas.geometry import matrix_from_quaternion -from compas.geometry import quaternion_from_matrix -from compas.geometry import subtract_vectors from compas.itertools import linspace - +from compas.linalg.transformations import axis_angle_vector_from_matrix +from compas.linalg.transformations import basis_vectors_from_matrix +from compas.linalg.transformations import decompose_matrix +from compas.linalg.transformations import euler_angles_from_matrix +from compas.linalg.transformations import matrix_from_axis_angle_vector +from compas.linalg.transformations import matrix_from_basis_vectors +from compas.linalg.transformations import matrix_from_euler_angles +from compas.linalg.transformations import matrix_from_quaternion +from compas.linalg.transformations import quaternion_from_matrix +from compas.linalg.vectors import argmax +from compas.linalg.vectors import cross_vectors +from compas.linalg.vectors import subtract_vectors + +from ._typing import PlaneType +from ._typing import QuaternionType +from ._typing import TransformationType from .point import Point from .quaternion import Quaternion from .vector import Vector +GeometryType = TypeVar("GeometryType", bound=Geometry) + class Frame(Geometry): """A frame is defined by a base point and two orthonormal base vectors. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point The origin of the frame. - xaxis : [float, float, float] | :class:`compas.geometry.Vector`, optional + xaxis The x-axis of the frame. Defaults to the unit X vector. - yaxis : [float, float, float] | :class:`compas.geometry.Vector`, optional + yaxis The y-axis of the frame. Defaults to the unit Y vector. - name : str, optional + name The name of the frame. - Attributes - ---------- - axes : list of :class:`compas.geometry.Vector`, read-only - The XYZ axes of the frame. - axis_angle_vector : :class:`compas.geometry.Vector`, read-only - The axis-angle vector representing the rotation of the frame. - normal : :class:`compas.geometry.Vector`, read-only - The normal of the base plane of the frame. - point : :class:`compas.geometry.Point` - The base point of the frame. - quaternion : :class:`compas.geometry.Quaternion`, read-only - The quaternion from the rotation given by the frame. - xaxis : :class:`compas.geometry.Vector` - The local X axis of the frame. - yaxis : :class:`compas.geometry.Vector` - The local Y axis of the frame. - zaxis : :class:`compas.geometry.Vector`, read-only - The Z axis of the frame. - Notes ----- All input vectors are orthonormalized when creating a frame, with the first @@ -69,28 +64,80 @@ class Frame(Geometry): >>> f = Frame(Point(0, 0, 0), Vector(1, 0, 0), Vector(0, 1, 0)) >>> f = Frame([0, 0, 0]) - """ + `Frame` implements `__len__`, `__iter__`, `__getitem__`, and `__setitem__`. + Its three items are the origin, X axis, and Y axis, in that order. + + >>> len(f) + 3 + >>> list(f) == [f.point, f.xaxis, f.yaxis] + True + >>> f[0] = [1.0, 2.0, 3.0] + >>> f.point == [1.0, 2.0, 3.0] + True + + Frames compare equal to other three-item frame representations when their + origin and axes are equal within the configured tolerance. + + >>> f == [f.point, f.xaxis, f.yaxis] + True + + Frames can be constructed from world-axis presets or three points. + + >>> Frame.worldXY() == Frame.from_points([0, 0, 0], [1, 0, 0], [0, 1, 0]) + True + >>> Frame.worldZX().xaxis == [0, 0, 1] + True + >>> Frame.worldYZ().xaxis == [0, 1, 0] + True + + Transformations, rotations, matrices, and flat matrix data can also define + a frame. + + >>> from compas.geometry import Rotation + >>> from compas.geometry import Transformation + >>> identity = Frame.worldXY() + >>> Frame.from_rotation(Rotation()) == identity + True + >>> transformation = Transformation.from_frame(identity) + >>> Frame.from_transformation(transformation) == identity + True + >>> Frame.from_matrix(transformation.matrix) == identity + True + >>> values = [value for row in transformation.matrix for value in row] + >>> Frame.from_list(values) == identity + True + + Rotation coefficients and planes provide further constructor forms. + + >>> Frame.from_quaternion([1, 0, 0, 0]) == identity + True + >>> Frame.from_axis_angle_vector([0, 0, 0]) == identity + True + >>> Frame.from_euler_angles([0, 0, 0]) == identity + True + >>> from compas.geometry import Plane + >>> Frame.from_plane(Plane.worldXY()).normal == [0, 0, 1] + True - DATASCHEMA = { - "type": "object", - "properties": { - "point": Point.DATASCHEMA, - "xaxis": Vector.DATASCHEMA, - "yaxis": Vector.DATASCHEMA, - }, - "required": ["point", "xaxis", "yaxis"], - } + """ @property - def __data__(self): + def __data__(self) -> dict[str, list[float]]: + """The data representation of the frame.""" return { "point": self.point.__data__, "xaxis": self.xaxis.__data__, "yaxis": self.yaxis.__data__, } - def __init__(self, point, xaxis=None, yaxis=None, name=None): - super(Frame, self).__init__(name=name) + def __init__( + self, + point: CoordinateType, + xaxis: Optional[CoordinateType] = None, + yaxis: Optional[CoordinateType] = None, + name: Optional[str] = None, + ) -> None: + super().__init__(name=name) self._point = None self._xaxis = None self._yaxis = None @@ -99,7 +146,7 @@ def __init__(self, point, xaxis=None, yaxis=None, name=None): self.xaxis = Vector(1, 0, 0) if xaxis is None else xaxis self.yaxis = Vector(0, 1, 0) if yaxis is None else yaxis - def __repr__(self): + def __repr__(self) -> str: return "{0}(point={1!r}, xaxis={2!r}, yaxis={3!r})".format( type(self).__name__, self.point, @@ -107,7 +154,7 @@ def __repr__(self): self.yaxis, ) - def __str__(self): + def __str__(self) -> str: return "{0}(point={1}, xaxis={2}, yaxis={3})".format( type(self).__name__, str(self.point), @@ -115,10 +162,10 @@ def __str__(self): str(self.yaxis), ) - def __len__(self): + def __len__(self) -> int: return 3 - def __getitem__(self, key): + def __getitem__(self, key: int) -> Union[Point, Vector]: if key == 0: return self.point if key == 1: @@ -127,7 +174,7 @@ def __getitem__(self, key): return self.yaxis raise KeyError - def __setitem__(self, key, value): + def __setitem__(self, key: int, value: CoordinateType) -> None: if key == 0: self.point = value return @@ -136,13 +183,16 @@ def __setitem__(self, key, value): return if key == 2: self.yaxis = value + return raise KeyError - def __iter__(self): + def __iter__(self) -> Iterator[Union[Point, Vector]]: return iter([self.point, self.xaxis, self.yaxis]) - def __eq__(self, other): - if not hasattr(other, "__iter__") or not hasattr(other, "__len__") or len(self) != len(other): + def __eq__(self, other: object) -> bool: + if not isinstance(other, Coordinates): + return False + if len(self) != len(other): return False return self.point == other[0] and self.xaxis == other[1] and self.yaxis == other[2] @@ -151,77 +201,191 @@ def __eq__(self, other): # ========================================================================== @property - def point(self): + def point(self) -> Point: + """The origin of the frame. + + Notes + ----- + Assigning a `Point` or three-component coordinate sequence creates an + independent `Point` with the same coordinates. + + Examples + -------- + >>> source = Point(1, 2, 3) + >>> frame = Frame.worldXY() + >>> frame.point = source + >>> frame.point == source and frame.point is not source + True + >>> source.x = 10 + >>> frame.point == [1, 2, 3] + True + + """ if not self._point: raise ValueError("The frame has no origin.") return self._point @point.setter - def point(self, point): - self._point = Point(*point) + def point(self, point: CoordinateType) -> None: + self._point = Point(point[0], point[1], point[2]) @property - def xaxis(self): + def xaxis(self) -> Vector: + """The unit X axis of the frame. + + Notes + ----- + Assigning a `Vector` or three-component coordinate sequence creates an + independent, unitized `Vector`. The Y axis is made orthogonal to the new + X axis and the cached Z axis is invalidated. + + Examples + -------- + >>> source = Vector(1, 1, 0) + >>> frame = Frame.worldXY() + >>> frame.xaxis = source + >>> frame.xaxis is not source + True + >>> frame.xaxis.dot(frame.yaxis) + 0.0 + + """ if not self._xaxis: raise ValueError("The frame has no x-axis.") return self._xaxis @xaxis.setter - def xaxis(self, vector): - xaxis = Vector(*vector) + def xaxis(self, vector: CoordinateType) -> None: + xaxis = Vector(vector[0], vector[1], vector[2]) + if not xaxis.length: + raise ValueError("The X axis cannot be a zero vector.") xaxis.unitize() + yaxis = self._yaxis + if self._yaxis is not None: + zaxis = xaxis.cross(self._yaxis) + if not zaxis.length: + raise ValueError("The X and Y axes cannot be parallel.") + zaxis.unitize() + yaxis = zaxis.cross(xaxis) self._xaxis = xaxis + self._yaxis = yaxis self._zaxis = None @property - def yaxis(self): + def yaxis(self) -> Vector: + """The unit Y axis of the frame. + + Notes + ----- + Assigning a `Vector` or three-component coordinate sequence creates an + independent vector. The input is unitized and then made orthogonal to + the X axis; the cached Z axis is invalidated. + + Examples + -------- + >>> source = Vector(1, 1, 0) + >>> frame = Frame.worldXY() + >>> frame.yaxis = source + >>> frame.yaxis == [0, 1, 0] and frame.yaxis is not source + True + >>> frame.xaxis.dot(frame.yaxis) + 0.0 + + """ if not self._yaxis: raise ValueError("The frame has no y-axis.") return self._yaxis @yaxis.setter - def yaxis(self, vector): - yaxis = Vector(*vector) + def yaxis(self, vector: CoordinateType) -> None: + yaxis = Vector(vector[0], vector[1], vector[2]) + if not yaxis.length: + raise ValueError("The Y axis cannot be a zero vector.") yaxis.unitize() zaxis = self.xaxis.cross(yaxis) + if not zaxis.length: + raise ValueError("The X and Y axes cannot be parallel.") zaxis.unitize() self._yaxis = zaxis.cross(self.xaxis) self._zaxis = None @property - def normal(self): + def normal(self) -> Vector: + """The normal of the frame, equivalent to its Z axis. + + Examples + -------- + >>> frame = Frame.worldXY() + >>> frame.normal is frame.zaxis + True + + """ return self.zaxis @property - def zaxis(self): + def zaxis(self) -> Vector: + """The unit Z axis defined by the cross product of X and Y. + + Notes + ----- + The Z axis is computed on first access and cached until either input + axis changes. + + Examples + -------- + >>> frame = Frame.worldXY() + >>> frame.zaxis == [0, 0, 1] + True + >>> frame.zaxis is frame.zaxis + True + + """ if not self._zaxis: self._zaxis = self.xaxis.cross(self.yaxis) return self._zaxis - def axes(self): + def axes(self) -> list[Vector]: return [self.xaxis, self.yaxis, self.zaxis] @property - def quaternion(self): + def quaternion(self) -> Quaternion: + """The rotation of the frame represented as a quaternion. + + Examples + -------- + >>> Frame.worldXY().quaternion == [1, 0, 0, 0] + True + + """ R = matrix_from_basis_vectors(self.xaxis, self.yaxis) - return Quaternion(*quaternion_from_matrix(R)) + values = quaternion_from_matrix(R) + return Quaternion(values[0], values[1], values[2], values[3]) @property - def axis_angle_vector(self): + def axis_angle_vector(self) -> Vector: + """The rotation of the frame represented as an axis-angle vector. + + Examples + -------- + >>> Frame.worldXY().axis_angle_vector == [0, 0, 0] + True + + """ R = matrix_from_basis_vectors(self.xaxis, self.yaxis) - return Vector(*axis_angle_vector_from_matrix(R)) + values = axis_angle_vector_from_matrix(R) + return Vector(values[0], values[1], values[2]) # ========================================================================== # Constructors # ========================================================================== @classmethod - def worldXY(cls): # type: () -> Frame + def worldXY(cls) -> Self: """Construct the world XY frame. Returns ------- - :class:`compas.geometry.Frame` + Self The world XY frame. Examples @@ -238,12 +402,12 @@ def worldXY(cls): # type: () -> Frame return cls([0, 0, 0], [1, 0, 0], [0, 1, 0]) @classmethod - def worldZX(cls): # type: () -> Frame + def worldZX(cls) -> Self: """Construct the world ZX frame. Returns ------- - :class:`compas.geometry.Frame` + Self The world ZX frame. Examples @@ -260,12 +424,12 @@ def worldZX(cls): # type: () -> Frame return cls([0, 0, 0], [0, 0, 1], [1, 0, 0]) @classmethod - def worldYZ(cls): # type: () -> Frame + def worldYZ(cls) -> Self: """Construct the world YZ frame. Returns ------- - :class:`compas.geometry.Frame` + Self The world YZ frame. Examples @@ -282,21 +446,21 @@ def worldYZ(cls): # type: () -> Frame return cls([0, 0, 0], [0, 1, 0], [0, 0, 1]) @classmethod - def from_points(cls, point, point_xaxis, point_xyplane): # type: (...) -> Frame + def from_points(cls, point: CoordinateType, point_xaxis: CoordinateType, point_xyplane: CoordinateType) -> Self: """Constructs a frame from 3 points. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point The origin of the frame. - point_xaxis : [float, float, float] | :class:`compas.geometry.Point` + point_xaxis A point on the x-axis of the frame. - point_xyplane : [float, float, float] | :class:`compas.geometry.Point` + point_xyplane A point within the xy-plane of the frame. Returns ------- - :class:`compas.geometry.Frame` + Self The constructed frame. Examples @@ -316,19 +480,19 @@ def from_points(cls, point, point_xaxis, point_xyplane): # type: (...) -> Frame return cls(point, xaxis, yaxis) @classmethod - def from_rotation(cls, rotation, point=[0, 0, 0]): # type: (...) -> Frame + def from_rotation(cls, rotation: Transformation, point: CoordinateType = (0, 0, 0)) -> Self: """Constructs a frame from a Rotation. Parameters ---------- - rotation : :class:`compas.geometry.Rotation` + rotation The rotation defines the orientation of the frame. - point : [float, float, float] | :class:`compas.geometry.Point`, optional + point The origin of the frame. Returns ------- - :class:`compas.geometry.Frame` + Self The constructed frame. Examples @@ -345,18 +509,18 @@ def from_rotation(cls, rotation, point=[0, 0, 0]): # type: (...) -> Frame return cls(point, xaxis, yaxis) @classmethod - def from_transformation(cls, transformation): # type: (...) -> Frame + def from_transformation(cls, transformation: Transformation) -> Self: """Constructs a frame from a Transformation. Parameters ---------- - transformation : :class:`compas.geometry.Transformation` + transformation The transformation defines the orientation of the frame through the rotation and the origin through the translation. Returns ------- - :class:`compas.geometry.Frame` + Self The constructed frame. Examples @@ -374,22 +538,22 @@ def from_transformation(cls, transformation): # type: (...) -> Frame return cls(point, xaxis, yaxis) @classmethod - def from_matrix(cls, matrix): # type: (...) -> Frame + def from_matrix(cls, matrix: CoordinatesType) -> Self: """Construct a frame from a matrix. Parameters ---------- - matrix : list[list[float]] + matrix The 4x4 transformation matrix in row-major order. Returns ------- - :class:`compas.geometry.Frame` + Self The constructed frame. Examples -------- - >>> from compas.geometry import matrix_from_euler_angles + >>> from compas.linalg import matrix_from_euler_angles >>> from compas.tolerance import TOL >>> ea1 = [0.5, 0.4, 0.8] >>> M = matrix_from_euler_angles(ea1) @@ -405,17 +569,17 @@ def from_matrix(cls, matrix): # type: (...) -> Frame return cls(point, xaxis, yaxis) @classmethod - def from_list(cls, values): # type: (...) -> Frame + def from_list(cls, values: MutableSequence[float]) -> Self: """Construct a frame from a list of 12 or 16 float values. Parameters ---------- - values : list[float] + values The list of 12 or 16 values representing a 4x4 matrix. Returns ------- - :class:`compas.geometry.Frame` + Self The constructed frame. Raises @@ -447,19 +611,19 @@ def from_list(cls, values): # type: (...) -> Frame return cls.from_matrix(matrix) @classmethod - def from_quaternion(cls, quaternion, point=[0, 0, 0]): # type: (...) -> Frame + def from_quaternion(cls, quaternion: QuaternionType, point: CoordinateType = (0, 0, 0)) -> Self: """Construct a frame from a rotation represented by quaternion coefficients. Parameters ---------- - quaternion : [float, float, float, float] | :class:`compas.geometry.Quaternion` + quaternion Four numbers that represent the four coefficient values of a quaternion. - point : [float, float, float] | :class:`compas.geometry.Point`, optional + point The point of the frame. Returns ------- - :class:`compas.geometry.Frame` + Self The constructed frame. Examples @@ -477,20 +641,20 @@ def from_quaternion(cls, quaternion, point=[0, 0, 0]): # type: (...) -> Frame return cls(point, xaxis, yaxis) @classmethod - def from_axis_angle_vector(cls, axis_angle_vector, point=[0, 0, 0]): # type: (...) -> Frame + def from_axis_angle_vector(cls, axis_angle_vector: CoordinateType, point: CoordinateType = (0, 0, 0)) -> Self: """Construct a frame from an axis-angle vector representing the rotation. Parameters ---------- - axis_angle_vector : [float, float, float] + axis_angle_vector Three numbers that represent the axis of rotation and angle of rotation by its magnitude. - point : [float, float, float] | :class:`compas.geometry.Point`, optional + point The point of the frame. Returns ------- - :class:`compas.geometry.Frame` + Self The constructed frame. Examples @@ -508,24 +672,30 @@ def from_axis_angle_vector(cls, axis_angle_vector, point=[0, 0, 0]): # type: (. return cls(point, xaxis, yaxis) @classmethod - def from_euler_angles(cls, euler_angles, static=True, axes="xyz", point=[0, 0, 0]): # type: (...) -> Frame + def from_euler_angles( + cls, + euler_angles: Sequence[float], + static: bool = True, + axes: str = "xyz", + point: CoordinateType = (0, 0, 0), + ) -> Self: """Construct a frame from a rotation represented by Euler angles. Parameters ---------- - euler_angles : [float, float, float] + euler_angles Three numbers that represent the angles of rotations about the defined axes. - static : bool, optional + static If True, the rotations are applied to a static frame. If False, to a rotational. - axes : str, optional + axes A 3 character string specifying the order of the axes. - point : [float, float, float] | :class:`compas.geometry.Point`, optional + point The point of the frame. Returns ------- - :class:`compas.geometry.Frame` + Self The constructed frame. Examples @@ -543,19 +713,19 @@ def from_euler_angles(cls, euler_angles, static=True, axes="xyz", point=[0, 0, 0 return cls(point, xaxis, yaxis) @classmethod - def from_plane(cls, plane): # type: (...) -> Frame + def from_plane(cls, plane: PlaneType) -> Self: """Constructs a frame from a plane. Xaxis and yaxis are arbitrarily selected based on the plane's normal. Parameters ---------- - plane : [point, vector] | :class:`compas.geometry.Plane` + plane A plane. Returns ------- - :class:`compas.geometry.Frame` + Self The constructed frame. Examples @@ -588,12 +758,12 @@ def from_plane(cls, plane): # type: (...) -> Frame # Conversions # ========================================================================== - def to_transformation(self): + def to_transformation(self) -> Transformation: """Convert the frame to a transformation. Returns ------- - :class:`compas.geometry.Transformation` + Transformation The transformation. """ @@ -603,34 +773,35 @@ def to_transformation(self): # Methods # ========================================================================== - def invert(self): + def invert(self) -> None: """Invert the frame while keeping the X axis fixed.""" self._yaxis = self.yaxis * -1 self._zaxis = None flip = invert - def inverted(self): + def inverted(self) -> Self: """Return an inverted copy of the frame.""" - frame = self.copy() # type: Frame + frame = self.copy() frame.invert() return frame flipped = inverted - def interpolate_frame(self, other, t): + def interpolate_frame(self, other: "Frame", t: float) -> Self: """Interpolates between two frames at a given parameter t in the range [0, 1] Parameters ---------- - other : :class:`compas.geometry.Frame` - t : float + other + The other frame. + t A parameter in the range [0-1]. Returns ------- - :class:`compas.geometry.Frame` - A list of the interpolated :class:`compas.geometry.Frame` instances. + Self + The interpolated frame. Examples -------- @@ -640,6 +811,7 @@ def interpolate_frame(self, other, t): >>> start_frame = frame1.interpolate_frame(frame2, 0) >>> TOL.is_allclose(start_frame.point, frame1.point) and TOL.is_allclose(start_frame.quaternion, frame1.quaternion) True + """ quat1 = Quaternion.from_frame(self) quat2 = Quaternion.from_frame(other) @@ -650,22 +822,24 @@ def interpolate_frame(self, other, t): rot_interpolated = quat1.slerp(quat2, t) # Create a new frame with the interpolated position and orientation - interpolated_frame = Frame.from_quaternion(rot_interpolated, point=origin_interpolated) + interpolated_frame = type(self).from_quaternion(rot_interpolated, point=origin_interpolated) return interpolated_frame - def interpolate_frames(self, other, steps): + def interpolate_frames(self, other: "Frame", steps: int) -> list[Self]: """Generates a specified number of interpolated frames between two given frames Parameters ---------- - other : :class:`compas.geometry.Frame` - steps : int + other + The other frame. + steps The number of interpolated frames to return. Returns ------- - list of :class:`compas.geometry.Frame` + list[Self] + The interpolated frames. Examples -------- @@ -675,18 +849,19 @@ def interpolate_frames(self, other, steps): >>> frames = frame1.interpolate_frames(frame2, steps) >>> print(len(frames) == steps) True + """ return [self.interpolate_frame(other, t) for t in linspace(0, 1, steps)] - def euler_angles(self, static=True, axes="xyz"): + def euler_angles(self, static: bool = True, axes: str = "xyz") -> list[float]: """The Euler angles from the rotation given by the frame. Parameters ---------- - static : bool, optional + static If True the rotations are applied to a static frame. If False, to a rotational. - axes : str, optional + axes A 3 character string specifying the order of the axes. Returns @@ -707,18 +882,26 @@ def euler_angles(self, static=True, axes="xyz"): R = matrix_from_basis_vectors(self.xaxis, self.yaxis) return euler_angles_from_matrix(R, static, axes) - def to_local_coordinates(self, obj_in_wcf): + @overload + def to_local_coordinates(self, obj_in_wcf: CoordinateType) -> Point: ... + + @overload + def to_local_coordinates(self, obj_in_wcf: GeometryType) -> GeometryType: ... + + def to_local_coordinates(self, obj_in_wcf: Union[CoordinateType, GeometryType]) -> Union[Point, GeometryType]: """Returns the object's coordinates in the local coordinate system of the frame. Parameters ---------- - obj_in_wcf : [float, float, float] | :class:`compas.geometry.Geometry` + obj_in_wcf An object in the world coordinate frame. Returns ------- - :class:`compas.geometry.Geometry` - The object in the local coordinate system of the frame. + Point + A point in local coordinates if `obj_in_wcf` is raw coordinates. + GeometryType + A transformed geometry of the same type if `obj_in_wcf` is a geometry object. Notes ----- @@ -735,22 +918,30 @@ def to_local_coordinates(self, obj_in_wcf): """ T = Transformation.from_change_of_basis(Frame.worldXY(), self) - if isinstance(obj_in_wcf, (list, tuple)): - return Point(*obj_in_wcf).transformed(T) - return obj_in_wcf.transformed(T) + if isinstance(obj_in_wcf, Geometry): + return obj_in_wcf.transformed(T) + 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: ... + + @overload + def to_world_coordinates(self, obj_in_lcf: GeometryType) -> GeometryType: ... - def to_world_coordinates(self, obj_in_lcf): + def to_world_coordinates(self, obj_in_lcf: Union[CoordinateType, GeometryType]) -> Union[Point, GeometryType]: """Returns the object's coordinates in the global coordinate frame. Parameters ---------- - obj_in_lcf : [float, float, float] | :class:`compas.geometry.Geometry` + obj_in_lcf An object in local coordinate system of the frame. Returns ------- - :class:`compas.geometry.Geometry` - The object in the world coordinate frame. + Point + A point in world coordinates if `obj_in_lcf` is raw coordinates. + GeometryType + A transformed geometry of the same type if `obj_in_lcf` is a geometry object. Notes ----- @@ -767,16 +958,16 @@ def to_world_coordinates(self, obj_in_lcf): """ T = Transformation.from_change_of_basis(self, Frame.worldXY()) - if isinstance(obj_in_lcf, list): - return Point(*obj_in_lcf).transformed(T) - return obj_in_lcf.transformed(T) + if isinstance(obj_in_lcf, Geometry): + return obj_in_lcf.transformed(T) + return Point(obj_in_lcf[0], obj_in_lcf[1], obj_in_lcf[2]).transformed(T) - def transform(self, T): + def transform(self, transformation: TransformationType) -> None: """Transform the frame. Parameters ---------- - T : :class:`compas.geometry.Transformation` + transformation The transformation. Examples @@ -790,7 +981,11 @@ def transform(self, T): True """ - X = T * Transformation.from_frame(self) + # Frame transformation uses concatenation, so raw matrix inputs need the + # same lightweight wrapper accepted by the base geometry API. + if not isinstance(transformation, Transformation): + transformation = Transformation(transformation) + X = transformation * Transformation.from_frame(self) point = X.translation_vector xaxis, yaxis = X.basis_vectors self.point = point diff --git a/src/compas/geometry/geometry.py b/src/compas/geometry/geometry.py index 4a16b2bbfbcc..fabf6f4fe64a 100644 --- a/src/compas/geometry/geometry.py +++ b/src/compas/geometry/geometry.py @@ -1,135 +1,125 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import TYPE_CHECKING +from typing import Optional +from typing import Sequence -try: - from typing import TypeVar # noqa: F401 -except ImportError: - pass -else: - G = TypeVar("G", bound="Geometry") +from typing_extensions import Self from compas.data import Data +if TYPE_CHECKING: + from compas.geometry import Box + from compas.geometry import Transformation + class Geometry(Data): """Base class for all geometric objects.""" - def __init__(self, name=None): + def __init__(self, name: Optional[str] = None) -> None: super(Geometry, self).__init__(name=name) - self._aabb = None - self._obb = None + self._aabb: Optional["Box"] = None + self._obb: Optional["Box"] = None - def __eq__(self, other): + def __eq__(self, other: object) -> bool: raise NotImplementedError - def __ne__(self, other): - # this is not obvious to ironpython + def __ne__(self, other: object) -> bool: return not self.__eq__(other) @property - def aabb(self): + def aabb(self) -> "Box": if self._aabb is None: self._aabb = self.compute_aabb() return self._aabb @property - def obb(self): + def obb(self) -> "Box": if self._obb is None: self._obb = self.compute_obb() return self._obb - def compute_aabb(self): + def compute_aabb(self) -> "Box": """Compute the axis-aligned bounding box of the geometry. Returns ------- - :class:`compas.geometry.Box` + Box """ raise NotImplementedError - def compute_obb(self): + def compute_obb(self) -> "Box": """Compute the oriented bounding box of the geometry. Returns ------- - :class:`compas.geometry.Box` + Box """ raise NotImplementedError - def transform(self, transformation): + def transform(self, transformation: "Transformation") -> None: """Transform the geometry. Parameters ---------- - transformation : :class:`compas.geometry.Transformation` + transformation The transformation used to transform the geometry. - Returns - ------- - None - See Also -------- - transformed - translate - rotate - scale + [`Geometry.transformed`][compas.geometry.Geometry.transformed] + [`Geometry.translate`][compas.geometry.Geometry.translate] + [`Geometry.rotate`][compas.geometry.Geometry.rotate] + [`Geometry.scale`][compas.geometry.Geometry.scale] """ raise NotImplementedError - def transformed(self, transformation): # type: (...) -> G + def transformed(self, transformation: "Transformation") -> Self: """Returns a transformed copy of this geometry. Parameters ---------- - transformation : :class:`compas.geometry.Transformation` + transformation The transformation used to transform the geometry. Returns ------- - :class:`Geometry` + Self The transformed geometry. See Also -------- - transform - translated - rotated - scaled + [`Geometry.transform`][compas.geometry.Geometry.transform] + [`Geometry.translated`][compas.geometry.Geometry.translated] + [`Geometry.rotated`][compas.geometry.Geometry.rotated] + [`Geometry.scaled`][compas.geometry.Geometry.scaled] """ - geometry = self.copy() # type: Geometry + geometry = self.copy() geometry.transform(transformation) - return geometry # type: ignore + return geometry - def scale(self, x, y=None, z=None): + def scale(self, x: float, y: Optional[float] = None, z: Optional[float] = None) -> None: """Scale the geometry. Parameters ---------- - x : float + x The scaling factor in the x-direction. - y : float, optional + y The scaling factor in the y-direction. - Defaults to ``x``. - z : float, optional + Defaults to `x`. + z The scaling factor in the z-direction. - Defaults to ``x``. - - Returns - ------- - None + Defaults to `x`. See Also -------- - scaled - translate - rotate - transform + [`Geometry.scaled`][compas.geometry.Geometry.scaled] + [`Geometry.translate`][compas.geometry.Geometry.translate] + [`Geometry.rotate`][compas.geometry.Geometry.rotate] + [`Geometry.transform`][compas.geometry.Geometry.transform] """ from compas.geometry import Scale @@ -142,110 +132,102 @@ def scale(self, x, y=None, z=None): self.transform(Scale.from_factors([x, y, z])) - def scaled(self, x, y=None, z=None): # type: (...) -> G + def scaled(self, x: float, y: Optional[float] = None, z: Optional[float] = None) -> Self: """Returns a scaled copy of this geometry. Parameters ---------- - x : float + x The scaling factor in the x-direction. - y : float, optional + y The scaling factor in the y-direction. - Defaults to ``x``. - z : float, optional + Defaults to `x`. + z The scaling factor in the z-direction. - Defaults to ``x``. + Defaults to `x`. Returns ------- - :class:`Geometry` + Self The scaled geometry. See Also -------- - scale - translated - rotated - transformed + [`Geometry.scale`][compas.geometry.Geometry.scale] + [`Geometry.translated`][compas.geometry.Geometry.translated] + [`Geometry.rotated`][compas.geometry.Geometry.rotated] + [`Geometry.transformed`][compas.geometry.Geometry.transformed] """ - geometry = self.copy() # type: Geometry + geometry = self.copy() geometry.scale(x=x, y=y, z=z) - return geometry # type: ignore + return geometry - def translate(self, vector): + def translate(self, vector: Sequence[float]) -> None: """Translate the geometry. Parameters ---------- - vector : :class:`compas.geometry.Vector` + vector The vector used to translate the geometry. - Returns - ------- - None - See Also -------- - translated - rotate - scale - transform + [`Geometry.translated`][compas.geometry.Geometry.translated] + [`Geometry.rotate`][compas.geometry.Geometry.rotate] + [`Geometry.scale`][compas.geometry.Geometry.scale] + [`Geometry.transform`][compas.geometry.Geometry.transform] """ from compas.geometry import Translation self.transform(Translation.from_vector(vector)) - def translated(self, vector): # type: (...) -> G + def translated(self, vector: Sequence[float]) -> Self: """Returns a translated copy of this geometry. Parameters ---------- - vector : :class:`compas.geometry.Vector` + vector The vector used to translate the geometry. Returns ------- - :class:`Geometry` + Self The translated geometry. See Also -------- - translate - rotated - scaled - transformed + [`Geometry.translate`][compas.geometry.Geometry.translate] + [`Geometry.rotated`][compas.geometry.Geometry.rotated] + [`Geometry.scaled`][compas.geometry.Geometry.scaled] + [`Geometry.transformed`][compas.geometry.Geometry.transformed] """ - geometry = self.copy() # type: Geometry + geometry = self.copy() geometry.translate(vector) - return geometry # type: ignore + return geometry - def rotate(self, angle, axis=None, point=None): + def rotate(self, angle: float, axis: Optional[Sequence[float]] = None, point: Optional[Sequence[float]] = None) -> None: """Rotate the geometry. Parameters ---------- - angle : float + angle The angle of rotation in radians. - axis : :class:`compas.geometry.Vector`, optional + axis The axis of rotation. Defaults to the z-axis. - point : :class:`compas.geometry.Point`, optional + point The base point of the rotation axis. Defaults to the origin. - Returns - ------- - None - See Also -------- - rotated - translate - scale - transform + [`Geometry.rotated`][compas.geometry.Geometry.rotated] + [`Geometry.translate`][compas.geometry.Geometry.translate] + [`Geometry.scale`][compas.geometry.Geometry.scale] + [`Geometry.transform`][compas.geometry.Geometry.transform] """ from compas.geometry import Rotation @@ -255,33 +237,33 @@ def rotate(self, angle, axis=None, point=None): self.transform(Rotation.from_axis_and_angle(axis, angle, point)) - def rotated(self, angle, axis=None, point=None): # type: (...) -> G + def rotated(self, angle: float, axis: Optional[Sequence[float]] = None, point: Optional[Sequence[float]] = None) -> Self: """Returns a rotated copy of this geometry. Parameters ---------- - angle : float + angle The angle of rotation in radians. - axis : :class:`compas.geometry.Vector`, optional + axis The axis of rotation. Defaults to the z-axis. - point : :class:`compas.geometry.Point`, optional + point The base point of the rotation axis. Defaults to the origin. Returns ------- - :class:`Geometry` + Self The rotated geometry. See Also -------- - rotate - translated - scaled - transformed + [`Geometry.rotate`][compas.geometry.Geometry.rotate] + [`Geometry.translated`][compas.geometry.Geometry.translated] + [`Geometry.scaled`][compas.geometry.Geometry.scaled] + [`Geometry.transformed`][compas.geometry.Geometry.transformed] """ - geometry = self.copy() # type: Geometry + geometry = self.copy() geometry.rotate(angle=angle, axis=axis, point=point) - return geometry # type: ignore + return geometry diff --git a/src/compas/geometry/hull.py b/src/compas/geometry/hull.py index 37932239f4f4..3ff222c229f7 100644 --- a/src/compas/geometry/hull.py +++ b/src/compas/geometry/hull.py @@ -1,11 +1,7 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from compas.geometry import cross_vectors -from compas.geometry import cross_vectors_xy -from compas.geometry import dot_vectors -from compas.geometry import subtract_vectors +from compas.linalg.vectors import cross_vectors +from compas.linalg.vectors import cross_vectors_xy +from compas.linalg.vectors import dot_vectors +from compas.linalg.vectors import subtract_vectors def convex_hull(points): diff --git a/src/compas/geometry/icp_numpy.py b/src/compas/geometry/icp_numpy.py index 661e656b8d65..1f4b355bbe30 100644 --- a/src/compas/geometry/icp_numpy.py +++ b/src/compas/geometry/icp_numpy.py @@ -9,7 +9,7 @@ from compas.geometry import pca_numpy from compas.geometry import transform_points_numpy -from compas.linalg import normrow +from compas.linalg.decompositions import normrow from compas.tolerance import TOL diff --git a/src/compas/geometry/interpolation_barycentric.py b/src/compas/geometry/interpolation_barycentric.py index c6a2c64d8de6..74d23d285ee2 100644 --- a/src/compas/geometry/interpolation_barycentric.py +++ b/src/compas/geometry/interpolation_barycentric.py @@ -1,9 +1,5 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from compas.geometry import dot_vectors -from compas.geometry import subtract_vectors +from compas.linalg.vectors import dot_vectors +from compas.linalg.vectors import subtract_vectors def barycentric_coordinates(point, triangle): diff --git a/src/compas/geometry/interpolation_coons.py b/src/compas/geometry/interpolation_coons.py index 3e69fab28f26..7d7e9887c60c 100644 --- a/src/compas/geometry/interpolation_coons.py +++ b/src/compas/geometry/interpolation_coons.py @@ -1,12 +1,8 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from compas.geometry import add_vectors -from compas.geometry import scale_vector -from compas.geometry import subtract_vectors -from compas.geometry import sum_vectors from compas.itertools import normalize_values +from compas.linalg.vectors import add_vectors +from compas.linalg.vectors import scale_vector +from compas.linalg.vectors import subtract_vectors +from compas.linalg.vectors import sum_vectors def discrete_coons_patch(ab, bc, dc, ad): diff --git a/src/compas/geometry/interpolation_tweening.py b/src/compas/geometry/interpolation_tweening.py index 47fbf3de0760..cbcc92f7ac36 100644 --- a/src/compas/geometry/interpolation_tweening.py +++ b/src/compas/geometry/interpolation_tweening.py @@ -1,11 +1,7 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from compas.geometry import add_vectors from compas.geometry import distance_point_point -from compas.geometry import scale_vector -from compas.geometry import subtract_vectors +from compas.linalg.vectors import add_vectors +from compas.linalg.vectors import scale_vector +from compas.linalg.vectors import subtract_vectors def tween_points(points1, points2, num): diff --git a/src/compas/geometry/intersection.py b/src/compas/geometry/intersection.py index 6f36d311bc20..72d205ac9ccd 100644 --- a/src/compas/geometry/intersection.py +++ b/src/compas/geometry/intersection.py @@ -1,121 +1,204 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from dataclasses import dataclass +from typing import Callable +from typing import Iterator +from typing import Optional +from typing import TypeVar -# from compas.precision import Precision -from compas.geometry import distance_point_point +from compas.tolerance import TOL +from .geometry import Geometry +from .intersections import intersection_line_line +from .intersections import intersection_line_plane +from .intersections import intersection_plane_plane +from .line import Line +from .plane import Plane +from .point import Point -class Intersection(object): - """A class for computing intersections between geometric objects. - Attributes +@dataclass(frozen=True) +class IntersectionResult: + """The geometry produced by an intersection operation. + + Parameters ---------- - number_of_intersections : int - The number of intersections. - points : list[:class:`compas.geometry.Point`] - The intersection points. + geometry + The intersection geometry. Examples -------- - >>> from compas.geometry import Line # doctest: +SKIP - >>> from compas.geometry import Intersection # doctest: +SKIP - >>> a = Line([0, 0, 0], [2, 0, 0]) # doctest: +SKIP - >>> b = Line([1, 0, 0], [1, 1, 0]) # doctest: +SKIP - >>> intersection = Intersection() # doctest: +SKIP - >>> intersection.line_line(a, b) # doctest: +SKIP - >>> intersection.number_of_intersections # doctest: +SKIP - 1 - >>> intersection.points[0] # doctest: +SKIP - Point(1.0, 0.0, z=0.0) + >>> result = intersection(Line([0, 0, 0], [1, 0, 0]), Plane.worldYZ()) + >>> result.points + (Point(x=0.000, y=0.000, z=0.000),) + >>> bool(result) + True """ - def __init__(self): - self.number_of_intersections = 0 - self.points = [] + geometry: tuple[Geometry, ...] = () - def __len__(self): - return self.number_of_intersections + def __bool__(self) -> bool: + return bool(self.geometry) - def __iter__(self): - return iter(self.points) + def __len__(self) -> int: + return len(self.geometry) - def __getitem__(self, key): - return self.points[key] + def __iter__(self) -> Iterator[Geometry]: + return iter(self.geometry) - def line_line(self, a, b, tol=1e-6): - """Compute the intersection of two lines. + @property + def number_of_intersections(self) -> int: + """The number of intersection geometries.""" + return len(self) - Parameters - ---------- - a : :class:`compas.geometry.Line` - A line. - b : :class:`compas.geometry.Line` - A line. - tol : float, optional - The tolerance for numerical fuzz. + @property + def points(self) -> tuple[Point, ...]: + """The point intersections.""" + return tuple(item for item in self.geometry if isinstance(item, Point)) - Returns - ------- - None + @property + def lines(self) -> tuple[Line, ...]: + """The line intersections.""" + return tuple(item for item in self.geometry if isinstance(item, Line)) - """ - from compas.geometry import intersection_line_line - x1, x2 = intersection_line_line(a, b) +HandlerType = TypeVar("HandlerType", bound=Callable[..., IntersectionResult]) - if x1 is None or x2 is None: - self.number_of_intersections = 0 - self.points = [] - return - if distance_point_point(x1, x2) < tol: - self.number_of_intersections = 1 - self.points = [x1] - return +class Intersection: + """Symmetric type-based dispatch for geometry intersections. - self.number_of_intersections = 2 - self.points = [x1, x2] + The predefined `intersection` instance is the public entry point for + computing intersections. Call it with two supported geometry objects. It + returns an `IntersectionResult`, which can be inspected through its + `geometry`, `points`, and `lines` properties, or iterated directly. - def line_segment(self, a, b): - pass + Use `register` to add a handler for a pair of geometry types. A registered + handler receives the two geometry objects in registration order, followed + by the tolerance. - def line_polyline(self, a, b): - pass + Examples + -------- + Compute the intersection of a line and a plane with the predefined + dispatcher. + + >>> from compas.geometry import intersection + >>> line = Line([0, 0, -1], [0, 0, 1]) + >>> result = intersection(line, Plane.worldXY()) + >>> result.points + (Point(x=0.000, y=0.000, z=0.000),) + >>> result.number_of_intersections + 1 - def line_plane(self, a, b): - pass + The order of the input objects does not matter. - def line_circle(self, a, b): - pass + >>> intersection(Plane.worldXY(), line) == result + True - def line_ellipse(self, a, b): - pass + Create a separate dispatcher and register a custom handler when extending + the supported type combinations. - def line_curve(self, a, b): - pass + >>> dispatcher = Intersection() + >>> @dispatcher.register(Line, Plane) + ... def line_plane(line, plane, tol=None): + ... return IntersectionResult((line.start,)) + >>> dispatcher(Plane.worldXY(), Line([0, 0, 0], [1, 0, 0])).points + (Point(x=0.000, y=0.000, z=0.000),) - def line_surface(self, a, b): - pass + """ - def line_box(self, a, b): - pass + def __init__(self) -> None: + self._registry: dict[tuple[type[object], type[object]], Callable[..., IntersectionResult]] = {} - def line_sphere(self, a, b): - pass + def register(self, type_a: type[Geometry], type_b: type[Geometry]) -> Callable[[HandlerType], HandlerType]: + """Register an intersection handler for a pair of geometry types. - def line_cylinder(self, a, b): - pass + Parameters + ---------- + type_a + The first geometry type expected by the handler. + type_b + The second geometry type expected by the handler. - def line_cone(self, a, b): - pass + Returns + ------- + Callable[[HandlerType], HandlerType] + A decorator that registers and returns the handler. + + Raises + ------ + ValueError + If either ordering of the type pair is already registered. + + """ - def line_torus(self, a, b): - pass + def decorator(handler: HandlerType) -> HandlerType: + key = type_a, type_b + reverse_key = type_b, type_a + if key in self._registry or reverse_key in self._registry: + raise ValueError("An intersection handler is already registered for {0} and {1}.".format(type_a.__name__, type_b.__name__)) + self._registry[key] = handler + return handler - def line_triangle(self, a, b): - pass + return decorator - def line_mesh(self, a, b): - pass + def __call__(self, a: Geometry, b: Geometry, tol: Optional[float] = None) -> IntersectionResult: + """Compute the intersection of two geometry objects. + + Parameters + ---------- + a + The first geometry object. + b + The second geometry object. + tol + The tolerance used by the intersection handler. + + Returns + ------- + IntersectionResult + The intersection geometry. + + Raises + ------ + TypeError + If no handler is registered for the geometry-type pair. + + """ + mro_a = type(a).__mro__ + mro_b = type(b).__mro__ + for candidate_a in mro_a: + for candidate_b in mro_b: + handler = self._registry.get((candidate_a, candidate_b)) + if handler is not None: + return handler(a, b, tol) + handler = self._registry.get((candidate_b, candidate_a)) + if handler is not None: + return handler(b, a, tol) + raise TypeError("Intersection is not implemented for {0} and {1}.".format(type(a).__name__, type(b).__name__)) + + +intersection = Intersection() + + +@intersection.register(Line, Line) +def _intersection_line_line(a: Line, b: Line, tol: Optional[float] = None) -> IntersectionResult: + point1, point2 = intersection_line_line(a, b, tol=tol) + if point1 is None or point2 is None or not TOL.is_allclose(point1, point2, atol=tol): + return IntersectionResult() + return IntersectionResult((Point(point1[0], point1[1], point1[2]),)) + + +@intersection.register(Line, Plane) +def _intersection_line_plane(line: Line, plane: Plane, tol: Optional[float] = None) -> IntersectionResult: + point = intersection_line_plane(line, plane, tol=tol) + if point is None: + return IntersectionResult() + return IntersectionResult((Point(point[0], point[1], point[2]),)) + + +@intersection.register(Plane, Plane) +def _intersection_plane_plane(a: Plane, b: Plane, tol: Optional[float] = None) -> IntersectionResult: + line = intersection_plane_plane(a, b, tol=tol) + if line is None: + return IntersectionResult() + return IntersectionResult((Line(line[0], line[1]),)) diff --git a/src/compas/geometry/intersections.py b/src/compas/geometry/intersections.py index 851d80ddc588..96dad6877390 100644 --- a/src/compas/geometry/intersections.py +++ b/src/compas/geometry/intersections.py @@ -1,44 +1,61 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - +from itertools import chain from math import fabs from math import sqrt +from typing import Literal +from typing import Optional +from typing import Union -from compas.geometry import add_vectors -from compas.geometry import cross_vectors -from compas.geometry import distance_point_point -from compas.geometry import dot_vectors -from compas.geometry import is_point_in_triangle -from compas.geometry import is_point_on_segment -from compas.geometry import is_point_on_segment_xy -from compas.geometry import length_vector_xy -from compas.geometry import normalize_vector -from compas.geometry import scale_vector -from compas.geometry import subtract_vectors -from compas.geometry import subtract_vectors_xy +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 +from compas.linalg.vectors import dot_vectors +from compas.linalg.vectors import length_vector +from compas.linalg.vectors import length_vector_xy +from compas.linalg.vectors import normalize_vector +from compas.linalg.vectors import scale_vector +from compas.linalg.vectors import subtract_vectors +from compas.linalg.vectors import subtract_vectors_xy from compas.plugins import PluginNotInstalledError from compas.plugins import pluggable from compas.tolerance import TOL - -def intersection_line_line(l1, l2, tol=None): - """Computes the intersection of two lines. +from ._core.distance import distance_point_point +from ._core.predicates_2 import is_point_on_segment_xy +from ._core.predicates_3 import is_point_in_triangle +from ._core.predicates_3 import is_point_on_segment +from ._typing import CircleType +from ._typing import LineType +from ._typing import MeshType +from ._typing import PlaneType +from ._typing import PolylineType +from ._typing import RayMeshHit +from ._typing import RayType +from ._typing import SphereType +from ._typing import TriangleType + + +def intersection_line_line( + l1: LineType, + l2: LineType, + tol: Optional[float] = None, +) -> tuple[Optional[list[float]], Optional[list[float]]]: + """Compute the closest points between two lines. Parameters ---------- - l1 : [point, point] | :class:`compas.geometry.Line` - XYZ coordinates of two points defining the first line. - l2 : [point, point] | :class:`compas.geometry.Line` - XYZ coordinates of two points defining the second line. - tol : float, optional + l1 + Two points defining the first line. + l2 + Two points defining the second line. + tol Tolerance for evaluating the intersection points of each of the lines with the corresponding skew plane. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- - tuple[[float, float, float], [float, float, float]] | tuple[None, None] + tuple[Optional[list[float]], Optional[list[float]]] Two intersection points. If the lines intersect, these two points are identical. If the lines are skewed and thus only have an apparent intersection, the two points are different. @@ -82,6 +99,8 @@ def intersection_line_line(l1, l2, tol=None): cd = subtract_vectors(d, c) n = cross_vectors(ab, cd) + if TOL.is_zero(length_vector(n), tol): + return None, None n1 = normalize_vector(cross_vectors(ab, n)) n2 = normalize_vector(cross_vectors(cd, n)) @@ -91,29 +110,33 @@ def intersection_line_line(l1, l2, tol=None): i1 = intersection_line_plane(l1, plane_2, tol=tol) i2 = intersection_line_plane(l2, plane_1, tol=tol) - if not i1 or not i2: + if i1 is None or i2 is None: return None, None return i1, i2 -def intersection_segment_segment(ab, cd, tol=None): +def intersection_segment_segment( + ab: LineType, + cd: LineType, + tol: Optional[float] = None, +) -> tuple[Optional[list[float]], Optional[list[float]]]: """Compute the intersection of two lines segments. Parameters ---------- - ab : [point, point] | :class:`compas.geometry.Line` - XYZ coordinates of two points defining a line segment. - cd : [point, point] | :class:`compas.geometry.Line` - XYZ coordinates of two points defining another line segment. - tol : float, optional + ab + Two points defining a line segment. + cd + Two points defining another line segment. + tol Tolerance value for computing the intersection points of the underlying lines, and for verifying that those points are contained by the segments. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- - tuple[[float, float, float], [float, float, float]] | tuple[None, None] + tuple[Optional[list[float]], Optional[list[float]]] Two intersection points. If the segments intersect and the intersection points lie on the respective segments, the two points are identical. If the segments are skew and the apparent intersection points lie on the respective segments, the two points are different. @@ -152,7 +175,7 @@ def intersection_segment_segment(ab, cd, tol=None): """ x1, x2 = intersection_line_line(ab, cd, tol=tol) - if not x1 or not x2: + if x1 is None or x2 is None: return None, None if not is_point_on_segment(x1, ab, tol=tol): @@ -164,23 +187,27 @@ def intersection_segment_segment(ab, cd, tol=None): return x1, x2 -def intersection_line_segment(line, segment, tol=None): +def intersection_line_segment( + line: LineType, + segment: LineType, + tol: Optional[float] = None, +) -> tuple[Optional[list[float]], Optional[list[float]]]: """Compute the intersection of a line and a segment. Parameters ---------- - line : [point, point] | :class:`compas.geometry.Line` + line Two points defining a line. - segment : [point, point] | :class:`compas.geometry.Line` + segment Two points defining a line segment. - tol : float, optional + tol Tolerance value for computing the intersection points of the underlying lines, and for verifying that those points are contained by the segment. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- - tuple[[float, float, float], [float, float, float]] | tuple[None, None] + tuple[Optional[list[float]], Optional[list[float]]] Two intersection points. If the line and segment intersect and the second intersection point lies on the segment, the two points are identical. If the line and segment are skew and the second apparent intersection point lies on the segment, the two points are different. @@ -189,7 +216,7 @@ def intersection_line_segment(line, segment, tol=None): """ x1, x2 = intersection_line_line(line, segment, tol=tol) - if not x1 or not x2: + if x1 is None or x2 is None: return None, None if not is_point_on_segment(x2, segment, tol=tol): @@ -198,29 +225,29 @@ def intersection_line_segment(line, segment, tol=None): return x1, x2 -def intersection_line_plane(line, plane, tol=None): - """Computes the intersection point of a line and a plane +def intersection_line_plane(line: LineType, plane: PlaneType, tol: Optional[float] = None) -> Optional[list[float]]: + """Compute the intersection point of a line and a plane. Parameters ---------- - line : [point, point] | :class:`compas.geometry.Line` + line Two points defining the line. - plane : [point, vector] + plane The base point and normal defining the plane. - tol : float, optional + tol Tolerance for evaluating that the dot product of the line direction and the plane normal is zero. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- - [float, float, float] | None + Optional[list[float]] The intersection point between the line and the plane, or None if the line and the plane are parallel. See Also -------- - :func:`intersection_segment_plane` - :func:`intersection_polyline_plane` + [`intersection_segment_plane`][compas.geometry.intersection_segment_plane] and + [`intersection_polyline_plane`][compas.geometry.intersection_polyline_plane]. """ a, b = line @@ -246,29 +273,29 @@ def intersection_line_plane(line, plane, tol=None): return add_vectors(a, ab) -def intersection_segment_plane(segment, plane, tol=None): - """Computes the intersection point of a line segment and a plane +def intersection_segment_plane(segment: LineType, plane: PlaneType, tol: Optional[float] = None) -> Optional[list[float]]: + """Compute the intersection point of a line segment and a plane. Parameters ---------- - segment : [point, point] | :class:`compas.geometry.Line` + segment Two points defining the line segment. - plane : [point, vector] + plane The base point and normal defining the plane. - tol : float, optional + tol Tolerance for evaluating that the dot product of the line direction and the plane normal is zero. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- - [float, float, float] | None + Optional[list[float]] The intersection point between the line and the plane, or None if the line and the plane are parallel. See Also -------- - :func:`intersection_line_plane` - :func:`intersection_polyline_plane` + [`intersection_line_plane`][compas.geometry.intersection_line_plane] and + [`intersection_polyline_plane`][compas.geometry.intersection_polyline_plane]. """ a, b = segment @@ -291,69 +318,73 @@ def intersection_segment_plane(segment, plane, tol=None): oa = subtract_vectors(a, o) ratio = -dot_vectors(n, oa) / cosa - if 0.0 <= ratio and ratio <= 1.0: + if 0.0 <= ratio <= 1.0: ab = scale_vector(ab, ratio) return add_vectors(a, ab) return None -def intersection_polyline_plane(polyline, plane, expected_number_of_intersections=None, tol=None): - """Calculate the intersection point of a plane with a polyline. Reduce expected_number_of_intersections to speed up. +def intersection_polyline_plane( + polyline: PolylineType, + plane: PlaneType, + expected_number_of_intersections: Optional[int] = None, + tol: Optional[float] = None, +) -> list[list[float]]: + """Compute the intersections of a polyline and a plane. Parameters ---------- - polyline : sequence[point] | :class:`compas.geometry.Polyline` + polyline Polyline to test intersection. - plane : [point, vector] + plane Plane to compute intersection. - expected_number_of_intersections : int, optional - Number of useful or expected intersections. - Default is the number of line segments of the polyline. - tol : float, optional + expected_number_of_intersections + Maximum number of intersections to return. + Default is all intersections. + tol Tolerance for computing the intersection points between the individual segments of the polyline and the plane. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- - list[[float, float, float]] + list[list[float]] The intersection points between the polyline segments and the plane. See Also -------- - :func:`intersection_segment_plane` - :func:`intersection_line_plane` + [`intersection_segment_plane`][compas.geometry.intersection_segment_plane] and + [`intersection_line_plane`][compas.geometry.intersection_line_plane]. """ - if not expected_number_of_intersections: - expected_number_of_intersections = len(polyline) - intersections = [] + if expected_number_of_intersections is not None and expected_number_of_intersections < 0: + raise ValueError("The expected number of intersections cannot be negative.") + intersections: list[list[float]] = [] for segment in pairwise(polyline): - if len(intersections) == expected_number_of_intersections: + if expected_number_of_intersections is not None and len(intersections) >= expected_number_of_intersections: break point = intersection_segment_plane(segment, plane, tol) - if point: + if point is not None: intersections.append(point) return intersections -def intersection_line_triangle(line, triangle, tol=None): - """Computes the intersection point of a line (ray) and a triangle - based on the Moeller Trumbore intersection algorithm +def intersection_line_triangle(line: LineType, triangle: TriangleType, tol: Optional[float] = None) -> Optional[list[float]]: + """Compute the intersection point of a line and a triangle. Parameters ---------- - line : [point, point] | :class:`compas.geometry.Line` + line Two points defining the line. - triangle : [point, point, point] + triangle XYZ coordinates of the triangle corners. - tol : float, optional + tol Tolerance value for computing the intersection between the line and the plane of the triangle. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- - [float, float, float] | None + Optional[list[float]] The intersection point between the line and the triangle, or None if the line and the plane are parallel. @@ -366,27 +397,31 @@ def intersection_line_triangle(line, triangle, tol=None): x = intersection_line_plane(line, plane, tol=tol) - if x: - if is_point_in_triangle(x, triangle): - return x + if x is not None and is_point_in_triangle(x, triangle): + return x + return None -def intersection_plane_plane(plane1, plane2, tol=None): - """Computes the intersection of two planes +def intersection_plane_plane( + plane1: PlaneType, + plane2: PlaneType, + tol: Optional[float] = None, +) -> Optional[tuple[list[float], list[float]]]: + """Compute the intersection of two planes. Parameters ---------- - plane1 : [point, vector] + plane1 The base point and normal (normalized) defining the 1st plane. - plane2 : [point, vector] + plane2 The base point and normal (normalized) defining the 2nd plane. - tol : float, optional - Tolerance for evaluating if the dot product of the plane normals is one. - Default is :attr:`TOL.absolute`. + tol + Tolerance for evaluating whether the plane normals are parallel. + Default is `TOL.absolute`. Returns ------- - tuple[[float, float, float], [float, float, float]] | None + Optional[tuple[list[float], list[float]]] Two points defining the intersection line. None if the planes are parallel. @@ -394,79 +429,99 @@ def intersection_plane_plane(plane1, plane2, tol=None): o1, n1 = plane1 o2, n2 = plane2 - if TOL.is_close(dot_vectors(n1, n2), 1.0, tol): - return None - # direction of intersection line d = cross_vectors(n1, n2) + if TOL.is_zero(length_vector(d), tol): + return None # vector in plane 1 perpendicular to the direction of the intersection line v1 = cross_vectors(d, n1) # point on plane 1 p1 = add_vectors(o1, v1) x1 = intersection_line_plane((o1, p1), plane2, tol=tol) + if x1 is None: + return None x2 = add_vectors(x1, d) return x1, x2 -def intersection_plane_plane_plane(plane1, plane2, plane3, tol=None): - """Computes the intersection of three planes +def intersection_plane_plane_plane( + plane1: PlaneType, + plane2: PlaneType, + plane3: PlaneType, + tol: Optional[float] = None, +) -> Optional[list[float]]: + """Compute the intersection of three planes. Parameters ---------- - plane1 : [point, vector] + plane1 The base point and normal (normalized) defining the 1st plane. - plane2 : [point, vector] + plane2 The base point and normal (normalized) defining the 2nd plane. - plane3 : [point, vector] + plane3 The base point and normal (normalized) defining the 3rd plane. - tol : float, optional + tol Tolerance for computing the intersection line between the first two planes, and between the intersection line and the third plane. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- - [float, float, float] | None + Optional[list[float]] The intersection point or None if at least one pair of planes is parallel. Notes ----- - Currently this only computes the intersection point. - For example, if two planes are parallel the intersection lines are not computed [1]_. + Currently this only computes a unique intersection point. If two planes + are parallel, their possible intersection with the third plane is not + returned.[^intersection-three-planes] References ---------- - .. [1] http://geomalgorithms.com/Pic_3-planes.gif + [^intersection-three-planes]: [Intersection of Three Planes](http://geomalgorithms.com/Pic_3-planes.gif) """ line = intersection_plane_plane(plane1, plane2, tol=tol) - if line: + if line is not None: return intersection_line_plane(line, plane3, tol=tol) + return None -def intersection_sphere_sphere(sphere1, sphere2): - """Computes the intersection of 2 spheres. +def intersection_sphere_sphere( + sphere1: SphereType, + sphere2: SphereType, + tol: Optional[float] = None, +) -> Optional[ + Union[ + tuple[Literal["point"], list[float]], + tuple[Literal["circle"], tuple[list[float], float, list[float]]], + tuple[Literal["sphere"], tuple[list[float], float]], + ] +]: + """Compute the intersection of two spheres. Parameters ---------- - sphere1 : [point, float] + sphere1 A sphere defined by a point and radius. - sphere2 : [point, float] + sphere2 A sphere defined by a point and radius. + tol + Tolerance for classifying tangent and coincident spheres. + Default is `TOL.absolute`. Returns ------- - {'point', 'circle', or 'sphere'} - The type of intersection. - [float, float, float] | tuple[[float, float, float], float, [float, float, float]] | tuple[[float, float, float], float] - If the type is 'point', the coordinates of the point. - If the type is 'circle', the center point and radius of the circle, and the normal of the plane containing the circle. - If the type is 'sphere', the center point and radius of the sphere. + Optional[Union[tuple[Literal["point"], list[float]], tuple[Literal["circle"], tuple[list[float], float, list[float]]], tuple[Literal["sphere"], tuple[list[float], float]]]] + The intersection type and geometry, or `None` if there is no + intersection. Point geometry is represented by coordinates, circle + geometry by center, radius, and normal, and coincident-sphere geometry + by center and radius. Notes ----- - There are 4 cases of sphere-sphere intersection [1]_: + There are four cases of sphere-sphere intersection:[^intersection-sphere-sphere] 1. the spheres intersect in a circle, 2. they intersect in a point, @@ -475,7 +530,7 @@ def intersection_sphere_sphere(sphere1, sphere2): References ---------- - .. [1] https://gamedev.stackexchange.com/questions/75756/sphere-sphere-intersection-and-circle-sphere-intersection + [^intersection-sphere-sphere]: [Sphere-Sphere Intersection](https://gamedev.stackexchange.com/questions/75756/sphere-sphere-intersection-and-circle-sphere-intersection) Examples -------- @@ -496,29 +551,32 @@ def intersection_sphere_sphere(sphere1, sphere2): center1, radius1 = sphere1 center2, radius2 = sphere2 + if radius1 < 0.0 or radius2 < 0.0: + raise ValueError("Sphere radii cannot be negative.") + distance = distance_point_point(center1, center2) # Case 4: No intersection - if radius1 + radius2 < distance: + if distance > radius1 + radius2 and not TOL.is_close(distance, radius1 + radius2, atol=tol): return None # Case 4: No intersection, sphere is within the other sphere - elif distance + min(radius1, radius2) < max(radius1, radius2): + if distance + min(radius1, radius2) < max(radius1, radius2) and not TOL.is_close(distance + min(radius1, radius2), max(radius1, radius2), atol=tol): return None # Case 3: sphere's overlap - elif radius1 == radius2 and distance == 0: - return "sphere", sphere1 + if TOL.is_zero(distance, tol) and TOL.is_close(radius1, radius2, atol=tol): + return "sphere", ([center1[0], center1[1], center1[2]], float(radius1)) # Case 2: point intersection - elif radius1 + radius2 == distance: + if TOL.is_close(radius1 + radius2, distance, atol=tol): ipt = subtract_vectors(center2, center1) ipt = scale_vector(ipt, radius1 / distance) ipt = add_vectors(center1, ipt) return "point", ipt # Case 2: point intersection, smaller sphere is within the bigger - elif distance + min(radius1, radius2) == max(radius1, radius2): + if TOL.is_close(distance + min(radius1, radius2), max(radius1, radius2), atol=tol): if radius1 > radius2: ipt = subtract_vectors(center2, center1) ipt = scale_vector(ipt, radius1 / distance) @@ -534,29 +592,33 @@ def intersection_sphere_sphere(sphere1, sphere2): ci = subtract_vectors(center2, center1) ci = scale_vector(ci, h) ci = add_vectors(center1, ci) - ri = sqrt(radius1**2 - h**2 * distance**2) + ri = sqrt(max(0.0, radius1**2 - h**2 * distance**2)) normal = scale_vector(subtract_vectors(center2, center1), 1 / distance) return "circle", (ci, ri, normal) -def intersection_segment_polyline(segment, polyline, tol=None): - """Calculate the intersection point of a segment and a polyline. +def intersection_segment_polyline( + segment: LineType, + polyline: PolylineType, + tol: Optional[float] = None, +) -> tuple[Optional[list[float]], Optional[list[float]]]: + """Compute the first intersection of a segment and a polyline. Parameters ---------- - segment : [point, point] | :class:`compas.geometry.Line` + segment XYZ coordinates of two points defining a line segment. - polyline : sequence[point] | :class:`compas.geometry.Polyline` + polyline XYZ coordinates of the points of the polyline. - tol : float, optional + tol Tolerance value for computing the intersection points between the segment and the polyline segments. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- - [float, float, float] | None - The intersection point - or None if the segment does not intersect with any of the polyline segments. + tuple[Optional[list[float]], Optional[list[float]]] + The closest points on the intersecting segments, or `(None, None)` if + there is no intersection. Examples -------- @@ -577,26 +639,35 @@ def intersection_segment_polyline(segment, polyline, tol=None): >>> distance_point_point((0.5, 0.0, 0.25), x) < 1e-6 True + """ for cd in pairwise(polyline): pt = intersection_segment_segment(segment, cd, tol) - if pt: + if pt[0] is not None and pt[1] is not None: return pt + return None, None -def intersection_sphere_line(sphere, line): - """Computes the intersection of a sphere and a line. +def intersection_sphere_line( + sphere: SphereType, + line: LineType, + tol: Optional[float] = None, +) -> Optional[Union[CoordinateType, tuple[CoordinateType, CoordinateType]]]: + """Compute the intersection of a sphere and a line. Parameters ---------- - sphere : [point, radius] + sphere A sphere defined by a point and a radius. - line : [point, point] | :class:`compas.geometry.Line` + line A line defined by two points. + tol + Tolerance for classifying a tangent intersection. + Default is `TOL.absolute`. Returns ------- - tuple[[float, float, float], [float, float, float]] | [float, float, float] | None + Optional[Union[CoordinateType, tuple[CoordinateType, CoordinateType]]] Two points (if the line goes through the sphere), one point (if the line is tangent to the sphere), or None (otherwise). Notes @@ -624,16 +695,24 @@ def intersection_sphere_line(sphere, line): l1, l2 = line sp, radius = sphere + if radius < 0.0: + raise ValueError("The sphere radius cannot be negative.") + a = (l2[0] - l1[0]) ** 2 + (l2[1] - l1[1]) ** 2 + (l2[2] - l1[2]) ** 2 + if TOL.is_zero(a, tol): + raise ValueError("A line requires two distinct points.") b = 2.0 * ((l2[0] - l1[0]) * (l1[0] - sp[0]) + (l2[1] - l1[1]) * (l1[1] - sp[1]) + (l2[2] - l1[2]) * (l1[2] - sp[2])) c = sp[0] ** 2 + sp[1] ** 2 + sp[2] ** 2 + l1[0] ** 2 + l1[1] ** 2 + l1[2] ** 2 - 2.0 * (sp[0] * l1[0] + sp[1] * l1[1] + sp[2] * l1[2]) - radius**2 i = b * b - 4.0 * a * c - if i < 0.0: # case 3: no intersection + if TOL.is_zero(i, tol): # case 2: one intersection + i = 0.0 + elif i < 0.0: # case 3: no intersection return None - elif i == 0.0: # case 2: one intersection + + if i == 0.0: mu = -b / (2.0 * a) ipt = ( l1[0] + mu * (l2[0] - l1[0]), @@ -641,7 +720,8 @@ def intersection_sphere_line(sphere, line): l1[2] + mu * (l2[2] - l1[2]), ) return ipt - elif i > 0.0: # case 1: two intersections + + if i > 0.0: # case 1: two intersections # 1. mu = (-b + sqrt(i)) / (2.0 * a) ipt1 = ( @@ -657,21 +737,29 @@ def intersection_sphere_line(sphere, line): l1[2] + mu * (l2[2] - l1[2]), ) return ipt1, ipt2 + return None -def intersection_plane_circle(plane, circle): - """Computes the intersection of a plane and a circle. +def intersection_plane_circle( + plane: PlaneType, + circle: CircleType, + tol: Optional[float] = None, +) -> Optional[Union[CoordinateType, tuple[CoordinateType, CoordinateType]]]: + """Compute the intersection of a plane and a circle. Parameters ---------- - plane : [point, vector] + plane A plane defined by a point and normal vector. - circle : [plane, float] + circle A circle defined by a plane and radius. + tol + Tolerance used for the plane and sphere intersections. + Default is `TOL.absolute`. Returns ------- - tuple[[float, float, float], [float, float, float]] | [float, float, float] | None + Optional[Union[CoordinateType, tuple[CoordinateType, CoordinateType]]] Two points (secant intersection), one point (tangent intersection), or None (otherwise). Notes @@ -695,29 +783,34 @@ def intersection_plane_circle(plane, circle): """ circle_plane, circle_radius = circle - line = intersection_plane_plane(plane, circle_plane) - if not line: + line = intersection_plane_plane(plane, circle_plane, tol=tol) + if line is None: return None circle_point = circle_plane[0] sphere = circle_point, circle_radius - return intersection_sphere_line(sphere, line) + return intersection_sphere_line(sphere, line, tol=tol) @pluggable(category="intersections") -def intersection_mesh_mesh(A, B): +def intersection_mesh_mesh(A: MeshType, B: MeshType) -> list[CoordinatesType]: """Compute the intersection of two meshes. Parameters ---------- - A : tuple of vertices and faces - Mesh A. - B : tuple of vertices and faces - Mesh B. + A + The vertices and faces of the first mesh. + B + The vertices and faces of the second mesh. Returns ------- - list of arrays of points - The intersection polylines as arrays of points. + list[CoordinatesType] + The intersection polylines. + + Raises + ------ + PluginNotInstalledError + If no intersection plugin is available. """ raise PluginNotInstalledError @@ -727,29 +820,26 @@ def intersection_mesh_mesh(A, B): @pluggable(category="intersections") -def intersection_ray_mesh(ray, mesh): +def intersection_ray_mesh(ray: RayType, mesh: MeshType) -> list[RayMeshHit]: """Compute the intersection(s) between a ray and a mesh. Parameters ---------- - ray : tuple of point and vector - A ray represented by a point and a direction vector. - mesh : tuple of vertices and faces - A mesh represented by a list of vertices and a list of faces. + ray + The ray origin and direction vector. + mesh + The vertices and faces of the mesh. Returns ------- - list of tuple - Per intersection of the ray with the mesh: + list[RayMeshHit] + For every hit, the intersected face index, the `u` and `v` barycentric + coordinates, and the distance from the ray origin. - 0. the index of the intersected face - 1. the u coordinate of the intersection in the barycentric coordinates of the face - 2. the u coordinate of the intersection in the barycentric coordinates of the face - 3. the distance between the ray origin and the hit - - Examples - -------- - >>> + Raises + ------ + PluginNotInstalledError + If no intersection plugin is available. """ raise PluginNotInstalledError @@ -763,22 +853,22 @@ def intersection_ray_mesh(ray, mesh): # ============================================================================== -def intersection_line_line_xy(l1, l2, tol=None): +def intersection_line_line_xy(l1: LineType, l2: LineType, tol: Optional[float] = None) -> Optional[list[float]]: """Compute the intersection of two lines, assuming they lie on the XY plane. Parameters ---------- - l1 : [point, point] | :class:`compas.geometry.Line` + l1 A line defined by two points, with at least XY coordinates. - l2 : [point, point] | :class:`compas.geometry.Line` + l2 A line defined by two points, with at least XY coordinates. - tol : float, optional + tol Tolerance for comparing the length of the cross product of the line directions with zero. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- - [float, float, 0.0] | None + Optional[list[float]] XYZ coordinates of intersection point if one exists, with Z = 0. Otherwise, None. @@ -804,127 +894,142 @@ def intersection_line_line_xy(l1, l2, tol=None): return [x, y, 0.0] -def intersection_line_segment_xy(line, segment, tol=None): +def intersection_line_segment_xy( + line: LineType, + segment: LineType, + tol: Optional[float] = None, +) -> Optional[list[float]]: """Compute the intersection between a line and a segment. Parameters ---------- - line : [point, point] | :class:`compas.geometry.Line` + line A line defined by two points, with at least XY coordinates. - segment : [point, point] | :class:`compas.geometry.Line` + segment A segment defined by two points, with at least XY coordinates. - tol : float, optional + tol Tolerance for computing the intersection between the line and the underlying line of the segment, and for verifying that the point is on the segment. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- - [float, float, 0.0] | None + Optional[list[float]] XYZ coordinates of the intersection, if one exists, with Z = 0. None otherwise. """ x = intersection_line_line_xy(line, segment, tol=tol) - if x: - if is_point_on_segment_xy(x, segment, tol=tol): - return x + if x is not None and is_point_on_segment_xy(x, segment, tol=tol): + return x + return None -def intersection_line_box_xy(line, box, tol=None): +def intersection_line_box_xy( + line: LineType, + box: CoordinatesType, + tol: Optional[float] = None, +) -> list[list[float]]: """Compute the intersection between a line and a box in the XY plane. Parameters ---------- - line : [point, point] | :class:`compas.geometry.Line` + line A line defined by two points, with at least XY coordinates. - box : [point, point, point, point] + box A box defined by 4 points, with at least XY coordinates. - tol : float, optional + tol A tolerance value for point comparison. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- - tuple[[float, float, 0.0], [float, float, 0.0]] | [float, float, 0.0] | None - Two points if the line goes through the box. - One point if the line goes through one of the box vertices only. - None otherwise. + list[list[float]] + The unique intersection points in box-edge order. + + Raises + ------ + ValueError + If the box does not have four corners. """ - points = [] - for segment in pairwise(box + box[:1]): + if len(box) != 4: + raise ValueError("An XY box requires exactly four corners.") + + points: list[list[float]] = [] + for segment in pairwise(chain(box, (box[0],))): x = intersection_line_segment_xy(line, segment, tol=tol) - if x: + if x is not None and not any(TOL.is_allclose(x, point, rtol=0.0, atol=tol) for point in points): points.append(x) + return points - if len(points) < 3: - return tuple(points) - - if len(points) == 3: - a, b, c = points - if TOL.is_allclose(a, b, rtol=0, atol=tol): - return a, c - - if TOL.is_allclose(b, c, rtol=0, atol=tol): - return a, b - - return b, c - - -def intersection_polyline_box_xy(polyline, box, tol=None): +def intersection_polyline_box_xy( + polyline: PolylineType, + box: CoordinatesType, + tol: Optional[float] = None, +) -> list[list[float]]: """Compute the intersection between a polyline and a box in the XY plane. Parameters ---------- - polyline : sequence[point] | :class:`compas.geometry.Polyline` + polyline A polyline defined by a sequence of points, with at least XY coordinates. - box : [point, point, point, point] + box A box defined by a sequence of 4 points, with at least XY coordinates. - tol : float, optional + tol A tolerance value for point comparison. Returns ------- - list[[float, float, 0.0]] + list[list[float]] A list of intersection points. + Raises + ------ + ValueError + If the box does not have four corners. + """ - precision = TOL.precision_from_tolerance(tol) - points = [] - for side in pairwise(box + box[:1]): + if len(box) != 4: + raise ValueError("An XY box requires exactly four corners.") + + points: list[list[float]] = [] + for side in pairwise(chain(box, (box[0],))): for segment in pairwise(polyline): x = intersection_segment_segment_xy(side, segment, tol=tol) - if x: + if x is not None and not any(TOL.is_allclose(x, point, rtol=0.0, atol=tol) for point in points): points.append(x) - points = {TOL.geometric_key(point, precision): point for point in points} - return list(points.values()) + return points -def intersection_segment_segment_xy(ab, cd, tol=None): +def intersection_segment_segment_xy( + ab: LineType, + cd: LineType, + tol: Optional[float] = None, +) -> Optional[list[float]]: """Compute the intersection of two lines segments, assuming they lie in the XY plane. Parameters ---------- - ab : [point, point] | :class:`compas.geometry.Line` + ab A segment defined by two points, with at least XY coordinates. - cd : [point, point] | :class:`compas.geometry.Line` + cd A segment defined by two points, with at least XY coordinates. - tol : float, optional + tol A tolerance for verifying that the point lies on both segments. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- - [float, float, 0.0] | None + Optional[list[float]] XYZ coordinates of intersection point if one exists. None otherwise. """ intx_pt = intersection_line_line_xy(ab, cd, tol=tol) - if not intx_pt: + if intx_pt is None: return None if not is_point_on_segment_xy(intx_pt, ab, tol=tol): @@ -936,41 +1041,57 @@ def intersection_segment_segment_xy(ab, cd, tol=None): return intx_pt -def intersection_circle_circle_xy(circle1, circle2): - """Calculates the intersection points of two circles in 2d lying in the XY plane. +def intersection_circle_circle_xy( + circle1: CircleType, + circle2: CircleType, + tol: Optional[float] = None, +) -> Optional[tuple[tuple[float, float, float], tuple[float, float, float]]]: + """Compute the intersection points of two circles in the XY plane. Parameters ---------- - circle1 : [plane, float] + circle1 Circle defined by a plane, with at least XY coordinates, and a radius. - circle2 : [plane, float] + circle2 Circle defined by a plane, with at least XY coordinates, and a radius. + tol + Tolerance for classifying tangent and concentric circles. + Default is `TOL.absolute`. Returns ------- - tuple[[float, float, float], [float, float, float]] | None + Optional[tuple[tuple[float, float, float], tuple[float, float, float]]] The intersection points if there are any. If the circles are tangent to each other, the two intersection points are identical. None otherwise. + Raises + ------ + ValueError + If either radius is negative. + """ plane1, r1 = circle1 plane2, r2 = circle2 - p1, n1 = plane1 - p2, n2 = plane2 + p1, _ = plane1 + p2, _ = plane2 + + if r1 < 0.0 or r2 < 0.0: + raise ValueError("Circle radii cannot be negative.") + R = length_vector_xy(subtract_vectors_xy(p2, p1)) - if R > r1 + r2: + if TOL.is_zero(R, tol): return None - if R < fabs(r1 - r2): + if R > r1 + r2 and not TOL.is_close(R, r1 + r2, atol=tol): return None - if (R == 0) and (r1 == r2): + if R < fabs(r1 - r2) and not TOL.is_close(R, fabs(r1 - r2), atol=tol): return None - x1, y1 = p1[:2] - x2, y2 = p2[:2] + x1, y1 = p1[0], p1[1] + x2, y2 = p2[0], p2[1] cx = 0.5 * (x1 + x2) cy = 0.5 * (y2 + y1) @@ -979,7 +1100,8 @@ def intersection_circle_circle_xy(circle1, circle2): R4 = R2 * R2 a = (r1 * r1 - r2 * r2) / (2 * R2) - b = 0.5 * sqrt(2 * (r1 * r1 + r2 * r2) / R2 - (r1 * r1 - r2 * r2) ** 2 / R4 - 1) + discriminant = 2 * (r1 * r1 + r2 * r2) / R2 - (r1 * r1 - r2 * r2) ** 2 / R4 - 1 + b = 0.5 * sqrt(max(0.0, discriminant)) i1 = cx + a * (x2 - x1) + b * (y2 - y1), cy + a * (y2 - y1) + b * (x1 - x2), 0 i2 = cx + a * (x2 - x1) - b * (y2 - y1), cy + a * (y2 - y1) - b * (x1 - x2), 0 @@ -987,23 +1109,26 @@ def intersection_circle_circle_xy(circle1, circle2): return i1, i2 -def intersection_segment_polyline_xy(segment, polyline, tol=None): - """ - Calculate the intersection point of a segment and a polyline on the XY-plane. +def intersection_segment_polyline_xy( + segment: LineType, + polyline: PolylineType, + tol: Optional[float] = None, +) -> Optional[list[float]]: + """Compute the first intersection of a segment and a polyline in the XY plane. Parameters ---------- - segment : [point, point] | :class:`compas.geometry.Line` + segment A line segment defined by two points, with at least XY coordinates. - polyline : sequence[point] | :class:`compas.geometry.Polyline` + polyline A polyline defined by a sequence of points, with at least XY coordinates. - tol : float, optional + tol Tolerance for computing the intersection points between the segment and the polyline segments. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- - [float, float, 0.0] | None + Optional[list[float]] XYZ coordinates of the first intersection point if one exists. None otherwise @@ -1025,33 +1150,50 @@ def intersection_segment_polyline_xy(segment, polyline, tol=None): """ for cd in pairwise(polyline): pt = intersection_segment_segment_xy(segment, cd, tol) - if pt: + if pt is not None: return pt + return None -def intersection_ellipse_line_xy(ellipse, line): - """Computes the intersection of an ellipse and a line in the XY plane. +def intersection_ellipse_line_xy( + ellipse: tuple[float, float], + line: LineType, + tol: Optional[float] = None, +) -> Optional[ + Union[ + tuple[float, float, float], + tuple[tuple[float, float, float], tuple[float, float, float]], + ] +]: + """Compute the intersection of an origin-centered ellipse and a line in the XY plane. Parameters ---------- - ellipse : tuple[float, float] - The major and minor of the ellipse. - line : [point, point] | :class:`compas.geometry.Line` + ellipse + The positive major and minor semi-axis lengths. + line A line defined by two points, with at least XY coordinates. + tol + Tolerance for classifying a tangent intersection. + Default is `TOL.absolute`. Returns ------- - tuple[[float, float, float], [float, float, float]] | [float, float, float] | None + Optional[Union[tuple[float, float, float], tuple[tuple[float, float, float], tuple[float, float, float]]]] Two points, if the line goes through the ellipse. One point, if the line is tangent to the ellipse. None, otherwise. + Raises + ------ + ValueError + If either semi-axis is not positive or the line points coincide. + References ---------- - Based on [1]_. + Based on the method described by C# Helper.[^intersection-ellipse-line-csharp] - .. [1] C# Helper. *Calculate where a line segment and an ellipse intersect in C#*. - Available at: http://csharphelper.com/blog/2017/08/calculate-where-a-line-segment-and-an-ellipse-intersect-in-c/ + [^intersection-ellipse-line-csharp]: [Calculate where a line segment and an ellipse intersect in C#](http://csharphelper.com/blog/2017/08/calculate-where-a-line-segment-and-an-ellipse-intersect-in-c/) Examples -------- @@ -1066,22 +1208,26 @@ def intersection_ellipse_line_xy(ellipse, line): a, b = ellipse + if a <= 0.0 or b <= 0.0: + raise ValueError("Ellipse semi-axis lengths must be positive.") + A = (x2 - x1) ** 2 / a**2 + (y2 - y1) ** 2 / b**2 + if TOL.is_zero(A, tol): + raise ValueError("A line requires two distinct points.") B = 2 * x1 * (x2 - x1) / a**2 + 2 * y1 * (y2 - y1) / b**2 C = x1**2 / a**2 + y1**2 / b**2 - 1 discriminant = B**2 - 4 * A * C - if discriminant == 0: + if TOL.is_zero(discriminant, tol): t = -B / (2 * A) return (x1 + (x2 - x1) * t, y1 + (y2 - y1) * t, 0.0) - elif discriminant > 0: + if discriminant > 0: t1 = (-B + sqrt(discriminant)) / (2 * A) t2 = (-B - sqrt(discriminant)) / (2 * A) p1 = (x1 + (x2 - x1) * t1, y1 + (y2 - y1) * t1, 0.0) p2 = (x1 + (x2 - x1) * t2, y1 + (y2 - y1) * t2, 0.0) return p1, p2 - else: - return None + return None # def intersection_line_circle_xy(line, circle): diff --git a/src/compas/geometry/kdtree.py b/src/compas/geometry/kdtree.py index 10457ba78042..b7c94b082645 100644 --- a/src/compas/geometry/kdtree.py +++ b/src/compas/geometry/kdtree.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import collections from ._core.distance import distance_point_point_sqrd diff --git a/src/compas/geometry/line.py b/src/compas/geometry/line.py new file mode 100644 index 000000000000..8849b900d011 --- /dev/null +++ b/src/compas/geometry/line.py @@ -0,0 +1,554 @@ +from typing import Iterator +from typing import Literal +from typing import Optional +from typing import Union +from typing import overload + +from typing_extensions import Self + +from compas._typing import Coordinates +from compas._typing import CoordinateType +from compas.linalg.vectors import add_vectors + +from ._typing import TransformationType +from .geometry import Geometry +from .point import Point +from .vector import Vector + + +class Line(Geometry): + """A line is a geometric primitive defined by two points. + + The first point is the start point of the line. + The second point is the end point of the line. + The vector between the two points defines the direction of the line. + The length of the vector is the length of the line. + The direction vector is the unit vector of the vector between start and end. + The parameterisation of the line is such that the start point corresponds + to `t = 0` and the end point to `t = 1`. + + The coordinate system of a line is always the world coordinate system (WCS). + Transformation of a line is performed by transforming the start and end point. + + Parameters + ---------- + start + The first point. + end + The second point. + name + The name of the line. + + Examples + -------- + >>> line = Line([0, 0, 0], [1, 1, 1]) + >>> print(line.start) + Point(x=0.000, y=0.000, z=0.000) + >>> print(line.midpoint) + Point(x=0.500, y=0.500, z=0.500) + >>> line.length == line.vector.length + True + >>> print(line.direction) + Vector(x=0.577, y=0.577, z=0.577) + + A line behaves as a two-item sequence containing its start and end points. + + >>> len(line) + 2 + >>> list(line) == [line.start, line.end] + True + >>> line[0] = [1, 0, 0] + >>> line[1] = [2, 0, 0] + >>> line == [[1, 0, 0], [2, 0, 0]] + True + + Lines can also be constructed from a point and vector, or from a point, + direction, and length. + + >>> Line.from_point_and_vector([0, 0, 0], [1, 0, 0]) == Line([0, 0, 0], [1, 0, 0]) + True + >>> Line.from_point_direction_length([0, 0, 0], [1, 0, 0], 2) == Line([0, 0, 0], [2, 0, 0]) + True + + """ + + @property + def __data__(self) -> dict[str, list[float]]: + """The data representation of the line.""" + return {"start": self.start.__data__, "end": self.end.__data__} + + def __init__(self, start: CoordinateType, end: CoordinateType, name: Optional[str] = None) -> None: + super().__init__(name=name) + self._point: Optional[Point] = None + self._vector: Optional[Vector] = None + self._direction: Optional[Vector] = None + self.start = start + self.end = end + + def __repr__(self) -> str: + return "{0}({1!r}, {2!r})".format( + type(self).__name__, + self.start, + self.end, + ) + + def __getitem__(self, key: int) -> Point: + if key == 0: + return self.start + if key == 1: + return self.end + raise KeyError + + def __setitem__(self, key: int, value: CoordinateType) -> None: + if key == 0: + self.start = value + elif key == 1: + self.end = value + else: + raise KeyError + + def __iter__(self) -> Iterator[Point]: + return iter([self.start, self.end]) + + def __len__(self) -> int: + return 2 + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Coordinates): + return False + if len(other) != 2: + return False + return self.start == other[0] and self.end == other[1] + + # ========================================================================== + # properties + # ========================================================================== + + @property + def point(self) -> Point: + """The base point of the line. + + Notes + ----- + Assigning a `Point` or three-component coordinate sequence creates an + independent `Point` with the same coordinates. The line vector is + retained, so changing the base point translates both endpoints. + + Examples + -------- + >>> source = Point(1, 2, 3) + >>> line = Line([0, 0, 0], [1, 0, 0]) + >>> line.point = source + >>> line.point == source and line.point is not source + True + >>> source.x = 10 + >>> line.point == [1, 2, 3] + True + >>> line.end == [2, 2, 3] + True + + """ + if not self._point: + raise ValueError("The line has no base point.") + return self._point + + @point.setter + def point(self, point: CoordinateType) -> None: + self._point = Point(point[0], point[1], point[2]) + + @property + def vector(self) -> Vector: + """The vector from the start point to the end point. + + Notes + ----- + Assigning a `Vector` or three-component coordinate sequence creates an + independent `Vector` with the same components. The cached direction is + invalidated and recomputed when next requested. + + Examples + -------- + >>> source = Vector(2, 0, 0) + >>> line = Line([0, 0, 0], [1, 0, 0]) + >>> previous_direction = line.direction + >>> line.vector = source + >>> line.vector == source and line.vector is not source + True + >>> line.direction is not previous_direction + True + >>> line.end == [2, 0, 0] + True + + """ + if not self._vector: + raise ValueError("The line has no direction vector.") + return self._vector + + @vector.setter + def vector(self, vector: CoordinateType) -> None: + self._vector = Vector(vector[0], vector[1], vector[2]) + self._direction = None + + @property + def length(self) -> float: + """The distance from the start point to the end point. + + Examples + -------- + >>> Line([0, 0, 0], [3, 4, 0]).length + 5.0 + + """ + return self.vector.length + + @property + def direction(self) -> Vector: + """The unit vector pointing from the start point to the end point. + + Notes + ----- + The direction is computed on first access and cached until the line + vector changes. + + Raises + ------ + ZeroDivisionError + If the start and end points coincide. + + Examples + -------- + >>> line = Line([0, 0, 0], [2, 0, 0]) + >>> line.direction == [1, 0, 0] + True + >>> line.direction is line.direction + True + + """ + if not self._direction: + self._direction = self.vector.unitized() + return self._direction + + @property + def start(self) -> Point: + """The start point of the line. + + Notes + ----- + Assignment has the same copying behavior as `Line.point`. The line + vector is retained, so assigning the start point also moves the end + point. + + Examples + -------- + >>> line = Line([0, 0, 0], [1, 0, 0]) + >>> line.start = [1, 2, 3] + >>> line.start == [1, 2, 3] + True + >>> line.end == [2, 2, 3] + True + + """ + return self.point + + @start.setter + def start(self, point: CoordinateType) -> None: + self.point = point + + @property + def end(self) -> Point: + """The end point of the line. + + Notes + ----- + Assigning a `Point` or three-component coordinate sequence recomputes + the line vector from the current start point. The assigned object is + not retained. + + Examples + -------- + >>> source = Point(2, 3, 4) + >>> line = Line([0, 0, 0], [1, 0, 0]) + >>> line.end = source + >>> line.end == source and line.end is not source + True + >>> line.vector == [2, 3, 4] + True + + """ + return self.start + self.vector + + @end.setter + def end(self, point: CoordinateType) -> None: + end = Point(point[0], point[1], point[2]) + self._vector = Vector.from_start_end(self.start, end) + self._direction = None + + @property + def midpoint(self) -> Point: + """The point halfway between the start point and the end point. + + Examples + -------- + >>> Line([0, 0, 0], [2, 4, 6]).midpoint == [1, 2, 3] + True + + """ + return self.point_at(0.5) + + # ========================================================================== + # Constructors + # ========================================================================== + + @classmethod + def from_point_and_vector(cls, point: CoordinateType, vector: CoordinateType) -> Self: + """Construct a line from a point and a vector. + + Parameters + ---------- + point + The start point of the line. + vector + The vector of the line. + + Returns + ------- + Line + The constructed line. + + See Also + -------- + [`Line.from_point_direction_length`][compas.geometry.Line.from_point_direction_length] + + Examples + -------- + >>> from compas.geometry import Point, Vector + >>> line = Line.from_point_and_vector(Point(0, 0, 0), Vector(1, 1, 1)) + >>> print(line.start) + Point(x=0.000, y=0.000, z=0.000) + >>> print(line.end) + Point(x=1.000, y=1.000, z=1.000) + + """ + return cls(point, add_vectors(point, vector)) + + @classmethod + def from_point_direction_length(cls, point: CoordinateType, direction: CoordinateType, length: float) -> Self: + """Construct a line from a point, a direction and a length. + + Parameters + ---------- + point + The start point of the line. + direction + The direction of the line. + length + The length of the line. + + Returns + ------- + Line + The constructed line. + + See Also + -------- + [`Line.from_point_and_vector`][compas.geometry.Line.from_point_and_vector] + + Examples + -------- + >>> from compas.geometry import Point, Vector + >>> line = Line.from_point_direction_length(Point(0, 0, 0), Vector(1, 1, 1), 1) + >>> print(line.start) + Point(x=0.000, y=0.000, z=0.000) + >>> print(line.end) + Point(x=0.577, y=0.577, z=0.577) + + """ + direction = Vector(direction[0], direction[1], direction[2]) + direction.unitize() + return cls(point, add_vectors(point, direction * length)) + + # ========================================================================== + # Transformations + # ========================================================================== + + def transform(self, transformation: TransformationType) -> None: + """Transform this line. + + Parameters + ---------- + transformation + The transformation. + + Examples + -------- + >>> from math import radians + >>> from compas.geometry import Rotation + >>> line = Line([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]) + >>> R = Rotation.from_axis_and_angle([0.0, 0.0, 1.0], radians(90)) + >>> line.transform(R) + >>> print(line.end) + Point(x=0.000, y=1.000, z=0.000) + + """ + self.point.transform(transformation) + self.vector.transform(transformation) + + # ========================================================================== + # Methods + # ========================================================================== + + def point_at(self, t: float) -> Point: + """Construct a point along the line at a fractional position. + + Parameters + ---------- + t + The relative position along the line as a fraction of the length of the line. + 0.0 corresponds to the start point and 1.0 corresponds to the end point. + Numbers outside of this range are also valid and correspond to points beyond the start and end point. + + Returns + ------- + Point + The point at the specified position. + + Examples + -------- + >>> line = Line([0, 0, 0], [1, 1, 1]) + >>> print(line.point_at(0.5)) + Point(x=0.500, y=0.500, z=0.500) + + """ + point = self.point + self.vector * t + return point + + def point_from_start(self, distance: float) -> Point: + """Construct a point along the line at a distance from the start point. + + Parameters + ---------- + distance + The distance along the line from the start point towards the end point. + If the distance is negative, the point is constructed in the opposite direction of the end point. + If the distance is larger than the length of the line, the point is constructed beyond the end point. + + Returns + ------- + Point + The point at the specified distance. + + Raises + ------ + ZeroDivisionError + If the start and end points coincide. + + """ + point = self.point + self.direction * distance + return point + + def point_from_end(self, distance: float) -> Point: + """Construct a point along the line at a distance from the end point. + + Parameters + ---------- + distance + The distance along the line from the end point towards the start point. + If the distance is negative, the point is constructed in the opposite direction of the start point. + If the distance is larger than the length of the line, the point is constructed beyond the start point. + + Returns + ------- + Point + The point at the specified distance. + + Raises + ------ + ZeroDivisionError + If the start and end points coincide. + + """ + point = self.end + self.direction * -distance + return point + + @overload + def closest_point(self, point: CoordinateType, 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: CoordinateType, return_parameter: bool = False) -> Union[Point, tuple[Point, float]]: + """Compute the closest point on the line to a given point. + + Parameters + ---------- + point + The point. + return_parameter + Return the parameter of the closest point on the line. + Default is `False`. + + Returns + ------- + Point + The closest point if `return_parameter` is `False`. + tuple[Point, float] + The closest point and its line parameter if `return_parameter` is `True`. + + Raises + ------ + ZeroDivisionError + If the start and end points coincide. + + Examples + -------- + >>> line = Line([0, 0, 0], [2, 0, 0]) + >>> line.closest_point(Point(1, 1, 0)) == [1, 0, 0] + True + >>> closest, parameter = line.closest_point(Point(1, 1, 0), return_parameter=True) + >>> closest == [1, 0, 0] and parameter == 0.5 + True + + """ + point = Point(point[0], point[1], point[2]) + vector = point - self.start + t = vector.dot(self.vector) / self.length**2 + closest = self.start + self.vector * t + if return_parameter: + return closest, t + return closest + + def flip(self) -> None: + """Flip the direction of the line. + + Examples + -------- + >>> line = Line([0, 0, 0], [1, 2, 3]) + >>> line + Line(Point(x=0.0, y=0.0, z=0.0), Point(x=1.0, y=2.0, z=3.0)) + >>> line.flip() + >>> line + Line(Point(x=1.0, y=2.0, z=3.0), Point(x=0.0, y=0.0, z=0.0)) + + """ + new_vector = self.vector.inverted() + self.start = self.end + self.vector = new_vector + + def flipped(self) -> Self: + """Return a new line with the direction flipped. + + Returns + ------- + Self + A new line. + + Examples + -------- + >>> line = Line([0, 0, 0], [1, 2, 3]) + >>> line + Line(Point(x=0.0, y=0.0, z=0.0), Point(x=1.0, y=2.0, z=3.0)) + >>> line.flipped() + Line(Point(x=1.0, y=2.0, z=3.0), Point(x=0.0, y=0.0, z=0.0)) + + """ + return type(self)(self.end, self.start) diff --git a/src/compas/geometry/offset.py b/src/compas/geometry/offset.py index b41c093ca879..a951ddc0a51d 100644 --- a/src/compas/geometry/offset.py +++ b/src/compas/geometry/offset.py @@ -1,19 +1,15 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas.data.validators import is_item_iterable -from compas.geometry import add_vectors from compas.geometry import centroid_points -from compas.geometry import cross_vectors from compas.geometry import intersection_line_line from compas.geometry import is_colinear from compas.geometry import normal_polygon -from compas.geometry import normalize_vector -from compas.geometry import scale_vector -from compas.geometry import subtract_vectors from compas.itertools import iterable_like from compas.itertools import pairwise +from compas.linalg.vectors import add_vectors +from compas.linalg.vectors import cross_vectors +from compas.linalg.vectors import normalize_vector +from compas.linalg.vectors import scale_vector +from compas.linalg.vectors import subtract_vectors def intersect_lines(l1, l2, tol): diff --git a/src/compas/geometry/pca_numpy.py b/src/compas/geometry/pca_numpy.py index 9e2fcf13d3f4..9e872d20b92b 100644 --- a/src/compas/geometry/pca_numpy.py +++ b/src/compas/geometry/pca_numpy.py @@ -36,6 +36,7 @@ def pca_numpy(data): Examples -------- >>> + """ X = asarray(data) n, dim = X.shape diff --git a/src/compas/geometry/plane.py b/src/compas/geometry/plane.py index ac6fefa7978d..c53a9bb8c448 100644 --- a/src/compas/geometry/plane.py +++ b/src/compas/geometry/plane.py @@ -1,41 +1,40 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import sqrt +from typing import TYPE_CHECKING +from typing import Iterator +from typing import Optional +from typing import Sequence +from typing import Union + +from typing_extensions import Self +from compas._typing import Coordinates +from compas._typing import CoordinatesType +from compas._typing import CoordinateType from compas.geometry import Geometry from compas.geometry import bestfit_plane -from compas.geometry import cross_vectors +from compas.linalg.vectors import cross_vectors from compas.tolerance import TOL +from ._typing import TransformationType from .point import Point from .vector import Vector +if TYPE_CHECKING: + from compas.geometry import Frame + class Plane(Geometry): """A plane is defined by a base point and a normal vector. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point The base point of the plane. - normal : [float, float, float] | :class:`compas.geometry.Vector` + normal The normal vector of the plane. - name : str, optional + name The name of the plane. - Attributes - ---------- - abcd : list[float], read-only - The coefficients of the plane equation. - d : float, read-only - The *d* parameter of the linear equation describing the plane. - normal : :class:`compas.geometry.Vector` - The normal vector of the plane. - point : :class:`compas.geometry.Plane` - The base point of the plane. - Examples -------- >>> plane = Plane([0, 0, 0], [0, 0, 1]) @@ -44,56 +43,82 @@ class Plane(Geometry): >>> print(plane.normal) Vector(x=0.000, y=0.000, z=1.000) - """ + A plane behaves as a two-item sequence containing its point and normal. + + >>> len(plane) + 2 + >>> list(plane) == [plane.point, plane.normal] + True + >>> plane[0] = [1, 2, 3] + >>> plane.point == [1, 2, 3] + True + >>> plane[1] = [0, 1, 0] + >>> plane.normal == [0, 1, 0] + True + >>> plane == [[1, 2, 3], [0, 1, 0]] + True + + Planes can be constructed from points and vectors, equation coefficients, + frames, point collections, or world-axis presets. + + >>> Plane.from_three_points([0, 0, 0], [1, 0, 0], [0, 1, 0]) == Plane.worldXY() + True + >>> Plane.from_point_and_two_vectors([0, 0, 0], [1, 0, 0], [0, 1, 0]) == Plane.worldXY() + True + >>> Plane.from_abcd([0, 0, 1, 0]) == Plane.worldXY() + True + >>> from compas.geometry import Frame + >>> Plane.from_frame(Frame.worldXY()) == Plane.worldXY() + True + >>> Plane.from_points([[0, 0, 0], [1, 0, 0], [0, 1, 0]]) == Plane.worldXY() + True + >>> Plane.worldYZ().normal == [1, 0, 0] + True + >>> Plane.worldZX().normal == [0, 1, 0] + True - DATASCHEMA = { - "type": "object", - "properties": { - "point": Point.DATASCHEMA, - "normal": Vector.DATASCHEMA, - }, - "required": ["point", "normal"], - } + """ @property - def __data__(self): + def __data__(self) -> dict[str, list[float]]: + """The data representation of the plane.""" return { "point": self.point.__data__, "normal": self.normal.__data__, } - def __init__(self, point, normal, name=None): - super(Plane, self).__init__(name=name) - self._point = None - self._normal = None + def __init__(self, point: CoordinateType, normal: CoordinateType, name: Optional[str] = None) -> None: + super().__init__(name=name) + self._point: Optional[Point] = None + self._normal: Optional[Vector] = None self.point = point self.normal = normal - def __repr__(self): + def __repr__(self) -> str: return "{0}(point={1!r}, normal={2!r})".format( type(self).__name__, self.point, self.normal, ) - def __str__(self): + def __str__(self) -> str: return "{0}(point={1}, normal={2})".format( type(self).__name__, str(self.point), str(self.normal), ) - def __len__(self): + def __len__(self) -> int: return 2 - def __getitem__(self, key): + def __getitem__(self, key: int) -> Union[Point, Vector]: if key == 0: return self.point if key == 1: return self.normal raise KeyError - def __setitem__(self, key, value): + def __setitem__(self, key: int, value: CoordinateType) -> None: if key == 0: self.point = value return @@ -102,10 +127,14 @@ def __setitem__(self, key, value): return raise KeyError - def __iter__(self): + def __iter__(self) -> Iterator[Union[Point, Vector]]: return iter([self.point, self.normal]) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + if not isinstance(other, Coordinates): + return False + if len(other) != 2: + return False return self.point == other[0] and self.normal == other[1] # ========================================================================== @@ -113,34 +142,88 @@ def __eq__(self, other): # ========================================================================== @property - def point(self): + def point(self) -> Point: + """The base point of the plane. + + Notes + ----- + Assigning a `Point` or three-component coordinate sequence creates an + independent `Point` with the same coordinates. + + Examples + -------- + >>> source = Point(1, 2, 3) + >>> plane = Plane.worldXY() + >>> plane.point = source + >>> plane.point == source and plane.point is not source + True + >>> source.x = 10 + >>> plane.point == [1, 2, 3] + True + + """ if not self._point: raise ValueError("The plane has no point.") return self._point @point.setter - def point(self, point): - self._point = Point(*point) + def point(self, point: CoordinateType) -> None: + self._point = Point(point[0], point[1], point[2]) @property - def normal(self): + def normal(self) -> Vector: + """The unit normal vector of the plane. + + Notes + ----- + Assigning a `Vector` or three-component coordinate sequence creates an + independent `Vector` and unitizes it. + + Examples + -------- + >>> source = Vector(0, 0, 2) + >>> plane = Plane.worldXY() + >>> plane.normal = source + >>> plane.normal == [0, 0, 1] and plane.normal is not source + True + >>> source.z = 4 + >>> plane.normal == [0, 0, 1] + True + + """ if not self._normal: raise ValueError("The plane has no normal.") return self._normal @normal.setter - def normal(self, vector): - self._normal = Vector(*vector) + def normal(self, vector: CoordinateType) -> None: + self._normal = Vector(vector[0], vector[1], vector[2]) self._normal.unitize() @property - def d(self): + def d(self) -> float: + """The constant $d$ of the equation $ax + by + cz + d = 0$. + + Examples + -------- + >>> Plane([0, 0, 2], [0, 0, 1]).d + -2.0 + + """ a, b, c = self.normal x, y, z = self.point return -a * x - b * y - c * z @property - def abcd(self): + def abcd(self) -> tuple[float, float, float, float]: + """The coefficients of the equation $ax + by + cz + d = 0$. + + Examples + -------- + >>> Plane([0, 0, 2], [0, 0, 1]).abcd + (0.0, 0.0, 1.0, -2.0) + + """ a, b, c = self.normal d = self.d return a, b, c, d @@ -150,21 +233,21 @@ def abcd(self): # ========================================================================== @classmethod - def from_three_points(cls, a, b, c): # type: (...) -> Plane + def from_three_points(cls, a: CoordinateType, b: CoordinateType, c: CoordinateType) -> Self: """Construct a plane from three points in three-dimensional space. Parameters ---------- - a : [float, float, float] | :class:`compas.geometry.Point` + a The first point. - b : [float, float, float] | :class:`compas.geometry.Point` - The second point. - c : [float, float, float] | :class:`compas.geometry.Point` + b The second point. + c + The third point. Returns ------- - :class:`compas.geometry.Plane` + Plane A plane with base point `a` and normal vector defined as the unitized cross product of the vectors `ab` and `ac`. @@ -177,28 +260,29 @@ def from_three_points(cls, a, b, c): # type: (...) -> Plane Vector(x=0.000, y=0.000, z=1.000) """ - a = Point(*a) - b = Point(*b) - c = Point(*c) - normal = Vector(*cross_vectors(b - a, c - a)) + a = Point(a[0], a[1], a[2]) + b = Point(b[0], b[1], b[2]) + c = Point(c[0], c[1], c[2]) + normal_data = cross_vectors(b - a, c - a) + normal = Vector(normal_data[0], normal_data[1], normal_data[2]) return cls(a, normal) @classmethod - def from_point_and_two_vectors(cls, point, u, v): # type: (...) -> Plane + def from_point_and_two_vectors(cls, point: CoordinateType, u: CoordinateType, v: CoordinateType) -> Self: """Construct a plane from a base point and two vectors. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point The base point. - u : [float, float, float] | :class:`compas.geometry.Vector` + u The first vector. - v : [float, float, float] | :class:`compas.geometry.Vector` + v The second vector. Returns ------- - :class:`compas.geometry.Plane` + Plane A plane with base point `point` and normal vector defined as the unitized cross product of vectors `u` and `v`. @@ -211,72 +295,89 @@ def from_point_and_two_vectors(cls, point, u, v): # type: (...) -> Plane Vector(x=0.000, y=0.000, z=1.000) """ - normal = Vector(*cross_vectors(u, v)) + normal_data = cross_vectors(u, v) + normal = Vector(normal_data[0], normal_data[1], normal_data[2]) return cls(point, normal) @classmethod - def from_abcd(cls, abcd): # type: (...) -> Plane + def from_abcd(cls, abcd: Sequence[float]) -> Self: """Construct a plane from the plane equation coefficients. Parameters ---------- - abcd : [float, float, float, float] - The equation coefficients. + abcd + The coefficients $a$, $b$, $c$, and $d$ of the equation + $ax + by + cz + d = 0$. Returns ------- - :class:`compas.geometry.Plane` + Plane + A plane satisfying the provided equation. + + Examples + -------- + >>> plane = Plane.from_abcd([0, 0, 2, -4]) + >>> plane.point == [0, 0, 2] + True + >>> plane.normal == [0, 0, 1] + True """ a, b, c, d = abcd - x = 1 / sqrt(a**2 + b**2 + c**2) + length = sqrt(a**2 + b**2 + c**2) normal = [a, b, c] - point = [a * d * x, b * d * x, c * d * x] + factor = -d / length**2 + point = [a * factor, b * factor, c * factor] return cls(point, normal) @classmethod - def worldXY(cls): # type: (...) -> Plane + def worldXY(cls) -> Self: """Construct the world XY plane. Returns ------- - :class:`compas.geometry.Plane` + Plane The world XY plane. """ return cls([0, 0, 0], [0, 0, 1]) @classmethod - def worldYZ(cls): # type: (...) -> Plane + def worldYZ(cls) -> Self: """Construct the world YZ plane. Returns ------- - :class:`compas.geometry.Plane` + Plane The world YZ plane. """ return cls([0, 0, 0], [1, 0, 0]) @classmethod - def worldZX(cls): # type: (...) -> Plane + def worldZX(cls) -> Self: """Construct the world ZX plane. Returns ------- - :class:`compas.geometry.Plane` + Plane The world ZX plane. """ return cls([0, 0, 0], [0, 1, 0]) @classmethod - def from_frame(cls, frame): # type: (...) -> Plane + def from_frame(cls, frame: "Frame") -> Self: """Construct a plane from a frame. + Parameters + ---------- + frame + The frame defining the plane. + Returns ------- - :class:`compas.geometry.Plane` + Plane A plane with the frame's `point` and the frame's `normal`. Examples @@ -286,31 +387,32 @@ def from_frame(cls, frame): # type: (...) -> Plane >>> plane = Plane.from_frame(frame) >>> print(plane.point) Point(x=1.000, y=1.000, z=1.000) - >>> print(plane.normal) # doctest: +SKIP - Vector(x=-0.299, y=-0.079, z=0.951)) + >>> print(plane.normal) + Vector(x=-0.299, y=-0.079, z=0.951) """ return cls(frame.point, frame.normal) @classmethod - def from_points(cls, points): # type: (...) -> Plane - """Construct a plane from a list of points. + def from_points(cls, points: CoordinatesType) -> 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. Parameters ---------- - points : list of [float, float, float] | :class:`compas.geometry.Point` + points The points. Returns ------- - :class:`compas.geometry.Plane` + Plane The plane defined by the points. See Also -------- - :func:`compas.geometry.bestfit_plane` + [`bestfit_plane`][compas.geometry.bestfit_plane] computes the best-fit + plane used for collections containing other than three points. Examples -------- @@ -331,18 +433,14 @@ def from_points(cls, points): # type: (...) -> Plane # Transformations # ========================================================================== - def transform(self, T): + def transform(self, transformation: TransformationType) -> None: """Transform this plane. Parameters ---------- - T : :class:`compas.geometry.Transformation` | list[list[float]] + transformation The transformation. - Returns - ------- - None - Examples -------- >>> from compas.geometry import Frame @@ -354,29 +452,29 @@ def transform(self, T): >>> plane.transform(T) """ - self.point.transform(T) - self.normal.transform(T) + self.point.transform(transformation) + self.normal.transform(transformation) # ========================================================================== # Methods # ========================================================================== - def is_parallel(self, other, tol=None): + def is_parallel(self, other: "Plane", tol: Optional[float] = None) -> bool: """Verify if this plane is parallel to another plane. Parameters ---------- - other : :class:`compas.geometry.Plane` + other The other plane. - tol : float, optional + tol Tolerance for the dot product of the normals. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- bool - ``True`` if the planes are parallel. - ``False`` otherwise. + `True` if the planes are parallel. + `False` otherwise. Examples -------- @@ -392,22 +490,22 @@ def is_parallel(self, other, tol=None): """ return TOL.is_close(abs(self.normal.dot(other.normal)), 1, rtol=0, atol=tol) - def is_perpendicular(self, other, tol=None): + def is_perpendicular(self, other: "Plane", tol: Optional[float] = None) -> bool: """Verify if this plane is perpendicular to another plane. Parameters ---------- - other : :class:`compas.geometry.Plane` + other The other plane. - tol : float, optional + tol Tolerance for the dot product of the normals. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- bool - ``True`` if the planes are perpendicular. - ``False`` otherwise. + `True` if the planes are perpendicular. + `False` otherwise. Examples -------- @@ -419,22 +517,22 @@ def is_perpendicular(self, other, tol=None): """ return TOL.is_zero(self.normal.dot(other.normal), tol) - def contains_point(self, point, tol=None): + def contains_point(self, point: CoordinateType, tol: Optional[float] = None) -> bool: """Verify if a given point lies in the plane. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point The point. - tol : float, optional + tol Tolerance for the distance from the point to the plane. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- bool - ``True`` if the point lies in the plane. - ``False`` otherwise. + `True` if the point lies in the plane. + `False` otherwise. Examples -------- @@ -448,12 +546,12 @@ def contains_point(self, point, tol=None): # move to Point.distance_to_plane? # point.distance_to_plane(plane) - def distance_to_point(self, point): + def distance_to_point(self, point: CoordinateType) -> float: """Compute the distance from a given point to the plane. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point The point. Returns @@ -474,17 +572,17 @@ def distance_to_point(self, point): # move to Point.closest_on_plane? # point.closest_on_plane(plane) # remove entirely? - def closest_point(self, point): + def closest_point(self, point: CoordinateType) -> Point: """Compute the closest point on the plane to a given point. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point The point. Returns ------- - :class:`compas.geometry.Point` + Point The closest point on the plane. Examples @@ -495,7 +593,7 @@ def closest_point(self, point): Point(x=1.000, y=1.000, z=0.000) """ - point = Point(*point) + point = Point(point[0], point[1], point[2]) vector = self.point - point distance = self.normal.dot(vector) return point + self.normal.scaled(distance) @@ -503,17 +601,20 @@ def closest_point(self, point): # move to Point.proejcted_on_plane? # point.projected_on_plane(plane) # point.project_on_plane(plane) - def projected_point(self, point, direction=None): + def projected_point(self, point: CoordinateType, direction: Optional[CoordinateType] = None) -> Optional[Point]: """Returns the projection of a given point onto the plane. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point The point. + direction + The projection direction. If omitted, the projection follows the + plane normal. Returns ------- - :class:`compas.geometry.Point` | None + Optional[Point] The projected point, or None if a direction is given and it is parallel to the plane. Examples @@ -528,25 +629,28 @@ def projected_point(self, point, direction=None): return self.closest_point(point) from compas.geometry import Line + from compas.geometry import intersection_line_plane line = Line.from_point_and_vector(point, direction) - intersection = self.intersection_with_line(line) - return intersection + intersection = intersection_line_plane(line, self) + if intersection is None: + return None + return Point(intersection[0], intersection[1], intersection[2]) # move to Point.mirrored_by_plane? # point.mirrored_by_plane(plane) # point.mirror_by_plane(plane) - def mirrored_point(self, point): + def mirrored_point(self, point: CoordinateType) -> Point: """Returns the mirror image of a given point. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point The point. Returns ------- - :class:`compas.geometry.Point` + Point The mirrored point. Examples @@ -557,127 +661,25 @@ def mirrored_point(self, point): Point(x=1.000, y=1.000, z=-1.000) """ - point = Point(*point) + point = Point(point[0], point[1], point[2]) vector = self.point - point distance = self.normal.dot(vector) return point + self.normal.scaled(2 * distance) - def intersection_with_line(self, line, tol=None): - """Compute the intersection of a plane and a line. - - Parameters - ---------- - line : :class:`compas.geometry.Line` - The line. - tol : float, optional - Tolerance for the dot product of the line vector and the plane normal. - Default is :attr:`TOL.absolute`. - - Returns - ------- - :class:`compas.geometry.Point` | None - The intersection point, or ``None`` if the line is parallel to the plane. - - Examples - -------- - >>> from compas.geometry import Line - >>> plane = Plane.worldXY() - >>> line = Line.from_point_and_vector(Point(0, 0, 1), Vector(1, 1, 1)) - >>> point = plane.intersection_with_line(line) - >>> print(point) - Point(x=-1.000, y=-1.000, z=0.000) - - """ - # The line is parallel to the plane - if TOL.is_zero(self.normal.dot(line.vector), tol): - return None - - t = (self.point - line.start).dot(self.normal) / line.vector.dot(self.normal) - return line.point_at(t) - - def intersection_with_plane(self, plane): - """Compute the intersection of two planes. - - Parameters - ---------- - plane : :class:`compas.geometry.Plane` - The other plane. - - Returns - ------- - :class:`compas.geometry.Line` | None - The intersection line, or None if the planes are parallel or coincident. - - Examples - -------- - >>> plane1 = Plane.worldXY() - >>> plane2 = Plane([1.0, 1.0, 1.0], [0.0, 0.0, 1.0]) - >>> line = plane1.intersection_with_plane(plane2) - - """ - from compas.geometry import Line - - if self.is_parallel(plane): - return None - - # direction of the line - direction = self.normal.cross(plane.normal) - - # point on the line - line = Line(self.point, self.point + self.normal.cross(direction)) - point = plane.intersection_with_line(line) - - return Line(point, point + direction) - - def intersections_with_curve(self, curve, tol=None): - """Compute the intersection of a plane and a curve. - - Parameters - ---------- - curve : :class:`compas.geometry.Curve` - The curve. - tol : float, optional - Tolerance for the dot product of the line vector and the plane normal. - Default is :attr:`TOL.absolute`. - - Returns - ------- - list of :class:`compas.geometry.Point` - The intersection points. - - """ - raise NotImplementedError - - def intersections_with_surface(self, surface): - """Compute the intersection of a plane and a surface. - - Parameters - ---------- - surface : :class:`compas.geometry.Surface` - The surface. - - Returns - ------- - list of :class:`compas.geometry.Point` - The intersection points. - - """ - raise NotImplementedError - - def offset(self, distance): + def offset(self, distance: float) -> Self: """Returns a new offset plane by a given distance. The plane normal is used as positive direction. Parameters ---------- - distance: float + distance The offset distance. Returns ------- - :class:`compas.geometry.Plane` + Plane The offset plane. """ - return Plane(self.point + self.normal.scaled(distance), self.normal) + return type(self)(self.point + self.normal.scaled(distance), self.normal) diff --git a/src/compas/geometry/point.py b/src/compas/geometry/point.py index 4f8a1e828794..de2862b10e18 100644 --- a/src/compas/geometry/point.py +++ b/src/compas/geometry/point.py @@ -1,7 +1,13 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import TYPE_CHECKING +from typing import Iterator +from typing import Optional +from typing import Sequence +from typing import Union +from typing import overload +from typing_extensions import Self + +from compas._typing import Coordinate from compas.geometry import centroid_points from compas.geometry import distance_point_line from compas.geometry import distance_point_plane @@ -17,51 +23,54 @@ from compas.geometry import transform_points from compas.tolerance import TOL +from ._typing import CoordinateType +from ._typing import LineType +from ._typing import PlaneType +from ._typing import PolygonType +from ._typing import PolylineType +from ._typing import TransformationType +from ._typing import TriangleType from .geometry import Geometry from .vector import Vector +if TYPE_CHECKING: + from compas.geometry import Circle + from compas.geometry import Curve + from compas.geometry import Polyhedron + class Point(Geometry): """A point is defined by XYZ coordinates. Parameters ---------- - x : float + x The X coordinate of the point. - y : float + y The Y coordinate of the point. - z : float, optional + z The Z coordinate of the point. - name : str, optional + name The name of the point. - Attributes - ---------- - x : float - The X coordinate of the point. - y : float - The Y coordinate of the point. - z : float - The Z coordinate of the point. - Notes ----- A `Point` object supports direct access to its xyz coordinates through the dot notation, as well list-style access using indices. Indexed access is implemented such that the `Point` behaves like a circular - list [1]_. + list.[^point-circular-list] References ---------- - .. [1] Stack Overflow. *Pythonic Circular List*. - Available at: https://stackoverflow.com/questions/8951020/pythonic-circular-list. + [^point-circular-list]: Stack Overflow. [*Pythonic Circular List*](https://stackoverflow.com/questions/8951020/pythonic-circular-list). Examples -------- >>> p1 = Point(1, 2, 3) >>> p2 = Point(4, 5, 6) - The XYZ coordinates of point objects can be accessed as object attributes or by trating the points as lists. + `Point` implements `__len__`, `__iter__`, `__getitem__`, and `__setitem__`, + so its coordinates can be accessed as attributes or through sequence operations. >>> p1.x 1.0 @@ -75,17 +84,34 @@ class Point(Geometry): 2.0 >>> p1[2] 3.0 - - Point objects support basic arithmetic operations. + >>> len(p1) + 3 + >>> list(p1) + [1.0, 2.0, 3.0] + >>> p1[0] = 1.5 + >>> p1 == [1.5, 2.0, 3.0] + True + >>> p1.x = 1.0 + + `Point.__add__` returns a point, whereas `Point.__sub__` returns the + displacement vector from the right operand to the point. Multiplication + and division apply a scalar to every coordinate. >>> result = p1 + p2 >>> print(result) Point(x=5.000, y=7.000, z=9.000) + >>> result = p2 - p1 + >>> print(result) + Vector(x=3.000, y=3.000, z=3.000) + >>> result = p1 * 2 >>> print(result) Point(x=2.000, y=4.000, z=6.000) + >>> print(p2 / 2) + Point(x=2.000, y=2.500, z=3.000) + >>> result = p1**2 >>> print(result) Point(x=1.000, y=4.000, z=9.000) @@ -109,23 +135,16 @@ class Point(Geometry): """ - DATASCHEMA = { - "type": "array", - "minItems": 3, - "maxItems": 3, - "items": {"type": "number"}, - } - @property - def __data__(self): + def __data__(self) -> list[float]: # type: ignore[override] return list(self) @classmethod - def __from_data__(cls, data): - return cls(*data) + def __from_data__(cls, data: Sequence[float]) -> Self: # type: ignore[override] + return cls(data[0], data[1], data[2]) - def __init__(self, x, y, z=0.0, name=None): - super(Point, self).__init__(name=name) + def __init__(self, x: float, y: float, z: float = 0.0, name: Optional[str] = None) -> None: + super().__init__(name=name) self._x = 0.0 self._y = 0.0 self._z = 0.0 @@ -133,7 +152,7 @@ def __init__(self, x, y, z=0.0, name=None): self.y = y self.z = z - def __repr__(self): + def __repr__(self) -> str: return "{0}(x={1}, y={2}, z={3})".format( type(self).__name__, self.x, @@ -141,7 +160,7 @@ def __repr__(self): self.z, ) - def __str__(self): + def __str__(self) -> str: return "{0}(x={1}, y={2}, z={3})".format( type(self).__name__, TOL.format_number(self.x), @@ -149,83 +168,161 @@ def __str__(self): TOL.format_number(self.z), ) - def __len__(self): + def __len__(self) -> int: return 3 - def __getitem__(self, key): + @overload + def __getitem__(self, key: int) -> float: ... + + @overload + def __getitem__(self, key: slice) -> list[float]: ... + + def __getitem__(self, key: Union[int, slice]) -> Union[float, list[float]]: if isinstance(key, slice): return [self[i] for i in range(*key.indices(len(self)))] - i = key % 3 - if i == 0: + if key == 0 or key == -3: return self.x - if i == 1: + if key == 1 or key == -2: return self.y - if i == 2: + if key == 2 or key == -1: return self.z - raise KeyError + raise IndexError("Point index out of range.") - def __setitem__(self, key, value): - i = key % 3 - if i == 0: + def __setitem__(self, key: int, value: float) -> None: + if key == 0 or key == -3: self.x = value return - if i == 1: + if key == 1 or key == -2: self.y = value return - if i == 2: + if key == 2 or key == -1: self.z = value return - raise KeyError + raise IndexError("Point assignment index out of range.") - def __iter__(self): + def __iter__(self) -> Iterator[float]: return iter([self.x, self.y, self.z]) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + if not isinstance(other, Coordinate) or len(other) != 3: + return False return TOL.is_allclose(self, other) - def __add__(self, other): + def __add__(self, other: CoordinateType) -> "Point": + """Return the coordinate-wise sum of this point and another coordinate. + + Examples + -------- + >>> list(Point(1, 2, 3) + [4, 5, 6]) + [5.0, 7.0, 9.0] + + """ return Point(self.x + other[0], self.y + other[1], self.z + other[2]) - def __sub__(self, other): + def __sub__(self, other: CoordinateType) -> Vector: + """Return the displacement vector from another coordinate to this point. + + Examples + -------- + >>> list(Point(4, 5, 6) - [1, 2, 3]) + [3.0, 3.0, 3.0] + + """ x = self.x - other[0] y = self.y - other[1] z = self.z - other[2] return Vector(x, y, z) - def __mul__(self, n): + def __mul__(self, n: float) -> "Point": + """Return a point with every coordinate multiplied by a scalar. + + Examples + -------- + >>> list(Point(1, 2, 3) * 2) + [2.0, 4.0, 6.0] + + """ return Point(n * self.x, n * self.y, n * self.z) - def __truediv__(self, n): + def __truediv__(self, n: float) -> "Point": + """Return a point with every coordinate divided by a scalar. + + Examples + -------- + >>> list(Point(2, 4, 6) / 2) + [1.0, 2.0, 3.0] + + """ return Point(self.x / n, self.y / n, self.z / n) - def __pow__(self, n): + def __pow__(self, n: float) -> "Point": return Point(self.x**n, self.y**n, self.z**n) - def __iadd__(self, other): + def __iadd__(self, other: CoordinateType) -> Self: + """Add another coordinate to this point in place and return this point. + + Examples + -------- + >>> point = Point(1, 2, 3) + >>> point += [4, 5, 6] + >>> list(point) + [5.0, 7.0, 9.0] + + """ self.x += other[0] self.y += other[1] self.z += other[2] return self - def __isub__(self, other): + def __isub__(self, other: CoordinateType) -> Self: + """Subtract another coordinate from this point in place and return this point. + + Examples + -------- + >>> point = Point(4, 5, 6) + >>> point -= [1, 2, 3] + >>> list(point) + [3.0, 3.0, 3.0] + + """ self.x -= other[0] self.y -= other[1] self.z -= other[2] return self - def __imul__(self, n): + def __imul__(self, n: float) -> Self: + """Multiply every coordinate by a scalar in place and return this point. + + Examples + -------- + >>> point = Point(1, 2, 3) + >>> point *= 2 + >>> list(point) + [2.0, 4.0, 6.0] + + """ self.x *= n self.y *= n self.z *= n return self - def __itruediv__(self, n): + def __itruediv__(self, n: float) -> Self: + """Divide every coordinate by a scalar in place and return this point. + + Examples + -------- + >>> point = Point(2, 4, 6) + >>> point /= 2 + >>> list(point) + [1.0, 2.0, 3.0] + + """ self.x /= n self.y /= n self.z /= n return self - def __ipow__(self, n): + def __ipow__(self, n: float) -> Self: self.x **= n self.y **= n self.z **= n @@ -236,39 +333,39 @@ def __ipow__(self, n): # ========================================================================== @property - def x(self): + def x(self) -> float: return self._x @x.setter - def x(self, x): + def x(self, x: float) -> None: self._x = float(x) @property - def y(self): + def y(self) -> float: return self._y @y.setter - def y(self, y): + def y(self, y: float) -> None: self._y = float(y) @property - def z(self): + def z(self) -> float: return self._z @z.setter - def z(self, z): + def z(self, z: float) -> None: self._z = float(z) # ========================================================================== # Methods # ========================================================================== - def distance_to_point(self, point): + def distance_to_point(self, point: CoordinateType) -> float: """Compute the distance to another point. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point The other point. Returns @@ -286,12 +383,12 @@ def distance_to_point(self, point): """ return distance_point_point(self, point) - def distance_to_line(self, line): + def distance_to_line(self, line: LineType) -> float: """Compute the distance to a line. Parameters ---------- - line : [point, point] | :class:`compas.geometry.Line` + line The line. Returns @@ -310,12 +407,12 @@ def distance_to_line(self, line): """ return distance_point_line(self, line) - def distance_to_plane(self, plane): + def distance_to_plane(self, plane: PlaneType) -> float: """Compute the distance to a plane. Parameters ---------- - plane : [point, vector] | :class:`compas.geometry.Plane` + plane The plane. Returns @@ -339,7 +436,7 @@ def distance_to_plane(self, plane): # 2D predicates # ========================================================================== - def in_polygon(self, polygon): + def in_polygon(self, polygon: PolygonType) -> bool: """Determine if the point lies inside the given polygon. For this test, the point and polygon are assumed to lie in the XY plane. @@ -351,7 +448,7 @@ def in_polygon(self, polygon): Parameters ---------- - polygon : sequence[point] | :class:`compas.geometry.Polygon` + polygon The polygon. Returns @@ -362,7 +459,7 @@ def in_polygon(self, polygon): See Also -------- - :meth:`in_convex_polygon` + [`Point.in_convex_polygon`][compas.geometry.Point.in_convex_polygon] Examples -------- @@ -375,7 +472,7 @@ def in_polygon(self, polygon): """ return is_point_in_polygon_xy(self, polygon) - def in_convex_polygon(self, polygon): + def in_convex_polygon(self, polygon: PolygonType) -> bool: """Determine if the point lies inside the given convex polygon. For this test, the point and polygon are assumed to lie in the XY plane. @@ -388,7 +485,7 @@ def in_convex_polygon(self, polygon): Parameters ---------- - polygon : sequence[point] | :class:`compas.geometry.Polygon` + polygon The polygon. Returns @@ -399,7 +496,7 @@ def in_convex_polygon(self, polygon): See Also -------- - :meth:`in_polygon` + [`Point.in_polygon`][compas.geometry.Point.in_polygon] Examples -------- @@ -416,16 +513,16 @@ def in_convex_polygon(self, polygon): # 3D predicates # ========================================================================== - def on_line(self, line, tol=None): + def on_line(self, line: LineType, tol: Optional[float] = None) -> bool: """Determine if the point lies on the given line. Parameters ---------- - line : [point, point] | :class:`compas.geometry.Line` + line The line. - tol : float, optional + tol A tolerance value for the distance between the point and the line. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- @@ -444,16 +541,16 @@ def on_line(self, line, tol=None): """ return TOL.is_zero(self.distance_to_line(line), tol) - def on_segment(self, segment, tol=None): + def on_segment(self, segment: LineType, tol: Optional[float] = None) -> bool: """Determine if the point lies on the given segment. Parameters ---------- - segment : [point, point] | :class:`compas.geometry.Line` + segment The segment. - tol : float, optional + tol A tolerance value for the distance between the point and the segment. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- @@ -472,12 +569,12 @@ def on_segment(self, segment, tol=None): """ return is_point_on_segment(self, segment, tol=tol) - def on_polyline(self, polyline): + def on_polyline(self, polyline: PolylineType) -> bool: """Determine if the point lies on the given polyline. Parameters ---------- - polyline : sequence[point] | :class:`compas.geometry.Polyline` + polyline The polyline. Returns @@ -497,16 +594,16 @@ def on_polyline(self, polyline): """ return is_point_on_polyline(self, polyline) - def on_plane(self, plane, tol=None): + def on_plane(self, plane: PlaneType, tol: Optional[float] = None) -> bool: """Determine if the point lies on the given plane. Parameters ---------- - plane : :class:`compas.geometry.Plane` + plane The plane. - tol : float, optional + tol A tolerance value for the distance between the point and the plane. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- @@ -526,16 +623,16 @@ def on_plane(self, plane, tol=None): """ return TOL.is_zero(self.distance_to_plane(plane), tol) - def on_circle(self, circle, tol=None): + def on_circle(self, circle: "Circle", tol: Optional[float] = None) -> bool: """Determine if the point lies on the given circle. Parameters ---------- - circle : :class:`compas.geometry.Circle` + circle The circle. - tol : float, optional + tol A tolerance value for the distance between the point and the circle. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- @@ -548,14 +645,14 @@ def on_circle(self, circle, tol=None): return False return TOL.is_close(self.distance_to_point(circle.center), circle.radius, rtol=0, atol=tol) - def on_curve(self, curve, tol=None): + def on_curve(self, curve: "Curve", tol: Optional[float] = None) -> bool: """Determine if the point lies on the given curve. Parameters ---------- - curve : :class:`compas.geometry.Curve` + curve The curve. - tol : float, optional + tol A tolerance value for the distance between the point and the curve. Returns @@ -567,12 +664,12 @@ def on_curve(self, curve, tol=None): """ return TOL.is_zero(self.distance_to_point(curve.closest_point(self)), tol) - def in_triangle(self, triangle): + def in_triangle(self, triangle: TriangleType) -> bool: """Determine if the point lies inside the given triangle. Parameters ---------- - triangle : [point, point, point] | :class:`compas.geometry.Polygon` + triangle The triangle. Returns @@ -592,12 +689,12 @@ def in_triangle(self, triangle): """ return is_point_in_triangle(self, triangle) - def in_circle(self, circle): + def in_circle(self, circle: "Circle") -> bool: """Determine if the point lies inside the given circle. Parameters ---------- - circle : :class:`compas.geometry.Circle` + circle The circle. Returns @@ -619,7 +716,7 @@ def in_circle(self, circle): """ return is_point_in_circle(self, (circle.plane, circle.radius)) - def in_polyhedron(self, polyhedron): + def in_polyhedron(self, polyhedron: "Polyhedron") -> bool: """Determine if the point lies inside the given polyhedron. This method verifies that the point lies behind the planes of all the faces of the polyhedron. @@ -628,7 +725,7 @@ def in_polyhedron(self, polyhedron): Parameters ---------- - polyhedron : [vertices, faces] | :class:`compas.geometry.Polyhedron` + polyhedron The polyhedron. Returns @@ -647,12 +744,12 @@ def in_polyhedron(self, polyhedron): # Transformations # ========================================================================== - def transform(self, T): + def transform(self, transformation: TransformationType) -> None: """Transform this point. Parameters ---------- - T : :class:`compas.geometry.Transformation` | list[list[float]] + transformation The transformation matrix. Examples @@ -665,7 +762,7 @@ def transform(self, T): True """ - point = transform_points([self], T)[0] - self.x = point[0] - self.y = point[1] - self.z = point[2] + transformed_point = transform_points([self], transformation)[0] + self.x = transformed_point[0] + self.y = transformed_point[1] + self.z = transformed_point[2] diff --git a/src/compas/geometry/pointcloud.py b/src/compas/geometry/pointcloud.py index db971cfaf5b7..7fc1194b15ec 100644 --- a/src/compas/geometry/pointcloud.py +++ b/src/compas/geometry/pointcloud.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from random import uniform from compas.geometry import Geometry @@ -35,13 +31,6 @@ class Pointcloud(Geometry): """ - DATASCHEMA = { - "type": "object", - "properties": { - "points": {"type": "array", "items": Point.DATASCHEMA, "minItems": 1}, - }, - "required": ["points"], - } @property def __data__(self): @@ -138,14 +127,10 @@ def from_ply(cls, filepath): :class:`compas.geometry.Pointcloud` """ - from compas.files import PLY + from compas.files import ply_data + from compas.files import read_ply - points = [] - ply = PLY(filepath) - for vertex in ply.reader.vertices: # type: ignore - points.append([vertex["x"], vertex["y"], vertex["z"]]) - cloud = cls(points) - return cloud + return cls(ply_data(read_ply(filepath)).vertices) @classmethod def from_pcd(cls, filepath): @@ -271,6 +256,7 @@ def transform(self, T): ------- None The cloud is modified in place. + """ for index, point in enumerate(transform_points(self.points, T)): self.points[index].x = point[0] diff --git a/src/compas/geometry/polygon.py b/src/compas/geometry/polygon.py index 9feffed84f0c..b5c61955f1ec 100644 --- a/src/compas/geometry/polygon.py +++ b/src/compas/geometry/polygon.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import math from compas.geometry import Frame @@ -84,11 +80,6 @@ class Polygon(Geometry): """ - DATASCHEMA = { - "type": "object", - "properties": {"points": {"type": "array", "minItems": 2, "items": Point.DATASCHEMA}}, - "required": ["points"], - } @property def __data__(self): @@ -277,8 +268,8 @@ def from_sides_and_radius_xy(cls, n, radius): Examples -------- - >>> from compas.geometry import dot_vectors - >>> from compas.geometry import subtract_vectors + >>> from compas.linalg import dot_vectors + >>> from compas.linalg import subtract_vectors >>> pentagon = Polygon.from_sides_and_radius_xy(5, 1.0) >>> len(pentagon.lines) == 5 True diff --git a/src/compas/geometry/polyhedron.py b/src/compas/geometry/polyhedron.py index 68720119daa3..18681f528a1c 100644 --- a/src/compas/geometry/polyhedron.py +++ b/src/compas/geometry/polyhedron.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import sqrt from compas.geometry import Line @@ -172,31 +168,6 @@ class Polyhedron(Geometry): """ - DATASCHEMA = { - "type": "object", - "properties": { - "vertices": { - "type": "array", - "minItems": 4, - "items": { - "type": "array", - "minItems": 3, - "maxItems": 3, - "items": {"type": "number"}, - }, - }, - "faces": { - "type": "array", - "minItems": 4, - "items": { - "type": "array", - "minItems": 3, - "items": {"type": "integer", "minimum": 0}, - }, - }, - }, - "required": ["vertices", "faces"], - } @property def __data__(self): @@ -430,9 +401,9 @@ def from_halfspaces(cls, halfspaces, interior_point): from scipy.spatial import HalfspaceIntersection # type: ignore from compas.datastructures import Mesh - from compas.geometry import cross_vectors - from compas.geometry import dot_vectors - from compas.geometry import length_vector + from compas.linalg.vectors import cross_vectors + from compas.linalg.vectors import dot_vectors + from compas.linalg.vectors import length_vector halfspaces = asarray(halfspaces, dtype=float) interior_point = asarray(interior_point, dtype=float) diff --git a/src/compas/geometry/curves/polyline.py b/src/compas/geometry/polyline.py similarity index 58% rename from src/compas/geometry/curves/polyline.py rename to src/compas/geometry/polyline.py index 37303e0c8433..4a2f49123999 100644 --- a/src/compas/geometry/curves/polyline.py +++ b/src/compas/geometry/polyline.py @@ -1,21 +1,29 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from compas.geometry import Frame -from compas.geometry import Line -from compas.geometry import Point -from compas.geometry import is_point_on_line -from compas.geometry import is_point_on_polyline -from compas.geometry import transform_points +from typing import Iterator +from typing import Optional +from typing import Sequence +from typing import Union +from typing import overload + +from typing_extensions import Self + +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 .curve import Curve +from ._core.predicates_3 import is_point_on_line +from ._core.predicates_3 import is_point_on_polyline +from ._core.transformations import transform_points +from ._typing import TransformationType +from .geometry import Geometry +from .line import Line +from .point import Point +from .vector import Vector -class Polyline(Curve): - """A polyline is a curve defined by a sequence of points connected by line segments. +class Polyline(Geometry): + """A polyline is a geometric primitive defined by connected line segments. A Polyline can be open or closed. It can be self-intersecting. @@ -27,32 +35,12 @@ class Polyline(Curve): Parameters ---------- - points : list[[float, float, float] | :class:`compas.geometry.Point`] + points An ordered list of points. Each consecutive pair of points forms a segment of the polyline. - name : str, optional + name The name of the polyline. - Attributes - ---------- - frame : :class:`compas.geometry.Frame`, read-only - The frame of the spatial coordinates of the polyline. - This is always the world XY frame. - points : list[:class:`compas.geometry.Point`] - The points of the polyline. - lines : list[:class:`compas.geometry.Line`], read-only - The lines of the polyline. - length : float, read-only - The length of the polyline. - start : :class:`compas.geometry.Point`, read-only - The start point of the polyline. - end : :class:`compas.geometry.Point`, read-only - The end point of the polyline. - is_selfintersecting : bool, read-only - True if the polyline is self-intersecting. - is_closed : bool, read-only - True if the polyline is closed. - Examples -------- >>> polyline = Polyline([[0, 0, 0], [1, 0, 0], [2, 0, 0], [3, 0, 0]]) @@ -71,45 +59,42 @@ class Polyline(Curve): """ - DATASCHEMA = { - "type": "object", - "properties": { - "points": {"type": "array", "minItems": 2, "items": Point.DATASCHEMA}, - }, - "required": ["points"], - } - @property - def __data__(self): + def __data__(self) -> dict[str, list[list[float]]]: + """The data representation of the polyline.""" return {"points": [point.__data__ for point in self.points]} - def __init__(self, points, name=None): - super(Polyline, self).__init__(name=name) - self._points = [] - self._lines = [] + def __init__(self, points: CoordinatesType, name: Optional[str] = None) -> None: + super().__init__(name=name) + self._points: list[Point] = [] self.points = points - def __repr__(self): + def __repr__(self) -> str: return "{0}({1!r})".format( type(self).__name__, self.points, ) - def __getitem__(self, key): + @overload + def __getitem__(self, key: int) -> Point: ... + + @overload + def __getitem__(self, key: slice) -> list[Point]: ... + + def __getitem__(self, key: Union[int, slice]) -> Union[Point, list[Point]]: return self.points[key] - def __setitem__(self, key, value): - self.points[key] = Point(*value) - self._lines = None + def __setitem__(self, key: int, value: CoordinateType) -> None: + self.points[key] = Point(value[0], value[1], value[2]) - def __iter__(self): + def __iter__(self) -> Iterator[Point]: return iter(self.points) - def __len__(self): + def __len__(self) -> int: return len(self.points) - def __eq__(self, other): - if not hasattr(other, "__iter__") or not hasattr(other, "__len__") or len(self) != len(other): + def __eq__(self, other: object) -> bool: + if not isinstance(other, Coordinates) or len(self) != len(other): return False return TOL.is_allclose(self, other) @@ -118,46 +103,92 @@ def __eq__(self, other): # ========================================================================== @property - def frame(self): - return Frame.worldXY() + def points(self) -> list[Point]: + """The defining points of the polyline. - @frame.setter - def frame(self, frame): - raise AttributeError("Setting the coordinate frame of a polyline is not supported.") + Notes + ----- + Assigning a collection of three-component coordinates creates an + independent `Point` for every item. The returned list itself is mutable; + the derived line segments always reflect its current contents. - @property - def points(self): + Examples + -------- + >>> source = Point(1, 2, 3) + >>> polyline = Polyline([[0, 0, 0], source]) + >>> polyline.points[1] == source and polyline.points[1] is not source + True + + """ return self._points @points.setter - def points(self, points): - self._points = [Point(*xyz) for xyz in points] - self._lines = None + def points(self, points: CoordinatesType) -> None: + self._points = [Point(xyz[0], xyz[1], xyz[2]) for xyz in points] @property - def lines(self): - if self._lines is None: - self._lines = [Line(a, b) for a, b in pairwise(self.points)] - return self._lines + def lines(self) -> list[Line]: + """The line segments connecting consecutive points. + + The lines are derived from the current points on every access. + + Examples + -------- + >>> polyline = Polyline([[0, 0, 0], [1, 0, 0], [1, 1, 0]]) + >>> len(polyline.lines) + 2 + >>> polyline.lines is polyline.lines + False + + """ + return [Line(a, b) for a, b in pairwise(self.points)] @property - def length(self): - return sum([line.length for line in self.lines]) + def length(self) -> float: + """The sum of the segment lengths. + + Examples + -------- + >>> Polyline([[0, 0, 0], [3, 4, 0]]).length + 5.0 + + """ + return sum(line.length for line in self.lines) @property - def start(self): + def start(self) -> Point: + """The first point of the polyline. + + Raises + ------ + IndexError + If the polyline has no points. + + """ return self.points[0] @property - def end(self): + def end(self) -> Point: + """The last point of the polyline. + + Raises + ------ + IndexError + If the polyline has no points. + + """ return self.points[-1] @property - def is_selfintersecting(self): - raise NotImplementedError + def is_closed(self) -> bool: + """Whether the first and last points coincide. - @property - def is_closed(self): + Raises + ------ + IndexError + If the polyline has no points. + + """ return self.points[0] == self.points[-1] # ========================================================================== @@ -168,12 +199,12 @@ def is_closed(self): # Transformations # ========================================================================== - def transform(self, T): + def transform(self, transformation: TransformationType) -> None: """Transform this polyline. Parameters ---------- - T : :class:`compas.geometry.Transformation` | list[list[float]] + transformation The transformation. Examples @@ -185,7 +216,7 @@ def transform(self, T): >>> polyline.transform(R) """ - for index, point in enumerate(transform_points(self.points, T)): + for index, point in enumerate(transform_points(self.points, transformation)): self.points[index].x = point[0] self.points[index].y = point[1] self.points[index].z = point[2] @@ -194,46 +225,56 @@ def transform(self, T): # Methods # ========================================================================== - def append(self, point): + def append(self, point: CoordinateType) -> None: """Append a point to the end of the polyline. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point The point to append. """ - self.points.append(Point(*point)) - self._lines = None + self.points.append(Point(point[0], point[1], point[2])) - def insert(self, i, point): + def insert(self, i: int, point: CoordinateType) -> None: """Insert a point at the specified index. Parameters ---------- - i : int + i The index of the insertion point. - point : [float, float, float] | :class:`compas.geometry.Point` + point The point to insert. """ - self.points.insert(i, Point(*point)) - self._lines = None + self.points.insert(i, Point(point[0], point[1], point[2])) - def point_at(self, t, snap=False): + def _parameterization_data(self) -> tuple[list[Line], float]: + lines = self.lines + length = sum(line.length for line in lines) + if length == 0.0: + raise ValueError("A zero-length polyline has no parameterization.") + return lines, length + + def point_at(self, t: float, snap: bool = False) -> Optional[Point]: """Point on the polyline at a specific normalized parameter. Parameters ---------- - t : float + t The parameter value. - snap : bool, optional - If True, return the closest polyline point. + snap + If `True`, return the closest defining point. Returns ------- - :class:`compas.geometry.Point` - The point on the polyline. + Optional[Point] + The point on the polyline, or `None` if `t` is outside `[0, 1]`. + + Raises + ------ + ValueError + If the polyline has zero length. Examples -------- @@ -245,19 +286,18 @@ def point_at(self, t, snap=False): if t < 0 or t > 1: return None + lines, polyline_length = self._parameterization_data() points = self.points if t == 0: return points[0] if t == 1: return points[-1] - polyline_length = self.length - x = 0 - i = 0 - while x <= t: - line = Line(points[i], points[i + 1]) + for line in lines: line_length = line.length + if line_length == 0.0: + continue dx = line_length / polyline_length if x + dx > t: if snap: @@ -267,24 +307,29 @@ def point_at(self, t, snap=False): return line.end return line.point_at((t - x) * polyline_length / line_length) x += dx - i += 1 + return points[-1] - def parameter_at(self, point, tol=None): + def parameter_at(self, point: CoordinateType, tol: Optional[float] = None) -> float: """Parameter of the polyline at a specific point. Parameters ---------- - point : [float, float, float] | :class:`compas.geometry.Point` + point The point on the polyline. - tol : float, optional + tol A tolerance value for verifying that the point is on the polyline. - Default is :attr:`TOL.absolute`. + Default is `TOL.absolute`. Returns ------- float The parameter of the polyline. + Raises + ------ + ValueError + If the polyline has zero length or the point is not on the polyline. + Examples -------- >>> from compas.geometry import Point @@ -293,29 +338,36 @@ def parameter_at(self, point, tol=None): 0.05 """ + point = Point(point[0], point[1], point[2]) + lines, polyline_length = self._parameterization_data() if not is_point_on_polyline(point, self, tol): - raise Exception("{} not found!".format(point)) + raise ValueError("{} not found!".format(point)) dx = 0 - for line in self.lines: + for line in lines: if not is_point_on_line(point, line, tol): dx += line.length continue dx += line.start.distance_to_point(point) break - return dx / self.length + return dx / polyline_length - def tangent_at(self, t): + def tangent_at(self, t: float) -> Optional[Vector]: """Tangent vector at a specific normalized parameter. Parameters ---------- - t : float + t The parameter value. Returns ------- - :class:`compas.geometry.Vector` - The tangent vector at the specified parameter. + Optional[Vector] + The tangent vector, or `None` if `t` is outside `[0, 1]`. + + Raises + ------ + ValueError + If the polyline has zero length. Examples -------- @@ -327,53 +379,65 @@ def tangent_at(self, t): if t < 0 or t > 1: return None - points = self.points + lines, polyline_length = self._parameterization_data() if t == 0: - return points[1] - points[0] + return next(line.direction for line in lines if line.length > 0.0) if t == 1: - return points[-1] - points[-2] - - polyline_length = self.length + return next(line.direction for line in reversed(lines) if line.length > 0.0) x = 0 - i = 0 - while x <= t: - line = Line(points[i], points[i + 1]) + tangent = None + for line in lines: line_length = line.length + if line_length == 0.0: + continue + tangent = line.direction dx = line_length / polyline_length if x + dx > t: - return line.direction + return tangent x += dx - i += 1 + return tangent - def tangent_at_point(self, point): - """Calculates the tangent vector of a point on a polyline + def tangent_at_point(self, point: CoordinateType) -> Vector: + """Calculate the tangent vector at a point on the polyline. Parameters ---------- - point: [float, float, float] | :class:`compas.geometry.Point` + point + The point on the polyline. Returns ------- - :class:`compas.geometry.Vector` + Vector + The tangent vector. + + Raises + ------ + ValueError + If the polyline has zero length or the point is not on the polyline. """ - for line in self.lines: + point = Point(point[0], point[1], point[2]) + lines, _ = self._parameterization_data() + for line in lines: + if line.length == 0.0: + continue if is_point_on_line(point, line): return line.direction - raise Exception("{} not found!".format(point)) + raise ValueError("{} not found!".format(point)) - def split_at_corners(self, angle_threshold): - """Splits a polyline at corners larger than the given angle_threshold + def split_at_corners(self, angle_threshold: float) -> list[Self]: + """Split the polyline at corners larger than a threshold. Parameters ---------- - angle_threshold : float + angle_threshold In radians. Returns ------- - list[:class:`compas.geometry.Polyline`] + list[Self] + The split polylines. """ corner_ids = [] @@ -397,27 +461,28 @@ def split_at_corners(self, angle_threshold): for id1, id2 in pairwise(corner_ids): if id1 < id2: - split_polylines.append(Polyline(points[id1 : id2 + 1])) + split_polylines.append(type(self)(points[id1 : id2 + 1])) else: looped_pts = [points[i] for i in range(id1, len(points))] + points[1 : id2 + 1] - split_polylines.append(Polyline(looped_pts)) + split_polylines.append(type(self)(looped_pts)) if self.is_closed and not corner_ids: - return [Polyline(self.points)] + return [type(self)(self.points)] return split_polylines - def divide_at_corners(self, angle_threshold): - """Divides a polyline at corners larger than the given angle_threshold + def divide_at_corners(self, angle_threshold: float) -> list[Point]: + """Return the points at corners larger than a threshold. Parameters ---------- - angle_threshold : float + angle_threshold In radians. Returns ------- - list[:class:`compas.geometry.Point`] + list[Point] + The corner points. """ corner_ids = [] @@ -431,17 +496,23 @@ def divide_at_corners(self, angle_threshold): corner_ids.append(seg1 + 1) return [self.points[i] for i in corner_ids] - def divide(self, num_segments): + def divide(self, num_segments: int) -> list[Point]: """Divide a polyline in equal segments. Parameters ---------- - num_segments : int + num_segments + The number of equal-length segments. Returns ------- - list - list[:class:`compas.geometry.Point`] + list[Point] + The division points. + + Raises + ------ + ValueError + If `num_segments` is less than one. Examples -------- @@ -450,25 +521,33 @@ def divide(self, num_segments): 4 """ + if num_segments < 1: + raise ValueError("Number of segments must be greater than or equal to 1.") segment_length = self.length / num_segments return self.divide_by_length(segment_length, False) - def divide_by_length(self, length, strict=True, tol=None): + def divide_by_length(self, length: float, strict: bool = True, tol: Optional[float] = None) -> list[Point]: """Divide a polyline in segments of a given length. Parameters ---------- - length : float + length Length of the segments. - strict : bool, optional - If False, the remainder segment will be added even if it is smaller than the desired length - tol : float, optional + strict + If `False`, include a remainder segment shorter than `length`. + tol Floating point error tolerance. Defaults to `TOL.absolute`. Returns ------- - list[:class:`compas.geometry.Point`] + list[Point] + The division points. + + Raises + ------ + ValueError + If `length` is not positive or is greater than the polyline length. Notes ----- @@ -486,26 +565,31 @@ def divide_by_length(self, length, strict=True, tol=None): 4 """ - tol = tol or TOL.absolute + if length <= 0.0: + raise ValueError("Length must be greater than zero.") + if length > self.length: + raise ValueError("Polyline length {0} is smaller than input length {1}.".format(self.length, length)) + + tol = TOL.absolute if tol is None else tol num_pts = int(self.length / length) - total_length = [0, 0] + total_length: list[float] = [0.0, 0.0] division_pts = [self.points[0]] new_polyline = self for i in range(num_pts): for i_ln, line in enumerate(new_polyline.lines): - total_length.append(total_length[-1] + line.length) # type: ignore + total_length.append(total_length[-1] + line.length) if total_length[-1] > length: amp = (length - total_length[-2]) / line.length new_pt = line.start + line.vector.scaled(amp) division_pts.append(new_pt) - total_length = [0, 0] + total_length = [0.0, 0.0] remaining_pts = new_polyline.points[i_ln + 2 :] - new_polyline = Polyline([new_pt, line.end] + remaining_pts) + new_polyline = type(self)([new_pt, line.end] + remaining_pts) break elif total_length[-1] == length: - total_length = [0, 0] + total_length = [0.0, 0.0] division_pts.append(line.end) if len(division_pts) == num_pts + 1: @@ -518,21 +602,20 @@ def divide_by_length(self, length, strict=True, tol=None): return division_pts - def split_by_length(self, length, strict=True): + def split_by_length(self, length: float, strict: bool = True) -> list[Self]: """Split a polyline in segments of a given length. Parameters ---------- - length : float + length Length of the segments. - strict : bool, optional - If False, the remainder segment will be added even if it is smaller than the desired length - tol : float, optional - Floating point error tolerance. + strict + If `False`, include a remainder segment shorter than `length`. Returns ------- - list[:class:`compas.geometry.Polyline`] + list[Self] + The split polylines. Examples -------- @@ -553,7 +636,7 @@ def split_by_length(self, length, strict=True): raise ValueError("Polyline length {0} is smaller than input length {1}.".format(self.length, length)) divided_polylines = [] polyline_copy = self.copy() - segment = Polyline([self[0]]) # Start a new segment + segment = type(self)([self[0]]) # Start a new segment i, current_length = 0, 0 polyline_points_num = len(polyline_copy) while i < polyline_points_num - 1: @@ -569,7 +652,7 @@ def split_by_length(self, length, strict=True): polyline_copy.points.insert(i + 1, new_pt) segment.points.append(new_pt) divided_polylines.append(segment) - segment = Polyline([new_pt]) # Start a new segment + segment = type(self)([new_pt]) # Start a new segment current_length = 0 i += 1 polyline_points_num = len(polyline_copy) @@ -577,17 +660,18 @@ def split_by_length(self, length, strict=True): divided_polylines.append(segment) # Add the last segment return divided_polylines - def split(self, num_segments): + def split(self, num_segments: int) -> list[Self]: """Split a polyline in equal segments. Parameters ---------- - num_segments : int + num_segments + The number of equal-length segments. Returns ------- - list - list[:class:`compas.geometry.Polyline`] + list[Self] + The split polylines. Examples -------- @@ -606,65 +690,60 @@ def split(self, num_segments): segment_length = total_length / num_segments return self.split_by_length(segment_length, False) - def extend(self, length): - """Extends a polyline by a given length, by modifying the first and/or last point tangentially. + def extend(self, length: Union[float, Sequence[float]]) -> None: + """Extend the polyline tangentially at one or both ends. Parameters ---------- - length: float or tuple[float, float] + length A single length value to extend the polyline only at the end, or two length values to extend at both ends. - Returns - ------- - None - """ - try: + if isinstance(length, Sequence): start, end = length self.points[0] = self.points[0] + self.lines[0].vector.unitized().scaled(-start) - self._lines = None - except TypeError: - start = end = length + else: + end = length self.points[-1] = self.points[-1] + self.lines[-1].vector.unitized().scaled(end) - self._lines = None - def extended(self, length): - """Returns a copy of this polyline extended by a given length. + def extended(self, length: Union[float, Sequence[float]]) -> Self: + """Return an extended copy of the polyline. Parameters ---------- - length: float or tuple[float, float] + length A single length value to extend the polyline only at the end, or two length values to extend at both ends. Returns ------- - :class:`compas.geometry.Polyline` + Self + The extended copy. """ - crv = self.copy() - crv.extend(length) - return crv + polyline = self.copy() + polyline.extend(length) + return polyline - def shorten(self, length): - """Shortens a polyline by a given length. + def shorten(self, length: Union[float, Sequence[float]]) -> None: + """Shorten the polyline at one or both ends. Parameters ---------- - length: float or tuple[float, float] + length A single length value to shorten the polyline only at the end, or two length values to shorten at both ends. - Returns - ------- - None - """ - try: + # Both ends are shortened against the original segmentation even though + # the point list is mutated during the operation. + lines = self.lines + + if isinstance(length, Sequence): start, end = length total_length = 0 - for line in self.lines: + for line in lines: total_length += line.length if total_length < start: del self.points[0] @@ -674,12 +753,12 @@ def shorten(self, length): else: self.points[0] = line.end + line.vector.unitized().scaled(-(total_length - start)) break - except TypeError: - start = end = length + else: + end = length total_length = 0 - for i in range(len(self.lines)): - line = self.lines[-(i + 1)] + for i in range(len(lines)): + line = lines[-(i + 1)] total_length += line.length if total_length < end: del self.points[-1] @@ -689,22 +768,22 @@ def shorten(self, length): else: self.points[-1] = line.start + line.vector.unitized().scaled(total_length - end) break - self._lines = None - def shortened(self, length): - """Returns a copy of this polyline shortened by a given length. + def shortened(self, length: Union[float, Sequence[float]]) -> Self: + """Return a shortened copy of the polyline. Parameters ---------- - length: float or tuple[float, float] + length A single length value to shorten the polyline only at the end, or two length values to shorten at both ends. Returns ------- - :class:`compas.geometry.Polyline` + Self + The shortened copy. """ - crv = self.copy() - crv.shorten(length) - return crv + polyline = self.copy() + polyline.shorten(length) + return polyline diff --git a/src/compas/geometry/projection.py b/src/compas/geometry/projection.py index d6a095b6dddf..256f7160d8da 100644 --- a/src/compas/geometry/projection.py +++ b/src/compas/geometry/projection.py @@ -9,15 +9,16 @@ Many thanks to Christoph Gohlke, Martin John Baker, Sachin Joglekar and Andrew Ippoliti for providing code and documentation. + """ from compas.geometry import Transformation -from compas.geometry import decompose_matrix -from compas.geometry import matrix_from_orthogonal_projection -from compas.geometry import matrix_from_parallel_projection -from compas.geometry import matrix_from_perspective_entries -from compas.geometry import matrix_from_perspective_projection from compas.itertools import flatten +from compas.linalg.transformations import decompose_matrix +from compas.linalg.transformations import matrix_from_orthogonal_projection +from compas.linalg.transformations import matrix_from_parallel_projection +from compas.linalg.transformations import matrix_from_perspective_entries +from compas.linalg.transformations import matrix_from_perspective_projection from compas.tolerance import TOL diff --git a/src/compas/geometry/quadmesh_planarize.py b/src/compas/geometry/quadmesh_planarize.py index 5d2084762bc7..1224f75aa57f 100644 --- a/src/compas/geometry/quadmesh_planarize.py +++ b/src/compas/geometry/quadmesh_planarize.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas.plugins import pluggable diff --git a/src/compas/geometry/quadmesh_planarize_none.py b/src/compas/geometry/quadmesh_planarize_none.py index 92487087aa3b..9c6c50cb48ca 100644 --- a/src/compas/geometry/quadmesh_planarize_none.py +++ b/src/compas/geometry/quadmesh_planarize_none.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas.geometry import bestfit_plane from compas.geometry import centroid_points from compas.geometry import distance_line_line diff --git a/src/compas/geometry/quaternion.py b/src/compas/geometry/quaternion.py index 19d5d452003c..0576018d256c 100644 --- a/src/compas/geometry/quaternion.py +++ b/src/compas/geometry/quaternion.py @@ -1,19 +1,16 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import math from sys import float_info +from typing import Iterator from compas.geometry import Geometry from compas.geometry import Rotation -from compas.geometry import quaternion_canonize -from compas.geometry import quaternion_conjugate -from compas.geometry import quaternion_from_matrix -from compas.geometry import quaternion_is_unit -from compas.geometry import quaternion_multiply -from compas.geometry import quaternion_norm -from compas.geometry import quaternion_unitize +from compas.linalg.quaternions import quaternion_canonize +from compas.linalg.quaternions import quaternion_conjugate +from compas.linalg.quaternions import quaternion_is_unit +from compas.linalg.quaternions import quaternion_multiply +from compas.linalg.quaternions import quaternion_norm +from compas.linalg.quaternions import quaternion_unitize +from compas.linalg.transformations import quaternion_from_matrix from compas.tolerance import TOL @@ -119,16 +116,6 @@ class Quaternion(Geometry): """ - DATASCHEMA = { - "type": "object", - "properties": { - "w": {"type": "number"}, - "x": {"type": "number"}, - "y": {"type": "number"}, - "z": {"type": "number"}, - }, - "required": ["w", "x", "y", "z"], - } @property def __data__(self): @@ -136,10 +123,10 @@ def __data__(self): def __init__(self, w, x, y, z, name=None): super(Quaternion, self).__init__(name=name) - self._w = None - self._x = None - self._y = None - self._z = None + self._w = 0.0 + self._x = 0.0 + self._y = 0.0 + self._z = 0.0 self.w = w self.x = x self.y = y @@ -162,7 +149,7 @@ def __eq__(self, other, tol=None): return False return TOL.is_allclose(self, other, rtol=0, atol=tol) - def __getitem__(self, key): + def __getitem__(self, key: int) -> float: if key == 0: return self.w if key == 1: @@ -188,10 +175,10 @@ def __setitem__(self, key, value): return raise KeyError(key) - def __iter__(self): + def __iter__(self) -> Iterator[float]: return iter(self.wxyz) - def __len__(self): + def __len__(self) -> int: return 4 # ========================================================================== @@ -199,39 +186,39 @@ def __len__(self): # ========================================================================== @property - def w(self): + def w(self) -> float: return self._w @w.setter - def w(self, w): + def w(self, w: float) -> None: self._w = float(w) @property - def x(self): + def x(self) -> float: return self._x @x.setter - def x(self, x): + def x(self, x: float) -> None: self._x = float(x) @property - def y(self): + def y(self) -> float: return self._y @y.setter - def y(self, y): + def y(self, y: float) -> None: self._y = float(y) @property - def z(self): + def z(self) -> float: return self._z @z.setter - def z(self, z): + def z(self, z: float) -> None: self._z = float(z) @property - def wxyz(self): + def wxyz(self) -> list[float]: return [self.w, self.x, self.y, self.z] @property @@ -328,7 +315,7 @@ def from_matrix(cls, M): Examples -------- - >>> from compas.geometry import matrix_from_euler_angles + >>> from compas.linalg import matrix_from_euler_angles >>> ea = [0.2, 0.6, 0.2] >>> M = matrix_from_euler_angles(ea) >>> Quaternion.from_matrix(M) diff --git a/src/compas/geometry/reflection.py b/src/compas/geometry/reflection.py index 314ed8470bdc..ad604375d3f6 100644 --- a/src/compas/geometry/reflection.py +++ b/src/compas/geometry/reflection.py @@ -9,16 +9,17 @@ Many thanks to Christoph Gohlke, Martin John Baker, Sachin Joglekar and Andrew Ippoliti for providing code and documentation. + """ from compas.geometry import Transformation -from compas.geometry import cross_vectors -from compas.geometry import decompose_matrix -from compas.geometry import dot_vectors -from compas.geometry import identity_matrix -from compas.geometry import matrix_from_perspective_entries -from compas.geometry import normalize_vector from compas.itertools import flatten +from compas.linalg.transformations import decompose_matrix +from compas.linalg.transformations import identity_matrix +from compas.linalg.transformations import matrix_from_perspective_entries +from compas.linalg.vectors import cross_vectors +from compas.linalg.vectors import dot_vectors +from compas.linalg.vectors import normalize_vector from compas.tolerance import TOL diff --git a/src/compas/geometry/rotation.py b/src/compas/geometry/rotation.py index f7505fba9ade..f1b43a74de24 100644 --- a/src/compas/geometry/rotation.py +++ b/src/compas/geometry/rotation.py @@ -9,21 +9,22 @@ Many thanks to Christoph Gohlke, Martin John Baker, Sachin Joglekar and Andrew Ippoliti for providing code and documentation. + """ from compas.geometry import Transformation -from compas.geometry import axis_and_angle_from_matrix -from compas.geometry import basis_vectors_from_matrix -from compas.geometry import cross_vectors -from compas.geometry import decompose_matrix -from compas.geometry import euler_angles_from_matrix -from compas.geometry import length_vector -from compas.geometry import matrix_from_axis_and_angle -from compas.geometry import matrix_from_euler_angles -from compas.geometry import matrix_from_frame -from compas.geometry import matrix_from_quaternion -from compas.geometry import normalize_vector from compas.itertools import flatten +from compas.linalg.transformations import axis_and_angle_from_matrix +from compas.linalg.transformations import basis_vectors_from_matrix +from compas.linalg.transformations import decompose_matrix +from compas.linalg.transformations import euler_angles_from_matrix +from compas.linalg.transformations import matrix_from_axis_and_angle +from compas.linalg.transformations import matrix_from_euler_angles +from compas.linalg.transformations import matrix_from_frame +from compas.linalg.transformations import matrix_from_quaternion +from compas.linalg.vectors import cross_vectors +from compas.linalg.vectors import length_vector +from compas.linalg.vectors import normalize_vector from compas.tolerance import TOL diff --git a/src/compas/geometry/scale.py b/src/compas/geometry/scale.py index ad5c66c38c8c..c7585e553ae8 100644 --- a/src/compas/geometry/scale.py +++ b/src/compas/geometry/scale.py @@ -9,15 +9,16 @@ Many thanks to Christoph Gohlke, Martin John Baker, Sachin Joglekar and Andrew Ippoliti for providing code and documentation. + """ from compas.geometry import Transformation -from compas.geometry import decompose_matrix -from compas.geometry import matrix_from_frame -from compas.geometry import matrix_from_scale_factors -from compas.geometry import matrix_inverse -from compas.geometry import multiply_matrices from compas.itertools import flatten +from compas.linalg.matrices import matrix_inverse +from compas.linalg.matrices import multiply_matrices +from compas.linalg.transformations import decompose_matrix +from compas.linalg.transformations import matrix_from_frame +from compas.linalg.transformations import matrix_from_scale_factors from compas.tolerance import TOL diff --git a/src/compas/geometry/shapes/box.py b/src/compas/geometry/shapes/box.py index f1f354b5253b..9e3855b72ee9 100644 --- a/src/compas/geometry/shapes/box.py +++ b/src/compas/geometry/shapes/box.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas.geometry import Frame from compas.geometry import Line from compas.geometry import Point # noqa: F401 @@ -106,17 +102,6 @@ class Box(Shape): """ - DATASCHEMA = { - "type": "object", - "properties": { - "xsize": {"type": "number", "minimum": 0}, - "ysize": {"type": "number", "minimum": 0}, - "zsize": {"type": "number", "minimum": 0}, - "frame": Frame.DATASCHEMA, - }, - "additionalProperties": False, - "minProperties": 4, - } @property def __data__(self): diff --git a/src/compas/geometry/shapes/capsule.py b/src/compas/geometry/shapes/capsule.py index 8ba87f97cd4c..5c04e0cf543b 100644 --- a/src/compas/geometry/shapes/capsule.py +++ b/src/compas/geometry/shapes/capsule.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import cos from math import pi from math import sin @@ -75,15 +71,6 @@ class Capsule(Shape): """ - DATASCHEMA = { - "type": "object", - "properties": { - "radius": {"type": "number", "minimum": 0}, - "height": {"type": "number", "minimum": 0}, - "frame": Frame.DATASCHEMA, - }, - "required": ["radius", "height", "frame"], - } @property def __data__(self): diff --git a/src/compas/geometry/shapes/cone.py b/src/compas/geometry/shapes/cone.py index 48fd50d1a45e..9c3937a4e375 100644 --- a/src/compas/geometry/shapes/cone.py +++ b/src/compas/geometry/shapes/cone.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import cos from math import pi from math import sin @@ -77,15 +73,6 @@ class Cone(Shape): """ - DATASCHEMA = { - "type": "object", - "properties": { - "radius": {"type": "number", "minimum": 0}, - "height": {"type": "number", "minimum": 0}, - "frame": Frame.DATASCHEMA, - }, - "required": ["radius", "height", "frame"], - } @property def __data__(self): diff --git a/src/compas/geometry/shapes/cylinder.py b/src/compas/geometry/shapes/cylinder.py index 29857a3743e2..3616e4285b54 100644 --- a/src/compas/geometry/shapes/cylinder.py +++ b/src/compas/geometry/shapes/cylinder.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import cos from math import pi from math import sin @@ -74,15 +70,6 @@ class Cylinder(Shape): """ - DATASCHEMA = { - "type": "object", - "properties": { - "radius": {"type": "number", "minimum": 0}, - "height": {"type": "number", "minimum": 0}, - "frame": Frame.DATASCHEMA, - }, - "required": ["radius", "height", "frame"], - } @property def __data__(self): diff --git a/src/compas/geometry/shapes/shape.py b/src/compas/geometry/shapes/shape.py index 693d96df0ea4..9e5e98e65761 100644 --- a/src/compas/geometry/shapes/shape.py +++ b/src/compas/geometry/shapes/shape.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import compas # noqa: F401 from compas.geometry import Frame from compas.geometry import Geometry @@ -14,6 +10,7 @@ if not compas.IPY: from typing import TYPE_CHECKING + from typing import Union # noqa: F401 if TYPE_CHECKING: import compas.datastructures # noqa: F401 @@ -55,7 +52,7 @@ class Shape(Geometry): """ - def __init__(self, frame=None, name=None): # type: (Frame | None, str | None) -> None + def __init__(self, frame=None, name=None): # type: (Union[Frame, None], Union[str, None]) -> None super(Shape, self).__init__(name=name) self._frame = None self._transformation = None @@ -82,7 +79,7 @@ def frame(self): # type: () -> Frame return self._frame @frame.setter - def frame(self, frame): # type: (Frame | None) -> None + def frame(self, frame): # type: (Union[Frame, None]) -> None if not frame: self._frame = None else: @@ -225,7 +222,7 @@ def compute_triangles(self): # type: () -> list[tuple[int, int, int]] # ============================================================================= def to_vertices_and_faces(self, triangulated=False, u=None, v=None): - # type: (bool, int | None, int | None) -> tuple[list[list[float]], list[list[int]] | list[tuple[int, int, int]]] + # type: (bool, Union[int, None], Union[int, None]) -> tuple[list[list[float]], Union[list[list[int]], list[tuple[int, int, int]]]] """Convert the shape to a list of vertices and faces. Parameters @@ -259,7 +256,7 @@ def to_vertices_and_faces(self, triangulated=False, u=None, v=None): return vertices, faces def to_polyhedron(self, triangulated=False, u=None, v=None): - # type: (bool, int | None, int | None) -> compas.geometry.Polyhedron + # type: (bool, Union[int, None], Union[int, None]) -> compas.geometry.Polyhedron """Convert the shape to a polyhedron. Parameters @@ -294,7 +291,7 @@ def to_polyhedron(self, triangulated=False, u=None, v=None): return Polyhedron(vertices, faces) def to_mesh(self, triangulated=False, u=None, v=None): - # type: (bool, int | None, int | None) -> compas.datastructures.Mesh + # type: (bool, Union[int, None], Union[int, None]) -> compas.datastructures.Mesh """Returns a mesh representation of the box. Parameters diff --git a/src/compas/geometry/shapes/sphere.py b/src/compas/geometry/shapes/sphere.py index 19007ba954bd..6cae6e1c81bf 100644 --- a/src/compas/geometry/shapes/sphere.py +++ b/src/compas/geometry/shapes/sphere.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import cos from math import pi from math import sin @@ -66,14 +62,6 @@ class Sphere(Shape): """ - DATASCHEMA = { - "type": "object", - "properties": { - "radius": {"type": "number", "minimum": 0}, - "frame": Frame.DATASCHEMA, - }, - "required": ["radius", "frame"], - } @property def __data__(self): diff --git a/src/compas/geometry/shapes/torus.py b/src/compas/geometry/shapes/torus.py index af8388713d4e..f1464cbf82c8 100644 --- a/src/compas/geometry/shapes/torus.py +++ b/src/compas/geometry/shapes/torus.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import cos from math import pi from math import sin @@ -64,15 +60,6 @@ class Torus(Shape): """ - DATASCHEMA = { - "type": "object", - "properties": { - "radius_axis": {"type": "number", "minimum": 0}, - "radius_pipe": {"type": "number", "minimum": 0}, - "frame": Frame.DATASCHEMA, - }, - "required": ["radius_axis", "radius_pipe", "frame"], - } @property def __data__(self): diff --git a/src/compas/geometry/shear.py b/src/compas/geometry/shear.py index 7de59bdc469e..0c0ecfdaba80 100644 --- a/src/compas/geometry/shear.py +++ b/src/compas/geometry/shear.py @@ -9,13 +9,14 @@ Many thanks to Christoph Gohlke, Martin John Baker, Sachin Joglekar and Andrew Ippoliti for providing code and documentation. + """ from compas.geometry import Transformation -from compas.geometry import decompose_matrix -from compas.geometry import matrix_from_shear -from compas.geometry import matrix_from_shear_entries 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 @@ -80,7 +81,7 @@ def from_angle_direction_plane(cls, angle, direction, plane): Examples -------- - >>> from compas.geometry import cross_vectors + >>> from compas.linalg import cross_vectors >>> angle = 0.1 >>> direction = [0.1, 0.2, 0.3] >>> point = [4, 3, 1] diff --git a/src/compas/geometry/surfaces/conical.py b/src/compas/geometry/surfaces/conical.py index 0bcda369aad7..0ea67a718d23 100644 --- a/src/compas/geometry/surfaces/conical.py +++ b/src/compas/geometry/surfaces/conical.py @@ -1,13 +1,19 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import cos from math import pi from math import sin +from math import sqrt +from typing import Any +from typing import Optional + +from typing_extensions 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 .surface import Surface @@ -15,31 +21,39 @@ class ConicalSurface(Surface): - """A cylindrical surface is defined by a radius and a local coordinate system. + """A conical surface defined by a base radius, height, and frame. Parameters ---------- - radius : float - The radius of the cone. - frame : :class:`Frame` - The local coordinate system of the cone. - name : str, optional + radius + The radius at the base of the cone. + height + The distance from the base to the apex. + frame + The local coordinate frame at the base. If `None`, the world XY frame + is used. + name The name of the surface. - """ + Examples + -------- + >>> cone = ConicalSurface(radius=2.0, height=3.0) + >>> cone.point_at(0.0, 0.0) + Point(x=2.000, y=0.000, z=0.000) + >>> cone.point_at(0.0, 1.0) + Point(x=0.000, y=0.000, z=3.000) + + A conical surface can also be constructed from a plane. - DATASCHEMA = { - "type": "object", - "properties": { - "radius": {"type": "number", "minimum": 0}, - "height": {"type": "number", "minimum": 0}, - "frame": Frame.DATASCHEMA, - }, - "required": ["radius", "height", "frame"], - } + >>> cone = ConicalSurface.from_plane_and_radius_height(Plane.worldXY(), 2.0, 3.0) + >>> cone.radius, cone.height + (2.0, 3.0) + + """ @property - def __data__(self): + def __data__(self) -> dict[str, Any]: + """The data representation of the conical surface.""" return { "radius": self.radius, "height": self.height, @@ -47,21 +61,27 @@ def __data__(self): } @classmethod - def __from_data__(cls, data): + def __from_data__(cls, data: dict[str, Any]) -> Self: return cls( radius=data["radius"], height=data["height"], frame=Frame.__from_data__(data["frame"]), ) - def __init__(self, radius, height, frame=None, name=None): - super(ConicalSurface, self).__init__(frame=frame, name=name) - self._radius = None - self._height = None + def __init__( + self, + radius: float, + height: float, + frame: Optional[Frame] = None, + name: Optional[str] = None, + ) -> None: + super().__init__(frame=frame, name=name) + self._radius: Optional[float] = None + self._height: Optional[float] = None self.radius = radius self.height = height - def __repr__(self): + def __repr__(self) -> str: return "{0}(radius={1}, height={2}, frame={3!r})".format( type(self).__name__, self.radius, @@ -69,55 +89,98 @@ def __repr__(self): self.frame, ) - def __eq__(self, other): - try: - other_frame = other.frame - other_radius = other.radius - other_height = other.height - except Exception: + def __eq__(self, other: object) -> bool: + if not isinstance(other, ConicalSurface): return False - return self.radius == other_radius and self.height == other_height and self.frame == other_frame + return self.radius == other.radius and self.height == other.height and self.frame == other.frame # ============================================================================= # Properties # ============================================================================= @property - def center(self): + def center(self) -> Point: + """The center point of the cone base. + + Notes + ----- + Assigning a point or three coordinates updates the surface frame and + creates an independent point. + + """ return self.frame.point @center.setter - def center(self, point): + def center(self, point: CoordinateType) -> None: self.frame.point = point @property - def radius(self): + def radius(self) -> float: + """The positive radius at the cone base.""" if self._radius is None: raise ValueError("The radius of the surface has not been set yet.") return self._radius @radius.setter - def radius(self, radius): - if radius < 0: - raise ValueError("The radius of a sphere should be larger than or equal to zero.") + def radius(self, radius: float) -> None: + if radius <= 0: + raise ValueError("The radius of a cone should be larger than zero.") self._radius = float(radius) @property - def height(self): + def height(self) -> float: + """The positive distance from the base to the apex.""" if self._height is None: raise ValueError("The height of the surface has not been set yet.") return self._height @height.setter - def height(self, height): - if height < 0: - raise ValueError("The height of the surface should be larger than or equal to zero.") + def height(self, height: float) -> None: + if height <= 0: + raise ValueError("The height of a cone should be larger than zero.") self._height = float(height) + @property + def area(self) -> float: + """The lateral area of the cone, excluding its circular base.""" + slant_height = sqrt(self.radius**2 + self.height**2) + return pi * self.radius * slant_height + + @property + def volume(self) -> float: + """The volume of the corresponding cone with a circular base.""" + return pi * self.radius**2 * self.height / 3.0 + + @property + def is_periodic_u(self) -> bool: + """Whether the surface is periodic in U, which is always `True`.""" + return True + # ============================================================================= # Constructors # ============================================================================= + @classmethod + def from_plane_and_radius_height(cls, plane: Plane, radius: float, height: float) -> Self: + """Construct a conical surface from a plane, radius, and height. + + Parameters + ---------- + plane + The plane of the cone base. + radius + The radius at the base. + height + The distance from the base to the apex. + + Returns + ------- + ConicalSurface + The constructed conical surface. + + """ + return cls(radius=radius, height=height, frame=Frame.from_plane(plane)) + # ============================================================================= # Conversions # ============================================================================= @@ -130,21 +193,63 @@ def height(self, height): # Methods # ============================================================================= - def point_at(self, u, v, world=True): + def isocurve_u(self, u: float) -> Line: + """Compute a generator line at a U parameter. + + Parameters + ---------- + u + The U parameter. + + Returns + ------- + Line + The line from the cone base to its apex. + + """ + return Line(self.point_at(u, self.domain_v[0]), self.point_at(u, self.domain_v[1])) + + def isocurve_v(self, v: float) -> Circle: + """Compute the circular isocurve at a V parameter. + + Parameters + ---------- + v + The V parameter. It should be smaller than `1.0`; the isocurve at + the apex degenerates to a point. + + Returns + ------- + Circle + The circular isocurve. + + Raises + ------ + ValueError + If `v` is `1.0` because the apex has no nondegenerate isocurve. + + """ + radius = (1.0 - v) * self.radius + if radius <= 0.0: + raise ValueError("The isocurve at or beyond the cone apex is degenerate.") + origin = self.center + self.frame.zaxis * (v * self.height) + return Circle(radius=radius, frame=Frame(origin, self.frame.xaxis, self.frame.yaxis)) + + def point_at(self, u: float, v: float, world: bool = True) -> Point: """Compute a point on the surface at the given parameters. Parameters ---------- - u : float - The first parameter. - v : float - The second parameter. - world : bool, optional - If ``True``, return the point in world coordinates. + u + The U parameter, mapped to an angle in `[0, 2 * pi]`. + v + The V parameter from the base at `0.0` to the apex at `1.0`. + world + If `True`, return the point in world coordinates. Returns ------- - :class:`compas.geometry.Point` + Point The point at the given parameters. """ @@ -157,3 +262,56 @@ def point_at(self, u, v, world=True): if world: point.transform(self.transformation) return point + + def normal_at(self, u: float, v: float, world: bool = True) -> Vector: + """Compute the outward normal at a point on the conical surface. + + Parameters + ---------- + u + The U parameter. + v + The V parameter. The limiting normal at the apex depends on U. + world + If `True`, return the normal in world coordinates. + + Returns + ------- + Vector + The outward unit normal. + + """ + angle = u * PI2 + normal = Vector(self.height * cos(angle), self.height * sin(angle), self.radius) + normal.unitize() + if world: + normal.transform(self.transformation) + return normal + + def frame_at(self, u: float, v: float, world: bool = True) -> Frame: + """Compute a frame at a point on the conical surface. + + Parameters + ---------- + u + The U parameter. + v + The V parameter. + world + If `True`, return the frame in world coordinates. + + Returns + ------- + Frame + The frame at the given parameters. Its X-axis follows increasing U, + its Y-axis points towards the apex, and its Z-axis is outward. + + """ + angle = u * PI2 + point = self.point_at(u, v, world=False) + tangent_u = Vector(-sin(angle), cos(angle), 0.0) + tangent_v = Vector(-self.radius * cos(angle), -self.radius * sin(angle), self.height) + frame = Frame(point, tangent_u, tangent_v) + if world: + frame.transform(self.transformation) + return frame diff --git a/src/compas/geometry/surfaces/cylindrical.py b/src/compas/geometry/surfaces/cylindrical.py index 0be9f8aac4b5..482e277aa585 100644 --- a/src/compas/geometry/surfaces/cylindrical.py +++ b/src/compas/geometry/surfaces/cylindrical.py @@ -1,14 +1,16 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import cos from math import pi from math import sin +from typing import Any +from typing import Optional + +from typing_extensions 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 @@ -22,129 +24,162 @@ class CylindricalSurface(Surface): Parameters ---------- - radius : float + radius The radius of the cylinder. - frame : :class:`Frame` - The local coordinate system of the cylinder. - name : str, optional + frame + The local coordinate frame. If `None`, the world XY frame is used. + name The name of the surface. - """ + Examples + -------- + >>> cylinder = CylindricalSurface(2.0) + >>> cylinder.point_at(0.0, 0.5) + Point(x=2.000, y=0.000, z=0.500) + + Cylindrical surfaces can also be constructed from a plane or three points. + + >>> cylinder = CylindricalSurface.from_plane_and_radius(Plane.worldXY(), 2.0) + >>> cylinder.radius + 2.0 - DATASCHEMA = { - "type": "object", - "properties": { - "radius": {"type": "number", "minimum": 0}, - "frame": Frame.DATASCHEMA, - }, - "required": ["radius", "frame"], - } + """ @property - def __data__(self): + def __data__(self) -> dict[str, Any]: + """The data representation of the cylindrical surface.""" return { "radius": self.radius, "frame": self.frame.__data__, } @classmethod - def __from_data__(cls, data): + def __from_data__(cls, data: dict[str, Any]) -> Self: return cls( radius=data["radius"], frame=Frame.__from_data__(data["frame"]), ) - def __init__(self, radius, frame=None, name=None): - super(CylindricalSurface, self).__init__(frame=frame, name=name) - self._radius = None + def __init__(self, radius: float, frame: Optional[Frame] = None, name: Optional[str] = None) -> None: + super().__init__(frame=frame, name=name) + self._radius: Optional[float] = None self.radius = radius - def __repr__(self): + def __repr__(self) -> str: return "{0}(radius={1}, frame={2!r})".format( type(self).__name__, self.radius, self.frame, ) - def __eq__(self, other): - try: - other_frame = other.frame - other_radius = other.radius - except Exception: + def __eq__(self, other: object) -> bool: + if not isinstance(other, CylindricalSurface): return False - return self.radius == other_radius and self.frame == other_frame + return self.radius == other.radius and self.frame == other.frame # ============================================================================= # Properties # ============================================================================= @property - def center(self): + def center(self) -> Point: + """The point on the cylinder axis at V parameter zero. + + Notes + ----- + Assigning a point or three coordinates updates the surface frame and + creates an independent point. + + """ return self.frame.point @center.setter - def center(self, point): + def center(self, point: CoordinateType) -> None: self.frame.point = point @property - def radius(self): + def radius(self) -> float: + """The positive radius of the cylinder.""" if self._radius is None: raise ValueError("The radius of the surface has not been set yet.") return self._radius @radius.setter - def radius(self, radius): - if radius < 0: - raise ValueError("The radius of a sphere should be larger than or equal to zero.") + def radius(self, radius: float) -> None: + if radius <= 0: + raise ValueError("The radius of a cylinder should be larger than zero.") self._radius = float(radius) @property - def area(self): - raise NotImplementedError + def area(self) -> float: + """The lateral area over the surface V domain. + + Notes + ----- + This excludes the areas of the two circular caps. + + """ + vmin, vmax = self.domain_v + height = abs(vmax - vmin) + return 2.0 * pi * self.radius * height + + @property + def volume(self) -> float: + """The volume of the capped cylinder over the surface V domain. + + Although the surface itself has no caps, this property reports the + volume of the corresponding solid cylinder bounded at the ends of the + V domain. + + """ + vmin, vmax = self.domain_v + height = abs(vmax - vmin) + return pi * self.radius**2 * height @property - def volume(self): - raise NotImplementedError + def is_periodic_u(self) -> bool: + """Whether the surface is periodic in U, which is always `True`.""" + return True # ============================================================================= # Constructors # ============================================================================= @classmethod - def from_plane_and_radius(cls, plane, radius): + def from_plane_and_radius(cls, plane: Plane, radius: float) -> Self: """Construct a cylindrical surface from a plane and a radius. Parameters ---------- - plane : :class:`compas.geometry.Plane` + plane The plane of the surface. - radius : float + radius The radius of the surface. Returns ------- - :class:`compas.geometry.CylindricalSurface` + CylindricalSurface A cylindrical surface. """ return cls(radius, frame=Frame.from_plane(plane)) @classmethod - def from_three_points(cls, a, b, c): + def from_three_points(cls, a: CoordinateType, b: CoordinateType, c: CoordinateType) -> Self: """Construct a cylindrical from three points. Parameters ---------- - a : :class:`compas.geometry.Point` + a The first point. - b : :class:`compas.geometry.Point` + b The second point. - c : :class:`compas.geometry.Point` + c The third point. Returns ------- - :class:`compas.geometry.CylindricalSurface` + CylindricalSurface A cylindrical surface. """ @@ -163,31 +198,34 @@ def from_three_points(cls, a, b, c): # Methods # ============================================================================= - def isocurve_u(self, u): - """Compute the isoparametric curve at parameter u. + def isocurve_u(self, u: float) -> Line: + """Compute the generator line at a U parameter. Parameters ---------- - u : float + u + The U parameter. Returns ------- - :class:`compas.geometry.Line` + Line + The generator over the surface V domain. """ - base = self.point_at(u=u, v=0.5) - return Line.from_point_direction_length(base, self.frame.zaxis, 1.0) + return Line(self.point_at(u, self.domain_v[0]), self.point_at(u, self.domain_v[1])) - def isocurve_v(self, v): - """Compute the isoparametric curve at parameter v. + def isocurve_v(self, v: float) -> Circle: + """Compute the circular isocurve at a V parameter. Parameters ---------- - v : float + v + The V parameter. Returns ------- - :class:`compas.geometry.Circle` + Circle + The circular isocurve. """ point = self.center + self.frame.zaxis * v @@ -196,21 +234,21 @@ def isocurve_v(self, v): frame = Frame(point, xaxis, yaxis) return Circle(radius=self.radius, frame=frame) - def point_at(self, u, v, world=True): + def point_at(self, u: float, v: float, world: bool = True) -> Point: """Compute a point on the surface at the given parameters. Parameters ---------- - u : float - The first parameter. - v : float - The second parameter. - world : bool, optional - If ``True``, the point is transformed to world coordinates. + u + The U parameter, mapped to an angle in `[0, 2 * pi]`. + v + The V parameter along the cylinder axis. + world + If `True`, return the point in world coordinates. Returns ------- - :class:`compas.geometry.Point` + Point The point at the given parameters. """ @@ -223,54 +261,52 @@ def point_at(self, u, v, world=True): point.transform(self.transformation) return point - def normal_at(self, u, world=True): + def normal_at(self, u: float, v: float, world: bool = True) -> Vector: """Compute the normal at a point on the surface at the given parameters. Parameters ---------- - u : float - The first parameter. - world : bool, optional - If ``True``, the normal is transformed to world coordinates. + u + The U parameter. + v + The V parameter. The normal is independent of this parameter. + world + If `True`, return the normal in world coordinates. Returns ------- - :class:`compas.geometry.Vector` - The normal at the given parameters. + Vector + The outward unit normal at the given parameters. """ u = u * PI2 - x = self.radius * cos(u) - y = self.radius * sin(u) - z = 0 - vector = Vector(x, y, z) - vector.unitize() + vector = Vector(cos(u), sin(u), 0.0) if world: vector.transform(self.transformation) return vector - def frame_at(self, u, v, world=True): + def frame_at(self, u: float, v: float, world: bool = True) -> Frame: """Compute the frame at a point on the surface at the given parameters. Parameters ---------- - u : float - The first parameter. - v : float - The second parameter. - world : bool, optional - If ``True``, the frame is transformed to world coordinates. + u + The U parameter. + v + The V parameter. + world + If `True`, return the frame in world coordinates. Returns ------- - :class:`compas.geometry.Frame` - The frame at the given parameters. + Frame + The frame at the given parameters. Its X-axis follows increasing U, + its Y-axis follows increasing V, and its Z-axis is the outward normal. """ - u = u * PI2 point = self.point_at(u, v, world=False) - zaxis = self.normal_at(u, world=False) - yaxis = self.frame.zaxis + zaxis = self.normal_at(u, v, world=False) + yaxis = Vector(0.0, 0.0, 1.0) xaxis = yaxis.cross(zaxis) frame = Frame(point, xaxis, yaxis) if world: diff --git a/src/compas/geometry/surfaces/extrusion.py b/src/compas/geometry/surfaces/extrusion.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/src/compas/geometry/surfaces/nurbs.py b/src/compas/geometry/surfaces/nurbs.py index 2120e935f614..47dceec28434 100644 --- a/src/compas/geometry/surfaces/nurbs.py +++ b/src/compas/geometry/surfaces/nurbs.py @@ -1,7 +1,14 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import TYPE_CHECKING +from typing import Any +from typing import Literal +from typing import Optional +from typing import Sequence +from typing import TypeVar +from typing_extensions import Self + +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 @@ -10,183 +17,177 @@ from .surface import Surface +if TYPE_CHECKING: + from compas.geometry import Curve + from compas.geometry import Cylinder + from compas.geometry import Frame + from compas.geometry import NurbsCurve + from compas.geometry import Plane + from compas.geometry import Sphere + from compas.geometry import Torus + from compas.geometry import Vector -@pluggable(category="factories") -def nurbssurface_from_cylinder(cls, *args, **kwargs): - raise PluginNotInstalledError +ControlPointGrid = Sequence[Sequence[CoordinateType]] +WeightGrid = Sequence[Sequence[float]] +NurbsSurfaceType = TypeVar("NurbsSurfaceType", bound="NurbsSurface") @pluggable(category="factories") -def nurbssurface_from_extrusion(cls, *args, **kwargs): +def nurbssurface_from_cylinder(cls: type[NurbsSurfaceType], cylinder: "Cylinder") -> NurbsSurfaceType: raise PluginNotInstalledError @pluggable(category="factories") -def nurbssurface_from_fill(cls, *args, **kwargs): +def nurbssurface_from_extrusion(cls: type[NurbsSurfaceType], curve: "Curve", vector: "Vector") -> NurbsSurfaceType: raise PluginNotInstalledError @pluggable(category="factories") -def nurbssurface_from_frame(cls, *args, **kwargs): +def nurbssurface_from_fill( + cls: type[NurbsSurfaceType], + curve1: "NurbsCurve", + curve2: "NurbsCurve", + curve3: Optional["NurbsCurve"] = None, + curve4: Optional["NurbsCurve"] = None, + style: Literal["stretch", "coons", "curved"] = "stretch", +) -> NurbsSurfaceType: raise PluginNotInstalledError @pluggable(category="factories") -def nurbssurface_from_interpolation(cls, *args, **kwargs): +def nurbssurface_from_frame(cls: type[NurbsSurfaceType], frame: "Frame") -> NurbsSurfaceType: raise PluginNotInstalledError @pluggable(category="factories") -def nurbssurface_from_native(cls, *args, **kwargs): +def nurbssurface_from_interpolation( + cls: type[NurbsSurfaceType], + points: ControlPointGrid, + precision: float = 1e-3, +) -> NurbsSurfaceType: raise PluginNotInstalledError @pluggable(category="factories") -def nurbssurface_from_parameters(cls, *args, **kwargs): +def nurbssurface_from_native(cls: type[NurbsSurfaceType], surface: object) -> NurbsSurfaceType: raise PluginNotInstalledError @pluggable(category="factories") -def nurbssurface_from_plane(cls, *args, **kwargs): +def nurbssurface_from_parameters( + cls: type[NurbsSurfaceType], + points: ControlPointGrid, + weights: WeightGrid, + knots_u: Sequence[float], + knots_v: Sequence[float], + mults_u: Sequence[int], + mults_v: Sequence[int], + degree_u: int, + degree_v: int, + is_periodic_u: bool = False, + is_periodic_v: bool = False, +) -> NurbsSurfaceType: raise PluginNotInstalledError @pluggable(category="factories") -def nurbssurface_from_points(cls, *args, **kwargs): +def nurbssurface_from_plane(cls: type[NurbsSurfaceType], plane: "Plane") -> NurbsSurfaceType: raise PluginNotInstalledError @pluggable(category="factories") -def nurbssurface_from_revolution(cls, *args, **kwargs): +def nurbssurface_from_points( + cls: type[NurbsSurfaceType], + points: ControlPointGrid, + degree_u: int = 3, + degree_v: int = 3, +) -> NurbsSurfaceType: raise PluginNotInstalledError @pluggable(category="factories") -def nurbssurface_from_sphere(cls, *args, **kwargs): +def nurbssurface_from_sphere(cls: type[NurbsSurfaceType], sphere: "Sphere") -> NurbsSurfaceType: raise PluginNotInstalledError @pluggable(category="factories") -def nurbssurface_from_step(cls, *args, **kwargs): +def nurbssurface_from_step(cls: type[NurbsSurfaceType], filepath: FilePath) -> NurbsSurfaceType: raise PluginNotInstalledError @pluggable(category="factories") -def nurbssurface_from_torus(cls, *args, **kwargs): +def nurbssurface_from_torus(cls: type[NurbsSurfaceType], torus: "Torus") -> NurbsSurfaceType: raise PluginNotInstalledError class NurbsSurface(Surface): - """A NURBS surface is defined by control points, weights, knots, and a degree, in two directions U and V. + """A NURBS surface defined by control points, weights, knots, and degrees. Parameters ---------- - name : str, optional + name The name of the surface. - Attributes - ---------- - points : list[list[:class:`compas.geometry.Point`]], read-only - The control points as rows along the U direction. - weights : list[list[float]], read-only - The weights of the control points. - knots_u : list[float], read-only - The knots in the U direction, without multiplicity. - knots_v : list[float], read-only - The knots in the V direction, without multiplicity. - mults_u : list[int], read-only - Multiplicity of the knots in the U direction. - mults_v : list[int], read-only - Multiplicity of the knots in the V direction. - degree_u : list[int], read-only - The degree of the surface in the U direction. - degree_v : list[int], read-only - The degree of the surface in the V direction. + Notes + ----- + `NurbsSurface` defines a backend contract. Concrete implementations are + supplied through the plugin mechanism, for example by Rhino or OCC. - """ + Knot data exposed by this contract uses the full mathematical convention. + The complete knot vectors include all endpoint knots, including the two + knots that openNURBS considers superfluous and omits from native Rhino knot + lists. Rhino implementations are responsible for removing those knots when + constructing native geometry and restoring them when exposing COMPAS knot + data. - DATASCHEMA = { - "type": "object", - "properties": { - "points": {"type": "array", "items": {"type": "array", "items": Point.DATASCHEMA}}, - "weights": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}}, - "knots_u": {"type": "array", "items": {"type": "number"}}, - "knots_v": {"type": "array", "items": {"type": "number"}}, - "mults_u": {"type": "array", "items": {"type": "integer"}}, - "mults_v": {"type": "array", "items": {"type": "integer"}}, - "degree_u": {"type": "integer", "exclusiveMinimum": 0}, - "degree_v": {"type": "integer", "exclusiveMinimum": 0}, - "is_periodic_u": {"type": "boolean"}, - "is_periodic_v": {"type": "boolean"}, - }, - "additionalProperties": False, - "minProperties": 10, - } + Control points and weights use row-major parameter order. The outer + sequence contains V rows and every row contains values along U; therefore a + nested value is addressed as `points[v][u]` or `weights[v][u]`. + + """ @property - def __dtype__(self): + def __dtype__(self) -> str: return "compas.geometry/NurbsSurface" @property - def __data__(self): + def __data__(self) -> dict[str, Any]: + """The data representation of the NURBS surface.""" return { - "points": [[point.__data__ for point in row] for row in self.points], # type: ignore - "weights": self.weights, - "knots_u": self.knots_u, - "knots_v": self.knots_v, - "mults_u": self.mults_u, - "mults_v": self.mults_v, + "points": [[point.__data__ for point in row] for row in self.points], + "weights": [list(row) for row in self.weights], + "knots_u": list(self.knots_u), + "knots_v": list(self.knots_v), + "mults_u": list(self.mults_u), + "mults_v": list(self.mults_v), "degree_u": self.degree_u, "degree_v": self.degree_v, "is_periodic_u": self.is_periodic_u, "is_periodic_v": self.is_periodic_v, - } + } @classmethod - def __from_data__(cls, data): - """Construct a Nurbs surface from its data representation. - - Parameters - ---------- - data : dict - The data dictionary. - - Returns - ------- - :class:`compas.geometry.NurbsSurface` - The constructed surface. - - """ - points = data["points"] # conversion is not needed because point data can be provided in their raw form as well - weights = data["weights"] - knots_u = data["knots_u"] - knots_v = data["knots_v"] - mults_u = data["mults_u"] - mults_v = data["mults_v"] - degree_u = data["degree_u"] - degree_v = data["degree_v"] - is_periodic_u = data["is_periodic_u"] - is_periodic_v = data["is_periodic_v"] + def __from_data__(cls, data: dict[str, Any]) -> Self: return cls.from_parameters( - points, - weights, - knots_u, - knots_v, - mults_u, - mults_v, - degree_u, - degree_v, - is_periodic_u, - is_periodic_v, + data["points"], + data["weights"], + data["knots_u"], + data["knots_v"], + data["mults_u"], + data["mults_v"], + data["degree_u"], + data["degree_v"], + data["is_periodic_u"], + data["is_periodic_v"], ) - def __new__(cls, *args, **kwargs): + def __new__(cls, *args: object, **kwargs: object) -> Self: if cls is NurbsSurface: raise TypeError("Making an instance of `NurbsSurface` using `NurbsSurface()` is not allowed. Please use one of the factory methods instead (`NurbsSurface.from_...`)") return object.__new__(cls) - def __repr__(self): - return "{0}(points={1!r}, weigths={2}, knots_u={3}, knots_v={4}, mults_u={5}, mults_v={6}, degree_u={7}, degree_v={8}, is_periodic_u={9}, is_periodic_v={10})".format( + def __repr__(self) -> str: + return "{0}(points={1!r}, weights={2}, knots_u={3}, knots_v={4}, mults_u={5}, mults_v={6}, degree_u={7}, degree_v={8}, is_periodic_u={9}, is_periodic_v={10})".format( type(self).__name__, self.points, self.weights, @@ -205,51 +206,88 @@ def __repr__(self): # ============================================================================== @property - def points(self): + def points(self) -> Sequence[Sequence[Point]]: + """The control points in V rows, with each row running along U.""" raise NotImplementedError @property - def weights(self): + def weights(self) -> Sequence[Sequence[float]]: + """The control-point weights in the same V-by-U layout as `points`.""" raise NotImplementedError @property - def knots_u(self): + def knots_u(self) -> Sequence[float]: + """The unique knots in the U direction.""" raise NotImplementedError @property - def mults_u(self): + def mults_u(self) -> Sequence[int]: + """The canonical multiplicity of each unique U knot. + + The endpoint multiplicities include knots omitted by native openNURBS + representations. + + """ raise NotImplementedError @property - def knotvector_u(self): - raise NotImplementedError + def knotvector_u(self) -> list[float]: + """The complete canonical U knot vector, including endpoint knots.""" + return [knot for knot, multiplicity in zip(self.knots_u, self.mults_u) for _ in range(multiplicity)] @property - def knots_v(self): + def knots_v(self) -> Sequence[float]: + """The unique knots in the V direction.""" raise NotImplementedError @property - def mults_v(self): + def mults_v(self) -> Sequence[int]: + """The canonical multiplicity of each unique V knot. + + The endpoint multiplicities include knots omitted by native openNURBS + representations. + + """ raise NotImplementedError @property - def knotvector_v(self): - raise NotImplementedError + def knotvector_v(self) -> list[float]: + """The complete canonical V knot vector, including endpoint knots.""" + return [knot for knot, multiplicity in zip(self.knots_v, self.mults_v) for _ in range(multiplicity)] @property - def degree_u(self): + def degree_u(self) -> int: + """The polynomial degree in the U direction.""" raise NotImplementedError @property - def degree_v(self): + def degree_v(self) -> int: + """The polynomial degree in the V direction.""" raise NotImplementedError @property - def domain_u(self): + def order_u(self) -> int: + """The polynomial order in U, equal to `degree_u + 1`.""" + return self.degree_u + 1 + + @property + def order_v(self) -> int: + """The polynomial order in V, equal to `degree_v + 1`.""" + return self.degree_v + 1 + + @property + def is_rational(self) -> bool: + """Whether any control-point weight differs from one.""" + return any(weight != 1.0 for row in self.weights for weight in row) + + @property + def domain_u(self) -> tuple[float, float]: + """The parameter domain in the U direction.""" raise NotImplementedError @property - def domain_v(self): + def domain_v(self) -> tuple[float, float]: + """The parameter domain in the V direction.""" raise NotImplementedError # ============================================================================== @@ -257,57 +295,67 @@ def domain_v(self): # ============================================================================== @classmethod - def from_cylinder(cls, cylinder, *args, **kwargs): + def from_cylinder(cls, cylinder: "Cylinder") -> Self: """Construct a surface from a cylinder. Parameters ---------- - cylinder : :class:`compas.geometry.Cylinder` + cylinder The cylinder. Returns ------- - :class:`compas.geometry.NurbsSurface` + Self + The constructed NURBS surface. """ - return nurbssurface_from_cylinder(cls, cylinder, *args, **kwargs) + return nurbssurface_from_cylinder(cls, cylinder) @classmethod - def from_extrusion(cls, curve, vector, *args, **kwargs): + def from_extrusion(cls, curve: "Curve", vector: "Vector") -> Self: """Construct a NURBS surface from an extrusion of a basis curve. Note that the extrusion surface is constructed by generating an infill - between the basis curve and a translated copy with :meth:`from_fill`. + between the basis curve and a translated copy with `from_fill`. Parameters ---------- - curve : :class:`compas.geometry.Curve` + curve The basis curve for the extrusion. - vector : :class:`compas.geometry.Vector` + vector The extrusion vector, which serves as a translation vector for the basis curve. Returns ------- - :class:`compas.geometry.NurbsSurface` + Self + The constructed NURBS surface. """ - return nurbssurface_from_extrusion(cls, curve, vector, *args, **kwargs) + return nurbssurface_from_extrusion(cls, curve, vector) @classmethod - def from_fill(cls, curve1, curve2, curve3=None, curve4=None, style="stretch"): + def from_fill( + cls, + curve1: "NurbsCurve", + curve2: "NurbsCurve", + curve3: Optional["NurbsCurve"] = None, + curve4: Optional["NurbsCurve"] = None, + style: Literal["stretch", "coons", "curved"] = "stretch", + ) -> Self: """Construct a NURBS surface from the infill between two, three or four contiguous NURBS curves. Parameters ---------- - curve1 : :class:`compas.geometry.NurbsCurve` - curve2 : :class:`compas.geometry.NurbsCurve` - curve3 : :class:`compas.geometry.NurbsCurve`, optional. - curve4 : :class:`compas.geometry.NurbsCurve`, optional. - style : Literal['stretch', 'coons', 'curved'], optional. - - * ``'stretch'`` produces the flattest patch. - * ``'curved'`` produces a rounded patch. - * ``'coons'`` is between stretch and coons. + curve1 + The first boundary curve. + curve2 + The second boundary curve. + curve3 + The optional third boundary curve. + curve4 + The optional fourth boundary curve. + style + The fill style: `stretch`, `coons`, or `curved`. Raises ------ @@ -316,61 +364,69 @@ def from_fill(cls, curve1, curve2, curve3=None, curve4=None, style="stretch"): Returns ------- - :class:`compas.geometry.NurbsSurface` + Self + The constructed NURBS surface. """ return nurbssurface_from_fill(cls, curve1, curve2, curve3, curve4, style) @classmethod - def from_frame(cls, frame, *args, **kwargs): + def from_frame(cls, frame: "Frame") -> Self: """Construct a surface from a frame. Parameters ---------- - frame : :class:`compas.geometry.Frame` - The plane. + frame + The surface frame. Returns ------- - :class:`compas.geometry.NurbsSurface` + Self + The constructed NURBS surface. """ - return nurbssurface_from_frame(cls, frame, *args, **kwargs) + return nurbssurface_from_frame(cls, frame) @classmethod - def from_interpolation(cls, points, *args, **kwargs): - """Construct a surface from a frame. + def from_interpolation(cls, points: ControlPointGrid, precision: float = 1e-3) -> Self: + """Construct a NURBS surface by interpolating a grid of points. Parameters ---------- - points : list[:class:`compas.geometry.Point`] - The interpolation points. + points + The interpolation point grid. + precision + The desired interpolation precision. Returns ------- - :class:`compas.geometry.NurbsSurface` + Self + The interpolated NURBS surface. """ - return nurbssurface_from_interpolation(cls, points, *args, **kwargs) + return nurbssurface_from_interpolation(cls, points, precision=precision) @classmethod - def from_meshgrid(cls, nu=10, nv=10): + def from_meshgrid(cls, nu: int = 10, nv: int = 10) -> Self: """Construct a NURBS surface from a mesh grid. Parameters ---------- - nu : int, optional - Number of control points in the U direction. - nv : int, optional - Number of control points in the V direction. + nu + The number of grid intervals in the U direction. + nv + The number of grid intervals in the V direction. Returns ------- - :class:`compas.geometry.NurbsSurface` + Self + The constructed NURBS surface. """ - UU, VV = meshgrid(linspace(0, nu, nu + 1), linspace(0, nv, nv + 1)) - points = [] + if nu < 1 or nv < 1: + raise ValueError("A NURBS mesh grid requires at least one interval in each direction.") + UU, VV = meshgrid(linspace(0.0, float(nu), nu + 1), linspace(0.0, float(nv), nv + 1)) + points: list[list[Point]] = [] for U, V in zip(UU, VV): row = [] for u, v in zip(U, V): @@ -379,7 +435,7 @@ def from_meshgrid(cls, nu=10, nv=10): return cls.from_points(points=points) @classmethod - def from_native(cls, surface): + def from_native(cls, surface: object) -> Self: """Construct a NURBS surface from a native surface geometry. Parameters @@ -389,7 +445,8 @@ def from_native(cls, surface): Returns ------- - :class:`compas.geometry.NurbsSurface` + Self + The constructed NURBS surface. """ return nurbssurface_from_native(cls, surface) @@ -397,41 +454,46 @@ def from_native(cls, surface): @classmethod def from_parameters( cls, - points, - weights, - knots_u, - knots_v, - mults_u, - mults_v, - degree_u, - degree_v, - is_periodic_u=False, - is_periodic_v=False, - ): + points: ControlPointGrid, + weights: WeightGrid, + knots_u: Sequence[float], + knots_v: Sequence[float], + mults_u: Sequence[int], + mults_v: Sequence[int], + degree_u: int, + degree_v: int, + is_periodic_u: bool = False, + is_periodic_v: bool = False, + ) -> Self: """Construct a NURBS surface from explicit parameters. Parameters ---------- - points : list[list[[float, float, float] | :class:`compas.geometry.Point`]] + points The control points. - weights : list[list[float]] + weights The weights of the control points. - knots_u : list[float] + knots_u The knots in the U direction, without multiplicity. - knots_v : list[float] + knots_v The knots in the V direction, without multiplicity. - mults_u : list[int] + mults_u Multiplicity of the knots in the U direction. - mults_v : list[int] + mults_v Multiplicity of the knots in the V direction. - degree_u : int + degree_u Degree in the U direction. - degree_v : int + degree_v Degree in the V direction. + is_periodic_u + Whether the surface is periodic in the U direction. + is_periodic_v + Whether the surface is periodic in the V direction. Returns ------- - :class:`compas.geometry.NurbsSurface` + Self + The constructed NURBS surface. """ return nurbssurface_from_parameters( @@ -449,87 +511,93 @@ def from_parameters( ) @classmethod - def from_plane(cls, plane, *args, **kwargs): + def from_plane(cls, plane: "Plane") -> Self: """Construct a surface from a plane. Parameters ---------- - plane : :class:`compas.geometry.Plane` + plane The plane. Returns ------- - :class:`compas.geometry.NurbsSurface` + Self + The constructed NURBS surface. """ - return nurbssurface_from_plane(cls, plane, *args, **kwargs) + return nurbssurface_from_plane(cls, plane) @classmethod - def from_points(cls, points, degree_u=3, degree_v=3): + def from_points(cls, points: ControlPointGrid, degree_u: int = 3, degree_v: int = 3) -> Self: """Construct a NURBS surface from control points. Parameters ---------- - points : list[list[[float, float, float] | :class:`compas.geometry.Point`]] + points The control points. - degree_u : int + degree_u Degree in the U direction. - degree_v : int + degree_v Degree in the V direction. Returns ------- - :class:`compas.geometry.NurbsSurface` + Self + The constructed NURBS surface. """ return nurbssurface_from_points(cls, points, degree_u=degree_u, degree_v=degree_v) @classmethod - def from_sphere(cls, sphere, *args, **kwargs): + def from_sphere(cls, sphere: "Sphere") -> Self: """Construct a surface from a sphere. Parameters ---------- - sphere : :class:`compas.geometry.Sphere` + sphere The sphere. Returns ------- - :class:`compas.geometry.NurbsSurface` + Self + The constructed NURBS surface. """ - return nurbssurface_from_sphere(cls, sphere, *args, **kwargs) + return nurbssurface_from_sphere(cls, sphere) @classmethod - def from_step(cls, filepath): + def from_step(cls, filepath: FilePath) -> Self: """Load a NURBS surface from a STP file. Parameters ---------- - filepath : str + filepath + The path to the STEP file. Returns ------- - :class:`compas.geometry.NurbsSurface` + Self + The loaded NURBS surface. """ return nurbssurface_from_step(cls, filepath) @classmethod - def from_torus(cls, torus, *args, **kwargs): + def from_torus(cls, torus: "Torus") -> Self: """Construct a surface from a torus. Parameters ---------- - torus : :class:`compas.geometry.Torus` + torus The torus. Returns ------- - :class:`compas.geometry.NurbsSurface` + Self + The constructed NURBS surface. """ - return nurbssurface_from_torus(cls, torus, *args, **kwargs) + return nurbssurface_from_torus(cls, torus) # ============================================================================== # Conversions @@ -539,15 +607,24 @@ def from_torus(cls, torus, *args, **kwargs): # Methods # ============================================================================== - def copy(self): + def copy(self, cls: Optional[type[Self]] = None, copy_guid: bool = False) -> Self: # type: ignore[override] """Make an independent copy of the surface. + Parameters + ---------- + cls + The NURBS surface type to construct. Default is `type(self)`. + copy_guid + If `True`, preserve the globally unique identifier. + Returns ------- - :class:`compas.geometry.NurbsSurface` + Self + The independent copy. """ - return NurbsSurface.from_parameters( + surface_type = cls or type(self) + surface = surface_type.from_parameters( self.points, self.weights, self.knots_u, @@ -559,3 +636,6 @@ def copy(self): self.is_periodic_u, self.is_periodic_v, ) + if copy_guid: + surface._guid = self.guid + return surface diff --git a/src/compas/geometry/surfaces/planar.py b/src/compas/geometry/surfaces/planar.py index 4b6797444f0b..da6fbf021d20 100644 --- a/src/compas/geometry/surfaces/planar.py +++ b/src/compas/geometry/surfaces/planar.py @@ -1,6 +1,7 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import Any +from typing import Optional + +from typing_extensions import Self from compas.geometry import Frame from compas.geometry import Plane @@ -15,52 +16,61 @@ class PlanarSurface(Surface): Parameters ---------- - frame : :class:`compas.geometry.Frame`, optional + frame The local coordinate system of the surface. - Default is ``None``, in which case the world coordinate system is used. - xsize : float, optional + If `None`, the world XY frame is used. + xsize The size of the surface in the local X-direction. - ysize : float, optional + ysize The size of the surface in the local Y-direction. - name : str, optional + name The name of the surface. - """ + Examples + -------- + >>> surface = PlanarSurface(xsize=2.0, ysize=3.0) + >>> surface.point_at(0.5, 0.5) + Point(x=1.000, y=1.500, z=0.000) - DATASCHEMA = { - "type": "object", - "properties": { - "xsize": {"type": "number", "minimum": 0}, - "ysize": {"type": "number", "minimum": 0}, - "frame": Frame.DATASCHEMA, - }, - "required": ["xsize", "ysize", "frame"], - } + A planar surface can also be constructed from a plane. + + >>> plane = Plane.worldXY() + >>> PlanarSurface.from_plane_and_size(plane, 2.0, 3.0).to_plane() == plane + True + + """ @property - def __data__(self): + def __data__(self) -> dict[str, Any]: + """The data representation of the planar surface.""" return { "xsize": self.xsize, "ysize": self.ysize, "frame": self.frame.__data__, - } + } @classmethod - def __from_data__(cls, data): + def __from_data__(cls, data: dict[str, Any]) -> Self: return cls( xsize=data["xsize"], ysize=data["ysize"], frame=Frame.__from_data__(data["frame"]), ) - def __init__(self, xsize=1.0, ysize=1.0, frame=None, name=None): - super(PlanarSurface, self).__init__(frame=frame, name=name) - self._xsize = None - self._ysize = None + def __init__( + self, + xsize: float = 1.0, + ysize: float = 1.0, + frame: Optional[Frame] = None, + name: Optional[str] = None, + ) -> None: + super().__init__(frame=frame, name=name) + self._xsize: Optional[float] = None + self._ysize: Optional[float] = None self.xsize = xsize self.ysize = ysize - def __repr__(self): + def __repr__(self) -> str: return "{0}(xsize={1}, ysize={2}, frame={3!r})".format( type(self).__name__, self.xsize, @@ -68,39 +78,37 @@ def __repr__(self): self.frame, ) - def __eq__(self, other): - try: - other_frame = other.frame - other_xsize = other.xsize - other_ysize = other.ysize - except Exception: + def __eq__(self, other: object) -> bool: + if not isinstance(other, PlanarSurface): return False - return self.xsize == other_xsize and self.ysize == other_ysize and self.frame == other_frame + return self.xsize == other.xsize and self.ysize == other.ysize and self.frame == other.frame # ============================================================================= # Properties # ============================================================================= @property - def xsize(self): + def xsize(self) -> float: + """The size of the surface in its local X-direction.""" if self._xsize is None: raise ValueError("The size of the surface in the local X-direction is not set.") return self._xsize @xsize.setter - def xsize(self, xsize): + def xsize(self, xsize: float) -> None: if xsize < 0: raise ValueError("The size of the surface in the local X-direction should be at least zero.") self._xsize = float(xsize) @property - def ysize(self): + def ysize(self) -> float: + """The size of the surface in its local Y-direction.""" if self._ysize is None: raise ValueError("The size of the surface in the local Y-direction is not set.") return self._ysize @ysize.setter - def ysize(self, ysize): + def ysize(self, ysize: float) -> None: if ysize < 0: raise ValueError("The size of the surface in the local Y-direction should be at least zero.") self._ysize = float(ysize) @@ -110,21 +118,21 @@ def ysize(self, ysize): # ============================================================================= @classmethod - def from_plane_and_size(cls, plane, xsize, ysize): + def from_plane_and_size(cls, plane: Plane, xsize: float, ysize: float) -> Self: """Construct a planar surface from a plane and x and y sizes. Parameters ---------- - plane : :class:`compas.geometry.Plane` - The plane of the sphere. - xsize : float - The size of the sphere in the local X-direction. - ysize : float - The size of the sphere in the local Y-direction. + plane + The plane of the surface. + xsize + The size of the surface in the local X-direction. + ysize + The size of the surface in the local Y-direction. Returns ------- - :class:`compas.geometry.PlanarSurface` + PlanarSurface A planar surface. """ @@ -134,12 +142,12 @@ def from_plane_and_size(cls, plane, xsize, ysize): # Conversions # ============================================================================= - def to_plane(self): + def to_plane(self) -> Plane: """Convert the planar surface to a plane. Returns ------- - :class:`compas.geometry.Plane` + Plane The plane of the planar surface. """ @@ -149,22 +157,22 @@ def to_plane(self): # Methods # ============================================================================= - def point_at(self, u, v, world=True): - """Construct a point on the planar surface. + def point_at(self, u: float, v: float, world: bool = True) -> Point: + """Compute a point on the planar surface. Parameters ---------- - u : float - The first parameter. - v : float - The second parameter. - world : bool, optional - If ``True``, return the point in world coordinates. + u + The U parameter. + v + The V parameter. + world + If `True`, return the point in world coordinates. Returns ------- - :class:`compas.geometry.Point` - A point on the sphere. + Point + The point at the given parameters. """ point = Point(u * self.xsize, v * self.ysize, 0) @@ -172,21 +180,26 @@ def point_at(self, u, v, world=True): point.transform(self.transformation) return point - def normal_at(self, u=None, v=None, world=True): - """Construct the normal at a point on the planar surface. + def normal_at( + self, + u: Optional[float] = None, + v: Optional[float] = None, + world: bool = True, + ) -> Vector: + """Compute the normal at a point on the planar surface. Parameters ---------- - u : float, optional - The first parameter. - The parameter is optional, because the normal is the same everywhere. - v : float, optional - The second parameter. - The parameter is optional, because the normal is the same everywhere. + u + The U parameter. This is optional because the normal is constant. + v + The V parameter. This is optional because the normal is constant. + world + If `True`, return the normal in world coordinates. Returns ------- - :class:`compas.geometry.Vector` + Vector The normal vector. """ @@ -194,22 +207,21 @@ def normal_at(self, u=None, v=None, world=True): return self.frame.zaxis return Vector(0, 0, 1) - def frame_at(self, u=None, v=None): - """Construct a frame at a point on the planar surface. + def frame_at(self, u: float, v: float) -> Frame: + """Compute a frame at a point on the planar surface. Parameters ---------- - u : float, optional - The first parameter. - The parameter is optional, because the frame is the same everywhere. - v : float, optional - The second parameter. - The parameter is optional, because the frame is the same everywhere. + u + The U parameter. + v + The V parameter. Returns ------- - :class:`compas.geometry.Frame` - The frame. + Frame + A new frame located at the given parameters and oriented like the + surface frame. """ - return self.frame + return Frame(self.point_at(u, v), self.frame.xaxis, self.frame.yaxis) diff --git a/src/compas/geometry/surfaces/revolution.py b/src/compas/geometry/surfaces/revolution.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/src/compas/geometry/surfaces/spherical.py b/src/compas/geometry/surfaces/spherical.py index c1086a035877..59c8d036ab70 100644 --- a/src/compas/geometry/surfaces/spherical.py +++ b/src/compas/geometry/surfaces/spherical.py @@ -1,13 +1,17 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import cos from math import pi from math import sin +from typing import Any +from typing import Optional + +from typing_extensions 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 @@ -21,136 +25,149 @@ class SphericalSurface(Surface): Parameters ---------- - radius : float + radius The radius of the sphere. - frame : :class:`Frame` - The frame of the sphere. - name : str, optional + frame + The local coordinate frame. If `None`, the world XY frame is used. + name The name of the surface. Examples -------- - >>> from compas.geometry import Frame - >>> from compas.geometry import SphericalSurface - >>> frame = Frame([0, 0, 0], [1, 0, 0], [0, 1, 0]) - >>> sphere = SphericalSurface(1.0, frame) + >>> sphere = SphericalSurface(1.0) + >>> sphere.point_at(0.0, 0.5) + Point(x=1.000, y=0.000, z=0.000) - """ + Spherical surfaces can also be constructed from a plane or three points. - DATASCHEMA = { - "type": "object", - "properties": { - "radius": {"type": "number", "minimum": 0}, - "frame": Frame.DATASCHEMA, - }, - "required": ["radius", "frame"], - } + >>> sphere = SphericalSurface.from_plane_and_radius(Plane.worldXY(), 2.0) + >>> sphere.radius + 2.0 + >>> sphere = SphericalSurface.from_three_points([1, 0, 0], [0, 1, 0], [-1, 0, 0]) + >>> sphere.radius + 1.0 + + """ @property - def __data__(self): + def __data__(self) -> dict[str, Any]: + """The data representation of the spherical surface.""" return { "radius": self.radius, "frame": self.frame.__data__, } @classmethod - def __from_data__(cls, data): + def __from_data__(cls, data: dict[str, Any]) -> Self: return cls( radius=data["radius"], frame=Frame.__from_data__(data["frame"]), ) - def __init__(self, radius, frame=None, name=None): - super(SphericalSurface, self).__init__(frame=frame, name=name) - self._radius = None + def __init__(self, radius: float, frame: Optional[Frame] = None, name: Optional[str] = None) -> None: + super().__init__(frame=frame, name=name) + self._radius: Optional[float] = None self.radius = radius - def __repr__(self): + def __repr__(self) -> str: return "{0}(radius={1}, frame={2!r})".format( type(self).__name__, self.radius, self.frame, ) - def __eq__(self, other): - try: - other_frame = other.frame - other_radius = other.radius - except Exception: + def __eq__(self, other: object) -> bool: + if not isinstance(other, SphericalSurface): return False - return self.radius == other_radius and self.frame == other_frame + return self.radius == other.radius and self.frame == other.frame # ============================================================================= # Properties # ============================================================================= @property - def center(self): + def center(self) -> Point: + """The center point of the sphere. + + Notes + ----- + Assigning a point or three coordinates updates the surface frame and + creates an independent point. + + """ return self.frame.point @center.setter - def center(self, point): + def center(self, point: CoordinateType) -> None: self.frame.point = point @property - def radius(self): + def radius(self) -> float: + """The nonnegative radius of the sphere.""" if self._radius is None: raise ValueError("The radius of the surface has not been set yet.") return self._radius @radius.setter - def radius(self, radius): + def radius(self, radius: float) -> None: if radius < 0: raise ValueError("The radius of a sphere should be larger than or equal to zero.") self._radius = float(radius) @property - def area(self): - return 4 * 3.14159 * self.radius**2 + def area(self) -> float: + """The surface area of the sphere.""" + return 4 * pi * self.radius**2 + + @property + def volume(self) -> float: + """The volume enclosed by the sphere.""" + return 4.0 / 3.0 * pi * self.radius**3 @property - def volume(self): - return 4 / 3 * 3.14159 * self.radius**3 + def is_periodic_u(self) -> bool: + """Whether the surface is periodic in U, which is always `True`.""" + return True # ============================================================================= # Constructors # ============================================================================= @classmethod - def from_plane_and_radius(cls, plane, radius): + def from_plane_and_radius(cls, plane: Plane, radius: float) -> Self: """Construct a sphere from a plane and a radius. Parameters ---------- - plane : :class:`compas.geometry.Plane` + plane The plane of the sphere. - radius : float + radius The radius of the sphere. Returns ------- - :class:`compas.geometry.SphericalSurface` + SphericalSurface A sphere. """ return cls(radius, frame=Frame.from_plane(plane)) @classmethod - def from_three_points(cls, a, b, c): + def from_three_points(cls, a: CoordinateType, b: CoordinateType, c: CoordinateType) -> Self: """Construct a sphere from three points. Parameters ---------- - a : :class:`compas.geometry.Point` + a The first point. - b : :class:`compas.geometry.Point` + b The second point. - c : :class:`compas.geometry.Point` + c The third point. Returns ------- - :class:`compas.geometry.SphericalSurface` + SphericalSurface A sphere. """ @@ -158,17 +175,17 @@ def from_three_points(cls, a, b, c): return cls(circle.radius, frame=circle.frame) @classmethod - def from_points(cls, points): + def from_points(cls, points: CoordinatesType) -> Self: """Construct the sphere that best fits a set of points in the least squares sense. Parameters ---------- - points : list of :class:`compas.geometry.Point` + points The points. Returns ------- - :class:`compas.geometry.SphericalSurface` + SphericalSurface A sphere. """ @@ -176,7 +193,7 @@ def from_points(cls, points): raise ValueError("At least three points are required to construct a sphere.") if len(points) == 3: - return cls.from_three_points(*points) + return cls.from_three_points(points[0], points[1], points[2]) from compas.geometry import bestfit_sphere_numpy @@ -191,64 +208,60 @@ def from_points(cls, points): # Methods # ============================================================================= - def isocurve_u(self, u): - """Compute the isoparametric curve at parameter u. + def isocurve_u(self, u: float) -> Arc: + """Compute the meridian arc at a U parameter. Parameters ---------- - u : float + u + The U parameter. Returns ------- - :class:`compas.geometry.Circle` + Arc + The pole-to-pole meridian arc. """ - origin = self.center - xaxis = self.point_at(u=u, v=0) - origin - yaxis = self.point_at(u=u, v=0.25) - origin - frame = Frame(origin, xaxis, yaxis) - return Circle(radius=xaxis.length, frame=frame) + angle = u * PI2 + radial = self.frame.xaxis * cos(angle) + self.frame.yaxis * sin(angle) + frame = Frame(self.center, self.frame.zaxis, radial) + return Arc(radius=self.radius, start_angle=0.0, end_angle=pi, frame=frame) - def isocurve_v(self, v): - """Compute the isoparametric curve at parameter v. + def isocurve_v(self, v: float) -> Circle: + """Compute the latitude circle at a V parameter. Parameters ---------- - v : float + v + The V parameter. Returns ------- - :class:`compas.geometry.Circle` + Circle + The latitude circle. """ - x = self.point_at(u=0, v=v) - y = self.point_at(u=0.25, v=v) - origin = self.center + self.frame.zaxis * (x - self.center).dot(self.frame.zaxis) - xaxis = x - origin - yaxis = y - origin - frame = Frame(origin, xaxis, yaxis) - return Circle(radius=xaxis.length, frame=frame) + angle = v * pi + origin = self.center + self.frame.zaxis * (self.radius * cos(angle)) + frame = Frame(origin, self.frame.xaxis, self.frame.yaxis) + return Circle(radius=self.radius * sin(angle), frame=frame) - def point_at(self, u, v, world=True): - """Construct a point on the sphere. + def point_at(self, u: float, v: float, world: bool = True) -> Point: + """Compute a point on the sphere. Parameters ---------- - u : float - The first parameter. - The parameter value should be between zero and one, - and will be mapped to the corresponding angle between zero and pi. - v : float - The second parameter. - The parameter value should be between zero and one, - and will be mapped to the corresponding angle between zero and 2 * pi. - world : bool, optional - If ``True``, return the point in world coordinates. + u + The U parameter, mapped to an azimuth in `[0, 2 * pi]`. + v + The V parameter, mapped to a polar angle in `[0, pi]`. + world + If `True`, return the point in world coordinates. Returns ------- - :class:`compas.geometry.Point` - A point on the sphere. + Point + The point at the given parameters. """ u = u * PI2 @@ -261,22 +274,22 @@ def point_at(self, u, v, world=True): point.transform(self.transformation) return point - def normal_at(self, u, v, world=True): - """Construct a normal vector at a point on the sphere. + def normal_at(self, u: float, v: float, world: bool = True) -> Vector: + """Compute the outward normal at a point on the sphere. Parameters ---------- - u : float - The first parameter. - v : float - The second parameter. - world : bool, optional - If ``True``, return the normal in world coordinates. + u + The U parameter. + v + The V parameter. + world + If `True`, return the normal in world coordinates. Returns ------- - :class:`compas.geometry.Vector` - The normal vector. + Vector + The outward unit normal. """ u = u * PI2 diff --git a/src/compas/geometry/surfaces/surface.py b/src/compas/geometry/surfaces/surface.py index 1cca7126065b..938b40eab60c 100644 --- a/src/compas/geometry/surfaces/surface.py +++ b/src/compas/geometry/surfaces/surface.py @@ -1,20 +1,39 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from itertools import product - +from math import isfinite +from typing import TYPE_CHECKING +from typing import Iterator +from typing import Literal +from typing import Optional +from typing import TypeVar +from typing import Union +from typing import overload + +from typing_extensions import Self + +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 Transformation +from compas.geometry import Vector from compas.itertools import linspace from compas.plugins import PluginNotInstalledError from compas.plugins import pluggable +if TYPE_CHECKING: + from compas.datastructures import Mesh + from compas.geometry import Brep + from compas.geometry import Curve + from compas.geometry import Line + from compas.geometry import Polyhedron + +SurfaceType = TypeVar("SurfaceType", bound="Surface") +SurfaceCurve = Union["Curve", "Line"] + @pluggable(category="factories") -def surface_from_native(cls, *args, **kwargs): +def surface_from_native(cls: type[SurfaceType], *args: object, **kwargs: object) -> SurfaceType: raise PluginNotInstalledError @@ -23,43 +42,24 @@ class Surface(Geometry): Parameters ---------- - name : str, optional + frame + The local coordinate frame. Default is the world XY frame. + name The name of the surface. - Attributes - ---------- - frame : :class:`compas.geometry.Frame` - The local coordinate system of the surface. - Default is the world coordinate system. - transformation : :class:`compas.geometry.Transformation`, read-only - The transformation from the surface's local coordinate system to the world coordinate system. - domain_u : tuple[float, float], read-only - The parameter domain of the surface in the U direction. - domain_v : tuple[float, float], read-only - The parameter domain of the surface in the V direction. - is_periodic_u : bool, read-only - Flag indicating if the surface is periodic in the U direction. - is_periodic_v : bool, read-only - Flag indicating if the surface is periodic in the V direction. + Notes + ----- + Assigning a frame creates an independent copy. Mutating `surface.frame` + afterwards changes the surface directly. """ - def __new__(cls, *args, **kwargs): - if cls is Surface: - raise TypeError("Making an instance of `Surface` using `Surface()` is not allowed. Please use one of the factory methods instead (`Surface.from_...`)") - return object.__new__(cls) - - def __init__(self, frame=None, name=None): - super(Surface, self).__init__(name=name) - self._frame = None - self._transformation = None - self._domain_u = None - self._domain_v = None - self._point = None - if frame: - self.frame = frame - - def __repr__(self): + def __init__(self, frame: Optional[Frame] = None, name: Optional[str] = None) -> None: + super().__init__(name=name) + self._frame: Optional[Frame] = None + self.frame = frame + + def __repr__(self) -> str: return "{0}(frame={1!r}, domain_u={2}, domain_v={3})".format( type(self).__name__, self.frame, @@ -72,81 +72,95 @@ def __repr__(self): # ============================================================================== @property - def frame(self): - if not self._frame: + def frame(self) -> Frame: + """The local coordinate frame of the surface. + + Notes + ----- + Assigning a `Frame` creates an independent copy. Assigning `None` + resets the frame to world XY on the next access. + + """ + if self._frame is None: self._frame = Frame.worldXY() return self._frame @frame.setter - def frame(self, frame): - if not frame: + def frame(self, frame: Optional[Frame]) -> None: + if frame is None: self._frame = None else: - self._frame = Frame(frame[0], frame[1], frame[2]) - self._transformation = None + if not isinstance(frame, Frame): + raise TypeError("The frame must be a Frame object or None.") + self._frame = Frame(frame.point, frame.xaxis, frame.yaxis) @property - def transformation(self): - if not self._transformation: - self._transformation = Transformation.from_frame(self.frame) - return self._transformation + def transformation(self) -> Transformation: + """The transformation from world XY to the surface frame.""" + return Transformation.from_frame(self.frame) @property - def point(self): - if not self._point: - return self.frame.point - return self._point + def point(self) -> Point: + """The origin of the surface frame. + + Notes + ----- + Assigning a point or three coordinates updates the frame origin and + creates an independent point. + + """ + return self.frame.point @point.setter - def point(self, point): - self._point = Point(*point) + def point(self, point: CoordinateType) -> None: + self.frame.point = point @property - def xaxis(self): + def xaxis(self) -> Vector: + """The x-axis of the surface frame.""" return self.frame.xaxis @property - def yaxis(self): + def yaxis(self) -> Vector: + """The y-axis of the surface frame.""" return self.frame.yaxis @property - def zaxis(self): + def zaxis(self) -> Vector: + """The z-axis of the surface frame.""" return self.frame.zaxis @property - def dimension(self): + def dimension(self) -> int: + """The dimension of the embedding space, which is always three.""" return 3 @property - def domain_u(self): - if not self._domain_u: - self._domain_u = (0.0, 1.0) - return self._domain_u + def domain_u(self) -> tuple[float, float]: + """The parameter domain in the u-direction.""" + return 0.0, 1.0 @property - def domain_v(self): - if not self._domain_v: - self._domain_v = (0.0, 1.0) - return self._domain_v + def domain_v(self) -> tuple[float, float]: + """The parameter domain in the v-direction.""" + return 0.0, 1.0 @property - def is_closed(self): - raise NotImplementedError + def is_periodic_u(self) -> bool: + """Whether the surface is periodic in the u-direction.""" + return False @property - def is_periodic_u(self): - raise NotImplementedError - - @property - def is_periodic_v(self): - raise NotImplementedError + def is_periodic_v(self) -> bool: + """Whether the surface is periodic in the v-direction.""" + return False # ============================================================================== # Constructors # ============================================================================== @classmethod - def from_native(cls, surface): + def from_native(cls, surface: object) -> Self: """Construct a parametric surface from a native surface geometry. Parameters @@ -156,91 +170,72 @@ def from_native(cls, surface): Returns ------- - :class:`compas.geometry.Surface` - A COMPAS surface. + Self + The constructed surface. """ return surface_from_native(cls, surface) - @classmethod - def from_obj(cls, filepath): - """Load a surface from an OBJ file. - - Parameters - ---------- - filepath : str - The path to the file. - - Returns - ------- - :class:`compas.geometry.Surface` - - """ - raise NotImplementedError - - @classmethod - def from_step(cls, filepath): - """Load a surface from a STP file. - - Parameters - ---------- - filepath : str - The path to the file. - - Returns - ------- - :class:`compas.geometry.Surface` - - """ - raise NotImplementedError - # ============================================================================== # Conversions # ============================================================================== - def to_step(self, filepath, schema="AP203"): + def to_step(self, filepath: FilePath, schema: str = "AP203") -> None: """Write the surface geometry to a STP file. Parameters ---------- - filepath : str - schema : str, optional - - Returns - ------- - None + filepath + The path to the output file. + schema + The STEP schema to use. Default is `"AP203"`. """ raise NotImplementedError - def to_vertices_and_faces(self, nu=16, nv=16, du=None, dv=None): + def to_vertices_and_faces( + self, + nu: int = 16, + nv: int = 16, + du: Optional[tuple[float, float]] = None, + dv: Optional[tuple[float, float]] = None, + ) -> tuple[list[Point], list[list[int]]]: """Convert the surface to a list of vertices and faces. Parameters ---------- - nu : int, optional + nu The number of faces in the u direction. - Default is ``16``. - nv : int, optional + Default is `16`. + nv The number of faces in the v direction. - Default is ``16``. - du : tuple, optional + Default is `16`. + du The subset of the domain in the u direction. - Default is ``None``, in which case the entire domain is used. - dv : tuple, optional + Default is `None`, in which case the entire domain is used. + dv The subset of the domain in the v direction. - Default is ``None``, in which case the entire domain is used. + Default is `None`, in which case the entire domain is used. Returns ------- - vertices : list of :class:`compas.geometry.Point` - The vertices of the surface discretisation. - faces : list of list of int - The faces of the surface discretisation as lists of vertex indices. + tuple[list[Point], list[list[int]]] + The vertices and quadrilateral faces of the discretization. + + Raises + ------ + ValueError + If either face count is less than one or a domain endpoint is not + finite. """ - domain_u = du or self.domain_u - domain_v = dv or self.domain_v + if nu < 1 or nv < 1: + raise ValueError("Surface discretization requires at least one face in each parameter direction.") + + domain_u = self.domain_u if du is None else du + domain_v = self.domain_v if dv is None else dv + if not all(isfinite(value) for value in domain_u + domain_v): + raise ValueError("Surface discretization requires finite parameter domains.") vertices = [ self.point_at(i, j) @@ -261,86 +256,106 @@ def to_vertices_and_faces(self, nu=16, nv=16, du=None, dv=None): return vertices, faces - def to_triangles(self, nu=16, nv=16, du=None, dv=None): + def to_triangles( + self, + nu: int = 16, + nv: int = 16, + du: Optional[tuple[float, float]] = None, + dv: Optional[tuple[float, float]] = None, + ) -> list[list[Point]]: """Convert the surface to a list of triangles. Parameters ---------- - nu : int, optional + nu The number of faces in the u direction. - Default is ``16``. - nv : int, optional + Default is `16`. + nv The number of faces in the v direction. - Default is ``16``. - du : tuple, optional + Default is `16`. + du The subset of the domain in the u direction. - Default is ``None``, in which case the entire domain is used. - dv : tuple, optional + Default is `None`, in which case the entire domain is used. + dv The subset of the domain in the v direction. - Default is ``None``, in which case the entire domain is used. + Default is `None`, in which case the entire domain is used. Returns ------- - list[list[:class:`compas.geometry.Point`]] + list[list[Point]] + The triangular faces as point lists. """ vertices, faces = self.to_vertices_and_faces(nu=nu, nv=nv, du=du, dv=dv) - triangles = [] + triangles: list[list[Point]] = [] for a, b, c, d in faces: triangles.append([vertices[a], vertices[b], vertices[c]]) triangles.append([vertices[a], vertices[c], vertices[d]]) return triangles - def to_quads(self, nu=16, nv=16, du=None, dv=None): + def to_quads( + self, + nu: int = 16, + nv: int = 16, + du: Optional[tuple[float, float]] = None, + dv: Optional[tuple[float, float]] = None, + ) -> list[list[Point]]: """Convert the surface to a list of quads. Parameters ---------- - nu : int, optional + nu The number of faces in the u direction. - Default is ``16``. - nv : int, optional + Default is `16`. + nv The number of faces in the v direction. - Default is ``16``. - du : tuple, optional + Default is `16`. + du The subset of the domain in the u direction. - Default is ``None``, in which case the entire domain is used. - dv : tuple, optional + Default is `None`, in which case the entire domain is used. + dv The subset of the domain in the v direction. - Default is ``None``, in which case the entire domain is used. + Default is `None`, in which case the entire domain is used. Returns ------- - list[list[:class:`compas.geometry.Point`]] + list[list[Point]] + The quadrilateral faces as point lists. """ vertices, faces = self.to_vertices_and_faces(nu=nu, nv=nv, du=du, dv=dv) - quads = [] + quads: list[list[Point]] = [] for a, b, c, d in faces: quads.append([vertices[a], vertices[b], vertices[c], vertices[d]]) return quads - def to_polyhedron(self, nu=16, nv=16, du=None, dv=None): + def to_polyhedron( + self, + nu: int = 16, + nv: int = 16, + du: Optional[tuple[float, float]] = None, + dv: Optional[tuple[float, float]] = None, + ) -> "Polyhedron": """Convert the surface to a polyhedron. Parameters ---------- - nu : int, optional + nu The number of faces in the u direction. - Default is ``16``. - nv : int, optional + Default is `16`. + nv The number of faces in the v direction. - Default is ``16``. - du : tuple, optional + Default is `16`. + du The subset of the domain in the u direction. - Default is ``None``, in which case the entire domain is used. - dv : tuple, optional + Default is `None`, in which case the entire domain is used. + dv The subset of the domain in the v direction. - Default is ``None``, in which case the entire domain is used. + Default is `None`, in which case the entire domain is used. Returns ------- - :class:`compas.datastructures.Polyhedron` + Polyhedron A polyhedron object. """ @@ -349,27 +364,33 @@ def to_polyhedron(self, nu=16, nv=16, du=None, dv=None): vertices, faces = self.to_vertices_and_faces(nu=nu, nv=nv, du=du, dv=dv) return Polyhedron(vertices, faces) - def to_mesh(self, nu=16, nv=16, du=None, dv=None): + def to_mesh( + self, + nu: int = 16, + nv: int = 16, + du: Optional[tuple[float, float]] = None, + dv: Optional[tuple[float, float]] = None, + ) -> "Mesh": """Convert the surface to a mesh. Parameters ---------- - nu : int, optional + nu The number of faces in the u direction. - Default is ``16``. - nv : int, optional + Default is `16`. + nv The number of faces in the v direction. - Default is ``16``. - du : tuple, optional + Default is `16`. + du The subset of the domain in the u direction. - Default is ``None``, in which case the entire domain is used. - dv : tuple, optional + Default is `None`, in which case the entire domain is used. + dv The subset of the domain in the v direction. - Default is ``None``, in which case the entire domain is used. + Default is `None`, in which case the entire domain is used. Returns ------- - :class:`compas.datastructures.Mesh` + Mesh A mesh object. """ @@ -378,13 +399,13 @@ def to_mesh(self, nu=16, nv=16, du=None, dv=None): vertices, faces = self.to_vertices_and_faces(nu=nu, nv=nv, du=du, dv=dv) return Mesh.from_vertices_and_faces(vertices, faces) - def to_brep(self): + def to_brep(self) -> "Brep": """Convert the surface to a BREP representation. Returns ------- - :class:`compas.geometry.Brep` - A BREP object. + Brep + The boundary representation. """ raise NotImplementedError @@ -393,19 +414,14 @@ def to_brep(self): # Transformations # ============================================================================== - def transform(self, T): + def transform(self, transformation: Transformation) -> None: """Transform the local coordinate system of the surface. Parameters ---------- - T : :class:`compas.geometry.Transformation` + transformation The transformation. - Returns - ------- - None - The curve is modified in-place. - Notes ----- The transformation matrix is applied to the local coordinate system of the surface. @@ -413,151 +429,155 @@ def transform(self, T): All other components of the transformation matrix are ignored. """ - self.frame.transform(T) + self.frame.transform(transformation) # ============================================================================== # Methods # ============================================================================== - def space_u(self, n=10): + def space_u(self, n: int = 10) -> Iterator[float]: """Compute evenly spaced parameters over the surface domain in the U direction. Parameters ---------- - n : int, optional + n The number of parameters. Returns ------- - list[float] + Iterator[float] """ umin, umax = self.domain_u return linspace(umin, umax, n) - def space_v(self, n=10): + def space_v(self, n: int = 10) -> Iterator[float]: """Compute evenly spaced parameters over the surface domain in the V direction. Parameters ---------- - n : int, optional + n The number of parameters. Returns ------- - list[float] + Iterator[float] """ vmin, vmax = self.domain_v return linspace(vmin, vmax, n) - def isocurve_u(self, u): - """Compute the isoparametric curve at parameter u. + def isocurve_u(self, u: float) -> SurfaceCurve: + """Compute the isoparametric curve at a U parameter. Parameters ---------- - u : float + u + The U parameter. Returns ------- - :class:`compas.geometry.Curve` + Curve | Line + The isoparametric curve. """ raise NotImplementedError - def isocurve_v(self, v): - """Compute the isoparametric curve at parameter v. + def isocurve_v(self, v: float) -> SurfaceCurve: + """Compute the isoparametric curve at a V parameter. Parameters ---------- - v : float + v + The V parameter. Returns ------- - :class:`compas.geometry.Curve` + Curve | Line + The isoparametric curve. """ raise NotImplementedError - def boundary(self): + def boundary(self) -> list[SurfaceCurve]: """Compute the boundary curves of the surface. Returns ------- - list[:class:`compas.geometry.Curve`] + list[Curve | Line] + The oriented boundary curves. """ raise NotImplementedError - def pointgrid(self, nu=10, nv=10): + def pointgrid(self, nu: int = 10, nv: int = 10) -> list[Point]: """Compute point locations corresponding to evenly spaced parameters over the surface domain. Parameters ---------- - nu : int, optional + nu The size of the grid in the U direction. - nv : int, optional + nv The size of the grid in the V direction. - """ - return [self.point_at(i, j) for i, j in product(self.space_u(nu), self.space_v(nv))] - - def point_at(self, u, v): - """Compute a point on the surface. - - Parameters - ---------- - u : float - v : float - Returns ------- - :class:`compas.geometry.Point` + list[Point] + The sampled points in row-major parameter order. """ - raise NotImplementedError + return [self.point_at(i, j) for i, j in product(self.space_u(nu), self.space_v(nv))] - def normal_at(self, u, v): - """Compute a normal at a point on the surface. + def point_at(self, u: float, v: float) -> Point: + """Compute a point on the surface. Parameters ---------- - u : float - v : float + u + The U parameter. + v + The V parameter. Returns ------- - :class:`compas.geometry.Point` + Point + The point at the given parameters. """ raise NotImplementedError - def curvature_at(self, u, v): - """Compute the curvature at a point on the surface. + def normal_at(self, u: float, v: float) -> Vector: + """Compute a normal at a point on the surface. Parameters ---------- - u : float - v : float + u + The U parameter. + v + The V parameter. Returns ------- - :class:`compas.geometry.Vector` + Vector + The surface normal at the given parameters. """ raise NotImplementedError - def frame_at(self, u, v): - """Compute the local frame at a point on the curve. + def frame_at(self, u: float, v: float) -> Frame: + """Compute the local frame at a point on the surface. Parameters ---------- - u : float - v : float + u + The U parameter. + v + The V parameter. Returns ------- - :class:`compas.geometry.Frame` + Frame + The frame at the given parameters. """ raise NotImplementedError @@ -566,92 +586,38 @@ def frame_at(self, u, v): # Methods continued # ============================================================================== - def closest_point(self, point, return_parameters=False): - """Compute the closest point on the curve to a given point. + @overload + def closest_point(self, point: CoordinateType, return_parameters: Literal[False] = False) -> Optional[Point]: ... - Parameters - ---------- - point : Point - The point to project to the surface. - return_parameters : bool, optional - If True, return the surface UV parameters in addition to the closest point. + @overload + def closest_point( + self, + point: CoordinateType, + return_parameters: Literal[True], + ) -> Optional[tuple[Point, tuple[float, float]]]: ... - Returns - ------- - :class:`compas.geometry.Point` | tuple[:class:`compas.geometry.Point`, tuple[float, float]] - If `return_parameters` is False, the nearest point on the surface. - If `return_parameters` is True, the UV parameters in addition to the nearest point on the surface. - - """ - raise NotImplementedError - - def aabb(self, precision=0.0, optimal=False): - """Compute the axis aligned bounding box of the surface. - - Parameters - ---------- - precision : float, optional - optimal : bool, optional - - Returns - ------- - :class:`compas.geometry.Box` - - """ - raise NotImplementedError - - def obb(self, precision=0.0): - """Compute the oriented bounding box of the surface. + def closest_point( + self, + point: CoordinateType, + return_parameters: bool = False, + ) -> Optional[Union[Point, tuple[Point, tuple[float, float]]]]: + """Compute the closest point on the surface to a given point. Parameters ---------- - precision : float, optional - - Returns - ------- - :class:`compas.geometry.Box` - - """ - raise NotImplementedError - - def intersections_with_line(self, line): - """Compute the intersections with a line. - - Parameters - ---------- - line : :class:`compas.geometry.Line` - - Returns - ------- - list[:class:`compas.geometry.Point`] - - """ - raise NotImplementedError - - def intersections_with_curve(self, curve): - """Compute the intersections with a curve. - - Parameters - ---------- - line : :class:`compas.geometry.Curve` - - Returns - ------- - list[:class:`compas.geometry.Point`] - - """ - raise NotImplementedError - - def intersections_with_plane(self, plane): - """Compute the intersections with a plane. - - Parameters - ---------- - plane : :class:`compas.geometry.Plane` + point + The point to project to the surface. + return_parameters + If `True`, also return the surface UV parameters. Returns ------- - list[:class:`compas.geometry.Curve`] + Point | None + The closest point if `return_parameters` is `False`, or `None` if + the projection fails. + tuple[Point, tuple[float, float]] | None + The closest point and its UV parameters if `return_parameters` is + `True`, or `None` if the projection fails. """ raise NotImplementedError diff --git a/src/compas/geometry/surfaces/toroidal.py b/src/compas/geometry/surfaces/toroidal.py index d239749a5394..f4521a8556a7 100644 --- a/src/compas/geometry/surfaces/toroidal.py +++ b/src/compas/geometry/surfaces/toroidal.py @@ -1,13 +1,17 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import cos from math import pi from math import sin +from typing import Any +from typing import Optional + +from typing_extensions 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 .surface import Surface @@ -15,38 +19,37 @@ class ToroidalSurface(Surface): - """A spherical surface is defined by a radius and a frame. + """A ring torus defined by an axis radius, pipe radius, and frame. Parameters ---------- - radius : float - The radius of the sphere. - frame : :class:`Frame` - The frame of the sphere. - name : str, optional + radius_axis + The distance from the torus axis to the center of the pipe. + radius_pipe + The radius of the pipe. It must be smaller than `radius_axis`. + frame + The local coordinate frame. If `None`, the world XY frame is used. + name The name of the surface. Examples -------- - >>> from compas.geometry import Frame - >>> from compas.geometry import ToroidalSurface - >>> frame = Frame([0, 0, 0], [1, 0, 0], [0, 1, 0]) - >>> sphere = ToroidalSurface(1.0, frame) + >>> torus = ToroidalSurface(radius_axis=2.0, radius_pipe=0.5) + >>> torus.point_at(0.0, 0.0) + Point(x=2.500, y=0.000, z=0.000) + + A toroidal surface can also be constructed from a plane. + + >>> torus = ToroidalSurface.from_plane_and_radii(Plane.worldXY(), 2.0, 0.5) + >>> torus.radius_axis, torus.radius_pipe + (2.0, 0.5) """ - DATASCHEMA = { - "type": "object", - "properties": { - "radius_axis": {"type": "number", "minimum": 0}, - "radius_pipe": {"type": "number", "minimum": 0}, - "frame": Frame.DATASCHEMA, - }, - "required": ["radius_axis", "radius_pipe", "frame"], - } @property - def __data__(self): + def __data__(self) -> dict[str, Any]: + """The data representation of the toroidal surface.""" return { "radius_axis": self.radius_axis, "radius_pipe": self.radius_pipe, @@ -54,21 +57,27 @@ def __data__(self): } @classmethod - def __from_data__(cls, data): + def __from_data__(cls, data: dict[str, Any]) -> Self: return cls( radius_axis=data["radius_axis"], radius_pipe=data["radius_pipe"], frame=Frame.__from_data__(data["frame"]), ) - def __init__(self, radius_axis, radius_pipe, frame=None, name=None): - super(ToroidalSurface, self).__init__(frame=frame, name=name) - self._radius_axis = None - self._radius_pipe = None + def __init__( + self, + radius_axis: float, + radius_pipe: float, + frame: Optional[Frame] = None, + name: Optional[str] = None, + ) -> None: + super().__init__(frame=frame, name=name) + self._radius_axis: Optional[float] = None + self._radius_pipe: Optional[float] = None self.radius_axis = radius_axis self.radius_pipe = radius_pipe - def __repr__(self): + def __repr__(self) -> str: return "{0}(radius_axis={1}, radius_pipe={2}, frame={3!r})".format( type(self).__name__, self.radius_axis, @@ -76,58 +85,102 @@ def __repr__(self): self.frame, ) - def __eq__(self, other): - try: - other_frame = other.frame - other_radius_axis = other.radius_axis - other_radius_pipe = other.radius_pipe - except Exception: + def __eq__(self, other: object) -> bool: + if not isinstance(other, ToroidalSurface): return False - return self.radius_axis == other_radius_axis and self.radius_pipe == other_radius_pipe and self.frame == other_frame + return self.radius_axis == other.radius_axis and self.radius_pipe == other.radius_pipe and self.frame == other.frame # ============================================================================= # Properties # ============================================================================= @property - def center(self): + def center(self) -> Point: + """The center point on the torus axis. + + Notes + ----- + Assigning a point or three coordinates updates the surface frame and + creates an independent point. + + """ return self.frame.point @center.setter - def center(self, point): + def center(self, point: CoordinateType) -> None: self.frame.point = point @property - def radius_axis(self): + def radius_axis(self) -> float: + """The radius from the torus axis to the pipe center.""" if self._radius_axis is None: raise ValueError("The radius of the surface main axis has not been set yet.") return self._radius_axis @radius_axis.setter - def radius_axis(self, radius): - if radius < 0: - raise ValueError("The radius of the main axis should be larger than or equal to zero.") + def radius_axis(self, radius: float) -> None: + if radius <= 0: + raise ValueError("The axis radius of a torus should be larger than zero.") + if self._radius_pipe is not None and radius <= self._radius_pipe: + raise ValueError("The axis radius of a ring torus should be larger than its pipe radius.") self._radius_axis = float(radius) + @property + def radius_pipe(self) -> float: + """The positive radius of the torus pipe.""" + if self._radius_pipe is None: + raise ValueError("The pipe radius of the surface has not been set yet.") + return self._radius_pipe + + @radius_pipe.setter + def radius_pipe(self, radius: float) -> None: + if radius <= 0: + raise ValueError("The pipe radius of a torus should be larger than zero.") + if self._radius_axis is not None and radius >= self._radius_axis: + raise ValueError("The pipe radius of a ring torus should be smaller than its axis radius.") + self._radius_pipe = float(radius) + + @property + def area(self) -> float: + """The surface area of the torus.""" + return 4.0 * pi**2 * self.radius_axis * self.radius_pipe + + @property + def volume(self) -> float: + """The volume enclosed by the torus.""" + return 2.0 * pi**2 * self.radius_axis * self.radius_pipe**2 + + @property + def is_periodic_u(self) -> bool: + """Whether the surface is periodic in U, which is always `True`.""" + return True + + @property + def is_periodic_v(self) -> bool: + """Whether the surface is periodic in V, which is always `True`.""" + return True + # ============================================================================= # Constructors # ============================================================================= @classmethod - def from_plane_and_radius(cls, plane, radius_axis, radius_pipe): - """Construct a sphere from a plane and a radius. + def from_plane_and_radii(cls, plane: Plane, radius_axis: float, radius_pipe: float) -> Self: + """Construct a toroidal surface from a plane and two radii. Parameters ---------- - plane : :class:`compas.geometry.Plane` - The plane of the sphere. - radius : float - The radius of the sphere. + plane + The plane of the torus. + radius_axis + The distance from the torus axis to the pipe center. + radius_pipe + The radius of the pipe. Returns ------- - :class:`compas.geometry.ToroidalSurface` - A sphere. + ToroidalSurface + The constructed toroidal surface. """ return cls(radius_axis, radius_pipe, frame=Frame.from_plane(plane)) @@ -140,24 +193,60 @@ def from_plane_and_radius(cls, plane, radius_axis, radius_pipe): # Methods # ============================================================================= - def point_at(self, u, v, world=True): - """Construct a point on the sphere. + def isocurve_u(self, u: float) -> Circle: + """Compute a pipe circle at a U parameter. + + Parameters + ---------- + u + The U parameter. + + Returns + ------- + Circle + The pipe circle. + + """ + angle = u * PI2 + radial = self.frame.xaxis * cos(angle) + self.frame.yaxis * sin(angle) + origin = self.center + radial * self.radius_axis + return Circle(self.radius_pipe, frame=Frame(origin, radial, self.frame.zaxis)) + + def isocurve_v(self, v: float) -> Circle: + """Compute a circle around the torus axis at a V parameter. + + Parameters + ---------- + v + The V parameter. + + Returns + ------- + Circle + The circular isocurve around the torus axis. + + """ + angle = v * PI2 + radius = self.radius_axis + self.radius_pipe * cos(angle) + origin = self.center + self.frame.zaxis * (self.radius_pipe * sin(angle)) + return Circle(radius, frame=Frame(origin, self.frame.xaxis, self.frame.yaxis)) + + def point_at(self, u: float, v: float, world: bool = True) -> Point: + """Compute a point on the toroidal surface. Parameters ---------- - u : float - The first parameter. - The parameter value should be between zero and one, - and will be mapped to the corresponding angle between zero and pi. - v : float - The second parameter. - The parameter value should be between zero and one, - and will be mapped to the corresponding angle between zero and 2 * pi. + u + The U parameter around the torus axis, mapped to `[0, 2 * pi]`. + v + The V parameter around the pipe, mapped to `[0, 2 * pi]`. + world + If `True`, return the point in world coordinates. Returns ------- - :class:`compas.geometry.Point` - A point on the sphere. + Point + The point at the given parameters. """ u = u * PI2 @@ -170,22 +259,56 @@ def point_at(self, u, v, world=True): point.transform(self.transformation) return point - def normal_at(self, u, v, world=True): - """Construct a normal vector at a point on the sphere. + def normal_at(self, u: float, v: float, world: bool = True) -> Vector: + """Compute the outward normal at a point on the toroidal surface. + + Parameters + ---------- + u + The U parameter. + v + The V parameter. + world + If `True`, return the normal in world coordinates. + + Returns + ------- + Vector + The outward unit normal. + + """ + u = u * PI2 + v = v * PI2 + normal = Vector(cos(v) * cos(u), cos(v) * sin(u), sin(v)) + if world: + normal.transform(self.transformation) + return normal + + def frame_at(self, u: float, v: float, world: bool = True) -> Frame: + """Compute a frame at a point on the toroidal surface. Parameters ---------- - u : float - The first parameter. - v : float - The second parameter. - world : bool, optional - If ``True``, the normal vector is transformed to world coordinates. + u + The U parameter. + v + The V parameter. + world + If `True`, return the frame in world coordinates. Returns ------- - :class:`compas.geometry.Vector` - The normal vector. + Frame + The frame at the given parameters. Its X-axis follows increasing U, + its Y-axis follows increasing V, and its Z-axis is outward. """ - raise NotImplementedError + angle_u = u * PI2 + angle_v = v * PI2 + point = self.point_at(u, v, world=False) + tangent_u = Vector(-sin(angle_u), cos(angle_u), 0.0) + tangent_v = Vector(-cos(angle_u) * sin(angle_v), -sin(angle_u) * sin(angle_v), cos(angle_v)) + frame = Frame(point, tangent_u, tangent_v) + if world: + frame.transform(self.transformation) + return frame diff --git a/src/compas/geometry/transformation.py b/src/compas/geometry/transformation.py index c6816b9efda0..0988a847a124 100644 --- a/src/compas/geometry/transformation.py +++ b/src/compas/geometry/transformation.py @@ -9,20 +9,21 @@ Many thanks to Christoph Gohlke, Martin John Baker, Sachin Joglekar and Andrew Ippoliti for providing code and documentation. + """ from compas.data import Data -from compas.geometry import basis_vectors_from_matrix -from compas.geometry import decompose_matrix -from compas.geometry import identity_matrix -from compas.geometry import matrix_determinant -from compas.geometry import matrix_from_euler_angles -from compas.geometry import matrix_from_frame -from compas.geometry import matrix_from_translation -from compas.geometry import matrix_inverse -from compas.geometry import multiply_matrices -from compas.geometry import translation_from_matrix -from compas.geometry import transpose_matrix +from compas.linalg.matrices import matrix_determinant +from compas.linalg.matrices import matrix_inverse +from compas.linalg.matrices import multiply_matrices +from compas.linalg.matrices import transpose_matrix +from compas.linalg.transformations import basis_vectors_from_matrix +from compas.linalg.transformations import decompose_matrix +from compas.linalg.transformations import identity_matrix +from compas.linalg.transformations import matrix_from_euler_angles +from compas.linalg.transformations import matrix_from_frame +from compas.linalg.transformations import matrix_from_translation +from compas.linalg.transformations import translation_from_matrix from compas.tolerance import TOL @@ -78,23 +79,6 @@ class Transformation(Data): """ - DATASCHEMA = { - "type": "object", - "properties": { - "matrix": { - "type": "array", - "items": { - "type": "array", - "items": {"type": "number"}, - "minItems": 4, - "maxItems": 4, - }, - "minItems": 4, - "maxItems": 4, - }, - }, - "required": ["matrix"], - } @property def __data__(self): diff --git a/src/compas/geometry/translation.py b/src/compas/geometry/translation.py index 2b0d5a820097..5d04612b2828 100644 --- a/src/compas/geometry/translation.py +++ b/src/compas/geometry/translation.py @@ -9,12 +9,13 @@ Many thanks to Christoph Gohlke, Martin John Baker, Sachin Joglekar and Andrew Ippoliti for providing code and documentation. + """ from compas.geometry import Transformation -from compas.geometry import matrix_from_translation -from compas.geometry import translation_from_matrix 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 diff --git a/src/compas/geometry/trimesh_curvature.py b/src/compas/geometry/trimesh_curvature.py index 56a79ef3ef95..0a9249e06a36 100644 --- a/src/compas/geometry/trimesh_curvature.py +++ b/src/compas/geometry/trimesh_curvature.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import pi from compas.geometry import angle_points diff --git a/src/compas/geometry/trimesh_gradient_numpy.py b/src/compas/geometry/trimesh_gradient_numpy.py index 7987686fa4e4..0ff643bbbadc 100644 --- a/src/compas/geometry/trimesh_gradient_numpy.py +++ b/src/compas/geometry/trimesh_gradient_numpy.py @@ -1,9 +1,9 @@ import numpy as np from scipy.sparse import coo_matrix # type: ignore -from compas.linalg import normalizerow -from compas.linalg import normrow -from compas.linalg import rot90 +from compas.linalg.decompositions import normalizerow +from compas.linalg.decompositions import normrow +from compas.linalg.decompositions import rot90 def trimesh_gradient_numpy(M, rtype="array"): diff --git a/src/compas/geometry/trimesh_isolines.py b/src/compas/geometry/trimesh_isolines.py index 0c8151e7001f..8d8551fc3127 100644 --- a/src/compas/geometry/trimesh_isolines.py +++ b/src/compas/geometry/trimesh_isolines.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas.plugins import pluggable diff --git a/src/compas/geometry/trimesh_matrices.py b/src/compas/geometry/trimesh_matrices.py index 49413a1a3887..e51e34ebf249 100644 --- a/src/compas/geometry/trimesh_matrices.py +++ b/src/compas/geometry/trimesh_matrices.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas.plugins import pluggable diff --git a/src/compas/geometry/trimesh_matrices_numpy.py b/src/compas/geometry/trimesh_matrices_numpy.py index d3a1f2186c54..d3b680927243 100644 --- a/src/compas/geometry/trimesh_matrices_numpy.py +++ b/src/compas/geometry/trimesh_matrices_numpy.py @@ -5,10 +5,10 @@ from scipy.sparse import coo_matrix from scipy.sparse import spdiags -from compas.geometry import cross_vectors -from compas.geometry import dot_vectors -from compas.geometry import length_vector -from compas.linalg import normrow +from compas.linalg.decompositions import normrow +from compas.linalg.vectors import cross_vectors +from compas.linalg.vectors import dot_vectors +from compas.linalg.vectors import length_vector def trimesh_edge_cotangent(mesh, edge): diff --git a/src/compas/geometry/trimesh_parametrisation.py b/src/compas/geometry/trimesh_parametrisation.py index 336b5b622b6c..62dace0d27a1 100644 --- a/src/compas/geometry/trimesh_parametrisation.py +++ b/src/compas/geometry/trimesh_parametrisation.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas.plugins import pluggable diff --git a/src/compas/geometry/trimesh_pull_points_numpy.py b/src/compas/geometry/trimesh_pull_points_numpy.py index 0b686339bab5..48fa052b41d3 100644 --- a/src/compas/geometry/trimesh_pull_points_numpy.py +++ b/src/compas/geometry/trimesh_pull_points_numpy.py @@ -2,10 +2,10 @@ from scipy.linalg import solve from scipy.spatial import distance_matrix -from compas.geometry import cross_vectors from compas.geometry import is_ccw_xy from compas.geometry import is_point_in_triangle -from compas.linalg import normalizerow +from compas.linalg.decompositions import normalizerow +from compas.linalg.vectors import cross_vectors def trimesh_pull_points_numpy(M, points): diff --git a/src/compas/geometry/trimesh_slicing.py b/src/compas/geometry/trimesh_slicing.py index f1e9b114370a..904c4500d943 100644 --- a/src/compas/geometry/trimesh_slicing.py +++ b/src/compas/geometry/trimesh_slicing.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas.plugins import pluggable diff --git a/src/compas/geometry/vector.py b/src/compas/geometry/vector.py index 135710b48aa6..9790e4417dec 100644 --- a/src/compas/geometry/vector.py +++ b/src/compas/geometry/vector.py @@ -1,44 +1,42 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import Iterator +from typing import Optional +from typing import Sequence +from typing import Type +from typing import Union +from typing import overload +from typing_extensions import Self + +from compas._typing import Coordinate from compas.geometry import Geometry from compas.geometry import angle_vectors from compas.geometry import angle_vectors_signed from compas.geometry import angles_vectors -from compas.geometry import cross_vectors -from compas.geometry import dot_vectors -from compas.geometry import length_vector -from compas.geometry import subtract_vectors from compas.geometry import transform_vectors +from compas.linalg.vectors import cross_vectors +from compas.linalg.vectors import dot_vectors +from compas.linalg.vectors import length_vector +from compas.linalg.vectors import subtract_vectors from compas.tolerance import TOL +from ._typing import CoordinateType +from ._typing import TransformationType + class Vector(Geometry): """A vector is defined by XYZ components and a homogenisation factor. Parameters ---------- - x : float + x The X component of the vector. - y : float + y The Y component of the vector. - z : float + z The Z component of the vector. - name : str, optional + name The name of the vector. - Attributes - ---------- - x : float - The X coordinate of the point. - y : float - The Y coordinate of the point. - z : float - The Z coordinate of the point. - length : float, read-only - The length of this vector. - Examples -------- >>> u = Vector(1, 0, 0) @@ -54,6 +52,17 @@ class Vector(Geometry): 1.0 >>> u.length 1.0 + >>> len(u) + 3 + >>> list(u) + [1.0, 0.0, 0.0] + >>> u[0] = 2.0 + >>> u == [2.0, 0.0, 0.0] + True + >>> u.x = 1.0 + + Addition and subtraction operate component-wise. Multiplication and + division accept either a scalar or another coordinate sequence. >>> result = u + v >>> print(result) @@ -67,6 +76,27 @@ class Vector(Geometry): >>> print(result) Vector(x=2.000, y=0.000, z=0.000) + >>> result = u / [2.0, 1.0, 1.0] + >>> print(result) + Vector(x=0.500, y=0.000, z=0.000) + + Reflected operators apply the coordinate sequence on the left. + + >>> list([2.0, 3.0, 4.0] * u) + [2.0, 0.0, 0.0] + >>> list([2.0, 3.0, 4.0] - u) + [1.0, 3.0, 4.0] + + In-place operators modify and return the original vector. + + >>> identity = id(u) + >>> u += [1.0, 2.0, 3.0] + >>> id(u) == identity + True + >>> list(u) + [2.0, 2.0, 3.0] + + >>> u = Vector(1, 0, 0) >>> u.dot(v) 0.0 @@ -74,35 +104,42 @@ class Vector(Geometry): >>> print(w) Vector(x=0.000, y=0.000, z=1.000) - """ + Unit vectors along the world axes are available through classmethods. + + >>> Vector.Xaxis() == [1.0, 0.0, 0.0] + True + >>> Vector.Yaxis() == [0.0, 1.0, 0.0] + True + >>> Vector.Zaxis() == [0.0, 0.0, 1.0] + True + + A vector can also be constructed from start and end coordinates. - DATASCHEMA = { - "type": "array", - "minItems": 3, - "maxItems": 3, - "items": {"type": "number"}, - } + >>> Vector.from_start_end([1.0, 2.0, 3.0], [4.0, 6.0, 3.0]) == [3.0, 4.0, 0.0] + True + + """ @property - def __data__(self): + def __data__(self) -> list[float]: # type: ignore[override] return list(self) @classmethod - def __from_data__(cls, data): - return cls(*data) + def __from_data__(cls, data: Sequence[float]) -> Self: # type: ignore[override] + return cls(data[0], data[1], data[2]) - def __init__(self, x, y, z=0.0, name=None): - super(Vector, self).__init__(name=name) + def __init__(self, x: float, y: float, z: float = 0.0, name: Optional[str] = None) -> None: + super().__init__(name=name) self._x = 0.0 self._y = 0.0 self._z = 0.0 - self._direction = None - self._magnitude = None + self._direction: Optional[Vector] = None + self._magnitude: Optional[float] = None self.x = x self.y = y self.z = z - def __repr__(self): + def __repr__(self) -> str: return "{0}(x={1}, y={2}, z={3})".format( type(self).__name__, self.x, @@ -110,7 +147,7 @@ def __repr__(self): self.z, ) - def __str__(self): + def __str__(self) -> str: return "{0}(x={1}, y={2}, z={3})".format( type(self).__name__, TOL.format_number(self.x), @@ -118,119 +155,229 @@ def __str__(self): TOL.format_number(self.z), ) - def __len__(self): + def __len__(self) -> int: return 3 - def __getitem__(self, key): + @overload + def __getitem__(self, key: int) -> float: ... + + @overload + def __getitem__(self, key: slice) -> list[float]: ... + + def __getitem__(self, key: Union[int, slice]) -> Union[float, list[float]]: if isinstance(key, slice): return [self[i] for i in range(*key.indices(len(self)))] - i = key % 3 - if i == 0: + if key == 0 or key == -3: return self.x - if i == 1: + if key == 1 or key == -2: return self.y - if i == 2: + if key == 2 or key == -1: return self.z - raise KeyError + raise IndexError("Vector index out of range.") - def __setitem__(self, key, value): - i = key % 3 - if i == 0: + def __setitem__(self, key: int, value: float) -> None: + if key == 0 or key == -3: self.x = value return - if i == 1: + if key == 1 or key == -2: self.y = value return - if i == 2: + if key == 2 or key == -1: self.z = value return - raise KeyError + raise IndexError("Vector assignment index out of range.") - def __iter__(self): + def __iter__(self) -> Iterator[float]: return iter([self.x, self.y, self.z]) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + if not isinstance(other, Coordinate) or len(other) != 3: + return False return TOL.is_allclose(self, other) - def __add__(self, other): + def __add__(self, other: CoordinateType) -> "Vector": + """Return the coordinate-wise sum of this vector and another coordinate. + + Examples + -------- + >>> list(Vector(1, 2, 3) + [4, 5, 6]) + [5.0, 7.0, 9.0] + + """ return Vector(self.x + other[0], self.y + other[1], self.z + other[2]) - def __sub__(self, other): + def __sub__(self, other: CoordinateType) -> "Vector": + """Return the coordinate-wise difference with another coordinate. + + Examples + -------- + >>> list(Vector(4, 5, 6) - [1, 2, 3]) + [3.0, 3.0, 3.0] + + """ return Vector(self.x - other[0], self.y - other[1], self.z - other[2]) - def __mul__(self, other): + def __mul__(self, other: Union[float, CoordinateType]) -> "Vector": + """Multiply by a scalar or multiply component-wise by another coordinate. + + Examples + -------- + >>> list(Vector(1, 2, 3) * [2, 3, 4]) + [2.0, 6.0, 12.0] + + """ if isinstance(other, (int, float)): return Vector(self.x * other, self.y * other, self.z * other) try: - other = Vector(*other) - return Vector(self.x * other.x, self.y * other.y, self.z * other.z) + z = other[2] if len(other) > 2 else 0.0 + return Vector(self.x * other[0], self.y * other[1], self.z * z) except TypeError: raise TypeError("Cannot cast {} {} to Vector".format(other, type(other))) - def __truediv__(self, other): + def __truediv__(self, other: Union[float, CoordinateType]) -> "Vector": + """Divide by a scalar or divide component-wise by another coordinate. + + Examples + -------- + >>> list(Vector(2, 6, 12) / [2, 3, 4]) + [1.0, 2.0, 3.0] + + """ if isinstance(other, (int, float)): return Vector(self.x / other, self.y / other, self.z / other) try: - other = Vector(*other) - return Vector(self.x / other.x, self.y / other.y, self.z / other.z) + z = other[2] if len(other) > 2 else 0.0 + return Vector(self.x / other[0], self.y / other[1], self.z / z) except TypeError: raise TypeError("Cannot cast {} {} to Vector".format(other, type(other))) - def __pow__(self, n): + def __pow__(self, n: float) -> "Vector": return Vector(self.x**n, self.y**n, self.z**n) - def __neg__(self): + def __neg__(self) -> Self: return self.scaled(-1.0) - def __iadd__(self, other): + def __iadd__(self, other: CoordinateType) -> Self: + """Add another coordinate to this vector in place and return this vector. + + Examples + -------- + >>> vector = Vector(1, 2, 3) + >>> vector += [4, 5, 6] + >>> list(vector) + [5.0, 7.0, 9.0] + + """ self.x += other[0] self.y += other[1] self.z += other[2] return self - def __isub__(self, other): + def __isub__(self, other: CoordinateType) -> Self: + """Subtract another coordinate from this vector in place and return this vector. + + Examples + -------- + >>> vector = Vector(4, 5, 6) + >>> vector -= [1, 2, 3] + >>> list(vector) + [3.0, 3.0, 3.0] + + """ self.x -= other[0] self.y -= other[1] self.z -= other[2] return self - def __imul__(self, n): + def __imul__(self, n: float) -> Self: + """Multiply this vector by a scalar in place and return this vector. + + Examples + -------- + >>> vector = Vector(1, 2, 3) + >>> vector *= 2 + >>> list(vector) + [2.0, 4.0, 6.0] + + """ self.x *= n self.y *= n self.z *= n return self - def __itruediv__(self, n): + def __itruediv__(self, n: float) -> Self: + """Divide this vector by a scalar in place and return this vector. + + Examples + -------- + >>> vector = Vector(2, 4, 6) + >>> vector /= 2 + >>> list(vector) + [1.0, 2.0, 3.0] + + """ self.x /= n self.y /= n self.z /= n return self - def __ipow__(self, n): + def __ipow__(self, n: float) -> Self: self.x **= n self.y **= n self.z **= n return self - def __rmul__(self, n): - return self.__mul__(n) + def __rmul__(self, other: Union[float, CoordinateType]) -> "Vector": + """Multiply by a scalar or component-wise coordinate on the left. + + Examples + -------- + >>> list([2, 3, 4] * Vector(1, 2, 3)) + [2.0, 6.0, 12.0] + + """ + return self.__mul__(other) - def __radd__(self, other): + def __radd__(self, other: CoordinateType) -> "Vector": + """Return the coordinate-wise sum with another coordinate on the left. + + Examples + -------- + >>> list([4, 5, 6] + Vector(1, 2, 3)) + [5.0, 7.0, 9.0] + + """ return self.__add__(other) - def __rsub__(self, other): + def __rsub__(self, other: CoordinateType) -> "Vector": + """Subtract this vector component-wise from a coordinate on the left. + + Examples + -------- + >>> list([4, 5, 6] - Vector(1, 2, 3)) + [3.0, 3.0, 3.0] + + """ try: - other = Vector(*other) - return other - self + z = other[2] if len(other) > 2 else 0.0 + return Vector(other[0] - self.x, other[1] - self.y, z - self.z) except TypeError: raise TypeError("Cannot cast {} {} to Vector".format(other, type(other))) - def __rtruediv__(self, other): + def __rtruediv__(self, other: CoordinateType) -> "Vector": + """Divide a coordinate on the left component-wise by this vector. + + Examples + -------- + >>> list([2, 6, 12] / Vector(2, 3, 4)) + [1.0, 2.0, 3.0] + + """ try: - other = Vector(*other) - return other / self + z = other[2] if len(other) > 2 else 0.0 + return Vector(other[0] / self.x, other[1] / self.y, z / self.z) except TypeError: raise TypeError("Cannot cast {} {} to Vector".format(other, type(other))) @@ -239,47 +386,47 @@ def __rtruediv__(self, other): # ========================================================================== @property - def x(self): + def x(self) -> float: return self._x @x.setter - def x(self, x): + def x(self, x: float) -> None: self._x = float(x) self._direction = None self._magnitude = None @property - def y(self): + def y(self) -> float: return self._y @y.setter - def y(self, y): + def y(self, y: float) -> None: self._y = float(y) self._direction = None self._magnitude = None @property - def z(self): + def z(self) -> float: return self._z @z.setter - def z(self, z): + def z(self, z: float) -> None: self._z = float(z) self._direction = None self._magnitude = None @property - def magnitude(self): + def magnitude(self) -> float: if self._magnitude is None: self._magnitude = length_vector(self) return self._magnitude @property - def length(self): + def length(self) -> float: return self.magnitude @property - def direction(self): + def direction(self) -> "Vector": if not self._direction: self._direction = self.unitized() return self._direction @@ -289,13 +436,13 @@ def direction(self): # ========================================================================== @classmethod - def Xaxis(cls): + def Xaxis(cls) -> Self: """Construct a unit vector along the X axis. Returns ------- - :class:`compas.geometry.Vector` - A vector with components ``x = 1.0, y = 0.0, z = 0.0``. + Self + A vector with components `x = 1.0, y = 0.0, z = 0.0`. Examples -------- @@ -306,13 +453,13 @@ def Xaxis(cls): return cls(1.0, 0.0, 0.0) @classmethod - def Yaxis(cls): + def Yaxis(cls) -> Self: """Construct a unit vector along the Y axis. Returns ------- - :class:`compas.geometry.Vector` - A vector with components ``x = 0.0, y = 1.0, z = 0.0``. + Self + A vector with components `x = 0.0, y = 1.0, z = 0.0`. Examples -------- @@ -323,13 +470,13 @@ def Yaxis(cls): return cls(0.0, 1.0, 0.0) @classmethod - def Zaxis(cls): + def Zaxis(cls) -> Self: """Construct a unit vector along the Z axis. Returns ------- - :class:`compas.geometry.Vector` - A vector with components ``x = 0.0, y = 0.0, z = 1.0``. + Self + A vector with components `x = 0.0, y = 0.0, z = 1.0`. Examples -------- @@ -340,19 +487,19 @@ def Zaxis(cls): return cls(0.0, 0.0, 1.0) @classmethod - def from_start_end(cls, start, end): + def from_start_end(cls, start: CoordinateType, end: CoordinateType) -> Self: """Construct a vector from start and end points. Parameters ---------- - start : [float, float, float] | :class:`compas.geometry.Point` + start The start point. - end : [float, float, float] | :class:`compas.geometry.Point` + end The end point. Returns ------- - :class:`compas.geometry.Vector` + Self The vector from start to end. Examples @@ -363,24 +510,23 @@ def from_start_end(cls, start, end): """ v = subtract_vectors(end, start) - return cls(*v) + z = v[2] if len(v) > 2 else 0.0 + return cls(v[0], v[1], z) # ========================================================================== # Static # ========================================================================== @staticmethod - def transform_collection(collection, X): + def transform_collection(collection: Sequence["Vector"], transformation: TransformationType) -> None: """Transform a collection of vector objects. Parameters ---------- - collection : list[[float, float, float] | :class:`compas.geometry.Vector`] + collection The collection of vectors. - - Returns - ------- - None + transformation + The transformation. Examples -------- @@ -396,24 +542,26 @@ def transform_collection(collection, X): True """ - data = transform_vectors(collection, X) + data = transform_vectors(collection, transformation) for vector, xyz in zip(collection, data): vector.x = xyz[0] vector.y = xyz[1] vector.z = xyz[2] @staticmethod - def transformed_collection(collection, X): + def transformed_collection(collection: Sequence["Vector"], transformation: TransformationType) -> list["Vector"]: """Create a collection of transformed vectors. Parameters ---------- - collection : list[[float, float, float] | :class:`compas.geometry.Vector`] + collection The collection of vectors. + transformation + The transformation. Returns ------- - list[:class:`compas.geometry.Vector`] + list[Vector] The transformed vectors. Examples @@ -431,16 +579,16 @@ def transformed_collection(collection, X): """ vectors = [vector.copy() for vector in collection] - Vector.transform_collection(vectors, X) + Vector.transform_collection(vectors, transformation) return vectors @staticmethod - def length_vectors(vectors): + def length_vectors(vectors: Sequence[CoordinateType]) -> list[float]: """Compute the length of multiple vectors. Parameters ---------- - vectors : list[[float, float, float] | :class:`compas.geometry.Vector`] + vectors A list of vectors. Returns @@ -458,17 +606,17 @@ def length_vectors(vectors): return [length_vector(vector) for vector in vectors] @staticmethod - def sum_vectors(vectors): + def sum_vectors(vectors: Sequence[CoordinateType]) -> "Vector": """Compute the sum of multiple vectors. Parameters ---------- - vectors : list[[float, float, float] | :class:`compas.geometry.Vector`] + vectors A list of vectors. Returns ------- - :class:`compas.geometry.Vector` + Vector A vector that is the sum of the vectors. Examples @@ -478,17 +626,19 @@ def sum_vectors(vectors): Vector(x=3.000, y=0.000, z=0.000) """ - return Vector(*[sum(axis) for axis in zip(*vectors)]) + data = [sum(axis) for axis in zip(*vectors)] + z = data[2] if len(data) > 2 else 0.0 + return Vector(data[0], data[1], z) @staticmethod - def dot_vectors(left, right): + def dot_vectors(left: Sequence[CoordinateType], right: Sequence[CoordinateType]) -> list[float]: """Compute the dot product of two lists of vectors. Parameters ---------- - left : list[[float, float, float] | :class:`compas.geometry.Vector`] + left A list of vectors. - right : list[[float, float, float] | :class:`compas.geometry.Vector`] + right A list of vectors. Returns @@ -503,22 +653,21 @@ def dot_vectors(left, right): [1.0, 4.0] """ - return [Vector.dot(u, v) for u, v in zip(left, right)] + return [dot_vectors(u, v) for u, v in zip(left, right)] @staticmethod - def cross_vectors(left, right): + def cross_vectors(left: Sequence[CoordinateType], right: Sequence[CoordinateType]) -> list["Vector"]: """Compute the cross product of two lists of vectors. Parameters ---------- - left : list[[float, float, float] | :class:`compas.geometry.Vector`] + left A list of vectors. - right : list[[float, float, float] | :class:`compas.geometry.Vector`] + right A list of vectors. Returns - ------- - list[:class:`compas.geometry.Vector`] + list[Vector] A list of cross products. Examples @@ -529,22 +678,26 @@ def cross_vectors(left, right): """ # cross_vectors(u,v) from src\compas\geometry\_core\_algebra.py - return [Vector(*cross_vectors(u, v)) for u, v in zip(left, right)] + vectors = [] + for u, v in zip(left, right): + coordinates = cross_vectors(u, v) + vectors.append(Vector(coordinates[0], coordinates[1], coordinates[2])) + return vectors @staticmethod - def angles_vectors(left, right): + def angles_vectors(left: Sequence[CoordinateType], right: Sequence[CoordinateType]) -> list[tuple[float, float]]: """Compute both angles between corresponding pairs of two lists of vectors. Parameters ---------- - left : list[[float, float, float] | :class:`compas.geometry.Vector`] + left A list of vectors. - right : list[[float, float, float] | :class:`compas.geometry.Vector`] + right A list of vectors. Returns ------- - list[float] + list[tuple[float, float]] A list of angle pairs. Examples @@ -557,14 +710,14 @@ def angles_vectors(left, right): return [angles_vectors(u, v) for u, v in zip(left, right)] @staticmethod - def angle_vectors(left, right): + def angle_vectors(left: Sequence[CoordinateType], right: Sequence[CoordinateType]) -> list[float]: """Compute the smallest angle between corresponding pairs of two lists of vectors. Parameters ---------- - left : list[[float, float, float] | :class:`compas.geometry.Vector`] + left A list of vectors. - right : list[[float, float, float] | :class:`compas.geometry.Vector`] + right A list of vectors. Returns @@ -585,12 +738,20 @@ def angle_vectors(left, right): # Helpers # ========================================================================== - def copy(self): + def copy(self, cls: Optional[Type[Self]] = None, copy_guid: bool = False) -> Self: # type: ignore[override] """Make a copy of this vector. + Parameters + ---------- + cls + The type of vector to return. + Defaults to the type of the current vector. + copy_guid + If True, the copy will have the same GUID as the original. + Returns ------- - :class:`compas.geometry.Vector` + Self The copy. Examples @@ -603,20 +764,20 @@ def copy(self): False """ - cls = type(self) - return cls(self.x, self.y, self.z) + if cls is None: + cls = type(self) + vector = cls.__from_data__([self.x, self.y, self.z]) + if copy_guid: + vector._guid = self.guid + return vector # ========================================================================== # Methods # ========================================================================== - def unitize(self): + def unitize(self) -> None: """Scale this vector to unit length. - Returns - ------- - None - Examples -------- >>> u = Vector(1.0, 2.0, 3.0) @@ -630,12 +791,12 @@ def unitize(self): self.y = self.y / length self.z = self.z / length - def unitized(self): - """Returns a unitized copy of this vector. + def unitized(self) -> Self: + """Return a unitized copy of this vector. Returns ------- - :class:`compas.geometry.Vector` + Self A unitized copy of the vector. Examples @@ -652,16 +813,12 @@ def unitized(self): v.unitize() return v - def invert(self): - """Invert the direction of this vector - - Returns - ------- - None + def invert(self) -> None: + """Invert the direction of this vector. Notes ----- - a negation of a vector is similar to inverting a vector + Negating a vector is equivalent to inverting it. Examples -------- @@ -681,12 +838,13 @@ def invert(self): flip = invert - def inverted(self): - """Returns a inverted copy of this vector + def inverted(self) -> Self: + """Return an inverted copy of this vector. Returns ------- - :class:`compas.geometry.Vector` + Self + The inverted copy. Examples -------- @@ -701,17 +859,19 @@ def inverted(self): flipped = inverted - def scale(self, n): - """Scale this vector by a factor n. + def scale(self, x: float, y: Optional[float] = None, z: Optional[float] = None) -> None: + """Scale this vector by one or more factors. Parameters ---------- - n : float - The scaling factor. - - Returns - ------- - None + x + The scaling factor in the X direction. + y + The scaling factor in the Y direction. + Defaults to `x`. + z + The scaling factor in the Z direction. + Defaults to `x`. Examples -------- @@ -721,21 +881,31 @@ def scale(self, n): 3.0 """ - self.x *= n - self.y *= n - self.z *= n + if y is None: + y = x + if z is None: + z = x + self.x *= x + self.y *= y + self.z *= z - def scaled(self, n): - """Returns a scaled copy of this vector. + def scaled(self, x: float, y: Optional[float] = None, z: Optional[float] = None) -> Self: + """Return a scaled copy of this vector. Parameters ---------- - n : float - The scaling factor. + x + The scaling factor in the X direction. + y + The scaling factor in the Y direction. + Defaults to `x`. + z + The scaling factor in the Z direction. + Defaults to `x`. Returns ------- - :class:`compas.geometry.Vector` + Self A scaled copy of the vector. Examples @@ -749,15 +919,15 @@ def scaled(self, n): """ v = self.copy() - v.scale(n) + v.scale(x, y, z) return v - def dot(self, other): + def dot(self, other: CoordinateType) -> float: """The dot product of this vector and another vector. Parameters ---------- - other : [float, float, float] | :class:`compas.geometry.Vector` + other The other vector. Returns @@ -775,17 +945,17 @@ def dot(self, other): """ return dot_vectors(self, other) - def cross(self, other): + def cross(self, other: CoordinateType) -> "Vector": """The cross product of this vector and another vector. Parameters ---------- - other : [float, float, float] | :class:`compas.geometry.Vector` + other The other vector. Returns ------- - :class:`compas.geometry.Vector` + Vector The cross product. Examples @@ -797,15 +967,18 @@ def cross(self, other): Vector(x=0.000, y=0.000, z=1.000) """ - return Vector(*cross_vectors(self, other)) + coordinates = cross_vectors(self, other) + return Vector(coordinates[0], coordinates[1], coordinates[2]) - def angle(self, other, degrees=False): + def angle(self, other: CoordinateType, degrees: bool = False) -> float: """Compute the smallest angle between this vector and another vector. Parameters ---------- - other : [float, float, float] | :class:`compas.geometry.Vector` + other The other vector. + degrees + If True, return the angle in degrees. Returns ------- @@ -822,14 +995,14 @@ def angle(self, other, degrees=False): """ return angle_vectors(self, other, deg=degrees) - def angle_signed(self, other, normal): + def angle_signed(self, other: CoordinateType, normal: CoordinateType) -> float: """Compute the signed angle between this vector and another vector. Parameters ---------- - other : [float, float, float] | :class:`compas.geometry.Vector` + other The other vector. - normal : [float, float, float] | :class:`compas.geometry.Vector` + normal The plane's normal spanned by this and the other vector. Returns @@ -849,12 +1022,12 @@ def angle_signed(self, other, normal): """ return angle_vectors_signed(self, other, normal) - def angles(self, other): + def angles(self, other: CoordinateType) -> tuple[float, float]: """Compute both angles between this vector and another vector. Parameters ---------- - other : [float, float, float] | :class:`compas.geometry.Vector` + other The other vector. Returns @@ -872,16 +1045,18 @@ def angles(self, other): """ return angles_vectors(self, other) - def component(self, other): + def component(self, other: CoordinateType) -> "Vector": """Compute the component of this vector in the direction of another vector. Parameters ---------- - other : [float, float, float] | :class:`compas.geometry.Vector` + other + The other vector. Returns ------- - :class:`compas.geometry.Vector` + Vector + The component in the direction of the other vector. """ cosa = self.dot(other) @@ -890,18 +1065,14 @@ def component(self, other): component.scale(cosa / L) return component - def transform(self, T): + def transform(self, transformation: TransformationType) -> None: """Transform this vector. Parameters ---------- - T : :class:`compas.geometry.Transformation` | list[list[float]] + transformation The transformation. - Returns - ------- - None - Examples -------- >>> from compas.geometry import Rotation @@ -912,22 +1083,22 @@ def transform(self, T): Vector(x=0.000, y=1.000, z=0.000) """ - point = transform_vectors([self], T)[0] - self.x = point[0] - self.y = point[1] - self.z = point[2] + transformed_vector = transform_vectors([self], transformation)[0] + self.x = transformed_vector[0] + self.y = transformed_vector[1] + self.z = transformed_vector[2] - def transformed(self, T): + def transformed(self, transformation: TransformationType) -> Self: """Return a transformed copy of this vector. Parameters ---------- - T : :class:`compas.geometry.Transformation` | list[list[float]] + transformation The transformation. Returns ------- - :class:`compas.geometry.Vector` + Self The transformed copy. Examples @@ -941,5 +1112,5 @@ def transformed(self, T): """ vector = self.copy() - vector.transform(T) + vector.transform(transformation) return vector diff --git a/src/compas/itertools.py b/src/compas/itertools.py index 5c0a4fa6d625..ece330cf2d04 100644 --- a/src/compas/itertools.py +++ b/src/compas/itertools.py @@ -1,8 +1,5 @@ # recipes with itertools # see: https://docs.python.org/3.6/library/itertools.html -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from functools import reduce from itertools import chain @@ -16,18 +13,6 @@ except ImportError: from itertools import izip_longest as zip_longest # type: ignore -__all__ = [ - "normalize_values", - "remap_values", - "meshgrid", - "linspace", - "flatten", - "reshape", - "pairwise", - "window", - "iterable_like", -] - def normalize_values(values, new_min=0.0, new_max=1.0): """Normalize a list of numbers to the range between new_min and new_max. @@ -401,6 +386,7 @@ def take(n, iterable): -------- >>> take(5, range(100)) [0, 1, 2, 3, 4] + """ return list(islice(iterable, n)) @@ -414,6 +400,7 @@ def padnone(iterable): """Returns the sequence elements and then returns None indefinitely. Useful for emulating the behavior of the built-in map() function. + """ return chain(iterable, repeat(None)) diff --git a/src/compas/linalg/__init__.py b/src/compas/linalg/__init__.py new file mode 100644 index 000000000000..8ba86cf49c7d --- /dev/null +++ b/src/compas/linalg/__init__.py @@ -0,0 +1,114 @@ +"""Linear algebra utilities for vectors, matrices, and quaternions.""" + +# The imports below intentionally define the public package-level API. +# ruff: noqa: F401 + +from .vectors import add_vectors +from .vectors import add_vectors_xy +from .vectors import allclose +from .vectors import argmax +from .vectors import argmin +from .transformations import axis_and_angle_from_matrix +from .quaternions import axis_angle_from_quaternion +from .transformations import axis_angle_vector_from_matrix +from .transformations import basis_vectors_from_matrix +from .vectors import close +from .transformations import compose_matrix +from .vectors import cross_vectors +from .vectors import cross_vectors_xy +from .transformations import decompose_matrix +from .vectors import dehomogenize_vectors +from .vectors import divide_vectors +from .vectors import divide_vectors_xy +from .vectors import dot_vectors +from .vectors import dot_vectors_xy +from .transformations import euler_angles_from_matrix +from .quaternions import euler_angles_from_quaternion +from .vectors import homogenize_vectors +from .transformations import identity_matrix +from .matrices import is_matrix_square +from .vectors import length_vector +from .vectors import length_vector_sqrd +from .vectors import length_vector_sqrd_xy +from .vectors import length_vector_xy +from .matrices import matrix_determinant +from .transformations import matrix_from_axis_and_angle +from .transformations import matrix_from_axis_angle_vector +from .transformations import matrix_from_basis_vectors +from .transformations import matrix_from_change_of_basis +from .transformations import matrix_from_euler_angles +from .transformations import matrix_from_frame +from .transformations import matrix_from_frame_to_frame +from .transformations import matrix_from_orthogonal_projection +from .transformations import matrix_from_parallel_projection +from .transformations import matrix_from_perspective_entries +from .transformations import matrix_from_perspective_projection +from .transformations import matrix_from_quaternion +from .transformations import matrix_from_scale_factors +from .transformations import matrix_from_shear +from .transformations import matrix_from_shear_entries +from .transformations import matrix_from_translation +from .matrices import matrix_inverse +from .matrices import matrix_minor +from .matrices import multiply_matrices +from .matrices import multiply_matrix_vector +from .vectors import multiply_vectors +from .vectors import multiply_vectors_xy +from .vectors import norm_vector +from .vectors import norm_vectors +from .vectors import normalize_vector +from .vectors import normalize_vector_xy +from .vectors import normalize_vectors +from .vectors import normalize_vectors_xy +from .vectors import orthonormalize_vectors +from .vectors import power_vector +from .vectors import power_vectors +from .quaternions import quaternion_from_axis_angle +from .quaternions import quaternion_from_euler_angles +from .transformations import quaternion_from_matrix +from .vectors import scale_vector +from .vectors import scale_vector_xy +from .vectors import scale_vectors +from .vectors import scale_vectors_xy +from .vectors import square_vector +from .vectors import square_vectors +from .vectors import subtract_vectors +from .vectors import subtract_vectors_xy +from .vectors import sum_vectors +from .transformations import translation_from_matrix +from .matrices import transpose_matrix +from .vectors import vector_average +from .vectors import vector_component +from .vectors import vector_component_xy +from .vectors import vector_standard_deviation +from .vectors import vector_variance +from .decompositions import chofactor +from .decompositions import dof +from .decompositions import lufactorized +from .decompositions import memoize +from .decompositions import Memoized +from .decompositions import nonpivots +from .decompositions import normalizerow +from .decompositions import normrow +from .decompositions import nullspace +from .decompositions import pivots +from .decompositions import rank +from .decompositions import rot90 +from .decompositions import rref +from .solvers import solve_with_known +from .solvers import spsolve_with_known +from .decompositions import uvw_lengths +from .operators import adjacency_matrix +from .operators import connectivity_matrix +from .operators import degree_matrix +from .operators import equilibrium_matrix +from .operators import face_matrix +from .operators import laplacian_matrix +from .operators import mass_matrix +from .operators import stiffness_matrix +from .quaternions import quaternion_canonize +from .quaternions import quaternion_conjugate +from .quaternions import quaternion_is_unit +from .quaternions import quaternion_multiply +from .quaternions import quaternion_norm +from .quaternions import quaternion_unitize diff --git a/src/compas/linalg.py b/src/compas/linalg/decompositions.py similarity index 70% rename from src/compas/linalg.py rename to src/compas/linalg/decompositions.py index 3f0f6cd53561..15010ebc609f 100644 --- a/src/compas/linalg.py +++ b/src/compas/linalg/decompositions.py @@ -1,50 +1,58 @@ import sys from functools import wraps +from typing import Any +from typing import Callable +from typing import Literal +from typing import Optional +from typing import Union +from typing import cast +from typing import overload from numpy import absolute from numpy import array from numpy import asarray from numpy import atleast_2d +from numpy import bool_ from numpy import cross +from numpy import integer from numpy import nan_to_num from numpy import nonzero from numpy import sum from numpy.linalg import cond -from scipy.linalg import cho_factor # type: ignore -from scipy.linalg import cho_solve # type: ignore -from scipy.linalg import lstsq # type: ignore -from scipy.linalg import qr # type: ignore -from scipy.linalg import svd # type: ignore -from scipy.sparse.linalg import factorized # type: ignore -from scipy.sparse.linalg import spsolve # type: ignore +from numpy.typing import ArrayLike +from numpy.typing import NDArray +from scipy.linalg import cho_factor +from scipy.linalg import qr +from scipy.linalg import svd +from scipy.sparse.linalg import factorized # ============================================================================== # Fundamentals # ============================================================================== -def nullspace(A, tol=0.001): +def nullspace(A: ArrayLike, tol: float = 0.001) -> NDArray[Any]: r"""Calculates the nullspace of the input matrix A. Parameters ---------- - A : array-like + A Matrix A represented as an array or list. - tol : float + tol Tolerance. Returns ------- - array + NDArray[Any] Null(A). Notes ----- The nullspace is the set of vector solutions to the equation - .. math:: - - \mathbf{A} \mathbf{x} = 0 + $$ + \mathbf{A} \mathbf{x} = 0 + $$ where 0 is a vector of zeros. @@ -72,19 +80,19 @@ def nullspace(A, tol=0.001): return null -def rank(A, tol=0.001): +def rank(A: ArrayLike, tol: float = 0.001) -> integer[Any]: r"""Calculates the rank of the input matrix A. Parameters ---------- - A : array-like + A Matrix A represented as an array or list. - tol : float + tol Tolerance. Returns ------- - int + numpy.integer[Any] rank(A) Notes @@ -94,37 +102,43 @@ def rank(A, tol=0.001): Examples -------- - >>> rank([[1, 2, 1], [-2, -3, 1], [3, 5, 0]]) + >>> int(rank([[1, 2, 1], [-2, -3, 1], [3, 5, 0]])) 2 """ A = atleast_2d(asarray(A, dtype=float)) - s = svd(A, compute_uv=False) + # SciPy's return type does not narrow to an array when compute_uv is False. + s = cast(NDArray[Any], svd(A, compute_uv=False)) tol = s[0] * tol r = (s >= tol).sum() return r -def dof(A, tol=0.001, condition=False): +@overload +def dof(A: ArrayLike, tol: float = 0.001, condition: Literal[False] = False) -> tuple[integer[Any], integer[Any]]: ... + + +@overload +def dof(A: ArrayLike, tol: float, condition: Literal[True]) -> tuple[integer[Any], integer[Any], float]: ... + + +def dof(A: ArrayLike, tol: float = 0.001, condition: bool = False) -> Union[tuple[integer[Any], integer[Any]], tuple[integer[Any], integer[Any], float]]: r"""Returns the degrees-of-freedom of the input matrix A. Parameters ---------- - A : array-like + A Matrix A represented as an array or list. - tol : float (0.001) + tol Tolerance. - condition : bool (False) + condition Return the condition number of the matrix. Returns ------- - int - Column degrees-of-freedom. - int - Row degrees-of-freedom. - float - Condition number, if ``condition`` is ``True``. + tuple[numpy.integer[Any], numpy.integer[Any]] | tuple[numpy.integer[Any], numpy.integer[Any], float] + The column and row degrees of freedom. If `condition` is `True`, + the condition number is included as the third element. Notes ----- @@ -148,17 +162,17 @@ def dof(A, tol=0.001, condition=False): return k, m -def pivots(U, tol=None): +def pivots(U: ArrayLike, tol: Optional[float] = None) -> list[int]: r"""Identify the pivots of input matrix U. Parameters ---------- - U : array-like + U Matrix U represented as an array or list. Returns ------- - list + list[int] Pivot column indices. Notes @@ -186,17 +200,17 @@ def pivots(U, tol=None): return pivots -def nonpivots(U, tol=None): +def nonpivots(U: ArrayLike, tol: Optional[float] = None) -> list[int]: r"""Identify the non-pivots of input matrix U. Parameters ---------- - U : array-like + U Matrix U represented as an array or list. Returns ------- - list + list[int] Non-pivot column indices. Notes @@ -217,29 +231,25 @@ def nonpivots(U, tol=None): return list(set(range(U.shape[1])) - set(cols)) -def rref(A, tol=None): +def rref(A: ArrayLike, tol: Optional[float] = None) -> Optional[NDArray[Any]]: r"""Reduced row-echelon form of matrix A. Parameters ---------- - A : array-like + A Matrix A represented as an array or list. - tol : float + tol Tolerance. Returns ------- - array + NDArray[Any] RREF of A. Notes ----- A matrix is in reduced row-echelon form after Gauss-Jordan elimination. - Examples - -------- - >>> - """ A = atleast_2d(asarray(A, dtype=float)) @@ -247,7 +257,8 @@ def rref(A, tol=None): # to have non-decreasing absolute values on the diagonal of R # column pivoting ensures that the largest absolute value is used # as leading element - _, U = qr(A) # type: ignore + # SciPy's return type does not narrow to the default two-array QR result. + _, U = cast(tuple[NDArray[Any], NDArray[Any]], qr(A)) lead_pos = 0 num_rows, num_cols = U.shape for r in range(num_rows): @@ -263,7 +274,7 @@ def rref(A, tol=None): if lead_pos == num_cols: return # swap the row with the nonzero lead with the current row - U[[i, r]] = U[[r, i]] # type: ignore + U[[i, r]] = U[[r, i]] # "normalize" the values of the row lead_val = U[r][lead_pos] U[r] = U[r] / lead_val @@ -283,13 +294,13 @@ def rref(A, tol=None): class Memoized: - """""" + """Cache function results by their final argument.""" - def __init__(self, f): + def __init__(self, f: Callable[..., Any]) -> None: self.f = f self.memo = {} - def __call__(self, *args): + def __call__(self, *args: Any) -> Any: key = args[-1] if key in self.memo: return self.memo[key] @@ -297,11 +308,11 @@ def __call__(self, *args): return res -def memoize(f): +def memoize(f: Callable[..., Any]) -> Callable[..., Any]: memo = {} @wraps(f) - def wrapper(*args): + def wrapper(*args: Any) -> Any: key = args[-1] if key in memo: return memo[key] @@ -311,17 +322,17 @@ def wrapper(*args): return wrapper -def _chofactor(A): +def _chofactor(A: ArrayLike) -> tuple[NDArray[Any], bool_]: r"""Returns the Cholesky factorisation/decomposition matrix. Parameters ---------- - A : array + A Matrix A represented as an (m x m) array. Returns ------- - array + NDArray[Any] Matrix (m x m) with upper/lower triangle containing Cholesky factor of A. Notes @@ -329,32 +340,33 @@ def _chofactor(A): The Cholesky factorisation decomposes a Hermitian positive-definite matrix into the product of a lower/upper triangular matrix and its transpose. - .. math:: - - \mathbf{A} = \mathbf{L} \mathbf{L}^{\mathrm{T}} + $$ + \mathbf{A} = \mathbf{L} \mathbf{L}^{\mathrm{T}} + $$ Examples -------- >>> _chofactor(array([[25, 15, -5], [15, 18, 0], [-5, 0, 11]])) (array([[ 5., 3., -1.], [15., 3., 1.], - [-5., 0., 3.]]), False) + [-5., 0., 3.]]), array(False)) """ - return cho_factor(A) + # SciPy's return type does not describe the NumPy boolean scalar precisely. + return cast(tuple[NDArray[Any], bool_], cho_factor(A)) -def _lufactorized(A): +def _lufactorized(A: Any) -> Callable[[ArrayLike], NDArray[Any]]: r"""Return a function for solving a sparse linear system (LU decomposition). Parameters ---------- - A : array + A Matrix A represented as an (m x n) array. Returns ------- - callable + Callable[[ArrayLike], NDArray[Any]] Function to solve linear system with input matrix (n x 1). Notes @@ -362,9 +374,9 @@ def _lufactorized(A): LU decomposition factors a matrix as the product of a lower triangular and an upper triangular matrix L and U. - .. math:: - - \mathbf{A} = \mathbf{L} \mathbf{U} + $$ + \mathbf{A} = \mathbf{L} \mathbf{U} + $$ Examples -------- @@ -385,26 +397,25 @@ def _lufactorized(A): # ------------------------------------------------------------------------------ -def uvw_lengths(C, X): +def uvw_lengths(C: Any, X: ArrayLike) -> tuple[NDArray[Any], NDArray[Any]]: r"""Calculates the lengths and co-ordinate differences. Parameters ---------- - C : sparse + C Connectivity matrix (m x n). - X : array + X Co-ordinates of vertices/points (n x 3). Returns ------- - array - Vectors of co-ordinate differences in x, y and z (m x 3). - array - Lengths of members (m x 1). + tuple[NDArray[Any], NDArray[Any]] + The coordinate-difference vectors with shape `(m, 3)`, followed by + the member lengths with shape `(m, 1)`. Examples -------- - >>> from compas.matrices import connectivity_matrix + >>> from compas.linalg import connectivity_matrix >>> C = connectivity_matrix([[0, 1], [1, 2]], "csr") >>> X = array([[0, 0, 0], [1, 1, 0], [0, 0, 1]]) >>> uvw_lengths(C, X) @@ -417,17 +428,17 @@ def uvw_lengths(C, X): return uvw, normrow(uvw) -def normrow(A): +def normrow(A: ArrayLike) -> NDArray[Any]: """Calculates the 2-norm of each row of matrix A. Parameters ---------- - A : array + A Matrix A represented as an (m x n) array. Returns ------- - array + NDArray[Any] Column vector (m x 1) of values. Notes @@ -460,19 +471,19 @@ def normrow(A): return (sum(A**2, axis=1) ** 0.5).reshape((-1, 1)) -def normalizerow(A, do_nan_to_num=True): +def normalizerow(A: ArrayLike, do_nan_to_num: bool = True) -> NDArray[Any]: """Normalise the rows of matrix A. Parameters ---------- - A : array + A Matrix A represented as an (m x n) array. - do_nan_to_num : bool + do_nan_to_num Convert NaNs and INF to numbers, default=True. Returns ------- - array + NDArray[Any] Matrix of normalized row vectors (m x n). Notes @@ -508,19 +519,19 @@ def normalizerow(A, do_nan_to_num=True): return A / normrow(A) -def rot90(vectors, axes): +def rot90(vectors: ArrayLike, axes: ArrayLike) -> NDArray[Any]: """Rotate an array of vectors through 90 degrees around an array of axes. Parameters ---------- - vectors : array + vectors An array of row vectors (m x 3). - axes : array + axes An array of axes (m x 3). Returns ------- - array + NDArray[Any] Matrix of row vectors (m x 3). Notes @@ -538,97 +549,7 @@ def rot90(vectors, axes): [ 5.3748385 , -7.5247739 , 4.2998708 ]]) """ - return normalizerow(cross(axes, vectors)) * normrow(vectors) - - -# ============================================================================== -# Solving -# ============================================================================== - - -def solve_with_known(A, b, x, known): - r"""Solve a system of linear equations with part of solution known. - - Parameters - ---------- - A : array - Coefficient matrix represented as an (m x n) array. - b : array - Right-hand-side represented as an (m x 1) array. - x : array - Unknowns/knowns represented as an (n x 1) array. - known : list - The indices of the known elements of ``x``. - - Returns - ------- - array: (n x 1) vector solution. - - Notes - ----- - Computes the solution of the system of linear equations. - - .. math:: - - \mathbf{A} \mathbf{x} = \mathbf{b} - - """ - eps = 1 / sys.float_info.epsilon - unknown = list(set(range(x.shape[0])) - set(known)) - A11 = A[unknown, :][:, unknown] - A12 = A[unknown, :][:, known] - b = b[unknown] - A12.dot(x[known]) - if cond(A11) < eps: - Y = cho_solve(cho_factor(A11), b) - x[unknown] = Y - return x - Y = lstsq(A11, b) - x[unknown] = Y[0] - return x - - -def spsolve_with_known(A, b, x, known): - r"""Solve (sparse) a system of linear equations with part of solution known. - - Parameters - ---------- - A : array - Coefficient matrix (sparse) represented as an (m x n) array. - b : array - Right-hand-side represented as an (m x 1) array. - x : array - Unknowns/knowns represented as an (n x 1) array. - known : list - The indices of the known elements of ``x``. - - Returns - ------- - array - (n x 1) vector solution. - - Notes - ----- - Computes the solution (using spsolve) of the system of linear equations. - - .. math:: - - \mathbf{A} \mathbf{x} = \mathbf{b} - - Same function as solve_with_known, but for sparse matrix A. - - Examples - -------- - >>> A = array([[2, 1, 3], [2, 6, 8], [6, 8, 18]]) - >>> b = array([[1], [3], [5]]) - >>> x = array([[0.3], [0], [0]]) - >>> x = solve_with_known(A, b, x, [0]) - >>> allclose(x, array([[0.3], [0.4], [0.0]])) - True - - """ - unknown = list(set(range(x.shape[0])) - set(known)) - A11 = A[unknown, :][:, unknown] - A12 = A[unknown, :][:, known] - b = b[unknown] - A12.dot(x[known]) - x[unknown] = spsolve(A11, b) - return x + # NumPy's cross type accepts a narrower input contract than ArrayLike. + vectors_array = asarray(vectors) + axes_array = asarray(axes) + return normalizerow(cross(axes_array, vectors_array)) * normrow(vectors_array) diff --git a/src/compas/linalg/matrices.py b/src/compas/linalg/matrices.py new file mode 100644 index 000000000000..bab6011155e2 --- /dev/null +++ b/src/compas/linalg/matrices.py @@ -0,0 +1,290 @@ +from typing import Iterable +from typing import Sequence + +from .vectors import dot_vectors + +# ============================================================================= +# general matrices +# ============================================================================= + + +def transpose_matrix(M: Iterable[Iterable[float]]) -> list[list[float]]: + """Transpose a matrix. + + Parameters + ---------- + M + The matrix to be transposed. + + Returns + ------- + list[list[float]] + The result matrix. + + """ + return list(map(list, zip(*list(M)))) + + +def multiply_matrices(A: Sequence[Sequence[float]], B: Sequence[Sequence[float]]) -> list[list[float]]: + r"""Mutliply a matrix with a matrix. + + Parameters + ---------- + A + The first matrix. + B + The second matrix. + + Returns + ------- + list[list[float]] + The result matrix. + + Raises + ------ + Exception + If the shapes of the matrices are not compatible. + If the row length of B is inconsistent. + + Notes + ----- + This is a pure Python version of the following linear algebra procedure: + + $$ + \mathbf{A} \cdot \mathbf{B} = \mathbf{C} + $$ + + with $\mathbf{A}$ [m x n], $\mathbf{B}$ [n x o], and $\mathbf{C}$ [m x o]. + + Examples + -------- + >>> A = [[2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 2.0]] + >>> B = [[2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 2.0]] + >>> multiply_matrices(A, B) + [[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]] + + """ + A = list(A) + B = list(B) + n = len(B) # number of rows in B + o = len(B[0]) # number of cols in B + if not all(len(row) == o for row in B): + raise Exception("Row length in matrix B is inconsistent.") + if not all([len(row) == n for row in A]): + raise Exception("Matrix shapes are not compatible.") + B = list(zip(*list(B))) + return [[dot_vectors(row, col) for col in B] for row in A] + + +def multiply_matrix_vector(A: Sequence[Sequence[float]], b: Sequence[float]) -> list[float]: + r"""Multiply a matrix with a vector. + + Parameters + ---------- + A + The matrix. + b + The vector. + + Returns + ------- + list[float] + The resulting vector. + + Raises + ------ + Exception + If not all rows of the matrix have the same length as the vector. + + Notes + ----- + This is a Python version of the following linear algebra procedure: + + $$ + \mathbf{A} \cdot \mathbf{x} = \mathbf{b} + $$ + + with $\mathbf{A}$ an *m* by *n* matrix, $\mathbf{x}$ a vector of + length *n*, and $\mathbf{b}$ a vector of length *m*. + + Examples + -------- + >>> matrix = [[2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 2.0]] + >>> vector = [1.0, 2.0, 3.0] + >>> multiply_matrix_vector(matrix, vector) + [2.0, 4.0, 6.0] + + """ + n = len(b) + if not all([len(row) == n for row in A]): + raise Exception("Matrix shape is not compatible with vector length.") + return [dot_vectors(row, b) for row in A] + + +def is_matrix_square(M: Sequence[Sequence[float]]) -> bool: + """Verify that a matrix is square. + + Parameters + ---------- + M + The matrix. + + Returns + ------- + bool + True if the length of every row is equal to the number of rows. + False otherwise. + + Examples + -------- + >>> M = identity_matrix(4) + >>> is_matrix_square(M) + True + + """ + number_of_rows = len(M) + for row in M: + if len(row) != number_of_rows: + return False + return True + + +def matrix_minor(M: Sequence[Sequence[float]], i: int, j: int) -> list[list[float]]: + """Construct the minor corresponding to an element of a matrix. + + Parameters + ---------- + M + The matrix. + i + Row index of the minor. + j + Column index of the minor. + + Returns + ------- + list[list[float]] + The minor. + + See Also + -------- + [`matrix_determinant`][compas.linalg.matrix_determinant] + [`matrix_inverse`][compas.linalg.matrix_inverse] + + """ + 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: + """Calculates the determinant of a square matrix M. + + Parameters + ---------- + M + A square matrix of any dimension. + check + If True, checks if the matrix is square. + + Raises + ------ + ValueError + If the matrix is not square. + + Returns + ------- + float + The determinant. + + See Also + -------- + [`matrix_minor`][compas.linalg.matrix_minor] + [`matrix_inverse`][compas.linalg.matrix_inverse] + + Examples + -------- + >>> M = identity_matrix(4) + >>> matrix_determinant(M) + 1.0 + + """ + dim = len(M) + + if check: + if not is_matrix_square(M): + raise ValueError("Not a square matrix") + + if dim == 2: + return M[0][0] * M[1][1] - M[0][1] * M[1][0] + + D = 0 + for c in range(dim): + D += (-1) ** c * M[0][c] * matrix_determinant(matrix_minor(M, 0, c), check=False) + return D + + +def matrix_inverse(M: Sequence[Sequence[float]]) -> list[list[float]]: + """Calculates the inverse of a square matrix M. + + Parameters + ---------- + M + A square matrix of any dimension. + + Returns + ------- + list[list[float]] + The inverted matrix. + + Raises + ------ + ValueError + If the matrix is not squared + ValueError + If the matrix is singular. + ValueError + If the matrix is not invertible. + + See Also + -------- + [`matrix_minor`][compas.linalg.matrix_minor] + [`matrix_determinant`][compas.linalg.matrix_determinant] + + Examples + -------- + >>> from compas.geometry import Frame + >>> f = Frame([1, 1, 1], [0.68, 0.68, 0.27], [-0.67, 0.73, -0.15]) + >>> T = matrix_from_frame(f) + >>> I = multiply_matrices(T, matrix_inverse(T)) + >>> I2 = identity_matrix(4) + >>> allclose(I[0], I2[0]) + True + >>> allclose(I[1], I2[1]) + True + >>> allclose(I[2], I2[2]) + True + >>> allclose(I[3], I2[3]) + True + + """ + D = matrix_determinant(M) + + if D == 0: + raise ValueError("The matrix is singular.") + + if len(M) == 2: + return [[M[1][1] / D, -1 * M[0][1] / D], [-1 * M[1][0] / D, M[0][0] / D]] + + cofactors = [] + for r in range(len(M)): + cofactor_row = [] + for c in range(len(M)): + cofactor_row.append((-1) ** (r + c) * matrix_determinant(matrix_minor(M, r, c))) + cofactors.append(cofactor_row) + + cofactors = transpose_matrix(cofactors) + + for r in range(len(cofactors)): + for c in range(len(cofactors)): + cofactors[r][c] = cofactors[r][c] / D + + return cofactors diff --git a/src/compas/linalg/operators.py b/src/compas/linalg/operators.py new file mode 100644 index 000000000000..f9c6a7d507ba --- /dev/null +++ b/src/compas/linalg/operators.py @@ -0,0 +1,480 @@ +from typing import Any +from typing import Literal +from typing import NoReturn +from typing import Sequence +from typing import Union +from typing import cast +from typing import overload + +from numpy import abs +from numpy import array +from numpy import asarray +from numpy import tile +from numpy.typing import ArrayLike +from numpy.typing import NDArray +from scipy.sparse import coo_matrix +from scipy.sparse import csc_matrix +from scipy.sparse import csr_matrix +from scipy.sparse import diags +from scipy.sparse import spmatrix +from scipy.sparse import vstack as svstack + +MatrixResult = Union[list[list[float]], NDArray[Any], spmatrix] +SparseFormat = Literal["csr", "csc", "coo"] + + +@overload +def _return_matrix(M: Any, rtype: Literal["list"]) -> list[list[float]]: ... + + +@overload +def _return_matrix(M: Any, rtype: Literal["array"]) -> NDArray[Any]: ... + + +@overload +def _return_matrix(M: Any, rtype: Literal["csr"]) -> csr_matrix: ... + + +@overload +def _return_matrix(M: Any, rtype: Literal["csc"]) -> csc_matrix: ... + + +@overload +def _return_matrix(M: Any, rtype: Literal["coo"]) -> coo_matrix: ... + + +@overload +def _return_matrix(M: Any, rtype: str) -> MatrixResult: ... + + +def _return_matrix(M: Any, rtype: str) -> MatrixResult: + # SciPy's sparse base type omits conversion methods shared by concrete matrices. + if rtype == "list": + return M.toarray().tolist() + if rtype == "array": + return M.toarray() + if rtype == "csr": + return M.tocsr() + if rtype == "csc": + return M.tocsc() + if rtype == "coo": + return M.tocoo() + return M + + +# ============================================================================== +# adjacency +# ============================================================================== + + +@overload +def adjacency_matrix(adjacency: Sequence[Sequence[int]], rtype: Literal["array"] = "array") -> NDArray[Any]: ... + + +@overload +def adjacency_matrix(adjacency: Sequence[Sequence[int]], rtype: Literal["list"]) -> list[list[float]]: ... + + +@overload +def adjacency_matrix(adjacency: Sequence[Sequence[int]], rtype: SparseFormat) -> spmatrix: ... + + +@overload +def adjacency_matrix(adjacency: Sequence[Sequence[int]], rtype: str) -> MatrixResult: ... + + +def adjacency_matrix(adjacency: Sequence[Sequence[int]], rtype: str = "array") -> MatrixResult: + """Creates a vertex adjacency matrix. + + Parameters + ---------- + adjacency + List of lists, vertex adjacency data. + rtype + Format of the result. + + Returns + ------- + list[list[float]] | numpy.typing.NDArray[Any] | scipy.sparse.spmatrix + Constructed adjacency matrix. + + """ + a = [(1, i, j) for i in range(len(adjacency)) for j in adjacency[i]] + data, rows, cols = zip(*a) + A = coo_matrix((data, (rows, cols))).asfptype() + return _return_matrix(A, rtype) + + +@overload +def face_matrix(face_vertices: Sequence[Sequence[int]], rtype: Literal["array"] = "array", normalize: bool = False) -> NDArray[Any]: ... + + +@overload +def face_matrix(face_vertices: Sequence[Sequence[int]], rtype: Literal["list"], normalize: bool = False) -> list[list[float]]: ... + + +@overload +def face_matrix(face_vertices: Sequence[Sequence[int]], rtype: SparseFormat, normalize: bool = False) -> spmatrix: ... + + +@overload +def face_matrix(face_vertices: Sequence[Sequence[int]], rtype: str, normalize: bool = False) -> MatrixResult: ... + + +def face_matrix(face_vertices: Sequence[Sequence[int]], rtype: str = "array", normalize: bool = False) -> MatrixResult: + """Creates a face-vertex adjacency matrix. + + Parameters + ---------- + face_vertices + List of lists, vertices per face. + rtype + Format of the result. + normalize + If `True`, divide each nonzero entry by the number of vertices in its face. + + Returns + ------- + list[list[float]] | numpy.typing.NDArray[Any] | scipy.sparse.spmatrix + Constructed face matrix. + + """ + if normalize: + f = array([(i, j, 1.0 / len(vertices)) for i, vertices in enumerate(face_vertices) for j in vertices]) + else: + f = array([(i, j, 1.0) for i, vertices in enumerate(face_vertices) for j in vertices]) + F = coo_matrix((f[:, 2], (f[:, 0].astype(int), f[:, 1].astype(int)))) + return _return_matrix(F, rtype) + + +# ============================================================================== +# degree +# ============================================================================== + + +@overload +def degree_matrix(adjacency: Sequence[Sequence[int]], rtype: Literal["array"] = "array") -> NDArray[Any]: ... + + +@overload +def degree_matrix(adjacency: Sequence[Sequence[int]], rtype: Literal["list"]) -> list[list[float]]: ... + + +@overload +def degree_matrix(adjacency: Sequence[Sequence[int]], rtype: SparseFormat) -> spmatrix: ... + + +@overload +def degree_matrix(adjacency: Sequence[Sequence[int]], rtype: str) -> MatrixResult: ... + + +def degree_matrix(adjacency: Sequence[Sequence[int]], rtype: str = "array") -> MatrixResult: + """Creates a matrix representing vertex degrees. + + Parameters + ---------- + adjacency + List of lists, vertex adjacency data. + rtype + Format of the result. + + Returns + ------- + list[list[float]] | numpy.typing.NDArray[Any] | scipy.sparse.spmatrix + Constructed degree matrix. + + """ + d = [(len(adjacency[i]), i, i) for i in range(len(adjacency))] + data, rows, cols = zip(*d) + D = coo_matrix((data, (rows, cols))).asfptype() + return _return_matrix(D, rtype) + + +# ============================================================================== +# connectivity +# ============================================================================== + + +@overload +def connectivity_matrix(edges: Sequence[Sequence[int]], rtype: Literal["array"] = "array") -> NDArray[Any]: ... + + +@overload +def connectivity_matrix(edges: Sequence[Sequence[int]], rtype: Literal["list"]) -> list[list[float]]: ... + + +@overload +def connectivity_matrix(edges: Sequence[Sequence[int]], rtype: Literal["csr"]) -> csr_matrix: ... + + +@overload +def connectivity_matrix(edges: Sequence[Sequence[int]], rtype: Literal["csc"]) -> csc_matrix: ... + + +@overload +def connectivity_matrix(edges: Sequence[Sequence[int]], rtype: Literal["coo"]) -> coo_matrix: ... + + +@overload +def connectivity_matrix(edges: Sequence[Sequence[int]], rtype: str) -> MatrixResult: ... + + +def connectivity_matrix(edges: Sequence[Sequence[int]], rtype: str = "array") -> MatrixResult: + r"""Creates a connectivity matrix from a list of vertex index pairs. + + Parameters + ---------- + edges + List of lists [[node_i, node_j], [node_k, node_l]]. + rtype + Format of the result. + + Returns + ------- + list[list[float]] | numpy.typing.NDArray[Any] | scipy.sparse.spmatrix + Constructed connectivity matrix. + + Notes + ----- + The connectivity matrix encodes how edges in a graph are connected + together. Each row represents an edge and has 1 and -1 inserted into the + columns for the start and end nodes. + + $$ + \mathbf{C}_{ij} = + \begin{cases} + -1 & \text{if edge } i \text{ starts at vertex } j \\ + +1 & \text{if edge } i \text{ ends at vertex } j \\ + 0 & \text{otherwise} + \end{cases} + $$ + + A connectivity matrix is generally sparse and will perform superior + in numerical calculations as a sparse matrix. + + Examples + -------- + >>> connectivity_matrix([[0, 1], [0, 2], [0, 3]], rtype="array") + array([[-1., 1., 0., 0.], + [-1., 0., 1., 0.], + [-1., 0., 0., 1.]]) + + """ + m = len(edges) + data = array([-1] * m + [1] * m) + rows = array(list(range(m)) + list(range(m))) + cols = array([edge[0] for edge in edges] + [edge[1] for edge in edges]) + C = coo_matrix((data, (rows, cols))).asfptype() + return _return_matrix(C, rtype) + + +# ============================================================================== +# laplacian +# ============================================================================== + + +# change this to a procedural approach +# constructing (fundamental) matrices should not involve matrix operations +@overload +def laplacian_matrix(edges: Sequence[Sequence[int]], normalize: bool = False, rtype: Literal["array"] = "array") -> NDArray[Any]: ... + + +@overload +def laplacian_matrix(edges: Sequence[Sequence[int]], normalize: bool, rtype: Literal["list"]) -> list[list[float]]: ... + + +@overload +def laplacian_matrix(edges: Sequence[Sequence[int]], normalize: bool, rtype: SparseFormat) -> spmatrix: ... + + +@overload +def laplacian_matrix(edges: Sequence[Sequence[int]], normalize: bool, rtype: str) -> MatrixResult: ... + + +def laplacian_matrix(edges: Sequence[Sequence[int]], normalize: bool = False, rtype: str = "array") -> MatrixResult: + r"""Creates a laplacian matrix from a list of edge topologies. + + Parameters + ---------- + edges + List of lists [[node_i, node_j], [node_k, node_l]]. + normalize + If `True`, normalize each row by its diagonal entry. + rtype + Format of the result. + + Returns + ------- + list[list[float]] | numpy.typing.NDArray[Any] | scipy.sparse.spmatrix + Constructed Laplacian matrix. + + Notes + ----- + The laplacian matrix is defined as + + $$ + \mathbf{L} = \mathbf{C}^{\mathrm{T}} \mathbf{C} + $$ + + The current implementation only supports umbrella weights. + + Examples + -------- + >>> laplacian_matrix([[0, 1], [0, 2], [0, 3]], rtype="array") + array([[ 3., -1., -1., -1.], + [-1., 1., 0., 0.], + [-1., 0., 1., 0.], + [-1., 0., 0., 1.]]) + + """ + C = connectivity_matrix(edges, rtype="csr") + L = C.transpose().dot(C) + if normalize: + L = L / L.diagonal().reshape((-1, 1)) + L = csr_matrix(L) + return _return_matrix(L, rtype) + + +# ============================================================================== +# structural +# ============================================================================== + + +@overload +def equilibrium_matrix(C: ArrayLike, xyz: ArrayLike, free: Sequence[int], rtype: Literal["array"] = "array") -> NDArray[Any]: ... + + +@overload +def equilibrium_matrix(C: ArrayLike, xyz: ArrayLike, free: Sequence[int], rtype: Literal["list"]) -> list[list[float]]: ... + + +@overload +def equilibrium_matrix(C: ArrayLike, xyz: ArrayLike, free: Sequence[int], rtype: SparseFormat) -> spmatrix: ... + + +@overload +def equilibrium_matrix(C: ArrayLike, xyz: ArrayLike, free: Sequence[int], rtype: str) -> MatrixResult: ... + + +def equilibrium_matrix(C: ArrayLike, xyz: ArrayLike, free: Sequence[int], rtype: str = "array") -> MatrixResult: + r"""Construct the equilibrium matrix of a structural system. + + Parameters + ---------- + C + Connectivity matrix (m x n). + xyz + Array of vertex coordinates (n x 3). + free + The index values of the free vertices. + rtype + Format of the result. + + Returns + ------- + list[list[float]] | numpy.typing.NDArray[Any] | scipy.sparse.spmatrix + Constructed equilibrium matrix. + + Notes + ----- + Analysis of the equilibrium matrix reveals some of the properties of the + structural system, its size is (2ni x m) where ni is the number of free or + internal nodes. It is calculated by + + $$ + \mathbf{E} + = + \left[ + \begin{array}{c} + \mathbf{C}^{\mathrm{T}}_{\mathrm{i}}\mathbf{U} \\[0.3em] + \hline \\[-0.7em] + \mathbf{C}^{\mathrm{T}}_{\mathrm{i}}\mathbf{V} + \end{array} + \right]. + $$ + + The matrix of vertex coordinates is vectorised to speed up the + calculations. + + Examples + -------- + >>> C = connectivity_matrix([[0, 1], [0, 2], [0, 3]]) + >>> xyz = [[0, 0, 1], [0, 1, 0], [-1, -1, 0], [1, -1, 0]] + >>> equilibrium_matrix(C, xyz, [0], rtype="array") + array([[ 0., 1., -1.], + [-1., 1., 1.]]) + + """ + xyz = asarray(xyz, dtype=float) + # Keep the concrete sparse matrix separate from the general ArrayLike input. + C_ = csr_matrix(C) + xy = xyz[:, :2] + uv = C_.dot(xy) + # SciPy's overload does not accept the supported sequence-of-offsets form. + offsets = cast(Any, [0]) + U = diags([uv[:, 0].flatten()], offsets) + V = diags([uv[:, 1].flatten()], offsets) + Ct = C_.transpose() + Cti = Ct[free, :] + E = svstack((Cti.dot(U), Cti.dot(V))) + return _return_matrix(E, rtype) + + +def mass_matrix( + Ct: spmatrix, + ks: NDArray[Any], + q: Union[NDArray[Any], float] = 0, + c: float = 1, + tiled: bool = True, +) -> NDArray[Any]: + r"""Creates a graph's nodal mass matrix. + + Parameters + ---------- + Ct + Sparse transpose of the connectivity matrix (n x m). + ks + Vector of member EA / L (m x 1). + q + Vector of member force densities (m x 1). + c + Convergence factor. + tiled + Whether to tile horizontally by 3 for x, y, z. + + Returns + ------- + numpy.typing.NDArray[Any] + Mass matrix, either (m x 1) or (m x 3). + + Notes + ----- + The mass matrix is defined as the sum of the member axial stiffnesses + (inline) of the elements connected to each node, plus the force density. + The force density ensures a non-zero value in form-finding/pre-stress + modelling where E=0. + + $$ + \mathbf{m} = + |\mathbf{C}^\mathrm{T}| + (\mathbf{E} \circ \mathbf{A} \oslash \mathbf{l} + \mathbf{f} \oslash \mathbf{l}) + $$ + + """ + # SciPy's sparse base type omits the NumPy ufunc support of concrete matrices. + m = c * abs(cast(Any, Ct)).dot(ks + q) + if tiled: + return tile(m.reshape((-1, 1)), (1, 3)) + return m + + +def stiffness_matrix() -> NoReturn: + """Raise because stiffness matrix construction is not implemented. + + Raises + ------ + NotImplementedError + Always. + + """ + raise NotImplementedError diff --git a/src/compas/linalg/quaternions.py b/src/compas/linalg/quaternions.py new file mode 100644 index 000000000000..e237719f749c --- /dev/null +++ b/src/compas/linalg/quaternions.py @@ -0,0 +1,311 @@ +import math +from typing import Optional +from typing import Sequence + +from compas._typing import FloatSequenceType +from compas.tolerance import TOL + +from .transformations import axis_and_angle_from_matrix +from .transformations import euler_angles_from_matrix +from .transformations import matrix_from_axis_and_angle +from .transformations import matrix_from_euler_angles +from .transformations import matrix_from_quaternion +from .transformations import quaternion_from_matrix + + +def quaternion_norm(q: FloatSequenceType) -> float: + """Calculates the length (euclidean norm) of a quaternion. + + Parameters + ---------- + q + Sequence of four floats `[w, x, y, z]`. + + Returns + ------- + float + The length (euclidean norm) of a quaternion. + + See Also + -------- + [`quaternion_is_unit`][compas.linalg.quaternion_is_unit] + [`quaternion_unitize`][compas.linalg.quaternion_unitize] + [`quaternion_multiply`][compas.linalg.quaternion_multiply] + [`quaternion_canonize`][compas.linalg.quaternion_canonize] + [`quaternion_conjugate`][compas.linalg.quaternion_conjugate] + + References + ---------- + * [Quaternion Norm](https://mathworld.wolfram.com/QuaternionNorm.html) + + """ + return math.sqrt(sum([x * x for x in q])) + + +def quaternion_unitize(q: Sequence[float]) -> list[float]: + """Makes a quaternion unit-length. + + Parameters + ---------- + q + Sequence of four floats `[w, x, y, z]`. + + Returns + ------- + list[float] + Quaternion of length 1 as a list of four real values `[nw, nx, ny, nz]`. + + See Also + -------- + [`quaternion_is_unit`][compas.linalg.quaternion_is_unit] + [`quaternion_norm`][compas.linalg.quaternion_norm] + [`quaternion_multiply`][compas.linalg.quaternion_multiply] + [`quaternion_canonize`][compas.linalg.quaternion_canonize] + [`quaternion_conjugate`][compas.linalg.quaternion_conjugate] + + """ + n = quaternion_norm(q) + + if TOL.is_zero(n): + raise ValueError("The given quaternion has zero length.") + + return [x / n for x in q] + + +def quaternion_is_unit(q: FloatSequenceType, tol: Optional[float] = None) -> bool: + """Checks if a quaternion is unit-length. + + Parameters + ---------- + q + Sequence of four floats `[w, x, y, z]`. + tol + The tolerance for comparing the quaternion norm to 1. + Default is `TOL.absolute`. + + Returns + ------- + bool + True if the quaternion is unit-length, + and False if otherwise. + + See Also + -------- + [`quaternion_unitize`][compas.linalg.quaternion_unitize] + [`quaternion_norm`][compas.linalg.quaternion_norm] + [`quaternion_multiply`][compas.linalg.quaternion_multiply] + [`quaternion_canonize`][compas.linalg.quaternion_canonize] + [`quaternion_conjugate`][compas.linalg.quaternion_conjugate] + + """ + n = quaternion_norm(q) + return TOL.is_close(n, 1.0, rtol=0.0, atol=tol) + + +def quaternion_multiply(r: Sequence[float], q: Sequence[float]) -> list[float]: + """Multiplies two quaternions. + + Parameters + ---------- + r + Sequence of four floats `[w, x, y, z]`. + q + Sequence of four floats `[w, x, y, z]`. + + Returns + ------- + list[float] + Quaternion `p = rq` as a list of four real values `[pw, px, py, pz]`. + + See Also + -------- + [`quaternion_is_unit`][compas.linalg.quaternion_is_unit] + [`quaternion_norm`][compas.linalg.quaternion_norm] + [`quaternion_unitize`][compas.linalg.quaternion_unitize] + [`quaternion_canonize`][compas.linalg.quaternion_canonize] + [`quaternion_conjugate`][compas.linalg.quaternion_conjugate] + + Notes + ----- + Multiplication of two quaternions `p = rq` can be interpreted as applying rotation `r` to an orientation `q`, + provided that both `r` and `q` are unit-length. + The result is also unit-length. + Multiplication of quaternions is not commutative! + + References + ---------- + * [Quaternion](https://mathworld.wolfram.com/Quaternion.html) + + """ + rw, rx, ry, rz = r + qw, qx, qy, qz = q + pw = rw * qw - rx * qx - ry * qy - rz * qz + px = rw * qx + rx * qw + ry * qz - rz * qy + py = rw * qy - rx * qz + ry * qw + rz * qx + pz = rw * qz + rx * qy - ry * qx + rz * qw + return [pw, px, py, pz] + + +def quaternion_canonize(q: Sequence[float]) -> Sequence[float]: + """Converts a quaternion into a canonic form if needed. + + Parameters + ---------- + q + Sequence of four floats `[w, x, y, z]`. + + Returns + ------- + Sequence[float] + Quaternion in canonic form as a sequence of four real values `[cw, cx, cy, cz]`. + + See Also + -------- + [`quaternion_is_unit`][compas.linalg.quaternion_is_unit] + [`quaternion_norm`][compas.linalg.quaternion_norm] + [`quaternion_unitize`][compas.linalg.quaternion_unitize] + [`quaternion_multiply`][compas.linalg.quaternion_multiply] + [`quaternion_conjugate`][compas.linalg.quaternion_conjugate] + + Notes + ----- + Canonic form means the scalar component is a non-negative number. + + """ + if q[0] < 0.0: + return [-x for x in q] + return q[:] + + +def quaternion_conjugate(q: Sequence[float]) -> list[float]: + """Conjugate of a quaternion. + + Parameters + ---------- + q + Sequence of four floats `[w, x, y, z]`. + + Returns + ------- + list[float] + Conjugate quaternion as a list of four real values `[cw, cx, cy, cz]`. + + See Also + -------- + [`quaternion_is_unit`][compas.linalg.quaternion_is_unit] + [`quaternion_norm`][compas.linalg.quaternion_norm] + [`quaternion_unitize`][compas.linalg.quaternion_unitize] + [`quaternion_multiply`][compas.linalg.quaternion_multiply] + [`quaternion_canonize`][compas.linalg.quaternion_canonize] + + References + ---------- + * [Quaternion Conjugate](https://mathworld.wolfram.com/QuaternionConjugate.html) + + """ + 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]: + """Returns a quaternion from Euler angles. + + Parameters + ---------- + e + Three numbers that represent the angles of rotations about the specified axes. + static + If True, the rotations are applied to a static frame. + If False, the rotations are applied to a rotational frame. + axes + A three-character string specifying the order of the axes. + + Returns + ------- + list[float] + Quaternion as a list of four real values `[w, x, y, z]`. + + """ + m = matrix_from_euler_angles(e, static, axes) + q = quaternion_from_matrix(m) + return q + + +def euler_angles_from_quaternion(q: Sequence[float], static: bool = True, axes: str = "xyz") -> list[float]: + """Returns Euler angles from a quaternion. + + Parameters + ---------- + q + Quaternion as a list of four real values `[w, x, y, z]`. + static + If True, the rotations are applied to a static frame. + If False, the rotations are applied to a rotational frame. + axes + A three-character string specifying the order of the axes. + + Returns + ------- + list[float] + Euler angles as a list of three real values `[a, b, c]`. + + """ + m = matrix_from_quaternion(q) + e = euler_angles_from_matrix(m, static, axes) + return e + + +def quaternion_from_axis_angle(axis: Sequence[float], angle: float) -> list[float]: + """Returns a quaternion describing a rotation around the given axis by the given angle. + + Parameters + ---------- + axis + XYZ coordinates of the rotation axis vector. + angle + Angle of rotation in radians. + + Returns + ------- + list[float] + Quaternion as a list of four real values `[qw, qx, qy, qz]`. + + Examples + -------- + >>> axis = [1.0, 0.0, 0.0] + >>> angle = math.pi / 2 + >>> q = quaternion_from_axis_angle(axis, angle) + >>> allclose(q, [math.sqrt(2) / 2, math.sqrt(2) / 2, 0, 0]) + True + + """ + m = matrix_from_axis_and_angle(axis, angle, None) + q = quaternion_from_matrix(m) + return q + + +def axis_angle_from_quaternion(q: Sequence[float]) -> tuple[list[float], float]: + """Returns an axis and an angle of rotation from the given quaternion. + + Parameters + ---------- + q + Quaternion as a list of four real values `[qw, qx, qy, qz]`. + + Returns + ------- + tuple[list[float], float] + The rotation axis and rotation angle in radians. + + Examples + -------- + >>> q = [1.0, 1.0, 0.0, 0.0] + >>> axis, angle = axis_angle_from_quaternion(q) + >>> allclose(axis, [1.0, 0.0, 0.0]) + True + >>> allclose([angle], [math.pi / 2], 1e-6) + True + + """ + m = matrix_from_quaternion(q) + axis, angle = axis_and_angle_from_matrix(m) + return axis, angle diff --git a/src/compas/linalg/solvers.py b/src/compas/linalg/solvers.py new file mode 100644 index 000000000000..db040e359369 --- /dev/null +++ b/src/compas/linalg/solvers.py @@ -0,0 +1,103 @@ +import sys +from typing import Any +from typing import Sequence + +from numpy.linalg import cond +from numpy.typing import NDArray +from scipy.linalg import cho_factor +from scipy.linalg import cho_solve +from scipy.linalg import lstsq +from scipy.sparse.linalg import spsolve + +# ============================================================================== +# Solving +# ============================================================================== + + +def solve_with_known(A: NDArray[Any], b: NDArray[Any], x: NDArray[Any], known: Sequence[int]) -> NDArray[Any]: + r"""Solve a system of linear equations with part of solution known. + + Parameters + ---------- + A + Coefficient matrix represented as an (m x n) array. + b + Right-hand-side represented as an (m x 1) array. + x + Unknowns/knowns represented as an (n x 1) array. + known + The indices of the known elements of `x`. + + Returns + ------- + NDArray[Any] + The solution vector with shape `(n, 1)`. + + Notes + ----- + Computes the solution of the system of linear equations. + + $$ + \mathbf{A} \mathbf{x} = \mathbf{b} + $$ + + """ + eps = 1 / sys.float_info.epsilon + unknown = list(set(range(x.shape[0])) - set(known)) + A11 = A[unknown, :][:, unknown] + A12 = A[unknown, :][:, known] + b = b[unknown] - A12.dot(x[known]) + if cond(A11) < eps: + Y = cho_solve(cho_factor(A11), b) + x[unknown] = Y + return x + Y = lstsq(A11, b) + x[unknown] = Y[0] + return x + + +def spsolve_with_known(A: Any, b: NDArray[Any], x: NDArray[Any], known: Sequence[int]) -> NDArray[Any]: + r"""Solve (sparse) a system of linear equations with part of solution known. + + Parameters + ---------- + A + Coefficient matrix (sparse) represented as an (m x n) array. + b + Right-hand-side represented as an (m x 1) array. + x + Unknowns/knowns represented as an (n x 1) array. + known + The indices of the known elements of `x`. + + Returns + ------- + NDArray[Any] + (n x 1) vector solution. + + Notes + ----- + Computes the solution (using spsolve) of the system of linear equations. + + $$ + \mathbf{A} \mathbf{x} = \mathbf{b} + $$ + + Same function as solve_with_known, but for sparse matrix A. + + Examples + -------- + >>> A = array([[2, 1, 3], [2, 6, 8], [6, 8, 18]]) + >>> b = array([[1], [3], [5]]) + >>> x = array([[0.3], [0], [0]]) + >>> x = solve_with_known(A, b, x, [0]) + >>> allclose(x, array([[0.3], [0.4], [0.0]])) + True + + """ + unknown = list(set(range(x.shape[0])) - set(known)) + A11 = A[unknown, :][:, unknown] + A12 = A[unknown, :][:, known] + b = b[unknown] - A12.dot(x[known]) + x[unknown] = spsolve(A11, b) + return x diff --git a/src/compas/linalg/transformations.py b/src/compas/linalg/transformations.py new file mode 100644 index 000000000000..c41c73915053 --- /dev/null +++ b/src/compas/linalg/transformations.py @@ -0,0 +1,1253 @@ +from copy import deepcopy +from math import acos +from math import asin +from math import atan2 +from math import cos +from math import fabs +from math import pi +from math import sin +from math import sqrt +from math import tan +from typing import Optional +from typing import Sequence + +from compas._typing import CoordinatesType +from compas._typing import CoordinateType +from compas._typing import FloatSequenceType +from compas.tolerance import TOL + +from .matrices import matrix_determinant +from .matrices import matrix_inverse +from .matrices import multiply_matrices +from .matrices import multiply_matrix_vector +from .matrices import transpose_matrix +from .vectors import allclose +from .vectors import cross_vectors +from .vectors import dot_vectors +from .vectors import length_vector +from .vectors import norm_vector +from .vectors import normalize_vector +from .vectors import scale_vector +from .vectors import subtract_vectors + +_SPEC2TUPLE = { + "sxyz": (0, 0, 0, 0), + "sxyx": (0, 0, 1, 0), + "sxzy": (0, 1, 0, 0), + "sxzx": (0, 1, 1, 0), + "syzx": (1, 0, 0, 0), + "syzy": (1, 0, 1, 0), + "syxz": (1, 1, 0, 0), + "syxy": (1, 1, 1, 0), + "szxy": (2, 0, 0, 0), + "szxz": (2, 0, 1, 0), + "szyx": (2, 1, 0, 0), + "szyz": (2, 1, 1, 0), + "rzyx": (0, 0, 0, 1), + "rxyx": (0, 0, 1, 1), + "ryzx": (0, 1, 0, 1), + "rxzx": (0, 1, 1, 1), + "rxzy": (1, 0, 0, 1), + "ryzy": (1, 0, 1, 1), + "rzxy": (1, 1, 0, 1), + "ryxy": (1, 1, 1, 1), + "ryxz": (2, 0, 0, 1), + "rzxz": (2, 0, 1, 1), + "rxyz": (2, 1, 0, 1), + "rzyz": (2, 1, 1, 1), +} +"""used for Euler angles: to map rotation type and axes to tuples of inner axis, parity, repetition, frame""" + +_NEXT_SPEC = [1, 2, 0, 1] +# ============================================================================= +# 4x4 matrices +# ============================================================================= + + +def decompose_matrix( + M: CoordinatesType, +) -> tuple[list[float], list[float], list[float], list[float], list[float]]: + """Calculates the components of rotation, translation, scale, shear, and + perspective of a given transformation matrix `M`.[^decompose-matrix-slabaugh] + + Parameters + ---------- + M + The square matrix of any dimension. + + Raises + ------ + ValueError + If matrix is singular or degenerative. + + Returns + ------- + tuple[list[float], list[float], list[float], list[float], list[float]] + The scale factors, shear factors, Euler angles, translation values, + and perspective entries, in that order. + + See Also + -------- + [`compose_matrix`][compas.linalg.compose_matrix] + + Examples + -------- + >>> trans1 = [1, 2, 3] + >>> angle1 = [-2.142, 1.141, -0.142] + >>> scale1 = [0.123, 2, 0.5] + >>> T = matrix_from_translation(trans1) + >>> R = matrix_from_euler_angles(angle1) + >>> S = matrix_from_scale_factors(scale1) + >>> M = multiply_matrices(multiply_matrices(T, R), S) + >>> # M = compose_matrix(scale1, None, angle1, trans1, None) + >>> scale2, shear2, angle2, trans2, persp2 = decompose_matrix(M) + >>> allclose(scale1, scale2) + True + >>> allclose(angle1, angle2) + True + >>> allclose(trans1, trans2) + True + + References + ---------- + [^decompose-matrix-slabaugh]: Slabaugh, G. [*Computing Euler Angles from a Rotation Matrix*](http://www.gregslabaugh.net/publications/euler.pdf), 1999. + + """ + detM = matrix_determinant(M) # raises ValueError if matrix is not squared + if detM == 0: + raise ValueError("The matrix is singular.") + + Mt = transpose_matrix(M) + if TOL.is_zero(Mt[3][3]): + raise ValueError("The element [3,3] of the matrix is zero.") + + for i in range(4): + for j in range(4): + Mt[i][j] /= Mt[3][3] + + # copy Mt[:3, :3] into row + row = [ + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + ] + for i in range(3): + for j in range(3): + row[i][j] = Mt[i][j] + + # translation + 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] + angles = [0.0, 0.0, 0.0] + + scale[0] = norm_vector(row[0]) + for i in range(3): + row[0][i] /= scale[0] + + shear[0] = dot_vectors(row[0], row[1]) + for i in range(3): + row[1][i] -= row[0][i] * shear[0] + + scale[1] = norm_vector(row[1]) + for i in range(3): + row[1][i] /= scale[1] + + shear[1] = dot_vectors(row[0], row[2]) + for i in range(3): + row[2][i] -= row[0][i] * shear[1] + + # why is the order different here? + # it certainly influences the result + + shear[2] = dot_vectors(row[1], row[2]) + for i in range(3): + row[2][i] -= row[0][i] * shear[2] + + scale[2] = norm_vector(row[2]) + for i in range(3): + row[2][i] /= scale[2] + + shear[0] /= scale[1] + shear[1] /= scale[2] + shear[2] /= scale[2] + + if dot_vectors(row[0], cross_vectors(row[1], row[2])) < 0: + scale = [-x for x in scale] + row = [[-x for x in y] for y in row] + + # angles + if row[0][2] != -1.0 and row[0][2] != 1.0: + beta1 = asin(-row[0][2]) + # beta2 = pi - beta1 + alpha1 = atan2(row[1][2] / cos(beta1), row[2][2] / cos(beta1)) + # alpha2 = atan2(row[1][2] / cos(beta2), row[2][2] / cos(beta2)) + gamma1 = atan2(row[0][1] / cos(beta1), row[0][0] / cos(beta1)) + # gamma2 = atan2(row[0][1] / cos(beta2), row[0][0] / cos(beta2)) + angles = [alpha1, beta1, gamma1] + + else: + gamma = 0.0 + if row[0][2] == -1.0: + beta = pi / 2.0 + alpha = gamma + atan2(row[1][0], row[2][0]) + else: # row[0][2] == 1 + beta = -pi / 2.0 + alpha = -gamma + atan2(-row[1][0], -row[2][0]) + angles = [alpha, beta, gamma] + + # perspective + 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 + Ptinv = matrix_inverse(transpose_matrix(P)) + perspective = multiply_matrix_vector(Ptinv, [Mt[0][3], Mt[1][3], Mt[2][3], Mt[3][3]]) + else: + perspective = [0.0, 0.0, 0.0, 1.0] + + return scale, shear, angles, translation, perspective + + +def compose_matrix( + scale: Optional[Sequence[float]] = None, + shear: Optional[Sequence[float]] = None, + angles: Optional[Sequence[float]] = None, + translation: Optional[Sequence[float]] = None, + perspective: Optional[Sequence[float]] = None, +) -> list[list[float]]: + """Calculates a matrix from the components of scale, shear, euler_angles, translation and perspective. + + Parameters + ---------- + scale + The 3 scale factors in x-, y-, and z-direction. + shear + The 3 shear factors for x-y, x-z, and y-z axes. + angles + The rotation specified through the 3 Euler angles about static x, y, z axes. + translation + The 3 values of translation. + perspective + The 4 perspective entries of the matrix. + + Returns + ------- + list[list[float]] + The 4x4 matrix that combines the provided transformation components. + + See Also + -------- + [`decompose_matrix`][compas.linalg.decompose_matrix] + + Examples + -------- + >>> trans1 = [1, 2, 3] + >>> angle1 = [-2.142, 1.141, -0.142] + >>> scale1 = [0.123, 2, 0.5] + >>> M = compose_matrix(scale1, None, angle1, trans1, None) + >>> scale2, shear2, angle2, trans2, persp2 = decompose_matrix(M) + >>> allclose(scale1, scale2) + True + >>> allclose(angle1, angle2) + True + >>> allclose(trans1, trans2) + True + + """ + M = [[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) + if translation is not None: + T = matrix_from_translation(translation) + M = multiply_matrices(M, T) + if angles is not None: + R = matrix_from_euler_angles(angles, static=True, axes="xyz") + M = multiply_matrices(M, R) + if shear is not None: + H = matrix_from_shear_entries(shear) + M = multiply_matrices(M, H) + if scale is not None: + S = matrix_from_scale_factors(scale) + M = multiply_matrices(M, S) + for i in range(4): + for j in range(4): + M[i][j] /= M[3][3] + return M + + +def identity_matrix(dim: int) -> list[list[float]]: + """Construct an identity matrix. + + Parameters + ---------- + dim + The number of rows and/or columns of the matrix. + + Returns + ------- + list[list[float]] + A list of `dim` lists, with each list containing `dim` elements. + The items on the "diagonal" are one. + All other items are zero. + + See Also + -------- + [`matrix_from_frame`][compas.linalg.matrix_from_frame] + [`matrix_from_frame_to_frame`][compas.linalg.matrix_from_frame_to_frame] + [`matrix_from_euler_angles`][compas.linalg.matrix_from_euler_angles] + [`matrix_from_axis_and_angle`][compas.linalg.matrix_from_axis_and_angle] + [`matrix_from_basis_vectors`][compas.linalg.matrix_from_basis_vectors] + [`matrix_from_translation`][compas.linalg.matrix_from_translation] + [`matrix_from_scale_factors`][compas.linalg.matrix_from_scale_factors] + [`matrix_from_shear_entries`][compas.linalg.matrix_from_shear_entries] + [`matrix_from_perspective_entries`][compas.linalg.matrix_from_perspective_entries] + + Examples + -------- + >>> identity_matrix(4) + [[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)] + + +def matrix_from_frame(frame: Sequence[Sequence[float]]) -> list[list[float]]: + """Computes a change of basis transformation from world XY to the frame. + + Parameters + ---------- + frame + A frame describing the targeted Cartesian coordinate system + + Returns + ------- + list[list[float]] + A 4x4 transformation matrix representing the transformation from + world coordinates to frame coordinates. + + Examples + -------- + >>> from compas.geometry import Frame + >>> f = Frame([1, 1, 1], [0.68, 0.68, 0.27], [-0.67, 0.73, -0.15]) + >>> T = matrix_from_frame(f) + + """ + # Core frame data contains an origin and two axes; derive the third axis. + point, xaxis, yaxis = frame + zaxis = cross_vectors(xaxis, yaxis) + M = identity_matrix(4) + M[0][0], M[1][0], M[2][0] = xaxis + M[0][1], M[1][1], M[2][1] = yaxis + M[0][2], M[1][2], M[2][2] = zaxis + M[0][3], M[1][3], M[2][3] = point + return M + + +def matrix_from_frame_to_frame(frame_from: Sequence[Sequence[float]], frame_to: Sequence[Sequence[float]]) -> list[list[float]]: + """Computes a transformation between two frames. + + This transformation allows to transform geometry from one Cartesian + coordinate system defined by `frame_from` to another Cartesian + coordinate system defined by `frame_to`. + + Parameters + ---------- + frame_from + A frame defining the original Cartesian coordinate system + frame_to + A frame defining the targeted Cartesian coordinate system + + Returns + ------- + list[list[float]] + A 4x4 transformation matrix representing the transformation + from one frame to another. + + Examples + -------- + >>> from compas.geometry import Frame + >>> f1 = Frame([2, 2, 2], [0.12, 0.58, 0.81], [-0.80, 0.53, -0.26]) + >>> f2 = Frame([1, 1, 1], [0.68, 0.68, 0.27], [-0.67, 0.73, -0.15]) + >>> T = matrix_from_frame_to_frame(f1, f2) + + """ + T1 = matrix_from_frame(frame_from) + T2 = matrix_from_frame(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]]: + """Computes a change of basis transformation between two frames. + + A basis change is essentially a remapping of geometry from one + coordinate system to another. + + Parameters + ---------- + frame_from + A frame defining the original Cartesian coordinate system + frame_to + A frame defining the targeted Cartesian coordinate system + + Returns + ------- + list[list[float]] + A 4x4 transformation matrix representing a change of basis. + + Examples + -------- + >>> from compas.geometry import Point, Frame + >>> f1 = Frame([2, 2, 2], [0.12, 0.58, 0.81], [-0.80, 0.53, -0.26]) + >>> f2 = Frame([1, 1, 1], [0.68, 0.68, 0.27], [-0.67, 0.73, -0.15]) + >>> T = matrix_from_change_of_basis(f1, f2) + + """ + T1 = matrix_from_frame(frame_from) + T2 = matrix_from_frame(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]]: + """Calculates a rotation matrix from Euler angles. + + In 3D space any orientation can be achieved by composing three elemental + rotations, rotations about the axes (x, y, z) of a coordinate system. A + triple of Euler angles can be interpreted in 24 ways, which depends on if + the rotations are applied to a static (extrinsic) or rotating (intrinsic) + frame and the order of axes. + + Parameters + ---------- + euler_angles + Three numbers that represent the angles of rotations about the defined axes. + static + If True the rotations are applied to a static frame. + If False, to a rotational. + axes + A 3 character string specifying order of the axes. + + Returns + ------- + list[list[float]] + A 4x4 transformation matrix representing a rotation. + + Examples + -------- + >>> ea1 = 1.4, 0.5, 2.3 + >>> R = matrix_from_euler_angles(ea1) + >>> ea2 = euler_angles_from_matrix(R) + >>> allclose(ea1, ea2) + True + + """ + global _SPEC2TUPLE + global _NEXT_SPEC + + ai, aj, ak = euler_angles + + if static: + firstaxis, parity, repetition, frame = _SPEC2TUPLE["s" + axes] + else: + firstaxis, parity, repetition, frame = _SPEC2TUPLE["r" + axes] + + i = firstaxis + j = _NEXT_SPEC[i + parity] + k = _NEXT_SPEC[i - parity + 1] + + 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) + cc, cs = ci * ck, ci * sk + sc, ss = si * ck, si * sk + + M = [[1.0 if x == y else 0.0 for x in range(4)] for y in range(4)] + + if repetition: + M[i][i] = cj + M[i][j] = sj * si + M[i][k] = sj * ci + M[j][i] = sj * sk + M[j][j] = -cj * ss + cc + M[j][k] = -cj * cs - sc + M[k][i] = -sj * ck + M[k][j] = cj * sc + cs + M[k][k] = cj * cc - ss + else: + M[i][i] = cj * ck + M[i][j] = sj * sc - cs + M[i][k] = sj * cc + ss + M[j][i] = cj * sk + M[j][j] = sj * ss + cc + M[j][k] = sj * cs - sc + M[k][i] = -sj + M[k][j] = cj * si + M[k][k] = cj * ci + + return M + + +def euler_angles_from_matrix(M: Sequence[Sequence[float]], 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. + + Parameters + ---------- + M + The 3x3 or 4x4 matrix in row-major order. + static + If True the rotations are applied to a static frame. + If False, to a rotational. + axes + A 3 character string specifying order of the axes. + + Returns + ------- + list[float] + The 3 Euler angles. + + Examples + -------- + >>> ea1 = 1.4, 0.5, 2.3 + >>> R = matrix_from_euler_angles(ea1) + >>> ea2 = euler_angles_from_matrix(R) + >>> allclose(ea1, ea2) + True + + """ + global _SPEC2TUPLE + global _NEXT_SPEC + + if static: + firstaxis, parity, repetition, frame = _SPEC2TUPLE["s" + axes] + else: + firstaxis, parity, repetition, frame = _SPEC2TUPLE["r" + axes] + + i = firstaxis + j = _NEXT_SPEC[i + parity] + k = _NEXT_SPEC[i - parity + 1] + + if repetition: + sy = sqrt(M[i][j] * M[i][j] + M[i][k] * M[i][k]) + if TOL.is_positive(sy): + ax = atan2(M[i][j], M[i][k]) + ay = atan2(sy, M[i][i]) + az = atan2(M[j][i], -M[k][i]) + else: + ax = atan2(-M[j][k], M[j][j]) + ay = atan2(sy, M[i][i]) + az = 0.0 + else: + cy = sqrt(M[i][i] * M[i][i] + M[j][i] * M[j][i]) + if TOL.is_positive(cy): + ax = atan2(M[k][j], M[k][k]) + ay = atan2(-M[k][i], cy) + az = atan2(M[j][i], M[i][i]) + else: + ax = atan2(-M[j][k], M[j][j]) + ay = atan2(-M[k][i], cy) + az = 0.0 + + if parity: + ax, ay, az = -ax, -ay, -az + if frame: + ax, az = az, ax + + return [ax, ay, az] + + +def matrix_from_axis_and_angle(axis: Sequence[float], angle: float, point: Optional[Sequence[float]] = None) -> list[list[float]]: + """Calculates a rotation matrix from an rotation axis, an angle and an optional + point of rotation. + + Parameters + ---------- + axis + Three numbers that represent the axis of rotation. + angle + The rotation angle in radians. + point + A point to perform a rotation around an origin other than [0, 0, 0]. + + Returns + ------- + list[list[float]] + A 4x4 transformation matrix representing a rotation. + + Notes + ----- + The rotation is based on the right hand rule, i.e. anti-clockwise if the + axis of rotation points towards the observer. + + Examples + -------- + >>> axis1 = normalize_vector([-0.043, -0.254, 0.617]) + >>> angle1 = 0.1 + >>> R = matrix_from_axis_and_angle(axis1, angle1) + >>> axis2, angle2 = axis_and_angle_from_matrix(R) + >>> allclose(axis1, axis2) + True + >>> allclose([angle1], [angle2]) + True + + """ + if not point: + point = [0.0, 0.0, 0.0] + + axis = list(axis) + if length_vector(axis): + axis = normalize_vector(axis) + + sina = sin(angle) + cosa = cos(angle) + + R = [[cosa, 0.0, 0.0], [0.0, cosa, 0.0], [0.0, 0.0, cosa]] + + outer_product = [[axis[i] * axis[j] * (1.0 - cosa) for i in range(3)] for j in range(3)] + R = [[R[i][j] + outer_product[i][j] for i in range(3)] for j in range(3)] + + axis = scale_vector(axis, sina) + m = [[0.0, -axis[2], axis[1]], [axis[2], 0.0, -axis[0]], [-axis[1], axis[0], 0.0]] + + M = identity_matrix(4) + + for i in range(3): + for j in range(3): + R[i][j] += m[i][j] + M[i][j] = R[i][j] + + # rotation about axis, angle AND point includes also translation + t = subtract_vectors(point, multiply_matrix_vector(R, point)) + M[0][3] = t[0] + M[1][3] = t[1] + M[2][3] = t[2] + + return M + + +def matrix_from_axis_angle_vector(axis_angle_vector: CoordinateType, point: CoordinateType = (0, 0, 0)) -> list[list[float]]: + """Calculates a rotation matrix from an axis-angle vector. + + Parameters + ---------- + axis_angle_vector + Three numbers that represent the axis of rotation and angle of rotation + through the vector's magnitude. + point + A point to perform a rotation around an origin other than [0, 0, 0]. + + Returns + ------- + list[list[float]] + The 4x4 transformation matrix representing a rotation. + + Examples + -------- + >>> aav1 = [-0.043, -0.254, 0.617] + >>> R = matrix_from_axis_angle_vector(aav1) + >>> aav2 = axis_angle_vector_from_matrix(R) + >>> allclose(aav1, aav2) + True + + """ + axis = list(axis_angle_vector) + angle = length_vector(axis_angle_vector) + return matrix_from_axis_and_angle(axis, angle, point) + + +def axis_and_angle_from_matrix(M: Sequence[Sequence[float]]) -> tuple[list[float], float]: + """Returns the axis and the angle of the rotation matrix M. + + Parameters + ---------- + M + The 4-by-4 transformation matrix. + + Returns + ------- + tuple[list[float], float] + The rotation axis and rotation angle in radians. + + """ + eps = 0.01 # margin to allow for rounding errors + eps2 = 0.1 # margin to distinguish between 0 and 180 degrees + + if all(fabs(M[i][j] - M[j][i]) < eps for i, j in [(0, 1), (0, 2), (1, 2)]): + if all(fabs(M[i][j] - M[j][i]) < eps2 for i, j in [(0, 1), (0, 2), (1, 2)]) and fabs(M[0][0] + M[1][1] + M[2][2] - 3) < eps2: + return [0, 0, 0], 0 + + angle = pi + xx = (M[0][0] + 1) / 2 + yy = (M[1][1] + 1) / 2 + zz = (M[2][2] + 1) / 2 + xy = (M[0][1] + M[1][0]) / 4 + xz = (M[0][2] + M[2][0]) / 4 + yz = (M[1][2] + M[2][1]) / 4 + root_half = sqrt(0.5) + if (xx > yy) and (xx > zz): + if xx < eps: + axis = [0, root_half, root_half] + else: + x = sqrt(xx) + axis = [x, xy / x, xz / x] + elif yy > zz: + if yy < eps: + axis = [root_half, 0, root_half] + else: + y = sqrt(yy) + axis = [xy / y, y, yz / y] + else: + if zz < eps: + axis = [root_half, root_half, 0] + else: + z = sqrt(zz) + axis = [xz / z, yz / z, z] + + return axis, angle + + s = sqrt((M[2][1] - M[1][2]) * (M[2][1] - M[1][2]) + (M[0][2] - M[2][0]) * (M[0][2] - M[2][0]) + (M[1][0] - M[0][1]) * (M[1][0] - M[0][1])) + + # should this also be an eps? + if fabs(s) < 0.001: + s = 1 + + angle = acos((M[0][0] + M[1][1] + M[2][2] - 1) / 2) + + x = (M[2][1] - M[1][2]) / s + y = (M[0][2] - M[2][0]) / s + z = (M[1][0] - M[0][1]) / s + + return [x, y, z], angle + + +def axis_angle_vector_from_matrix(M: Sequence[Sequence[float]]) -> list[float]: + """Returns the axis-angle vector of the rotation matrix M. + + Parameters + ---------- + M + The 4-by-4 transformation matrix. + + Returns + ------- + list[float] + The axis-angle vector. + + """ + axis, angle = axis_and_angle_from_matrix(M) + return scale_vector(axis, angle) + + +def matrix_from_quaternion(quaternion: FloatSequenceType) -> list[list[float]]: + """Calculates a rotation matrix from quaternion coefficients. + + Parameters + ---------- + quaternion + Four numbers that represents the four coefficient values of a quaternion. + + Returns + ------- + list[list[float]] + The 4x4 transformation matrix representing a rotation. + + Raises + ------ + ValueError + If quaternion is invalid. + + Examples + -------- + >>> q1 = [0.945, -0.021, -0.125, 0.303] + >>> R = matrix_from_quaternion(q1) + >>> q2 = quaternion_from_matrix(R) + >>> allclose(q1, q2, tol=1e-03) + True + + """ + q = quaternion + n = q[0] ** 2 + q[1] ** 2 + q[2] ** 2 + q[3] ** 2 # dot product + + # perhaps this should not be hard-coded? + eps = 1.0e-15 + + if n < eps: + raise ValueError("Invalid quaternion, dot product must be != 0.") + + q = [v * sqrt(2.0 / n) for v in q] + q = [[q[i] * q[j] for i in range(4)] for j in range(4)] # outer_product + + rotation = [ + [1.0 - q[2][2] - q[3][3], q[1][2] - q[3][0], q[1][3] + q[2][0], 0.0], + [q[1][2] + q[3][0], 1.0 - q[1][1] - q[3][3], q[2][3] - q[1][0], 0.0], + [q[1][3] - q[2][0], q[2][3] + q[1][0], 1.0 - q[1][1] - q[2][2], 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + return rotation + + +def quaternion_from_matrix(M: Sequence[Sequence[float]]) -> list[float]: + """Returns the 4 quaternion coefficients from a rotation matrix. + + Parameters + ---------- + M + The coefficients of the rotation matrix, row per row. + + Returns + ------- + list[float] + The quaternion coefficients. + + Examples + -------- + >>> q1 = [0.945, -0.021, -0.125, 0.303] + >>> R = matrix_from_quaternion(q1) + >>> q2 = quaternion_from_matrix(R) + >>> allclose(q1, q2, tol=1e-03) + True + + """ + qw, qx, qy, qz = 0, 0, 0, 0 + trace = M[0][0] + M[1][1] + M[2][2] + + if trace > 0.0: + s = 0.5 / sqrt(trace + 1.0) + qw = 0.25 / s + qx = (M[2][1] - M[1][2]) * s + qy = (M[0][2] - M[2][0]) * s + qz = (M[1][0] - M[0][1]) * s + + elif (M[0][0] > M[1][1]) and (M[0][0] > M[2][2]): + s = 2.0 * sqrt(1.0 + M[0][0] - M[1][1] - M[2][2]) + qw = (M[2][1] - M[1][2]) / s + qx = 0.25 * s + qy = (M[0][1] + M[1][0]) / s + qz = (M[0][2] + M[2][0]) / s + + elif M[1][1] > M[2][2]: + s = 2.0 * sqrt(1.0 + M[1][1] - M[0][0] - M[2][2]) + qw = (M[0][2] - M[2][0]) / s + qx = (M[0][1] + M[1][0]) / s + qy = 0.25 * s + qz = (M[1][2] + M[2][1]) / s + else: + s = 2.0 * sqrt(1.0 + M[2][2] - M[0][0] - M[1][1]) + qw = (M[1][0] - M[0][1]) / s + qx = (M[0][2] + M[2][0]) / s + qy = (M[1][2] + M[2][1]) / s + qz = 0.25 * s + + return [qw, qx, qy, qz] + + +def matrix_from_basis_vectors(xaxis: CoordinateType, yaxis: CoordinateType) -> list[list[float]]: + """Creates a rotation matrix from basis vectors (= orthonormal vectors). + + Parameters + ---------- + xaxis + The x-axis of the frame. + yaxis + The y-axis of the frame. + + Returns + ------- + list[list[float]] + A 4x4 transformation matrix representing a rotation. + + Notes + ----- + ```text + [ x0 y0 z0 0 ] + [ x1 y1 z1 0 ] + [ x2 y2 z2 0 ] + [ 0 0 0 1 ] + ``` + + Examples + -------- + >>> xaxis = [0.68, 0.68, 0.27] + >>> yaxis = [-0.67, 0.73, -0.15] + >>> R = matrix_from_basis_vectors(xaxis, yaxis) + + """ + xaxis = normalize_vector(list(xaxis)) + yaxis = normalize_vector(list(yaxis)) + zaxis = cross_vectors(xaxis, yaxis) + yaxis = cross_vectors(zaxis, xaxis) + + R = identity_matrix(4) + R[0][0], R[1][0], R[2][0] = xaxis + R[0][1], R[1][1], R[2][1] = yaxis + R[0][2], R[1][2], R[2][2] = zaxis + return R + + +def basis_vectors_from_matrix(R: Sequence[Sequence[float]]) -> tuple[list[float], list[float]]: + """Returns the basis vectors from the rotation matrix R. + + Parameters + ---------- + R + A 4-by-4 transformation matrix, or a 3-by-3 rotation matrix. + + Returns + ------- + tuple[list[float], list[float]] + The first and second basis vectors of the rotation. + + Raises + ------ + ValueError + If rotation matrix is invalid. + + Examples + -------- + >>> from compas.geometry import Frame + >>> f = Frame([0, 0, 0], [0.68, 0.68, 0.27], [-0.67, 0.73, -0.15]) + >>> R = matrix_from_frame(f) + >>> xaxis, yaxis = basis_vectors_from_matrix(R) + + """ + xaxis = [R[0][0], R[1][0], R[2][0]] + yaxis = [R[0][1], R[1][1], R[2][1]] + zaxis = [R[0][2], R[1][2], R[2][2]] + + if not allclose(zaxis, cross_vectors(xaxis, yaxis)): + raise ValueError("Matrix is invalid rotation matrix.") + + return xaxis, yaxis + + +def matrix_from_translation(translation: Sequence[float]) -> list[list[float]]: + """Returns a 4x4 translation matrix in row-major order. + + Parameters + ---------- + translation + The x, y and z components of the translation. + + Returns + ------- + list[list[float]] + The 4x4 transformation matrix representing a translation. + + Notes + ----- + ```text + [ . . . 0 ] + [ . . . 1 ] + [ . . . 2 ] + [ . . . . ] + ``` + + Examples + -------- + >>> T = matrix_from_translation([1, 2, 3]) + + """ + M = identity_matrix(4) + M[0][3] = float(translation[0]) + M[1][3] = float(translation[1]) + M[2][3] = float(translation[2]) + + return M + + +def translation_from_matrix(M: Sequence[Sequence[float]]) -> list[float]: + """Returns the 3 values of translation from the matrix M. + + Parameters + ---------- + M + A 4-by-4 transformation matrix. + + Returns + ------- + list[float] + The translation vector. + + """ + return [M[0][3], M[1][3], M[2][3]] + + +def matrix_from_orthogonal_projection( + plane: tuple[Sequence[float], Sequence[float]], +) -> list[list[float]]: + """Returns an orthogonal projection matrix to project onto a plane. + + Parameters + ---------- + plane + The plane to project onto. + + Returns + ------- + list[list[float]] + The 4x4 transformation matrix representing an orthogonal projection. + + Examples + -------- + >>> point = [0, 0, 0] + >>> normal = [0, 0, 1] + >>> plane = (point, normal) + >>> P = matrix_from_orthogonal_projection(plane) + + """ + point, normal = plane + T = identity_matrix(4) + normal = normalize_vector(normal) + + for j in range(3): + for i in range(3): + T[i][j] -= normal[i] * normal[j] # outer_product + + T[0][3], T[1][3], T[2][3] = scale_vector(normal, dot_vectors(point, normal)) + return T + + +def matrix_from_parallel_projection(plane: tuple[Sequence[float], Sequence[float]], direction: Sequence[float]) -> list[list[float]]: + """Returns an parallel projection matrix to project onto a plane. + + Parameters + ---------- + plane + The plane to project onto. + direction + Direction of the projection. + + Returns + ------- + list[list[float]] + A 4-by-4 transformation matrix. + + Examples + -------- + >>> point = [0, 0, 0] + >>> normal = [0, 0, 1] + >>> plane = (point, normal) + >>> direction = [1, 1, 1] + >>> P = matrix_from_parallel_projection(plane, direction) + + """ + point, normal = plane + T = identity_matrix(4) + normal = normalize_vector(normal) + + scale = dot_vectors(direction, normal) + for j in range(3): + for i in range(3): + T[i][j] -= direction[i] * normal[j] / scale + + T[0][3], T[1][3], T[2][3] = scale_vector(direction, dot_vectors(point, normal) / scale) + return T + + +def matrix_from_perspective_projection(plane: tuple[Sequence[float], Sequence[float]], center_of_projection: Sequence[float]) -> list[list[float]]: + """Returns a perspective projection matrix to project onto a plane along lines that emanate from a single point, called the center of projection. + + Parameters + ---------- + plane + The plane to project onto. + center_of_projection + The camera view point. + + Returns + ------- + list[list[float]] + A 4-by-4 transformation matrix. + + Examples + -------- + >>> point = [0, 0, 0] + >>> normal = [0, 0, 1] + >>> plane = (point, normal) + >>> center_of_projection = [1, 1, 0] + >>> P = matrix_from_perspective_projection(plane, center_of_projection) + + """ + point, normal = plane + T = identity_matrix(4) + normal = normalize_vector(normal) + + T[0][0] = T[1][1] = T[2][2] = dot_vectors(subtract_vectors(center_of_projection, point), normal) + + for j in range(3): + for i in range(3): + T[i][j] -= center_of_projection[i] * normal[j] + + T[0][3], T[1][3], T[2][3] = scale_vector(center_of_projection, dot_vectors(point, normal)) + + for i in range(3): + T[3][i] -= normal[i] + + T[3][3] = dot_vectors(center_of_projection, normal) + + return T + + +def matrix_from_perspective_entries(perspective: Sequence[float]) -> list[list[float]]: + """Returns a matrix from perspective entries. + + Parameters + ---------- + perspective + The 4 perspective entries of a matrix. + + Returns + ------- + list[list[float]] + A 4-by-4 transformation matrix. + + Notes + ----- + ```text + [ . . . . ] + [ . . . . ] + [ . . . . ] + [ 0 1 2 3 ] + ``` + """ + M = identity_matrix(4) + M[3][0] = float(perspective[0]) + M[3][1] = float(perspective[1]) + M[3][2] = float(perspective[2]) + M[3][3] = float(perspective[3]) + return M + + +def matrix_from_shear_entries(shear_entries: Sequence[float]) -> list[list[float]]: + """Returns a shear matrix from the 3 factors for x-y, x-z, and y-z axes. + + Parameters + ---------- + shear_entries + The 3 shear factors for x-y, x-z, and y-z axes. + + Returns + ------- + list[list[float]] + A 4-by-4 transformation matrix. + + Notes + ----- + ```text + [ . 0 1 . ] + [ . . 2 . ] + [ . . . . ] + [ . . . . ] + ``` + + Examples + -------- + >>> Sh = matrix_from_shear_entries([1, 2, 3]) + + """ + M = identity_matrix(4) + M[0][1] = float(shear_entries[0]) + M[0][2] = float(shear_entries[1]) + M[1][2] = float(shear_entries[2]) + return M + + +def matrix_from_shear(angle: float, direction: Sequence[float], point: Sequence[float], normal: Sequence[float]) -> list[list[float]]: + """Constructs a shear matrix by an angle along the direction vector on the + shear plane (defined by point and normal). + + Parameters + ---------- + angle + The angle in radians. + direction + The direction vector as list of 3 numbers. + It must be orthogonal to the normal vector. + point + The point of the shear plane as list of 3 numbers. + normal + The normal of the shear plane as list of 3 numbers. + + Returns + ------- + list[list[float]] + A 4-by-4 transformation matrix. + + Raises + ------ + ValueError + If direction and normal are not orthogonal. + + Notes + ----- + A point P is transformed by the shear matrix into P" such that + the vector P-P" is parallel to the direction vector and its extent is + given by the angle of P-P'-P", where P' is the orthogonal projection + of P onto the shear plane (defined by point and normal). + + Examples + -------- + >>> angle = 0.1 + >>> direction = [0.1, 0.2, 0.3] + >>> point = [4, 3, 1] + >>> normal = cross_vectors(direction, [1, 0.3, -0.1]) + >>> S = matrix_from_shear(angle, direction, point, normal) + + """ + normal = normalize_vector(normal) + direction = normalize_vector(direction) + + if not TOL.is_zero(dot_vectors(normal, direction)): + raise ValueError("Direction and normal vectors are not orthogonal") + + angle = tan(angle) + M = identity_matrix(4) + + for j in range(3): + for i in range(3): + M[i][j] += angle * direction[i] * normal[j] + + M[0][3], M[1][3], M[2][3] = scale_vector(direction, -angle * dot_vectors(point, normal)) + + return M + + +def matrix_from_scale_factors(scale_factors: Sequence[float]) -> list[list[float]]: + """Returns a 4x4 scaling transformation. + + Parameters + ---------- + scale_factors + Three numbers defining the scaling factors in x, y, and z respectively. + + Returns + ------- + list[list[float]] + A 4-by-4 transformation matrix. + + Notes + ----- + ```text + [ 0 . . . ] + [ . 1 . . ] + [ . . 2 . ] + [ . . . . ] + ``` + + Examples + -------- + >>> Sc = matrix_from_scale_factors([1, 2, 3]) + + """ + M = identity_matrix(4) + M[0][0] = float(scale_factors[0]) + M[1][1] = float(scale_factors[1]) + M[2][2] = float(scale_factors[2]) + + return M diff --git a/src/compas/linalg/vectors.py b/src/compas/linalg/vectors.py new file mode 100644 index 000000000000..1350a38b4bfe --- /dev/null +++ b/src/compas/linalg/vectors.py @@ -0,0 +1,1155 @@ +from math import sqrt +from typing import Iterable +from typing import Optional +from typing import Sequence +from typing import overload + +from compas._typing import CoordinateType +from compas.tolerance import TOL + +_SPEC2TUPLE = { + "sxyz": (0, 0, 0, 0), + "sxyx": (0, 0, 1, 0), + "sxzy": (0, 1, 0, 0), + "sxzx": (0, 1, 1, 0), + "syzx": (1, 0, 0, 0), + "syzy": (1, 0, 1, 0), + "syxz": (1, 1, 0, 0), + "syxy": (1, 1, 1, 0), + "szxy": (2, 0, 0, 0), + "szxz": (2, 0, 1, 0), + "szyx": (2, 1, 0, 0), + "szyz": (2, 1, 1, 0), + "rzyx": (0, 0, 0, 1), + "rxyx": (0, 0, 1, 1), + "ryzx": (0, 1, 0, 1), + "rxzx": (0, 1, 1, 1), + "rxzy": (1, 0, 0, 1), + "ryzy": (1, 0, 1, 1), + "rzxy": (1, 1, 0, 1), + "ryxy": (1, 1, 1, 1), + "ryxz": (2, 0, 0, 1), + "rzxz": (2, 0, 1, 1), + "rxyz": (2, 1, 0, 1), + "rzyz": (2, 1, 1, 1), +} +"""used for Euler angles: to map rotation type and axes to tuples of inner axis, parity, repetition, frame""" + +_NEXT_SPEC = [1, 2, 0, 1] + + +def vector_average(vector: Sequence[float]) -> float: + """Average of a vector. + + Parameters + ---------- + vector + List of values. + + Returns + ------- + float + The mean value. + + """ + return sum(vector) / float(len(vector)) + + +def vector_variance(vector: Sequence[float]) -> float: + """Variance of a vector. + + Parameters + ---------- + vector + List of values. + + Returns + ------- + float + The variance value. + + """ + m = vector_average(vector) + return (sum([(i - m) ** 2 for i in vector]) / float(len(vector))) ** 0.5 + + +def vector_standard_deviation(vector: Sequence[float]) -> float: + """Standard deviation of a vector. + + Parameters + ---------- + vector + List of values. + + Returns + ------- + float + The standard deviation value. + + """ + return vector_variance(vector) ** 0.5 + + +def argmax(values: Sequence[float]) -> int: + """Returns the index of the first maximum value within an array. + + Parameters + ---------- + values + A list of values. + + Returns + ------- + int + The index of the first maximum value within an array. + + Notes + ----- + NumPy's `argmax` function is different: it returns an array of indices.[^argmax-numpy] + + References + ---------- + [^argmax-numpy]: [NumPy `argmax`](https://numpy.org/doc/stable/reference/generated/numpy.argmax.html) + + Examples + -------- + >>> argmax([2, 4, 4, 3]) + 1 + + """ + return max(range(len(values)), key=lambda i: values[i]) + + +def argmin(values: Sequence[float]) -> int: + """Returns the index of the first minimum value within an array. + + Parameters + ---------- + values + A list of values. + + Returns + ------- + int + The index of the first minimum value within an array. + + Notes + ----- + NumPy's `argmin` function is different: it returns an array of indices.[^argmin-numpy] + + References + ---------- + [^argmin-numpy]: [NumPy `argmin`](https://numpy.org/doc/stable/reference/generated/numpy.argmin.html) + + Examples + -------- + >>> argmin([4, 2, 2, 3]) + 1 + + """ + return min(range(len(values)), key=lambda i: values[i]) + + +# ============================================================================== +# these return something of smaller dimension/length/... +# something_(of)vector/s +# ============================================================================== + + +def sum_vectors(vectors: Sequence[Sequence[float]], axis: int = 0) -> list[float]: + """Calculate the sum of a series of vectors along the specified axis. + + Parameters + ---------- + vectors + A list of vectors. + axis + If `axis == 0`, the sum is taken per column. + If `axis == 1`, the sum is taken per row. + + Returns + ------- + list[float] + The length of the list is `len(vectors[0])`, if `axis == 0`. + The length is `len(vectors)`, otherwise. + + Examples + -------- + >>> vectors = [[1.0, 2.0, 3.0], [1.0, 2.0, 3.0], [1.0, 2.0, 3.0]] + >>> sum_vectors(vectors) + [3.0, 6.0, 9.0] + >>> sum_vectors(vectors, axis=1) + [6.0, 6.0, 6.0] + + """ + if axis == 0: + return [sum(vector) for vector in zip(*vectors)] + return [sum(vector) for vector in vectors] + + +def norm_vector(vector: Sequence[float]) -> float: + """Calculate the length of a vector. + + Parameters + ---------- + vector + XYZ components of the vector. + + Returns + ------- + float + The L2 norm, or *length* of the vector. + + Examples + -------- + >>> norm_vector([2.0, 0.0, 0.0]) + 2.0 + + >>> norm_vector([1.0, 1.0, 0.0]) == sqrt(2.0) + True + + """ + return sqrt(sum(axis**2 for axis in vector)) + + +def norm_vectors(vectors: Sequence[Sequence[float]]) -> list[float]: + """ + Calculate the norm of each vector in a list of vectors. + + Parameters + ---------- + vectors + A list of vectors + + Returns + ------- + list[float] + A list with the lengths of all vectors. + + Examples + -------- + >>> norm_vectors([[1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [3.0, 0.0, 0.0]]) + [1.0, 2.0, 3.0] + + """ + return [norm_vector(vector) for vector in vectors] + + +def length_vector(vector: CoordinateType) -> float: + """Calculate the length of the vector. + + Parameters + ---------- + vector + XYZ components of the vector. + + Returns + ------- + float + The length of the vector. + + Examples + -------- + >>> length_vector([2.0, 0.0, 0.0]) + 2.0 + + >>> length_vector([1.0, 1.0, 0.0]) == sqrt(2.0) + True + + """ + return sqrt(length_vector_sqrd(vector)) + + +def length_vector_xy(vector: Sequence[float]) -> float: + """Compute the length of a vector, assuming it lies in the XY plane. + + Parameters + ---------- + vector + XY(Z) components of the vector. + + Returns + ------- + float + The length of the XY component of the vector. + + Examples + -------- + >>> length_vector_xy([2.0, 0.0]) + 2.0 + + >>> length_vector_xy([2.0, 0.0, 0.0]) + 2.0 + + >>> length_vector_xy([2.0, 0.0, 2.0]) + 2.0 + + """ + return sqrt(length_vector_sqrd_xy(vector)) + + +def length_vector_sqrd(vector: Sequence[float]) -> float: + """Compute the squared length of a vector. + + Parameters + ---------- + vector + XYZ components of the vector. + + Returns + ------- + float + The squared length. + + Examples + -------- + >>> length_vector_sqrd([1.0, 1.0, 0.0]) + 2.0 + + """ + return vector[0] ** 2 + vector[1] ** 2 + vector[2] ** 2 + + +def length_vector_sqrd_xy(vector: Sequence[float]) -> float: + """Compute the squared length of a vector, assuming it lies in the XY plane. + + Parameters + ---------- + vector + XY(Z) components of the vector. + + Returns + ------- + float + The squared length. + + Examples + -------- + >>> length_vector_sqrd_xy([1.0, 1.0]) + 2.0 + + >>> length_vector_sqrd_xy([1.0, 1.0, 0.0]) + 2.0 + + >>> length_vector_sqrd_xy([1.0, 1.0, 1.0]) + 2.0 + + """ + return vector[0] ** 2 + vector[1] ** 2 + + +# ============================================================================== +# these perform an operation on a vector and return a modified vector +# -> elementwise operations on 1 vector +# should this not bet ...ed_vector +# ... or else modify the vector in-place +# ============================================================================== + + +def scale_vector(vector: Sequence[float], factor: float) -> list[float]: + """Scale a vector by a given factor. + + Parameters + ---------- + vector + XYZ components of the vector. + factor + The scaling factor. + + Returns + ------- + list[float] + The scaled vector. + + Examples + -------- + >>> scale_vector([1.0, 2.0, 3.0], 2.0) + [2.0, 4.0, 6.0] + + >>> v = [2.0, 0.0, 0.0] + >>> scale_vector(v, 1 / length_vector(v)) + [1.0, 0.0, 0.0] + + """ + return [axis * factor for axis in vector] + + +def scale_vector_xy(vector: Sequence[float], factor: float) -> list[float]: + """Scale a vector by a given factor, assuming it lies in the XY plane. + + Parameters + ---------- + vector + XY(Z) components of the vector. + factor + Scale factor. + + Returns + ------- + list[float] + The scaled vector in the XY-plane. + + Examples + -------- + >>> scale_vector_xy([1.0, 2.0, 3.0], 2.0) + [2.0, 4.0, 0.0] + + """ + return [vector[0] * factor, vector[1] * factor, 0.0] + + +def scale_vectors(vectors: Sequence[Sequence[float]], factor: float) -> list[list[float]]: + """Scale multiple vectors by a given factor. + + Parameters + ---------- + vectors + A list of vectors. + factor + The scaling factor. + + Returns + ------- + list[list[float]] + The scaled vectors. + + """ + return [scale_vector(vector, factor) for vector in vectors] + + +def scale_vectors_xy(vectors: Sequence[Sequence[float]], factor: float) -> list[list[float]]: + """Scale multiple vectors by a given factor, assuming they lie in the XY plane. + + Parameters + ---------- + vectors + A list of vectors. + factor + The scaling factor. + + Returns + ------- + list[list[float]] + The scaled vectors in the XY plane. + + """ + return [scale_vector_xy(vector, factor) for vector in vectors] + + +@overload +def normalize_vector(vector: list[float]) -> list[float]: ... + + +@overload +def normalize_vector(vector: Sequence[float]) -> Sequence[float]: ... + + +def normalize_vector(vector: Sequence[float]) -> Sequence[float]: + """Normalise a given vector. + + Parameters + ---------- + vector + XYZ components of the vector. + + Returns + ------- + Sequence[float] + The normalized vector. + + """ + length = length_vector(vector) + if not length: + return vector + return [vector[0] / length, vector[1] / length, vector[2] / length] + + +@overload +def normalize_vector_xy(vector: list[float]) -> list[float]: ... + + +@overload +def normalize_vector_xy(vector: Sequence[float]) -> Sequence[float]: ... + + +def normalize_vector_xy(vector: Sequence[float]) -> Sequence[float]: + """Normalize a vector, assuming it lies in the XY-plane. + + Parameters + ---------- + vector + XY(Z) components of the vector. + + Returns + ------- + Sequence[float] + The normalized vector in the XY-plane. + + """ + length = length_vector_xy(vector) + if not length: + return vector + return [vector[0] / length, vector[1] / length, 0.0] + + +def normalize_vectors(vectors: Sequence[Sequence[float]]) -> list[Sequence[float]]: + """Normalise multiple vectors. + + Parameters + ---------- + vectors + A list of vectors. + + Returns + ------- + list[Sequence[float]] + The normalized vectors. + + """ + return [normalize_vector(vector) for vector in vectors] + + +def normalize_vectors_xy(vectors: Sequence[Sequence[float]]) -> list[Sequence[float]]: + """Normalise multiple vectors, assuming they lie in the XY plane. + + Parameters + ---------- + vectors + A list of vectors. + + Returns + ------- + list[Sequence[float]] + The normalized vectors in the XY plane. + + """ + return [normalize_vector_xy(vector) for vector in vectors] + + +def power_vector(vector: Sequence[float], power: float) -> list[float]: + """Raise a vector to the given power. + + Parameters + ---------- + vector + XYZ components of the vector. + power + The power to which to raise the vector. + + Returns + ------- + list[float] + The raised vector. + + """ + return [axis**power for axis in vector] + + +def power_vectors(vectors: Sequence[Sequence[float]], power: float) -> list[list[float]]: + """Raise a list of vectors to the given power. + + Parameters + ---------- + vectors + A list of vectors. + power + The power to which to raise the vectors. + + Returns + ------- + list[list[float]] + The raised vectors. + + """ + return [power_vector(vector, power) for vector in vectors] + + +def square_vector(vector: Sequence[float]) -> list[float]: + """Raise a vector to the power 2. + + Parameters + ---------- + vector + XYZ components of the vector. + + Returns + ------- + list[float] + The squared vector. + + """ + return power_vector(vector, 2) + + +def square_vectors(vectors: Sequence[Sequence[float]]) -> list[list[float]]: + """Raise a multiple vectors to the power 2. + + Parameters + ---------- + vectors + A list of vectors. + + Returns + ------- + list[list[float]] + The squared vectors. + + """ + return [square_vector(vector) for vector in vectors] + + +# ============================================================================== +# these perform an operation with corresponding elements of the (2) input vectors as operands +# and return a vector with the results +# -> elementwise operations on two vectors +# ============================================================================== + + +def add_vectors(u: Iterable[float], v: Iterable[float]) -> list[float]: + """Add two vectors. + + Parameters + ---------- + u + XYZ components of the first vector. + v + XYZ components of the second vector. + + Returns + ------- + list[float] + The resulting vector. + + """ + return [a + b for (a, b) in zip(u, v)] + + +def add_vectors_xy(u: Sequence[float], v: Sequence[float]) -> list[float]: + """Add two vectors, assuming they lie in the XY-plane. + + Parameters + ---------- + u + XY(Z) components of the first vector. + v + XY(Z) components of the second vector. + + Returns + ------- + list[float] + Resulting vector in the XY-plane. + + """ + return [u[0] + v[0], u[1] + v[1], 0.0] + + +def subtract_vectors(u: CoordinateType, v: CoordinateType) -> list[float]: + """Subtract one vector from another. + + Parameters + ---------- + u + XYZ components of the first vector. + v + XYZ components of the second vector. + + Returns + ------- + list[float] + The resulting vector. + + """ + return [a - b for (a, b) in zip(u, v)] + + +def subtract_vectors_xy(u: Sequence[float], v: Sequence[float]) -> list[float]: + """Subtract one vector from another, assuming they lie in the XY plane. + + Parameters + ---------- + u + The XY(Z) components of the first vector. + v + The XY(Z) components of the second vector. + + Returns + ------- + list[float] + Resulting vector in the XY-plane. + + """ + return [u[0] - v[0], u[1] - v[1], 0.0] + + +def multiply_vectors(u: Sequence[float], v: Sequence[float]) -> list[float]: + """Element-wise multiplication of two vectors. + + Parameters + ---------- + u + The XYZ components of the first vector. + v + The XYZ components of the second vector. + + Returns + ------- + list[float] + Resulting vector. + + """ + return [a * b for (a, b) in zip(u, v)] + + +def multiply_vectors_xy(u: Sequence[float], v: Sequence[float]) -> list[float]: + """Element-wise multiplication of two vectors assumed to lie in the XY plane. + + Parameters + ---------- + u + The XY(Z) components of the first vector. + v + The XY(Z) components of the second vector. + + Returns + ------- + list[float] + Resulting vector in the XY plane. + + """ + return [u[0] * v[0], u[1] * v[1], 0.0] + + +def divide_vectors(u: Sequence[float], v: Sequence[float]) -> list[float]: + """Element-wise division of two vectors. + + Parameters + ---------- + u + The XYZ components of the first vector. + v + The XYZ components of the second vector. + + Returns + ------- + list[float] + Resulting vector. + + """ + return [a / b for (a, b) in zip(u, v)] + + +def divide_vectors_xy(u: Sequence[float], v: Sequence[float]) -> list[float]: + """Element-wise division of two vectors assumed to lie in the XY plane. + + Parameters + ---------- + u + The XY(Z) components of the first vector. + v + The XY(Z) components of the second vector. + + Returns + ------- + list[float] + Resulting vector in the XY plane. + + """ + return [u[0] / v[0], u[1] / v[1], 0.0] + + +# ============================================================================== +# ... +# ============================================================================== + + +def cross_vectors(u: CoordinateType, v: CoordinateType) -> list[float]: + r"""Compute the cross product of two vectors. + + Parameters + ---------- + u + XYZ components of the first vector. + v + XYZ components of the second vector. + + Returns + ------- + list[float] + The cross product of the two vectors. + + Notes + ----- + The xyz components of the cross product of two vectors $\mathbf{u}$ + and $\mathbf{v}$ can be computed as the *minors* of the following matrix: + + $$ + \begin{bmatrix} + x & y & z \\ + u_{x} & u_{y} & u_{z} \\ + v_{x} & v_{y} & v_{z} + \end{bmatrix} + $$ + + Therefore, the cross product can be written as: + + $$ + \begin{aligned} + \mathbf{u} \times \mathbf{v} + & = + \begin{bmatrix} + u_{y} * v_{z} - u_{z} * v_{y} \\ + u_{z} * v_{x} - u_{x} * v_{z} \\ + u_{x} * v_{y} - u_{y} * v_{x} + \end{bmatrix} + \end{aligned} + $$ + + Examples + -------- + >>> cross_vectors([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]) + [0.0, 0.0, 1.0] + + """ + return [ + u[1] * v[2] - u[2] * v[1], + u[2] * v[0] - u[0] * v[2], + u[0] * v[1] - u[1] * v[0], + ] + + +def cross_vectors_xy(u: Sequence[float], v: Sequence[float]) -> list[float]: + """Compute the cross product of two vectors, assuming they lie in the XY-plane. + + Parameters + ---------- + u + XY(Z) coordinates of the first vector. + v + XY(Z) coordinates of the second vector. + + Returns + ------- + list[float] + The cross product of the two vectors. + This vector will be perpendicular to the XY plane. + + Examples + -------- + >>> cross_vectors_xy([1.0, 0.0], [0.0, 1.0]) + [0.0, 0.0, 1.0] + + >>> cross_vectors_xy([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]) + [0.0, 0.0, 1.0] + + >>> cross_vectors_xy([1.0, 0.0, 1.0], [0.0, 1.0, 1.0]) + [0.0, 0.0, 1.0] + + """ + return [0.0, 0.0, u[0] * v[1] - u[1] * v[0]] + + +def dot_vectors(u: CoordinateType, v: CoordinateType) -> float: + """Compute the dot product of two vectors. + + Parameters + ---------- + u + XYZ components of the first vector. + v + XYZ components of the second vector. + + Returns + ------- + float + The dot product of the two vectors. + + Examples + -------- + >>> dot_vectors([1.0, 0, 0], [2.0, 0, 0]) + 2.0 + + """ + return sum(a * b for a, b in zip(u, v)) + + +def dot_vectors_xy(u: Sequence[float], v: Sequence[float]) -> float: + """Compute the dot product of two vectors, assuming they lie in the XY-plane. + + Parameters + ---------- + u + XY(Z) coordinates of the first vector. + v + XY(Z) coordinates of the second vector. + + Returns + ------- + float + The dot product of the XY components of the two vectors. + + Examples + -------- + >>> dot_vectors_xy([1.0, 0], [2.0, 0]) + 2.0 + + >>> dot_vectors_xy([1.0, 0, 0], [2.0, 0, 0]) + 2.0 + + >>> dot_vectors_xy([1.0, 0, 1], [2.0, 0, 1]) + 2.0 + + """ + return u[0] * v[0] + u[1] * v[1] + + +def vector_component(u: Sequence[float], v: Sequence[float]) -> list[float]: + """Compute the component of u in the direction of v. + + Parameters + ---------- + u + XYZ components of the vector. + v + XYZ components of the direction. + + Returns + ------- + list[float] + The component of u in the direction of v. + + Notes + ----- + This is similar to computing direction cosines, or to the projection of + a vector onto another vector.[^vector-component-direction-cosine] [^vector-component-projection] + + References + ---------- + [^vector-component-direction-cosine]: [Direction cosine](https://en.wikipedia.org/wiki/Direction_cosine) + [^vector-component-projection]: [Vector projection](https://en.wikipedia.org/wiki/Vector_projection) + + Examples + -------- + >>> vector_component([1.0, 2.0, 3.0], [1.0, 0.0, 0.0]) + [1.0, 0.0, 0.0] + + """ + l2 = length_vector_sqrd(v) + if not l2: + return [0, 0, 0] + x = dot_vectors(u, v) / l2 + return scale_vector(v, x) + + +def vector_component_xy(u: Sequence[float], v: Sequence[float]) -> list[float]: + """Compute the component of u in the direction of v, assuming they lie in the XY-plane. + + Parameters + ---------- + u + XYZ components of the vector. + v + XYZ components of the direction. + + Returns + ------- + list[float] + The component of u in the XY plane, in the direction of v. + + Notes + ----- + This is similar to computing direction cosines, or to the projection of + a vector onto another vector.[^vector-component-xy-direction-cosine] [^vector-component-xy-projection] + + References + ---------- + [^vector-component-xy-direction-cosine]: [Direction cosine](https://en.wikipedia.org/wiki/Direction_cosine) + [^vector-component-xy-projection]: [Vector projection](https://en.wikipedia.org/wiki/Vector_projection) + + Examples + -------- + >>> vector_component_xy([1, 2, 0], [1, 0, 0]) + [1.0, 0.0, 0.0] + + """ + l2 = length_vector_sqrd_xy(v) + if not l2: + return [0, 0, 0] + x = dot_vectors_xy(u, v) / l2 + return scale_vector_xy(v, x) + + +# ============================================================================== +# linalg +# ============================================================================== + + +def homogenize_vectors(vectors: Sequence[Sequence[float]], w: float = 1.0) -> list[list[float]]: + """Homogenise a list of vectors. + + Parameters + ---------- + vectors + A list of vectors. + w + Homogenisation parameter. + + Returns + ------- + list[list[float]] + Homogenised vectors. + + Notes + ----- + Vectors described by XYZ components are homogenised by appending a homogenisation + parameter to the components, and by dividing each component by that parameter. + Homogenisatioon of vectors is often used in relation to transformations. + + Examples + -------- + >>> vectors = [[1.0, 0.0, 0.0]] + >>> homogenize_vectors(vectors) + [[1.0, 0.0, 0.0, 1.0]] + + """ + return [[x / w, y / w, z / w, w] for x, y, z in vectors] + + +def dehomogenize_vectors(vectors: Sequence[Sequence[float]]) -> list[list[float]]: + """Dehomogenise a list of vectors. + + Parameters + ---------- + vectors + A list of vectors. + + Returns + ------- + list[list[float]] + Dehomogenised vectors. + + """ + return [[x * w, y * w, z * w] for x, y, z, w in vectors] + + +def orthonormalize_vectors(vectors: Sequence[Sequence[float]]) -> list[Sequence[float]]: + """Orthonormalize a set of vectors. + + Parameters + ---------- + vectors + The set of vectors to othonormalize. + + Returns + ------- + list[Sequence[float]] + An othonormal basis for the input vectors. + + Notes + ----- + This creates a basis for the range (column space) of the matrix A.T, + with A = vectors. + + Orthonormalisation is according to the Gram-Schmidt process. + + Examples + -------- + >>> orthonormalize_vectors([[1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) + [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + + """ + basis = [] + for v in vectors: + if basis: + e = subtract_vectors(v, sum_vectors([vector_component(v, b) for b in basis])) + else: + e = v + if any(axis > 1e-10 for axis in e): + basis.append(normalize_vector(e)) + return basis + + +# ============================================================================= +# ============================================================================= +# ============================================================================= +# ============================================================================= +# ============================================================================= +# ============================================================================= +# ============================================================================= +# Deprecated +# ============================================================================= +# ============================================================================= +# ============================================================================= +# ============================================================================= +# ============================================================================= +# ============================================================================= +# ============================================================================= + + +def close(value1: float, value2: float, tol: float = 1e-05) -> bool: + """Returns True if two values are equal within a tolerance. + + Parameters + ---------- + value1 + value2 + tol + The absolute tolerance for comparing values. + Default is `TOL.absolute`. + + Returns + ------- + bool + True if the values are closer than the tolerance. + False otherwise. + + Warnings + -------- + Deprecated since version 2.0. This function will be removed in version 2.1. + Use [`TOL.is_close`][compas.tolerance.Tolerance.is_close] instead. + + The tolerance value used by this function is an absolute tolerance. + It is more accurate to use a combination of absolute and relative tolerance. + Therefore, use [`TOL.is_close`][compas.tolerance.Tolerance.is_close] instead. + + """ + return TOL.is_close(value1, value2, rtol=0.0, atol=tol) + + +def allclose(l1: Sequence[float], l2: Sequence[float], tol: Optional[float] = None) -> bool: + """Returns True if two lists are element-wise equal within a tolerance. + + Parameters + ---------- + l1 + The first list of values. + l2 + The second list of values. + tol + The absolute tolerance for comparing values. + Default is `TOL.absolute`. + + Returns + ------- + bool + True if all corresponding values of the two lists are closer than the tolerance. + False otherwise. + + Warnings + -------- + Deprecated since version 2.0. This function will be removed in version 2.1. + Use [`TOL.is_allclose`][compas.tolerance.Tolerance.is_allclose] instead. + + The tolerance value used by this function is an absolute tolerance. + It is more accurate to use a combination of absolute and relative tolerance. + Therefore, use [`TOL.is_allclose`][compas.tolerance.Tolerance.is_allclose] instead. + + Notes + ----- + The function is similar to NumPy's `allclose` function.[^allclose-numpy] + + References + ---------- + [^allclose-numpy]: [NumPy `allclose`](https://numpy.org/doc/stable/reference/generated/numpy.allclose.html) + + """ + return TOL.is_allclose(l1, l2, atol=tol) diff --git a/src/compas/matrices.py b/src/compas/matrices.py deleted file mode 100644 index 603e0f4c1fc6..000000000000 --- a/src/compas/matrices.py +++ /dev/null @@ -1,314 +0,0 @@ -from numpy import abs -from numpy import array -from numpy import asarray -from numpy import tile -from scipy.sparse import coo_matrix # type: ignore -from scipy.sparse import csr_matrix # type: ignore -from scipy.sparse import diags # type: ignore -from scipy.sparse import vstack as svstack # type: ignore - - -def _return_matrix(M, rtype): - if rtype == "list": - return M.toarray().tolist() - if rtype == "array": - return M.toarray() - if rtype == "csr": - return M.tocsr() - if rtype == "csc": - return M.tocsc() - if rtype == "coo": - return M.tocoo() - return M - - -# ============================================================================== -# adjacency -# ============================================================================== - - -def adjacency_matrix(adjacency, rtype="array"): - """Creates a vertex adjacency matrix. - - Parameters - ---------- - adjacency : list - List of lists, vertex adjacency data. - rtype : {'array', 'csc', 'csr', 'coo', 'list'} - Format of the result. - - Returns - ------- - array-like - Constructed adjacency matrix. - - """ - a = [(1, i, j) for i in range(len(adjacency)) for j in adjacency[i]] - data, rows, cols = zip(*a) - A = coo_matrix((data, (rows, cols))).asfptype() - return _return_matrix(A, rtype) - - -def face_matrix(face_vertices, rtype="array", normalize=False): - """Creates a face-vertex adjacency matrix. - - Parameters - ---------- - face_vertices : list - List of lists, vertices per face. - rtype : {'array', 'csc', 'csr', 'coo', 'list'} - Format of the result. - - Returns - ------- - array-like - Constructed face matrix. - - """ - if normalize: - f = array([(i, j, 1.0 / len(vertices)) for i, vertices in enumerate(face_vertices) for j in vertices]) - else: - f = array([(i, j, 1.0) for i, vertices in enumerate(face_vertices) for j in vertices]) - F = coo_matrix((f[:, 2], (f[:, 0].astype(int), f[:, 1].astype(int)))) - return _return_matrix(F, rtype) - - -# ============================================================================== -# degree -# ============================================================================== - - -def degree_matrix(adjacency, rtype="array"): - """Creates a matrix representing vertex degrees. - - Parameters - ---------- - adjacency : list - List of lists, vertex adjacency data. - rtype : {'array', 'csc', 'csr', 'coo', 'list'} - Format of the result. - - Returns - ------- - array-like - Constructed degree matrix. - - """ - d = [(len(adjacency[i]), i, i) for i in range(len(adjacency))] - data, rows, cols = zip(*d) - D = coo_matrix((data, (rows, cols))).asfptype() - return _return_matrix(D, rtype) - - -# ============================================================================== -# connectivity -# ============================================================================== - - -def connectivity_matrix(edges, rtype="array"): - r"""Creates a connectivity matrix from a list of vertex index pairs. - - Parameters - ---------- - edges : list - List of lists [[node_i, node_j], [node_k, node_l]]. - rtype : {'array', 'csc', 'csr', 'coo', 'list'} - Format of the result. - - Returns - ------- - array-like - Constructed connectivity matrix. - - Notes - ----- - The connectivity matrix encodes how edges in a graph are connected - together. Each row represents an edge and has 1 and -1 inserted into the - columns for the start and end nodes. - - .. math:: - - \mathbf{C}_{ij} = - \cases{ - -1 & if edge i starts at vertex j \cr - +1 & if edge i ends at vertex j \cr - 0 & otherwise - } - - A connectivity matrix is generally sparse and will perform superior - in numerical calculations as a sparse matrix. - - Examples - -------- - >>> connectivity_matrix([[0, 1], [0, 2], [0, 3]], rtype="array") - array([[-1., 1., 0., 0.], - [-1., 0., 1., 0.], - [-1., 0., 0., 1.]]) - - """ - m = len(edges) - data = array([-1] * m + [1] * m) - rows = array(list(range(m)) + list(range(m))) - cols = array([edge[0] for edge in edges] + [edge[1] for edge in edges]) - C = coo_matrix((data, (rows, cols))).asfptype() - return _return_matrix(C, rtype) - - -# ============================================================================== -# laplacian -# ============================================================================== - - -# change this to a procedural approach -# constructing (fundamental) matrices should not involve matrix operations -def laplacian_matrix(edges, normalize=False, rtype="array"): - r"""Creates a laplacian matrix from a list of edge topologies. - - Parameters - ---------- - edges : list - List of lists [[node_i, node_j], [node_k, node_l]]. - rtype : {'array', 'csc', 'csr', 'coo', 'list'} - Format of the result. - - Returns - ------- - array-like - Constructed Laplacian matrix. - - Notes - ----- - The laplacian matrix is defined as - - .. math:: - - \mathbf{L} = \mathbf{C} ^ \mathrm{T} \mathbf{C} - - The current implementation only supports umbrella weights. - - Examples - -------- - >>> laplacian_matrix([[0, 1], [0, 2], [0, 3]], rtype="array") - array([[ 3., -1., -1., -1.], - [-1., 1., 0., 0.], - [-1., 0., 1., 0.], - [-1., 0., 0., 1.]]) - - """ - C = connectivity_matrix(edges, rtype="csr") - L = C.transpose().dot(C) # type: ignore - if normalize: - L = L / L.diagonal().reshape((-1, 1)) - L = csr_matrix(L) - return _return_matrix(L, rtype) - - -# ============================================================================== -# structural -# ============================================================================== - - -def equilibrium_matrix(C, xyz, free, rtype="array"): - r"""Construct the equilibrium matrix of a structural system. - - Parameters - ---------- - C : array-like - Connectivity matrix (m x n). - xyz : array-like - Array of vertex coordinates (n x 3). - free : list - The index values of the free vertices. - rtype : {'array', 'csc', 'csr', 'coo', 'list'} - Format of the result. - - Returns - ------- - array-like - Constructed equilibrium matrix. - - Notes - ----- - Analysis of the equilibrium matrix reveals some of the properties of the - structural system, its size is (2ni x m) where ni is the number of free or - internal nodes. It is calculated by - - .. math:: - - \mathbf{E} - = - \left[ - \begin{array}{c} - \mathbf{C}^{\mathrm{T}}_{\mathrm{i}}\mathbf{U} \\[0.3em] - \hline \\[-0.7em] - \mathbf{C}^{\mathrm{T}}_{\mathrm{i}}\mathbf{V} - \end{array} - \right]. - - The matrix of vertex coordinates is vectorised to speed up the - calculations. - - Examples - -------- - >>> C = connectivity_matrix([[0, 1], [0, 2], [0, 3]]) - >>> xyz = [[0, 0, 1], [0, 1, 0], [-1, -1, 0], [1, -1, 0]] - >>> equilibrium_matrix(C, xyz, [0], rtype="array") - array([[ 0., 1., -1.], - [-1., 1., 1.]]) - - """ - xyz = asarray(xyz, dtype=float) - C = csr_matrix(C) - xy = xyz[:, :2] - uv = C.dot(xy) - U = diags([uv[:, 0].flatten()], [0]) - V = diags([uv[:, 1].flatten()], [0]) - Ct = C.transpose() - Cti = Ct[free, :] - E = svstack((Cti.dot(U), Cti.dot(V))) - return _return_matrix(E, rtype) - - -def mass_matrix(Ct, ks, q=0, c=1, tiled=True): - r"""Creates a graph's nodal mass matrix. - - Parameters - ---------- - Ct : sparse - Sparse transpose of the connectivity matrix (n x m). - ks : array - Vector of member EA / L (m x 1). - q : array - Vector of member force densities (m x 1). - c : float - Convergence factor. - tiled : bool - Whether to tile horizontally by 3 for x, y, z. - - Returns - ------- - array - Mass matrix, either (m x 1) or (m x 3). - - Notes - ----- - The mass matrix is defined as the sum of the member axial stiffnesses - (inline) of the elements connected to each node, plus the force density. - The force density ensures a non-zero value in form-finding/pre-stress - modelling where E=0. - - .. math:: - - \mathbf{m} = - |\mathbf{C}^\mathrm{T}| - (\mathbf{E} \circ \mathbf{A} \oslash \mathbf{l} + \mathbf{f} \oslash \mathbf{l}) - - """ - m = c * abs(Ct).dot(ks + q) - if tiled: - return tile(m.reshape((-1, 1)), (1, 3)) - return m - - -def stiffness_matrix(): - raise NotImplementedError diff --git a/src/compas/plugins.py b/src/compas/plugins.py index 96210c9482cd..82eb30c90628 100644 --- a/src/compas/plugins.py +++ b/src/compas/plugins.py @@ -1,6 +1,18 @@ -""" -COMPAS has an extensible architecture based on plugins that allows to -customize and extend the functionality of the core framework. +"""COMPAS plugin infrastructure. + +Notes +----- +Plugin discovery currently imports every installed top-level ``compas*`` +package and every module listed in its ``__all_plugins__`` attribute. Plugin +modules should therefore remain lightweight and defer imports of optional or +heavy dependencies until the plugin function is called. In particular, GUI +toolkits may terminate the process during import instead of raising an +``ImportError``, which cannot be handled by :class:`Importer`. + +In the future, discovery should preferably use explicit package entry points +so that unrelated extension packages do not have to be imported merely to +locate plugin implementations. + """ # The COMPAS plugin system owes a lot to pluggy, the pytest plugin framework @@ -13,10 +25,6 @@ # # https://github.com/pytest-dev/pluggy -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import functools import inspect import pkgutil @@ -207,6 +215,7 @@ def register_module(self, plugin_module): ------- int Count of successfully registered plugins in the module. + """ count = 0 @@ -365,6 +374,7 @@ def plugin( Domain name that "owns" the pluggable extension point. This is useful to disambiguate name collisions between extension points of different packages. + """ def setattr_hookspec_opts(func): @@ -412,6 +422,7 @@ def try_import(self, module_name): ------- module If importable, it returns the imported module, otherwise ``None``. + """ module = None try: @@ -439,6 +450,7 @@ def check_importable(self, module_name): ------- bool ``True`` if the module can be imported correctly, otherwise ``False``. + """ if module_name not in self._cache: self.try_import(module_name) diff --git a/src/compas/rpc/__init__.py b/src/compas/rpc/__init__.py index c34107367d59..1c09ffa8f87e 100644 --- a/src/compas/rpc/__init__.py +++ b/src/compas/rpc/__init__.py @@ -7,14 +7,11 @@ Through RPC, COMPAS can be used as a server for remote clients, and as a client for remote servers. A typical use case is to run algorithms that require packages like ``numpy`` or ``scipy`` on a remote server, when working in Rhino. Or to use COMPAS in a browser application. -""" -from __future__ import absolute_import +""" +# ruff: noqa: F401 from .errors import RPCClientError, RPCServerError from .proxy import Proxy from .server import Server from .dispatcher import Dispatcher - - -__all__ = ["RPCClientError", "RPCServerError", "Proxy", "Server", "Dispatcher"] diff --git a/src/compas/rpc/dispatcher.py b/src/compas/rpc/dispatcher.py index ef0853833632..695a60d2792e 100644 --- a/src/compas/rpc/dispatcher.py +++ b/src/compas/rpc/dispatcher.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import importlib import json import pstats diff --git a/src/compas/rpc/errors.py b/src/compas/rpc/errors.py index 89d37f6881b0..f23f85931020 100644 --- a/src/compas/rpc/errors.py +++ b/src/compas/rpc/errors.py @@ -1,8 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - - class RPCServerError(Exception): """Exception for errors originating from the server.""" diff --git a/src/compas/rpc/proxy.py b/src/compas/rpc/proxy.py index 474bc40d0007..de88cfbe65d8 100644 --- a/src/compas/rpc/proxy.py +++ b/src/compas/rpc/proxy.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import json import time @@ -102,6 +98,7 @@ class Proxy(object): Starting a new proxy server... # doctest: +SKIP New proxy server started. # doctest: +SKIP Stopping the server proxy. # doctest: +SKIP + """ def __init__( @@ -367,6 +364,7 @@ def _terminate_process(self): The process reference might not be present, e.g. in the case of reusing an existing connection. In that case, this is a no-op. + """ if not self._process: return diff --git a/src/compas/rpc/server.py b/src/compas/rpc/server.py index e570f0f437a0..d4b162a0cd55 100644 --- a/src/compas/rpc/server.py +++ b/src/compas/rpc/server.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import threading try: diff --git a/src/compas/scene/__init__.py b/src/compas/scene/__init__.py index 8aaa2c03581d..b08957ebaea3 100644 --- a/src/compas/scene/__init__.py +++ b/src/compas/scene/__init__.py @@ -2,11 +2,9 @@ This package defines sceneobjects for visualising COMPAS items (geometry & datastructures). Every item type is paired with a corresponding scene object type that is capable of visualizing the data of the object. The scene objects are implemented as pluggables, and automatically switch between plugins depending on the contexct in which they are used. -""" -from __future__ import print_function -from __future__ import absolute_import -from __future__ import division +""" +# ruff: noqa: F401 from .exceptions import SceneObjectNotRegisteredError from .sceneobject import SceneObject @@ -38,21 +36,3 @@ def register_scene_objects_base(): register(Mesh, MeshObject, context=None) register(Graph, GraphObject, context=None) register(VolMesh, VolMeshObject, context=None) - - -__all__ = [ - "SceneObjectNotRegisteredError", - "SceneObject", - "MeshObject", - "GraphObject", - "GeometryObject", - "VolMeshObject", - "Scene", - "clear", - "before_draw", - "after_draw", - "register_scene_objects", - "get_sceneobject_cls", - "register", - "Group", -] diff --git a/src/compas/scene/context.py b/src/compas/scene/context.py index c53052a4a233..40b57fbefe54 100644 --- a/src/compas/scene/context.py +++ b/src/compas/scene/context.py @@ -22,6 +22,7 @@ def clear(guids=None): Returns ------- None + """ raise NotImplementedError diff --git a/src/compas/scene/descriptors/colordict.py b/src/compas/scene/descriptors/colordict.py index 03a6db90a695..41f841a34395 100644 --- a/src/compas/scene/descriptors/colordict.py +++ b/src/compas/scene/descriptors/colordict.py @@ -1,9 +1,5 @@ -import compas +from collections.abc import Mapping -if compas.PY2: - from collections import Mapping -else: - from collections.abc import Mapping from compas.colors.colordict import ColorDict diff --git a/src/compas/scene/descriptors/protocol.py b/src/compas/scene/descriptors/protocol.py index b7a20ad42836..a27da4322a2d 100644 --- a/src/compas/scene/descriptors/protocol.py +++ b/src/compas/scene/descriptors/protocol.py @@ -1,8 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - - class DescriptorProtocol(type): """Meta class to provide support for the descriptor protocol in Python versions lower than 3.6""" diff --git a/src/compas/scene/exceptions.py b/src/compas/scene/exceptions.py index 6b9882e324e2..4851a67fe14d 100644 --- a/src/compas/scene/exceptions.py +++ b/src/compas/scene/exceptions.py @@ -1,7 +1,2 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - - class SceneObjectNotRegisteredError(Exception): """Exception that is raised when no scene object is registered for a given data type.""" diff --git a/src/compas/scene/geometryobject.py b/src/compas/scene/geometryobject.py index b8af998d2de2..28492d272a1e 100644 --- a/src/compas/scene/geometryobject.py +++ b/src/compas/scene/geometryobject.py @@ -1,6 +1,4 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import Union # noqa: F401 import compas.colors # noqa: F401 import compas.geometry # noqa: F401 @@ -46,9 +44,9 @@ class GeometryObject(SceneObject): def __init__( self, - pointcolor=None, # type: compas.colors.Color | None - linecolor=None, # type: compas.colors.Color | None - surfacecolor=None, # type: compas.colors.Color | None + pointcolor=None, # type: Union[compas.colors.Color, None] + linecolor=None, # type: Union[compas.colors.Color, None] + surfacecolor=None, # type: Union[compas.colors.Color, None] pointsize=1.0, # type: float linewidth=1.0, # type: float show_points=False, # type: bool diff --git a/src/compas/scene/graphobject.py b/src/compas/scene/graphobject.py index 866cdd668c83..6049f8b8a6cd 100644 --- a/src/compas/scene/graphobject.py +++ b/src/compas/scene/graphobject.py @@ -1,6 +1,4 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import Union # noqa: F401 import compas.colors # noqa: F401 import compas.datastructures # noqa: F401 @@ -51,10 +49,10 @@ class GraphObject(SceneObject): def __init__( self, - show_nodes=True, # type: bool | list - show_edges=True, # type: bool | list - nodecolor=None, # type: dict | compas.colors.Color | None - edgecolor=None, # type: dict | compas.colors.Color | None + show_nodes=True, # type: Union[bool, list] + show_edges=True, # type: Union[bool, list] + nodecolor=None, # type: Union[dict, compas.colors.Color, None] + edgecolor=None, # type: Union[dict, compas.colors.Color, None] nodesize=1.0, # type: float edgewidth=1.0, # type: float **kwargs # type: dict @@ -95,7 +93,7 @@ def graph(self, graph): @property def transformation(self): - # type: () -> compas.geometry.Transformation | None + # type: () -> Union[compas.geometry.Transformation, None] return self._transformation @transformation.setter @@ -106,7 +104,7 @@ def transformation(self, transformation): @property def node_xyz(self): - # type: () -> dict[int | str, list[float]] + # type: () -> dict[Union[int, str], list[float]] if self.graph: if self._node_xyz is None: points = self.graph.nodes_attributes("xyz") @@ -116,7 +114,7 @@ def node_xyz(self): @node_xyz.setter def node_xyz(self, node_xyz): - # type: (dict[int | str, list[float]]) -> None + # type: (dict[Union[int, str], list[float]]) -> None self._node_xyz = node_xyz def draw_nodes(self): diff --git a/src/compas/scene/group.py b/src/compas/scene/group.py index a440e62f9d07..807b9d44cc64 100644 --- a/src/compas/scene/group.py +++ b/src/compas/scene/group.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import compas.data # noqa: F401 from .sceneobject import SceneObject @@ -74,6 +73,7 @@ def add(self, item, **kwargs): ------ ValueError If the scene object does not have an associated scene node. + """ group_kwargs = self.kwargs.copy() group_kwargs.update(kwargs) diff --git a/src/compas/scene/meshobject.py b/src/compas/scene/meshobject.py index 9c51e072d53d..4030aa11e3c2 100644 --- a/src/compas/scene/meshobject.py +++ b/src/compas/scene/meshobject.py @@ -1,6 +1,4 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import Union # noqa: F401 import compas.colors # noqa: F401 import compas.datastructures # noqa: F401 @@ -58,12 +56,12 @@ class MeshObject(SceneObject): def __init__( self, - show_vertices=False, # type: bool | list - show_edges=False, # type: bool | list - show_faces=True, # type: bool | list - vertexcolor=None, # type: dict | compas.colors.Color | None - edgecolor=None, # type: dict | compas.colors.Color | None - facecolor=None, # type: dict | compas.colors.Color | None + show_vertices=False, # type: Union[bool, list] + show_edges=False, # type: Union[bool, list] + show_faces=True, # type: Union[bool, list] + vertexcolor=None, # type: Union[dict, compas.colors.Color, None] + edgecolor=None, # type: Union[dict, compas.colors.Color, None] + facecolor=None, # type: Union[dict, compas.colors.Color, None] vertexsize=1.0, # type: float edgewidth=1.0, # type: float **kwargs # dict diff --git a/src/compas/scene/scene.py b/src/compas/scene/scene.py index 55c5846e066a..4759326e8dd3 100644 --- a/src/compas/scene/scene.py +++ b/src/compas/scene/scene.py @@ -1,3 +1,12 @@ +from typing import TYPE_CHECKING +from typing import Any +from typing import Optional +from typing import Type +from typing import Union +from typing import cast + +from typing_extensions import Self + import compas.data # noqa: F401 import compas.datastructures # noqa: F401 import compas.geometry # noqa: F401 @@ -11,6 +20,9 @@ from .group import Group from .sceneobject import SceneObject +if TYPE_CHECKING: + from compas.files import GLTFDocument + class Scene(Tree): """A scene is a container for hierarchical scene objects which are to be visualised in a given context. @@ -41,8 +53,7 @@ class Scene(Tree): """ @property - def __data__(self): - # type: () -> dict + def __data__(self) -> dict[str, Any]: items = {str(object.item.guid): object.item for object in self.objects if object.item is not None} return { "name": self.name, @@ -51,8 +62,7 @@ def __data__(self): } @classmethod - def __from_data__(cls, data): - # type: (dict) -> Scene + def __from_data__(cls, data: dict[str, Any]) -> Self: scene = cls(data["name"]) items = {str(item.guid): item for item in data["items"]} @@ -70,27 +80,64 @@ def add(node, parent, items): return scene - def __init__(self, name="Scene", context=None): - # type: (str, str | None) -> None - super(Scene, self).__init__(name=name) - super(Scene, self).add(TreeNode(name="ROOT")) + def __init__(self, name: str = "Scene", context: Optional[str] = None) -> None: + super().__init__(name=name) + super().add(TreeNode(name="ROOT")) self.context = context or detect_current_context() + @classmethod + def from_gltf(cls, document: "GLTFDocument", scene_key: Optional[int] = None) -> Self: + """Construct a COMPAS scene from a glTF document. + + Parameters + ---------- + document + Source glTF document. + scene_key + Scene to convert. By default, use the document's default or first scene. + + Returns + ------- + Scene + Converted scene. + + """ + from compas.files.gltf.gltf_conversions import gltf_to_scene + + return cast(Self, gltf_to_scene(document, scene_key, scene_type=cls)) + + def to_gltf(self) -> "GLTFDocument": + """Convert this scene to a glTF document. + + Unsupported scene items are omitted with a warning. + + Returns + ------- + GLTFDocument + Converted document. + + """ + from compas.files.gltf.gltf_conversions import scene_to_gltf + + return scene_to_gltf(self) + @property - def objects(self): - # type: () -> list[SceneObject] + def objects(self) -> list[SceneObject]: return [node for node in self.nodes if not node.is_root] # type: ignore @property - def context_objects(self): - # type: () -> list - guids = [] + def context_objects(self) -> list[Any]: + guids: list[Any] = [] for obj in self.objects: guids += obj.guids return guids - def add(self, item, parent=None, **kwargs): - # type: (compas.geometry.Geometry | compas.datastructures.Datastructure, SceneObject | TreeNode | None, dict) -> SceneObject + def add( + self, + item: Any, + parent: Optional[Union[SceneObject, TreeNode]] = None, + **kwargs: Any, + ) -> SceneObject: """Add an item to the scene. Parameters @@ -106,6 +153,7 @@ def add(self, item, parent=None, **kwargs): ------- :class:`compas.scene.SceneObject` The scene object associated with the item. + """ parent = parent or self.root @@ -123,17 +171,20 @@ def add(self, item, parent=None, **kwargs): group_kwargs.update(kwargs) kwargs = group_kwargs sceneobject = SceneObject(item=item, context=self.context, **kwargs) # type: ignore - super(Scene, self).add(sceneobject, parent=parent) + super().add(sceneobject, parent=parent) return sceneobject - def add_group(self, name, parent=None, **kwargs): - # type: (str, SceneObject | TreeNode | None, dict) -> SceneObject + def add_group( + self, + name: str, + parent: Optional[Union[SceneObject, TreeNode]] = None, + **kwargs: Any, + ) -> Group: group = Group(name=name, **kwargs) self.add(group, parent=parent) return group - def clear_context(self, guids=None): - # type: (list | None) -> None + def clear_context(self, guids: Optional[list[Any]] = None) -> None: """Clear the visualisation context. Parameters @@ -159,8 +210,7 @@ def clear_context(self, guids=None): """ clear(guids) - def clear(self, clear_scene=True, clear_context=True): - # type: (bool, bool) -> None + def clear(self, clear_scene: bool = True, clear_context: bool = True) -> None: """Clear the scene. Parameters @@ -194,7 +244,7 @@ def clear(self, clear_scene=True, clear_context=True): if clear_context: self.clear_context(guids) - def draw(self): + def draw(self) -> list[Any]: """Draw the scene. This will just draw all scene objects in the scene tree, @@ -208,7 +258,7 @@ def draw(self): before_draw() - drawn_objects = [] + drawn_objects: list[Any] = [] for sceneobject in self.objects: if sceneobject.show: drawn_objects += sceneobject.draw() @@ -217,7 +267,7 @@ def draw(self): return drawn_objects - def redraw(self): + def redraw(self) -> None: """Redraw the scene. This removes all previously drawn objects from the visualisation context, @@ -227,8 +277,7 @@ def redraw(self): self.clear(clear_scene=False, clear_context=True) self.draw() - def find_by_name(self, name): - # type: (str) -> SceneObject + def find_by_name(self, name: str) -> Optional[SceneObject]: """Find the first scene object with the given name. Parameters @@ -243,8 +292,7 @@ def find_by_name(self, name): """ return self.get_node_by_name(name=name) - def find_by_itemtype(self, itemtype): - # type: (...) -> SceneObject | None + def find_by_itemtype(self, itemtype: Type[Any]) -> Optional[SceneObject]: """Find the first scene object with a data item of the given type. Parameters @@ -261,8 +309,7 @@ def find_by_itemtype(self, itemtype): if isinstance(obj.item, itemtype): return obj - def find_all_by_itemtype(self, itemtype): - # type: (...) -> list[SceneObject] + def find_all_by_itemtype(self, itemtype: Type[Any]) -> list[SceneObject]: """Find all scene objects with a data item of the given type. Parameters @@ -275,7 +322,7 @@ def find_all_by_itemtype(self, itemtype): list[:class:`SceneObject`] """ - sceneobjects = [] + sceneobjects: list[SceneObject] = [] for obj in self.objects: if isinstance(obj.item, itemtype): sceneobjects.append(obj) diff --git a/src/compas/scene/sceneobject.py b/src/compas/scene/sceneobject.py index f227b43e5ac5..f218c085c0d6 100644 --- a/src/compas/scene/sceneobject.py +++ b/src/compas/scene/sceneobject.py @@ -1,9 +1,6 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from functools import reduce from operator import mul +from typing import Union # noqa: F401 import compas.colors # noqa: F401 import compas.data # noqa: F401 @@ -90,13 +87,13 @@ def __new__(cls, item=None, **kwargs): def __init__( self, - item=None, # type: compas.data.Data | None - name=None, # type: str | None - color=None, # type: compas.colors.Color | None + item=None, # type: Union[compas.data.Data, None] + name=None, # type: Union[str, None] + color=None, # type: Union[compas.colors.Color, None] opacity=1.0, # type: float show=True, # type: bool - transformation=None, # type: compas.geometry.Transformation | None - context=None, # type: str | None + transformation=None, # type: Union[compas.geometry.Transformation, None] + context=None, # type: Union[str, None] **kwargs # type: dict ): # fmt: skip # type: (...) -> None @@ -138,7 +135,7 @@ def __repr__(self): @property def scene(self): - # type: () -> compas.scene.Scene | None + # type: () -> Union[compas.scene.Scene, None] return self.tree @property @@ -153,7 +150,7 @@ def guids(self): @property def frame(self): - # type: () -> compas.geometry.Frame | None + # type: () -> Union[compas.geometry.Frame, None] return Frame.from_transformation(self.worldtransformation) @frame.setter @@ -163,7 +160,7 @@ def frame(self, frame): @property def transformation(self): - # type: () -> compas.geometry.Transformation | None + # type: () -> Union[compas.geometry.Transformation, None] return self._transformation @transformation.setter @@ -197,7 +194,7 @@ def worldtransformation(self, worldtransformation): @property def contrastcolor(self): - # type: () -> compas.colors.Color | None + # type: () -> Union[compas.colors.Color, None] if not self._contrastcolor: if self.color: if self.color.is_light: @@ -248,6 +245,7 @@ def add(self, item, **kwargs): ------ ValueError If the scene object does not have an associated scene node. + """ if isinstance(item, SceneObject): sceneobject = item diff --git a/src/compas/scene/volmeshobject.py b/src/compas/scene/volmeshobject.py index 5b391cb4188a..c13e14b2b4b2 100644 --- a/src/compas/scene/volmeshobject.py +++ b/src/compas/scene/volmeshobject.py @@ -1,6 +1,4 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from typing import Union # noqa: F401 import compas.colors # noqa: F401 import compas.datastructures # noqa: F401 @@ -69,14 +67,14 @@ class VolMeshObject(SceneObject): def __init__( self, - show_vertices=False, # type: bool | list - show_edges=True, # type: bool | list - show_faces=False, # type: bool | list - show_cells=True, # type: bool | list - vertexcolor=None, # type: compas.colors.Color | dict | None - edgecolor=None, # type: compas.colors.Color | dict | None - facecolor=None, # type: compas.colors.Color | dict | None - cellcolor=None, # type: compas.colors.Color | dict | None + show_vertices=False, # type: Union[bool, list] + show_edges=True, # type: Union[bool, list] + show_faces=False, # type: Union[bool, list] + show_cells=True, # type: Union[bool, list] + vertexcolor=None, # type: Union[compas.colors.Color, dict, None] + edgecolor=None, # type: Union[compas.colors.Color, dict, None] + facecolor=None, # type: Union[compas.colors.Color, dict, None] + cellcolor=None, # type: Union[compas.colors.Color, dict, None] vertexsize=1.0, # type: float edgewidth=1.0, # type: float **kwargs # type: dict @@ -123,7 +121,7 @@ def volmesh(self, volmesh): @property def transformation(self): - # type: () -> compas.geometry.Transformation | None + # type: () -> Union[compas.geometry.Transformation, None] return self._transformation @transformation.setter diff --git a/src/compas/tolerance.py b/src/compas/tolerance.py index 5fe4cdb1048c..124cce73ce26 100644 --- a/src/compas/tolerance.py +++ b/src/compas/tolerance.py @@ -28,10 +28,6 @@ """ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from contextlib import contextmanager from decimal import Decimal from warnings import warn @@ -39,8 +35,6 @@ import compas from compas.data import Data -__all__ = ["Tolerance", "TOL"] - class Tolerance(Data): """Tolerance settings for geometric operations. diff --git a/src/compas/topology/__init__.py b/src/compas/topology/__init__.py index 747858736961..f925be4349f0 100644 --- a/src/compas/topology/__init__.py +++ b/src/compas/topology/__init__.py @@ -1,8 +1,8 @@ """ Package containing topological algorithms for traversal, connectivity, combinatorics, etc. -""" -from __future__ import absolute_import +""" +# ruff: noqa: F401 from .traversal import ( depth_first_ordering, @@ -23,23 +23,3 @@ edges_from_faces, faces_from_edges, ) - -__all__ = [ - "astar_lightest_path", - "astar_shortest_path", - "breadth_first_ordering", - "breadth_first_traverse", - "breadth_first_paths", - "connected_components", - "depth_first_ordering", - "dijkstra_distances", - "dijkstra_path", - "edges_from_faces", - "face_adjacency", - "faces_from_edges", - "shortest_path", - "unify_cycles", - "vertex_adjacency_from_edges", - "vertex_adjacency_from_faces", - "vertex_coloring", -] diff --git a/src/compas/topology/combinatorics.py b/src/compas/topology/combinatorics.py index dff34e6d4220..f1551917f0d6 100644 --- a/src/compas/topology/combinatorics.py +++ b/src/compas/topology/combinatorics.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from collections import deque from compas.topology.traversal import breadth_first_traverse diff --git a/src/compas/topology/connectivity.py b/src/compas/topology/connectivity.py index f271290c7149..263e6e5f77c8 100644 --- a/src/compas/topology/connectivity.py +++ b/src/compas/topology/connectivity.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas.itertools import pairwise diff --git a/src/compas/topology/orientation.py b/src/compas/topology/orientation.py index 72da1b21e363..6b1b7567c8b1 100644 --- a/src/compas/topology/orientation.py +++ b/src/compas/topology/orientation.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas.geometry import centroid_points from compas.itertools import pairwise from compas.topology import breadth_first_traverse diff --git a/src/compas/topology/traversal.py b/src/compas/topology/traversal.py index d185d4301307..3fab11f634ea 100644 --- a/src/compas/topology/traversal.py +++ b/src/compas/topology/traversal.py @@ -1,13 +1,5 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -try: - from queue import PriorityQueue -except ImportError: - from Queue import PriorityQueue # type: ignore - from collections import deque +from queue import PriorityQueue from compas.geometry import distance_point_point diff --git a/src/compas/utilities/__init__.py b/src/compas/utilities/__init__.py index 2b926960a1af..d066bfc92ccc 100644 --- a/src/compas/utilities/__init__.py +++ b/src/compas/utilities/__init__.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import +# ruff: noqa: F401 from .azync import await_callback from .datetime import now, timestamp @@ -11,15 +11,3 @@ from .remote import download_file_from_remote from .ssh import SSH - -__all__ = [ - "await_callback", - "timestamp", - "now", - "abstractstaticmethod", - "abstractclassmethod", - "memoize", - "print_profile", - "download_file_from_remote", - "SSH", -] diff --git a/src/compas/utilities/azync.py b/src/compas/utilities/azync.py index 147cd50cb240..49eb1850cf42 100644 --- a/src/compas/utilities/azync.py +++ b/src/compas/utilities/azync.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import sys import threading diff --git a/src/compas/utilities/datetime.py b/src/compas/utilities/datetime.py index 372236cdedf2..ab815c7b572d 100644 --- a/src/compas/utilities/datetime.py +++ b/src/compas/utilities/datetime.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import datetime import time diff --git a/src/compas/utilities/decorators.py b/src/compas/utilities/decorators.py index b39bf31df553..dea771bd3462 100644 --- a/src/compas/utilities/decorators.py +++ b/src/compas/utilities/decorators.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import functools import pstats from functools import wraps diff --git a/src/compas/utilities/remote.py b/src/compas/utilities/remote.py index c8ec7eddbd28..25c59fb9362e 100644 --- a/src/compas/utilities/remote.py +++ b/src/compas/utilities/remote.py @@ -1,13 +1,5 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import os - -try: - from urllib.request import urlretrieve -except ImportError: - from urllib import urlretrieve +from urllib.request import urlretrieve def download_file_from_remote(source, target, overwrite=True): diff --git a/src/compas/utilities/ssh.py b/src/compas/utilities/ssh.py index 8ae61721185b..bd62af20c5e5 100644 --- a/src/compas/utilities/ssh.py +++ b/src/compas/utilities/ssh.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - try: from paramiko import AutoAddPolicy from paramiko import SSHClient diff --git a/src/compas_blender/__init__.py b/src/compas_blender/__init__.py index 935d6abe545b..45d447fd6702 100644 --- a/src/compas_blender/__init__.py +++ b/src/compas_blender/__init__.py @@ -20,15 +20,6 @@ INSTALLATION_ARGUMENTS = None - -__all__ = [ - "INSTALLABLE_PACKAGES", - "SUPPORTED_VERSIONS", - "DEFAULT_VERSION", - "clear", - "redraw", -] - __all_plugins__ = [ "compas_blender.geometry.booleans", "compas_blender.install", @@ -120,6 +111,7 @@ def _try_remove_bootstrapper(path): Returns ------- bool: True if the operation did not cause errors, False otherwise. + """ bootstrapper = _get_bootstrapper_path(path) diff --git a/src/compas_blender/conversions/__init__.py b/src/compas_blender/conversions/__init__.py index b754e883cdf0..768d0aa750e0 100644 --- a/src/compas_blender/conversions/__init__.py +++ b/src/compas_blender/conversions/__init__.py @@ -1,5 +1,6 @@ """ This package provides functions to convert between COMPAS data/objects and Blender data/objects. + """ from .colors import color_to_blender_material diff --git a/src/compas_blender/drawing.py b/src/compas_blender/drawing.py index 4b88f823e2a2..080ec9ebd076 100644 --- a/src/compas_blender/drawing.py +++ b/src/compas_blender/drawing.py @@ -8,7 +8,7 @@ from compas.geometry import centroid_points from compas.geometry import distance_point_point -from compas.geometry import subtract_vectors +from compas.linalg.vectors import subtract_vectors from compas_blender.collections import create_collection RGBColor = Union[Tuple[int, int, int], Tuple[float, float, float]] diff --git a/src/compas_blender/geometry/__init__.py b/src/compas_blender/geometry/__init__.py index 1c604156a69d..1d8eba5d0d01 100644 --- a/src/compas_blender/geometry/__init__.py +++ b/src/compas_blender/geometry/__init__.py @@ -1,5 +1,6 @@ """ This package provides plugins for various geometry pluggables using Blender as the backend. + """ from .booleans import boolean_difference_mesh_mesh diff --git a/src/compas_blender/install.py b/src/compas_blender/install.py index 359abc6206c8..929c41fb1275 100644 --- a/src/compas_blender/install.py +++ b/src/compas_blender/install.py @@ -252,6 +252,7 @@ def installable_blender_packages(): ------- :obj:`list` of :obj:`str` List of package names to make available inside Blender. + """ pass diff --git a/src/compas_blender/scene/__init__.py b/src/compas_blender/scene/__init__.py index 4d6e4d315245..b9a9a578d6a6 100644 --- a/src/compas_blender/scene/__init__.py +++ b/src/compas_blender/scene/__init__.py @@ -1,6 +1,7 @@ """ This package provides scene object plugins for visualising COMPAS objects in Blender. When working in Blender, :class:`compas.scene.SceneObject` will automatically use the corresponding Blender object for each COMPAS object type. + """ import compas_blender diff --git a/src/compas_blender/scene/frameobject.py b/src/compas_blender/scene/frameobject.py index 7a4a7e65d196..9581af0fe9dd 100644 --- a/src/compas_blender/scene/frameobject.py +++ b/src/compas_blender/scene/frameobject.py @@ -60,6 +60,7 @@ def draw( ------- list[:blender:`bpy.types.Object`] The objects created in Blender. + """ objects = [] diff --git a/src/compas_blender/scene/planeobject.py b/src/compas_blender/scene/planeobject.py index cbf3c5a27a1c..53a4eb954ddb 100644 --- a/src/compas_blender/scene/planeobject.py +++ b/src/compas_blender/scene/planeobject.py @@ -49,6 +49,7 @@ def draw(self, color: Optional[Color] = None, collection: Optional[str] = None) ------- list[:blender:`bpy.types.Object`] The objects created in Blender. + """ objects = [] diff --git a/src/compas_ghpython/__init__.py b/src/compas_ghpython/__init__.py index c9aa9ea301c0..0580d4648a52 100644 --- a/src/compas_ghpython/__init__.py +++ b/src/compas_ghpython/__init__.py @@ -16,17 +16,6 @@ __version__ = "2.15.0" -__all__ = [ - "get_grasshopper_managedplugin_path", - "get_grasshopper_library_path", - "get_grasshopper_userobjects_path", - "fetch_ghio_lib", - "create_id", - "warning", - "error", - "remark", - "message", -] __all_plugins__ = [ "compas_ghpython.install", "compas_ghpython.uninstall", @@ -75,6 +64,7 @@ def warning(component, message): The component instance. Pre-Rhino8 use `self`. Post-Rhino8 use `ghenv.Component`. message : str The message to display. + """ component.AddRuntimeMessage(Grasshopper.Kernel.GH_RuntimeMessageLevel.Warning, message) @@ -88,6 +78,7 @@ def error(component, message): The component instance. Pre-Rhino8 use `self`. Post-Rhino8 use `ghenv.Component`. message : str The message to display. + """ component.AddRuntimeMessage(Grasshopper.Kernel.GH_RuntimeMessageLevel.Error, message) @@ -101,6 +92,7 @@ def remark(component, message): The component instance. Pre-Rhino8 use `self`. Post-Rhino8 use `ghenv.Component`. message : str The message to display. + """ component.AddRuntimeMessage(Grasshopper.Kernel.GH_RuntimeMessageLevel.Remark, message) @@ -114,6 +106,7 @@ def message(component, message): The component instance. Pre-Rhino8 use `self`. Post-Rhino8 use `ghenv.Component`. message : str The message to display. + """ component.Message = message diff --git a/src/compas_ghpython/components/Compas_FromJson/code.py b/src/compas_ghpython/components/Compas_FromJson/code.py index 995799fc92bf..63a78196b990 100644 --- a/src/compas_ghpython/components/Compas_FromJson/code.py +++ b/src/compas_ghpython/components/Compas_FromJson/code.py @@ -1,5 +1,6 @@ """ Deserializes JSON into COMPAS objects. + """ from ghpythonlib.componentbase import executingcomponent as component diff --git a/src/compas_ghpython/components/Compas_Info/code.py b/src/compas_ghpython/components/Compas_Info/code.py index 35913c198339..6490c4a1346d 100644 --- a/src/compas_ghpython/components/Compas_Info/code.py +++ b/src/compas_ghpython/components/Compas_Info/code.py @@ -1,5 +1,6 @@ """ Displays information about the active COMPAS environment. + """ import compas_bootstrapper diff --git a/src/compas_ghpython/components/Compas_RpcCall/code.py b/src/compas_ghpython/components/Compas_RpcCall/code.py index 81f0fef2d40a..bb4d38464cd0 100644 --- a/src/compas_ghpython/components/Compas_RpcCall/code.py +++ b/src/compas_ghpython/components/Compas_RpcCall/code.py @@ -1,5 +1,6 @@ """ Remote Procedure Call: to invoke Python functions outside of Rhino, in the context of the CPython interpreter. + """ from ghpythonlib.componentbase import executingcomponent as component diff --git a/src/compas_ghpython/components/Compas_ToJson/code.py b/src/compas_ghpython/components/Compas_ToJson/code.py index dc5406152a51..acb4de565d2f 100644 --- a/src/compas_ghpython/components/Compas_ToJson/code.py +++ b/src/compas_ghpython/components/Compas_ToJson/code.py @@ -1,5 +1,6 @@ """ Serializes COMPAS objects to JSON. + """ from ghpythonlib.componentbase import executingcomponent as component diff --git a/src/compas_ghpython/components/Compas_ToRhinoGeometry/code.py b/src/compas_ghpython/components/Compas_ToRhinoGeometry/code.py index fcc03a1ca0b0..ed6795c472c3 100644 --- a/src/compas_ghpython/components/Compas_ToRhinoGeometry/code.py +++ b/src/compas_ghpython/components/Compas_ToRhinoGeometry/code.py @@ -1,5 +1,6 @@ """ Draws COMPAS geometry in Grasshopper. + """ from ghpythonlib.componentbase import executingcomponent as component diff --git a/src/compas_ghpython/components/__init__.py b/src/compas_ghpython/components/__init__.py index b087f63f3f1a..66241e8f8723 100644 --- a/src/compas_ghpython/components/__init__.py +++ b/src/compas_ghpython/components/__init__.py @@ -1,8 +1,8 @@ """ This package provides a small set of functions to easily install and uninstall user-defined GH Components. + """ -from __future__ import absolute_import import glob import os @@ -31,6 +31,7 @@ def install_userobjects(source): ------- list List of tuples (name, success) indicating whether each of the user objects was successfully installed. + """ version = get_version_from_args() @@ -76,6 +77,7 @@ def uninstall_userobjects(userobjects=None): ------- list List of tuples (name, success) indicating whether each of the user objects was successfully removed. + """ version = get_version_from_args() dstdir = get_grasshopper_userobjects_path(version) diff --git a/src/compas_ghpython/components_cpython/Compas_FromJson/code.py b/src/compas_ghpython/components_cpython/Compas_FromJson/code.py index fcb217c0cd6b..a240e66f8962 100644 --- a/src/compas_ghpython/components_cpython/Compas_FromJson/code.py +++ b/src/compas_ghpython/components_cpython/Compas_FromJson/code.py @@ -1,6 +1,7 @@ # r: compas>=2.14.1 """ Deserializes JSON into COMPAS objects. + """ import Grasshopper diff --git a/src/compas_ghpython/components_cpython/Compas_Info/code.py b/src/compas_ghpython/components_cpython/Compas_Info/code.py index b073abbc9530..cd1d04c2f92d 100644 --- a/src/compas_ghpython/components_cpython/Compas_Info/code.py +++ b/src/compas_ghpython/components_cpython/Compas_Info/code.py @@ -1,6 +1,7 @@ # r: compas>=2.14.1 """ Displays information about the active COMPAS environment. + """ import os diff --git a/src/compas_ghpython/components_cpython/Compas_RpcCall/code.py b/src/compas_ghpython/components_cpython/Compas_RpcCall/code.py index 64f67911ceeb..ba8aac94cdea 100644 --- a/src/compas_ghpython/components_cpython/Compas_RpcCall/code.py +++ b/src/compas_ghpython/components_cpython/Compas_RpcCall/code.py @@ -1,6 +1,7 @@ # r: compas>=2.14.1 """ Remote Procedure Call: to invoke Python functions outside of Rhino, in the context of the CPython interpreter. + """ import Grasshopper diff --git a/src/compas_ghpython/components_cpython/Compas_ToJson/code.py b/src/compas_ghpython/components_cpython/Compas_ToJson/code.py index 52e5a2ffd198..6a587fde9713 100644 --- a/src/compas_ghpython/components_cpython/Compas_ToJson/code.py +++ b/src/compas_ghpython/components_cpython/Compas_ToJson/code.py @@ -1,6 +1,7 @@ # r: compas>=2.14.1 """ Serializes COMPAS objects to JSON. + """ from typing import Any diff --git a/src/compas_ghpython/components_cpython/Compas_ToRhinoGeometry/code.py b/src/compas_ghpython/components_cpython/Compas_ToRhinoGeometry/code.py index 3b7e14b55d1c..f5679552a73f 100644 --- a/src/compas_ghpython/components_cpython/Compas_ToRhinoGeometry/code.py +++ b/src/compas_ghpython/components_cpython/Compas_ToRhinoGeometry/code.py @@ -1,6 +1,7 @@ # r: compas>=2.14.1 """ Draws COMPAS geometry in Grasshopper. + """ from typing import Any diff --git a/src/compas_ghpython/drawing.py b/src/compas_ghpython/drawing.py index 5a8d43a078e9..bcbc11f925d9 100644 --- a/src/compas_ghpython/drawing.py +++ b/src/compas_ghpython/drawing.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function import rhinoscriptsyntax as rs import scriptcontext as sc diff --git a/src/compas_ghpython/install.py b/src/compas_ghpython/install.py index 7a6d423d71b8..62ccf638cc43 100644 --- a/src/compas_ghpython/install.py +++ b/src/compas_ghpython/install.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function import os diff --git a/src/compas_ghpython/scene/__init__.py b/src/compas_ghpython/scene/__init__.py index 536a675c47b5..edc7dc719987 100644 --- a/src/compas_ghpython/scene/__init__.py +++ b/src/compas_ghpython/scene/__init__.py @@ -1,9 +1,9 @@ """ This package provides scene object plugins for visualising COMPAS objects in Grasshopper. When working in GH Python components, :class:`compas.scene.SceneObject` will automatically use the corresponding GHPython scene object for each COMPAS object type. -""" -from __future__ import absolute_import +""" +# ruff: noqa: F401 from compas.plugins import plugin from compas.scene import register @@ -88,29 +88,3 @@ def register_scene_objects(): register(Brep, BrepObject, context="Grasshopper") print("GH SceneObjects registered.") - - -__all__ = [ - "GHSceneObject", - "BoxObject", - "CapsuleObject", - "CircleObject", - "ConeObject", - "CurveObject", - "CylinderObject", - "EllipseObject", - "FrameObject", - "LineObject", - "MeshObject", - "GraphObject", - "PlaneObject", - "PointObject", - "PolygonObject", - "PolyhedronObject", - "PolylineObject", - "SphereObject", - "SurfaceObject", - "TorusObject", - "VectorObject", - "VolMeshObject", -] diff --git a/src/compas_ghpython/scene/boxobject.py b/src/compas_ghpython/scene/boxobject.py index 21bedc66df75..8a29aab9559b 100644 --- a/src/compas_ghpython/scene/boxobject.py +++ b/src/compas_ghpython/scene/boxobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import GeometryObject from compas_rhino import conversions diff --git a/src/compas_ghpython/scene/brepobject.py b/src/compas_ghpython/scene/brepobject.py index a4ba1cdf7550..c0e89d6688f0 100644 --- a/src/compas_ghpython/scene/brepobject.py +++ b/src/compas_ghpython/scene/brepobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import GeometryObject from compas_rhino import conversions diff --git a/src/compas_ghpython/scene/capsuleobject.py b/src/compas_ghpython/scene/capsuleobject.py index 4f243d273f46..834b9b637ab3 100644 --- a/src/compas_ghpython/scene/capsuleobject.py +++ b/src/compas_ghpython/scene/capsuleobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import GeometryObject from compas_rhino import conversions diff --git a/src/compas_ghpython/scene/circleobject.py b/src/compas_ghpython/scene/circleobject.py index ee8a16e81f76..286c00a4eca6 100644 --- a/src/compas_ghpython/scene/circleobject.py +++ b/src/compas_ghpython/scene/circleobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import GeometryObject from compas_rhino import conversions diff --git a/src/compas_ghpython/scene/coneobject.py b/src/compas_ghpython/scene/coneobject.py index 621aac32479b..9365b431e7d6 100644 --- a/src/compas_ghpython/scene/coneobject.py +++ b/src/compas_ghpython/scene/coneobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import GeometryObject from compas_rhino import conversions diff --git a/src/compas_ghpython/scene/curveobject.py b/src/compas_ghpython/scene/curveobject.py index 1bac2aedcd86..4a4d2f77f695 100644 --- a/src/compas_ghpython/scene/curveobject.py +++ b/src/compas_ghpython/scene/curveobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import GeometryObject from compas_rhino import conversions diff --git a/src/compas_ghpython/scene/cylinderobject.py b/src/compas_ghpython/scene/cylinderobject.py index f8cc986723f1..db83119bcce8 100644 --- a/src/compas_ghpython/scene/cylinderobject.py +++ b/src/compas_ghpython/scene/cylinderobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import GeometryObject from compas_rhino import conversions diff --git a/src/compas_ghpython/scene/ellipseobject.py b/src/compas_ghpython/scene/ellipseobject.py index 3c7263fe506b..e60d84386e49 100644 --- a/src/compas_ghpython/scene/ellipseobject.py +++ b/src/compas_ghpython/scene/ellipseobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import GeometryObject from compas_rhino import conversions diff --git a/src/compas_ghpython/scene/frameobject.py b/src/compas_ghpython/scene/frameobject.py index dde68046f429..f2aa551beef5 100644 --- a/src/compas_ghpython/scene/frameobject.py +++ b/src/compas_ghpython/scene/frameobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import GeometryObject from compas_rhino import conversions diff --git a/src/compas_ghpython/scene/graphobject.py b/src/compas_ghpython/scene/graphobject.py index 7a0ef394f3fa..c1537435fb63 100644 --- a/src/compas_ghpython/scene/graphobject.py +++ b/src/compas_ghpython/scene/graphobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import GraphObject as BaseGraphObject from compas_rhino import conversions @@ -18,6 +15,7 @@ def draw(self): ------- list[:rhino:`Rhino.Geometry.Point3d`, :rhino:`Rhino.Geometry.Line`] List of created Rhino geometries. + """ self._guids = self.draw_edges() + self.draw_nodes() return self.guids diff --git a/src/compas_ghpython/scene/lineobject.py b/src/compas_ghpython/scene/lineobject.py index 86159db3c01f..21bed5beb8c0 100644 --- a/src/compas_ghpython/scene/lineobject.py +++ b/src/compas_ghpython/scene/lineobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import GeometryObject from compas_rhino import conversions diff --git a/src/compas_ghpython/scene/meshobject.py b/src/compas_ghpython/scene/meshobject.py index b6f82ff88e00..15e932efdc51 100644 --- a/src/compas_ghpython/scene/meshobject.py +++ b/src/compas_ghpython/scene/meshobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import MeshObject as BaseMeshObject from compas_rhino import conversions diff --git a/src/compas_ghpython/scene/planeobject.py b/src/compas_ghpython/scene/planeobject.py index c483f85ba6d4..cd59d1d0c615 100644 --- a/src/compas_ghpython/scene/planeobject.py +++ b/src/compas_ghpython/scene/planeobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.geometry import Frame from compas.scene import GeometryObject @@ -38,6 +35,7 @@ def draw(self): ------- list[:rhino:`Rhino.Geometry.Line`, :rhino:`Rhino.Geometry.Mesh`] List of created Rhino geometries. + """ frame = Frame.from_plane(self._item) normal = conversions.line_to_rhino([frame.to_world_coordinates([0, 0, 0]), frame.to_world_coordinates([0, 0, self.scale])]) diff --git a/src/compas_ghpython/scene/pointobject.py b/src/compas_ghpython/scene/pointobject.py index 93ec8ef6572e..a61d3d14a69e 100644 --- a/src/compas_ghpython/scene/pointobject.py +++ b/src/compas_ghpython/scene/pointobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import GeometryObject from compas_rhino import conversions @@ -18,6 +15,7 @@ def draw(self): ------- list[:rhino:`Rhino.Geometry.Point3d`] List of created Rhino points. + """ geometry = conversions.point_to_rhino(self.geometry) geometry.Transform(conversions.transformation_to_rhino(self.worldtransformation)) diff --git a/src/compas_ghpython/scene/polygonobject.py b/src/compas_ghpython/scene/polygonobject.py index 0ac74a969820..12fed6332970 100644 --- a/src/compas_ghpython/scene/polygonobject.py +++ b/src/compas_ghpython/scene/polygonobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import GeometryObject from compas_rhino import conversions diff --git a/src/compas_ghpython/scene/polyhedronobject.py b/src/compas_ghpython/scene/polyhedronobject.py index c68331e0ecef..7a71d948fffc 100644 --- a/src/compas_ghpython/scene/polyhedronobject.py +++ b/src/compas_ghpython/scene/polyhedronobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import GeometryObject from compas_rhino import conversions diff --git a/src/compas_ghpython/scene/polylineobject.py b/src/compas_ghpython/scene/polylineobject.py index 4f3b4c029485..1de08ea5d11f 100644 --- a/src/compas_ghpython/scene/polylineobject.py +++ b/src/compas_ghpython/scene/polylineobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import GeometryObject from compas_rhino import conversions diff --git a/src/compas_ghpython/scene/sceneobject.py b/src/compas_ghpython/scene/sceneobject.py index c4eaa0e9988b..b1c1b0e54e3b 100644 --- a/src/compas_ghpython/scene/sceneobject.py +++ b/src/compas_ghpython/scene/sceneobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import SceneObject diff --git a/src/compas_ghpython/scene/sphereobject.py b/src/compas_ghpython/scene/sphereobject.py index a7ad12aa6f9a..b57e9db0e449 100644 --- a/src/compas_ghpython/scene/sphereobject.py +++ b/src/compas_ghpython/scene/sphereobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import GeometryObject from compas_rhino import conversions diff --git a/src/compas_ghpython/scene/surfaceobject.py b/src/compas_ghpython/scene/surfaceobject.py index 321e8fdb097e..91364c5d1425 100644 --- a/src/compas_ghpython/scene/surfaceobject.py +++ b/src/compas_ghpython/scene/surfaceobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import GeometryObject from compas_rhino import conversions diff --git a/src/compas_ghpython/scene/torusobject.py b/src/compas_ghpython/scene/torusobject.py index f5740ca0c650..5155ed77e189 100644 --- a/src/compas_ghpython/scene/torusobject.py +++ b/src/compas_ghpython/scene/torusobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import GeometryObject from compas_rhino import conversions diff --git a/src/compas_ghpython/scene/vectorobject.py b/src/compas_ghpython/scene/vectorobject.py index 1055b521889f..7ee766e72420 100644 --- a/src/compas_ghpython/scene/vectorobject.py +++ b/src/compas_ghpython/scene/vectorobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.geometry import Point from compas.scene import GeometryObject diff --git a/src/compas_ghpython/scene/volmeshobject.py b/src/compas_ghpython/scene/volmeshobject.py index 2393e2155de6..acef60a8c438 100644 --- a/src/compas_ghpython/scene/volmeshobject.py +++ b/src/compas_ghpython/scene/volmeshobject.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function from compas.scene import VolMeshObject as BaseVolMeshObject from compas_rhino import conversions diff --git a/src/compas_ghpython/sets.py b/src/compas_ghpython/sets.py index e0df9740b873..54d346981add 100644 --- a/src/compas_ghpython/sets.py +++ b/src/compas_ghpython/sets.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function import Grasshopper # type: ignore import System # type: ignore diff --git a/src/compas_ghpython/timer.py b/src/compas_ghpython/timer.py index 5c3c46751265..70ec45bc18a5 100644 --- a/src/compas_ghpython/timer.py +++ b/src/compas_ghpython/timer.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function import Grasshopper # type: ignore diff --git a/src/compas_ghpython/uninstall.py b/src/compas_ghpython/uninstall.py index 3638856faa1b..caa6b91b7387 100644 --- a/src/compas_ghpython/uninstall.py +++ b/src/compas_ghpython/uninstall.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function import glob import os diff --git a/src/compas_rhino/__init__.py b/src/compas_rhino/__init__.py index 7f64b6ef539b..e62861e19cb4 100644 --- a/src/compas_rhino/__init__.py +++ b/src/compas_rhino/__init__.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import - import io import os @@ -25,20 +23,6 @@ unload_modules = DevTools.unload_modules -__all__ = [ - "PURGE_ON_DELETE", - "INSTALLABLE_PACKAGES", - "SUPPORTED_VERSIONS", - "DEFAULT_VERSION", - "INSTALLED_VERSION", - "IRONPYTHON_PLUGIN_GUID", - "GRASSHOPPER_PLUGIN_GUID", - "RHINOCYCLES_PLUGIN_GUID", - "clear", - "redraw", - "unload_modules", -] - __all_plugins__ = [ "compas_rhino.geometry.booleans", "compas_rhino.geometry.trimesh_curvature", @@ -121,6 +105,7 @@ def _try_remove_bootstrapper(path): Returns ------- bool: True if the operation did not cause errors, False otherwise. + """ bootstrapper = _get_bootstrapper_path(path) diff --git a/src/compas_rhino/conduits/__init__.py b/src/compas_rhino/conduits/__init__.py index debec314089a..6f52e1cf1f3e 100644 --- a/src/compas_rhino/conduits/__init__.py +++ b/src/compas_rhino/conduits/__init__.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import +# ruff: noqa: F401 from .base import BaseConduit @@ -6,11 +6,3 @@ from .labels import LabelsConduit from .lines import LinesConduit from .points import PointsConduit - -__all__ = [ - "BaseConduit", - "FacesConduit", - "LabelsConduit", - "LinesConduit", - "PointsConduit", -] diff --git a/src/compas_rhino/conduits/base.py b/src/compas_rhino/conduits/base.py index 9a37013a1504..5571884cee9b 100644 --- a/src/compas_rhino/conduits/base.py +++ b/src/compas_rhino/conduits/base.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import time from contextlib import contextmanager diff --git a/src/compas_rhino/conduits/faces.py b/src/compas_rhino/conduits/faces.py index 2743f35f8184..36910cfdd691 100644 --- a/src/compas_rhino/conduits/faces.py +++ b/src/compas_rhino/conduits/faces.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore import System # type: ignore diff --git a/src/compas_rhino/conduits/labels.py b/src/compas_rhino/conduits/labels.py index e36c17397d46..9a5f1048fc54 100644 --- a/src/compas_rhino/conduits/labels.py +++ b/src/compas_rhino/conduits/labels.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore import System # type: ignore diff --git a/src/compas_rhino/conduits/lines.py b/src/compas_rhino/conduits/lines.py index 300f351f427e..49f3cdaf9f6e 100644 --- a/src/compas_rhino/conduits/lines.py +++ b/src/compas_rhino/conduits/lines.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore import System # type: ignore diff --git a/src/compas_rhino/conduits/points.py b/src/compas_rhino/conduits/points.py index fe850924a5ab..377c27b5a0bd 100644 --- a/src/compas_rhino/conduits/points.py +++ b/src/compas_rhino/conduits/points.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore import System # type: ignore diff --git a/src/compas_rhino/conversions/__init__.py b/src/compas_rhino/conversions/__init__.py index a80dbb921d08..a74919000221 100644 --- a/src/compas_rhino/conversions/__init__.py +++ b/src/compas_rhino/conversions/__init__.py @@ -1,8 +1,8 @@ """ This package provides functions to convert between COMPAS data/objects and Rhino data/objects. -""" -from __future__ import absolute_import +""" +# ruff: noqa: F401 from .exceptions import ConversionError @@ -94,85 +94,3 @@ meshobject_to_compas, pointobject_to_compas, ) - - -__all__ = [ - "ConversionError", - # geometry - "point_to_rhino", - "vector_to_rhino", - "plane_to_rhino", - "frame_to_rhino", - "frame_to_rhino_plane", - "polygon_to_rhino", - "point_to_compas", - "vector_to_compas", - "plane_to_compas", - "plane_to_compas_frame", - "polygon_to_compas", - # curves - "line_to_rhino", - "line_to_rhino_curve", - "polyline_to_rhino", - "polyline_to_rhino_curve", - "circle_to_rhino", - "circle_to_rhino_curve", - "ellipse_to_rhino", - "ellipse_to_rhino_curve", - "arc_to_rhino", - "curve_to_rhino", - "line_to_compas", - "polyline_to_compas", - "circle_to_compas", - "ellipse_to_compas", - "arc_to_compas", - "curve_to_compas_circle", - "curve_to_compas_ellipse", - "curve_to_compas_line", - "curve_to_compas_polyline", - "curve_to_compas", - # surfaces - "surface_to_rhino", - "surface_to_compas", - "surface_to_compas_mesh", - # shapes - "box_to_rhino", - "sphere_to_rhino", - "capsule_to_rhino", - "capsule_to_rhino_brep", - "cone_to_rhino", - "cone_to_rhino_brep", - "cylinder_to_rhino", - "cylinder_to_rhino_brep", - "torus_to_rhino", - "torus_to_rhino_brep", - "box_to_compas", - "sphere_to_compas", - "cone_to_compas", - "cylinder_to_compas", - # meshes - "mesh_to_rhino", - "polyhedron_to_rhino", - "vertices_and_faces_to_rhino", - "mesh_to_compas", - # breps - "brep_to_rhino", - "brep_to_compas", - "brep_to_compas_box", - "brep_to_compas_cone", - "brep_to_compas_cylinder", - "brep_to_compas_sphere", - "brep_to_compas_mesh", - # extrusions - "extrusion_to_compas_box", - "extrusion_to_compas_cylinder", - "extrusion_to_compas_torus", - # transformations - "transformation_to_rhino", - "transformation_matrix_to_rhino", - # docobjects - "brepobject_to_compas", - "curveobject_to_compas", - "meshobject_to_compas", - "pointobject_to_compas", -] diff --git a/src/compas_rhino/conversions/breps.py b/src/compas_rhino/conversions/breps.py index 3be86027198b..7d4b2ab7c33f 100644 --- a/src/compas_rhino/conversions/breps.py +++ b/src/compas_rhino/conversions/breps.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino.Geometry # type: ignore # noqa: F401 import scriptcontext as sc # type: ignore @@ -22,6 +18,7 @@ if not compas.IPY: from typing import Callable # noqa: F401 from typing import Type # noqa: F401 + from typing import Union # noqa: F401 # ============================================================================= # To Rhino @@ -116,7 +113,7 @@ def brep_to_compas_cone(brep): def brep_to_compas_cylinder(brep, tol=None): - # type: (Rhino.Geometry.Brep, float | None) -> compas.geometry.Cylinder + # type: (Rhino.Geometry.Brep, Union[float, None]) -> compas.geometry.Cylinder """Convert a Rhino brep to a COMPAS cylinder. Parameters @@ -186,7 +183,7 @@ def brep_to_compas_sphere(brep): def brep_to_compas_surface(brep, tol=None): - # type: (Rhino.Geometry.Brep, float | None) -> compas.geometry.NurbsSurface + # type: (Rhino.Geometry.Brep, Union[float, None]) -> compas.geometry.NurbsSurface """Convert a Rhino brep to a COMPAS surface. Parameters @@ -225,7 +222,7 @@ def brep_to_compas_surface(brep, tol=None): def brep_to_compas_mesh(brep, facefilter=None, cleanup=False, cls=None): - # type: (Rhino.Geometry.Brep, Callable | None, bool, Type[Mesh] | None) -> Mesh + # type: (Rhino.Geometry.Brep, Union[Callable, None], bool, Union[Type[Mesh], None]) -> Mesh """Convert the face loops of a Rhino brep to a COMPAS mesh. Parameters diff --git a/src/compas_rhino/conversions/curves.py b/src/compas_rhino/conversions/curves.py index 56f50574112e..7aee29f45ca2 100644 --- a/src/compas_rhino/conversions/curves.py +++ b/src/compas_rhino/conversions/curves.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore import scriptcontext as sc # type: ignore diff --git a/src/compas_rhino/conversions/docobjects.py b/src/compas_rhino/conversions/docobjects.py index a6d827a6ffcb..bf22d6d4cb5b 100644 --- a/src/compas_rhino/conversions/docobjects.py +++ b/src/compas_rhino/conversions/docobjects.py @@ -1,16 +1,11 @@ -from __future__ import absolute_import # noqa: I001 -from __future__ import division -from __future__ import print_function - import Rhino.Geometry # type: ignore # noqa: F401 import System # type: ignore import compas_rhino.objects -from .exceptions import ConversionError - from .breps import brep_to_compas from .curves import curve_to_compas +from .exceptions import ConversionError from .geometry import point_to_compas from .meshes import mesh_to_compas diff --git a/src/compas_rhino/conversions/exceptions.py b/src/compas_rhino/conversions/exceptions.py index 00fe6125e096..8a8afb4039d7 100644 --- a/src/compas_rhino/conversions/exceptions.py +++ b/src/compas_rhino/conversions/exceptions.py @@ -1,7 +1,2 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - - class ConversionError(Exception): """Raised when a conversion is not possible.""" diff --git a/src/compas_rhino/conversions/extrusions.py b/src/compas_rhino/conversions/extrusions.py index caf71dfb4482..4059b12cc558 100644 --- a/src/compas_rhino/conversions/extrusions.py +++ b/src/compas_rhino/conversions/extrusions.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore import scriptcontext as sc # type: ignore diff --git a/src/compas_rhino/conversions/geometry.py b/src/compas_rhino/conversions/geometry.py index 584790e9bf5f..5bbb1da57385 100644 --- a/src/compas_rhino/conversions/geometry.py +++ b/src/compas_rhino/conversions/geometry.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore from compas.geometry import Frame diff --git a/src/compas_rhino/conversions/meshes.py b/src/compas_rhino/conversions/meshes.py index deff5233ae03..c187275a2e7f 100644 --- a/src/compas_rhino/conversions/meshes.py +++ b/src/compas_rhino/conversions/meshes.py @@ -1,11 +1,4 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -try: - from itertools import zip_longest -except ImportError: - from itertools import izip_longest as zip_longest # type: ignore +from itertools import zip_longest import Rhino # type: ignore import System # type: ignore diff --git a/src/compas_rhino/conversions/shapes.py b/src/compas_rhino/conversions/shapes.py index 320db85a7eec..caba8d2e9d99 100644 --- a/src/compas_rhino/conversions/shapes.py +++ b/src/compas_rhino/conversions/shapes.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore import scriptcontext as sc # type: ignore diff --git a/src/compas_rhino/conversions/surfaces.py b/src/compas_rhino/conversions/surfaces.py index ada0588f315a..da331fe3cad2 100644 --- a/src/compas_rhino/conversions/surfaces.py +++ b/src/compas_rhino/conversions/surfaces.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas.datastructures import Mesh from compas.geometry import NurbsSurface from compas.geometry import Surface diff --git a/src/compas_rhino/conversions/transformations.py b/src/compas_rhino/conversions/transformations.py index 089fe85c7b90..a11499079246 100644 --- a/src/compas_rhino/conversions/transformations.py +++ b/src/compas_rhino/conversions/transformations.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore diff --git a/src/compas_rhino/devtools.py b/src/compas_rhino/devtools.py index 375fa5d33bb4..788b5e5eda19 100644 --- a/src/compas_rhino/devtools.py +++ b/src/compas_rhino/devtools.py @@ -1,10 +1,6 @@ import os import sys -__all__ = [ - "DevTools", -] - class DevTools(object): """Tools for working with Python code in development mode, unloading, and reloading code.""" @@ -29,6 +25,7 @@ def unload_modules(top_level_module_name): ------- list List of unloaded module names. + """ to_remove = [name for name in sys.modules if name.startswith(top_level_module_name)] @@ -41,14 +38,18 @@ def unload_modules(top_level_module_name): def enable_reloader(cls): """Enables the code reload on the current folder. - The file must have been saved already in order for this to work.""" + The file must have been saved already in order for this to work. + + """ cls._manage_reloader(enable=True) @classmethod def disable_reloader(cls): """Disables the code reload on the current folder. - The file must have been saved already in order for this to work.""" + The file must have been saved already in order for this to work. + + """ cls._manage_reloader(enable=False) @classmethod diff --git a/src/compas_rhino/drawing.py b/src/compas_rhino/drawing.py index b4b99a83da0f..00b4974f350d 100644 --- a/src/compas_rhino/drawing.py +++ b/src/compas_rhino/drawing.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from functools import wraps import Rhino # type: ignore diff --git a/src/compas_rhino/geometry/__init__.py b/src/compas_rhino/geometry/__init__.py index 2481a1bfb8f3..c00e03e1b348 100644 --- a/src/compas_rhino/geometry/__init__.py +++ b/src/compas_rhino/geometry/__init__.py @@ -1,8 +1,8 @@ """ This package provides plugins for various geometry pluggables using Rhino as the backend. -""" -from __future__ import absolute_import +""" +# ruff: noqa: F401 from .curves.nurbs import RhinoNurbsCurve from .surfaces.nurbs import RhinoNurbsSurface @@ -23,22 +23,3 @@ from .trimesh_curvature import trimesh_principal_curvature from .trimesh_slicing import trimesh_slice - - -__all__ = [ - "boolean_difference_mesh_mesh", - "boolean_intersection_mesh_mesh", - "boolean_union_mesh_mesh", - "trimesh_gaussian_curvature", - "trimesh_mean_curvature", - "trimesh_principal_curvature", - "trimesh_slice", - "RhinoNurbsCurve", - "RhinoNurbsSurface", - "RhinoBrep", - "RhinoBrepVertex", - "RhinoBrepEdge", - "RhinoBrepFace", - "RhinoBrepLoop", - "RhinoBrepTrim", -] diff --git a/src/compas_rhino/geometry/booleans.py b/src/compas_rhino/geometry/booleans.py index 49c26b7a3cfc..64764e496ff5 100644 --- a/src/compas_rhino/geometry/booleans.py +++ b/src/compas_rhino/geometry/booleans.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore from compas.plugins import plugin diff --git a/src/compas_rhino/geometry/brep/brep.py b/src/compas_rhino/geometry/brep/brep.py index 96de26b7b065..432a12b96aab 100644 --- a/src/compas_rhino/geometry/brep/brep.py +++ b/src/compas_rhino/geometry/brep/brep.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore import rhinoscriptsyntax as rs # type: ignore diff --git a/src/compas_rhino/geometry/brep/builder.py b/src/compas_rhino/geometry/brep/builder.py index b7cf503748ef..1717219befa6 100644 --- a/src/compas_rhino/geometry/brep/builder.py +++ b/src/compas_rhino/geometry/brep/builder.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore from compas.geometry import BrepInvalidError diff --git a/src/compas_rhino/geometry/brep/edge.py b/src/compas_rhino/geometry/brep/edge.py index 82dd88e6b1f0..63c06c75fd4b 100644 --- a/src/compas_rhino/geometry/brep/edge.py +++ b/src/compas_rhino/geometry/brep/edge.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore from compas.geometry import Arc @@ -229,6 +225,7 @@ def closest_point(self, point): ------- float The parameter of the closest point on the edge. + """ rgpoint = Rhino.Geometry.Point3d(point.x, point.y, point.z) success, parameter = self._edge.ClosestPoint(rgpoint) @@ -249,6 +246,7 @@ def point_at(self, parameter): ------- :class:`compas.geometry.Point` The point on the edge at the given parameter. + """ rgpoint = self._edge.PointAt(parameter) return point_to_compas(rgpoint) diff --git a/src/compas_rhino/geometry/brep/face.py b/src/compas_rhino/geometry/brep/face.py index c64e03eca967..6274dd330eeb 100644 --- a/src/compas_rhino/geometry/brep/face.py +++ b/src/compas_rhino/geometry/brep/face.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore from compas.geometry import Brep @@ -326,6 +322,7 @@ def point_at(self, u, v): ------- :class:`compas.geometry.Point` The point at the given uv parameters. + """ rgpoint = self._face.PointAt(u, v) return point_to_compas(rgpoint) @@ -365,6 +362,7 @@ def is_point_on_face(self, u, v): ------- bool True if the point is on the face (inside or on the boundary), False otherwise. + """ relation = self._face.IsPointOnFace(u, v) if relation in (Rhino.Geometry.PointFaceRelation.Interior, Rhino.Geometry.PointFaceRelation.Boundary): @@ -386,6 +384,7 @@ def is_point_on_boundary(self, u, v): ------- bool True if the point is on the boundary of the face, False otherwise. + """ relation = self._face.IsPointOnFace(u, v) if relation == Rhino.Geometry.PointFaceRelation.Boundary: diff --git a/src/compas_rhino/geometry/brep/loop.py b/src/compas_rhino/geometry/brep/loop.py index b79fc967d1a9..735b80a9b57c 100644 --- a/src/compas_rhino/geometry/brep/loop.py +++ b/src/compas_rhino/geometry/brep/loop.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore from compas.geometry import BrepLoop diff --git a/src/compas_rhino/geometry/brep/trim.py b/src/compas_rhino/geometry/brep/trim.py index e15f3e0e182f..16064951ca74 100644 --- a/src/compas_rhino/geometry/brep/trim.py +++ b/src/compas_rhino/geometry/brep/trim.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore from compas.geometry import BrepTrim @@ -33,6 +29,7 @@ class RhinoBrepTrim(BrepTrim): The list of vertices which comprise this trim (start and end). edge : :class:compas_rhino.geometry.RhinoBrepEdge The edge associated with this trim. + """ def __init__(self, rhino_trim=None): diff --git a/src/compas_rhino/geometry/brep/vertex.py b/src/compas_rhino/geometry/brep/vertex.py index c00a7fe26483..e45c7ccd978e 100644 --- a/src/compas_rhino/geometry/brep/vertex.py +++ b/src/compas_rhino/geometry/brep/vertex.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from compas.geometry import BrepVertex from compas.geometry import Point from compas_rhino.conversions import point_to_compas diff --git a/src/compas_rhino/geometry/curves/curve.py b/src/compas_rhino/geometry/curves/curve.py index dca2c2bb90da..64e57b1c0ab1 100644 --- a/src/compas_rhino/geometry/curves/curve.py +++ b/src/compas_rhino/geometry/curves/curve.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino.Geometry # type: ignore from compas.geometry import Curve @@ -168,6 +164,7 @@ def to_polyline(self, tolerance=1, angle_tolerance=1, minimum_lenght=0, maximum_ ------- :class:`compas.geometry.Polyline` The polyline representation of the curve. + """ curve_polyline = self.native_curve.ToPolyline(tolerance, angle_tolerance, minimum_lenght, maximum_length) polyline_created, polyline = curve_polyline.TryGetPolyline() diff --git a/src/compas_rhino/geometry/curves/nurbs.py b/src/compas_rhino/geometry/curves/nurbs.py index c6e893eb82e9..89fb1f59f4d3 100644 --- a/src/compas_rhino/geometry/curves/nurbs.py +++ b/src/compas_rhino/geometry/curves/nurbs.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from itertools import groupby import Rhino.Geometry # type: ignore @@ -49,7 +45,7 @@ class RhinoNurbsCurve(RhinoCurve, NurbsCurve): The knot vector, without duplicates. multiplicities : list[int], read-only The multiplicities of the knots in the knot vector. - knotsequence : list[float], read-only + knotvector : list[float], read-only The knot vector, with repeating values according to the multiplicities. continuity : int, read-only The degree of continuity of the curve. @@ -125,11 +121,6 @@ def knots(self): if self.native_curve: return [key for key, _ in groupby(self.native_curve.Knots)] - @property - def knotsequence(self): - if self.native_curve: - return list(self.native_curve.Knots) - @property def multiplicities(self): if self.native_curve: diff --git a/src/compas_rhino/geometry/surfaces/nurbs.py b/src/compas_rhino/geometry/surfaces/nurbs.py index 8fd7210c6354..90d1c64aab33 100644 --- a/src/compas_rhino/geometry/surfaces/nurbs.py +++ b/src/compas_rhino/geometry/surfaces/nurbs.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from itertools import groupby import Rhino.Geometry # type: ignore @@ -25,10 +21,11 @@ def __init__(self, surface): @property def points(self): + # COMPAS stores V rows containing U values; Rhino indexes U first. points = [] - for i in range(self.native_surface.Points.CountU): + for j in range(self.native_surface.Points.CountV): row = [] - for j in range(self.native_surface.Points.CountV): + for i in range(self.native_surface.Points.CountU): row.append(point_to_compas(self.native_surface.Points.GetControlPoint(i, j).Location)) points.append(row) return points @@ -47,7 +44,7 @@ def __setitem__(self, index, point): self.native_surface.Points.SetControlPoint(u, v, Rhino.Geometry.ControlPoint(point_to_rhino(point))) def __len__(self): - return self.native_surface.Points.CountU + return self.native_surface.Points.CountV def __iter__(self): return iter(self.points) @@ -67,8 +64,9 @@ def native_surface_from_parameters( ): order_u = degree_u + 1 order_v = degree_v + 1 - pointcount_u = len(points) - pointcount_v = len(points[0]) + # COMPAS stores V rows containing U values; Rhino indexes U first. + pointcount_u = len(points[0]) + pointcount_v = len(points) is_rational = any(weight != 1.0 for weight in flatten(weights)) dimensions = 3 @@ -106,9 +104,9 @@ def native_surface_from_parameters( for index, knot in enumerate(knotvector_v): native_surface.KnotsV[index] = knot # add control points - for i in range(pointcount_u): - for j in range(pointcount_v): - native_surface.Points.SetPoint(i, j, point_to_rhino(points[i][j]), weights[i][j]) + for j in range(pointcount_v): + for i in range(pointcount_u): + native_surface.Points.SetPoint(i, j, point_to_rhino(points[j][i]), weights[j][i]) return native_surface @@ -142,22 +140,13 @@ class RhinoNurbsSurface(RhinoSurface, NurbsSurface): @property def __data__(self): - # add superfluous knots - # for compatibility with all/most other NURBS implementations - # https://developer.rhino3d.com/guides/opennurbs/superfluous-knots/ - mults_u = self.mults_u[:] # type: ignore - mults_v = self.mults_v[:] # type: ignore - mults_u[0] += 1 - mults_u[-1] += 1 - mults_v[0] += 1 - mults_v[-1] += 1 return { "points": [[point.__data__ for point in row] for row in self.points], # type: ignore "weights": self.weights, "knots_u": self.knots_u, "knots_v": self.knots_v, - "mults_u": mults_u, - "mults_v": mults_v, + "mults_u": self.mults_u, + "mults_v": self.mults_v, "degree_u": self.degree_u, "degree_v": self.degree_v, "is_periodic_u": self.is_periodic_u, @@ -178,10 +167,11 @@ def points(self): @property def weights(self): if self.native_surface: + # Keep the weight grid aligned with the canonical point-grid layout. weights = [] - for i in range(self.native_surface.Points.CountU): + for j in range(self.native_surface.Points.CountV): row = [] - for j in range(self.native_surface.Points.CountV): + for i in range(self.native_surface.Points.CountU): row.append(self.native_surface.Points.GetWeight(i, j)) weights.append(row) return weights @@ -194,12 +184,16 @@ def knots_u(self): @property def mults_u(self): if self.native_surface: - return [len(list(group)) for _, group in groupby(self.native_surface.KnotsU)] + multiplicities = [len(list(group)) for _, group in groupby(self.native_surface.KnotsU)] + # Restore the endpoint knots omitted by openNURBS. + multiplicities[0] += 1 + multiplicities[-1] += 1 + return multiplicities @property def knotvector_u(self): if self.native_surface: - return list(self.native_surface.KnotsU) + return [knot for knot, multiplicity in zip(self.knots_u, self.mults_u) for _ in range(multiplicity)] @property def knots_v(self): @@ -209,12 +203,16 @@ def knots_v(self): @property def mults_v(self): if self.native_surface: - return [len(list(group)) for _, group in groupby(self.native_surface.KnotsV)] + multiplicities = [len(list(group)) for _, group in groupby(self.native_surface.KnotsV)] + # Restore the endpoint knots omitted by openNURBS. + multiplicities[0] += 1 + multiplicities[-1] += 1 + return multiplicities @property def knotvector_v(self): if self.native_surface: - return list(self.native_surface.KnotsV) + return [knot for knot, multiplicity in zip(self.knots_v, self.mults_v) for _ in range(multiplicity)] @property def degree_u(self): diff --git a/src/compas_rhino/geometry/surfaces/surface.py b/src/compas_rhino/geometry/surfaces/surface.py index eb66ca6e8366..e1d4b71d919e 100644 --- a/src/compas_rhino/geometry/surfaces/surface.py +++ b/src/compas_rhino/geometry/surfaces/surface.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino.Geometry # type: ignore from compas.geometry import Surface diff --git a/src/compas_rhino/geometry/trimesh_curvature.py b/src/compas_rhino/geometry/trimesh_curvature.py index e8a9465c94fb..db3db1cc38e7 100644 --- a/src/compas_rhino/geometry/trimesh_curvature.py +++ b/src/compas_rhino/geometry/trimesh_curvature.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from math import atan2 from math import pi from math import sqrt diff --git a/src/compas_rhino/geometry/trimesh_slicing.py b/src/compas_rhino/geometry/trimesh_slicing.py index 79974640451b..e7f4779ca50f 100644 --- a/src/compas_rhino/geometry/trimesh_slicing.py +++ b/src/compas_rhino/geometry/trimesh_slicing.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore from compas.plugins import plugin diff --git a/src/compas_rhino/install.py b/src/compas_rhino/install.py index c66fd865dfdb..504066f68608 100644 --- a/src/compas_rhino/install.py +++ b/src/compas_rhino/install.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import importlib import itertools import os @@ -281,6 +277,7 @@ def installable_rhino_packages(): ------- :obj:`list` of :obj:`str` List of package names to make available inside Rhino. + """ pass @@ -311,6 +308,7 @@ def after_rhino_install(installed_packages): ------- :obj:`list` of 3-tuple (str, str, bool) List containing a 3-tuple with component name, message and True/False success flag. + """ pass diff --git a/src/compas_rhino/layers.py b/src/compas_rhino/layers.py index a449fcc6af9a..106c36384a2d 100644 --- a/src/compas_rhino/layers.py +++ b/src/compas_rhino/layers.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from collections import deque import rhinoscriptsyntax as rs # type: ignore diff --git a/src/compas_rhino/objects.py b/src/compas_rhino/objects.py index dae80f58712c..426944e11b34 100644 --- a/src/compas_rhino/objects.py +++ b/src/compas_rhino/objects.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import json import warnings diff --git a/src/compas_rhino/scene/__init__.py b/src/compas_rhino/scene/__init__.py index cc243b8022fd..bb0ad2cf5696 100644 --- a/src/compas_rhino/scene/__init__.py +++ b/src/compas_rhino/scene/__init__.py @@ -1,9 +1,9 @@ """ This package provides scene object plugins for visualising COMPAS objects in Rhino. When working in Rhino, :class:`compas.scene.SceneObject` will automatically use the corresponding Rhino scene object for each COMPAS object type. -""" -from __future__ import absolute_import +""" +# ruff: noqa: F401 from compas.plugins import plugin from compas.scene import register @@ -95,30 +95,3 @@ def register_scene_objects(): register(Brep, RhinoBrepObject, context="Rhino") # print("Rhino SceneObjects registered.") - - -__all__ = [ - "RhinoSceneObject", - "RhinoCircleObject", - "RhinoEllipseObject", - "RhinoFrameObject", - "RhinoLineObject", - "RhinoPlaneObject", - "RhinoPointObject", - "RhinoPolygonObject", - "RhinoPolylineObject", - "RhinoVectorObject", - "RhinoBoxObject", - "RhinoCapsuleObject", - "RhinoConeObject", - "RhinoCylinderObject", - "RhinoPolyhedronObject", - "RhinoSphereObject", - "RhinoTorusObject", - "RhinoMeshObject", - "RhinoGraphObject", - "RhinoVolMeshObject", - "RhinoCurveObject", - "RhinoSurfaceObject", - "RhinoBrepObject", -] diff --git a/src/compas_rhino/scene/boxobject.py b/src/compas_rhino/scene/boxobject.py index 6ef643fb3b9e..097ce7114e79 100644 --- a/src/compas_rhino/scene/boxobject.py +++ b/src/compas_rhino/scene/boxobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.scene import GeometryObject diff --git a/src/compas_rhino/scene/brepobject.py b/src/compas_rhino/scene/brepobject.py index 7e9bed8a3e47..b1cc3a4a4930 100644 --- a/src/compas_rhino/scene/brepobject.py +++ b/src/compas_rhino/scene/brepobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.scene import GeometryObject diff --git a/src/compas_rhino/scene/capsuleobject.py b/src/compas_rhino/scene/capsuleobject.py index 9774a034ab20..59f3811b2c77 100644 --- a/src/compas_rhino/scene/capsuleobject.py +++ b/src/compas_rhino/scene/capsuleobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.scene import GeometryObject diff --git a/src/compas_rhino/scene/circleobject.py b/src/compas_rhino/scene/circleobject.py index e2a23f176e10..5a1bec4e99b4 100644 --- a/src/compas_rhino/scene/circleobject.py +++ b/src/compas_rhino/scene/circleobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.scene import GeometryObject diff --git a/src/compas_rhino/scene/coneobject.py b/src/compas_rhino/scene/coneobject.py index 53bbe9c10ec7..8d08300dfa14 100644 --- a/src/compas_rhino/scene/coneobject.py +++ b/src/compas_rhino/scene/coneobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.scene import GeometryObject diff --git a/src/compas_rhino/scene/curveobject.py b/src/compas_rhino/scene/curveobject.py index bffb887367df..c1d7593d33f2 100644 --- a/src/compas_rhino/scene/curveobject.py +++ b/src/compas_rhino/scene/curveobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.scene import GeometryObject diff --git a/src/compas_rhino/scene/cylinderobject.py b/src/compas_rhino/scene/cylinderobject.py index b29060f45ce5..57d2deb2a386 100644 --- a/src/compas_rhino/scene/cylinderobject.py +++ b/src/compas_rhino/scene/cylinderobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.scene import GeometryObject diff --git a/src/compas_rhino/scene/ellipseobject.py b/src/compas_rhino/scene/ellipseobject.py index b0d44c65f011..5d1653b1ab43 100644 --- a/src/compas_rhino/scene/ellipseobject.py +++ b/src/compas_rhino/scene/ellipseobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.scene import GeometryObject diff --git a/src/compas_rhino/scene/frameobject.py b/src/compas_rhino/scene/frameobject.py index ae7d3c4beb4b..3df859cbc748 100644 --- a/src/compas_rhino/scene/frameobject.py +++ b/src/compas_rhino/scene/frameobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.colors import Color diff --git a/src/compas_rhino/scene/graphobject.py b/src/compas_rhino/scene/graphobject.py index a761a559e36e..d9d467839175 100644 --- a/src/compas_rhino/scene/graphobject.py +++ b/src/compas_rhino/scene/graphobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore import scriptcontext as sc # type: ignore diff --git a/src/compas_rhino/scene/helpers.py b/src/compas_rhino/scene/helpers.py index 6463acc3edfd..c42d9da13161 100644 --- a/src/compas_rhino/scene/helpers.py +++ b/src/compas_rhino/scene/helpers.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import rhinoscriptsyntax as rs # type: ignore import scriptcontext as sc # type: ignore diff --git a/src/compas_rhino/scene/lineobject.py b/src/compas_rhino/scene/lineobject.py index 9ab5c22e8408..1c0528818b37 100644 --- a/src/compas_rhino/scene/lineobject.py +++ b/src/compas_rhino/scene/lineobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.scene import GeometryObject diff --git a/src/compas_rhino/scene/meshobject.py b/src/compas_rhino/scene/meshobject.py index 7958a16fae6a..0eed5f5feb1f 100644 --- a/src/compas_rhino/scene/meshobject.py +++ b/src/compas_rhino/scene/meshobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore import scriptcontext as sc # type: ignore diff --git a/src/compas_rhino/scene/planeobject.py b/src/compas_rhino/scene/planeobject.py index eece052c32c6..ea03d31ae7e1 100644 --- a/src/compas_rhino/scene/planeobject.py +++ b/src/compas_rhino/scene/planeobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.geometry import Frame diff --git a/src/compas_rhino/scene/pointobject.py b/src/compas_rhino/scene/pointobject.py index 4091fb7534b2..9bdcd7b96fe6 100644 --- a/src/compas_rhino/scene/pointobject.py +++ b/src/compas_rhino/scene/pointobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.scene import GeometryObject diff --git a/src/compas_rhino/scene/polygonobject.py b/src/compas_rhino/scene/polygonobject.py index 3cde33d58742..28ec4bbbda19 100644 --- a/src/compas_rhino/scene/polygonobject.py +++ b/src/compas_rhino/scene/polygonobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.scene import GeometryObject diff --git a/src/compas_rhino/scene/polyhedronobject.py b/src/compas_rhino/scene/polyhedronobject.py index 8800026b5191..b92bc6f7bf98 100644 --- a/src/compas_rhino/scene/polyhedronobject.py +++ b/src/compas_rhino/scene/polyhedronobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.scene import GeometryObject diff --git a/src/compas_rhino/scene/polylineobject.py b/src/compas_rhino/scene/polylineobject.py index b00f7226708a..94a739a47a3a 100644 --- a/src/compas_rhino/scene/polylineobject.py +++ b/src/compas_rhino/scene/polylineobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.scene import GeometryObject diff --git a/src/compas_rhino/scene/sceneobject.py b/src/compas_rhino/scene/sceneobject.py index ac1f098b655a..7c76104a974a 100644 --- a/src/compas_rhino/scene/sceneobject.py +++ b/src/compas_rhino/scene/sceneobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import Rhino # type: ignore import scriptcontext as sc # type: ignore import System # type: ignore diff --git a/src/compas_rhino/scene/sphereobject.py b/src/compas_rhino/scene/sphereobject.py index ad6278cff993..6ee6ac38c6be 100644 --- a/src/compas_rhino/scene/sphereobject.py +++ b/src/compas_rhino/scene/sphereobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.scene import GeometryObject diff --git a/src/compas_rhino/scene/surfaceobject.py b/src/compas_rhino/scene/surfaceobject.py index e352b237584f..1079576dd022 100644 --- a/src/compas_rhino/scene/surfaceobject.py +++ b/src/compas_rhino/scene/surfaceobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.scene import GeometryObject diff --git a/src/compas_rhino/scene/torusobject.py b/src/compas_rhino/scene/torusobject.py index 477d24403dc0..864ca598e37f 100644 --- a/src/compas_rhino/scene/torusobject.py +++ b/src/compas_rhino/scene/torusobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.scene import GeometryObject diff --git a/src/compas_rhino/scene/vectorobject.py b/src/compas_rhino/scene/vectorobject.py index d95eb321a66f..f9f30fa39d4a 100644 --- a/src/compas_rhino/scene/vectorobject.py +++ b/src/compas_rhino/scene/vectorobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from compas.geometry import Point diff --git a/src/compas_rhino/scene/volmeshobject.py b/src/compas_rhino/scene/volmeshobject.py index 9a512a1c82fa..31f79491df4b 100644 --- a/src/compas_rhino/scene/volmeshobject.py +++ b/src/compas_rhino/scene/volmeshobject.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import scriptcontext as sc # type: ignore from Rhino.Geometry import TextDot # type: ignore diff --git a/src/compas_rhino/ui.py b/src/compas_rhino/ui.py index bdbd98eaebc0..a43bd9a63db1 100644 --- a/src/compas_rhino/ui.py +++ b/src/compas_rhino/ui.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import os import rhinoscriptsyntax as rs # type: ignore diff --git a/src/compas_rhino/uninstall.py b/src/compas_rhino/uninstall.py index cb4e4a656efe..997baf62e9f1 100644 --- a/src/compas_rhino/uninstall.py +++ b/src/compas_rhino/uninstall.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import itertools import os import sys @@ -215,6 +211,7 @@ def after_rhino_uninstall(uninstalled_packages): ------- :obj:`list` of 3-tuple (str, str, bool) List containing a 3-tuple with component name, message and True/False success flag. + """ pass diff --git a/src/compas_rhino/uninstall_plugin.py b/src/compas_rhino/uninstall_plugin.py index 21c61566ac1b..7c2e903cc80e 100644 --- a/src/compas_rhino/uninstall_plugin.py +++ b/src/compas_rhino/uninstall_plugin.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import os import compas_rhino diff --git a/src/compas_rhino/utilities/__init__.py b/src/compas_rhino/utilities/__init__.py deleted file mode 100644 index 51790bb787eb..000000000000 --- a/src/compas_rhino/utilities/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import absolute_import -from warnings import warn - -from ..drawing import ( - draw_labels, - draw_points, - draw_lines, - draw_geodesics, - draw_polylines, - draw_breps, - draw_faces, - draw_cylinders, - draw_pipes, - draw_spheres, - draw_mesh, - draw_circles, - draw_curves, - draw_surfaces, - draw_brep, -) - -__all__ = [ - "draw_labels", - "draw_points", - "draw_lines", - "draw_geodesics", - "draw_polylines", - "draw_breps", - "draw_faces", - "draw_cylinders", - "draw_pipes", - "draw_spheres", - "draw_mesh", - "draw_circles", - "draw_curves", - "draw_surfaces", - "draw_brep", -] - -warn("compas_rhino.utilities will be removed in version 2.3. Please use compas_rhino.drawing instead.", DeprecationWarning, stacklevel=2) diff --git a/tasks.py b/tasks.py index 7286febdb086..f24abc45a4e9 100644 --- a/tasks.py +++ b/tasks.py @@ -1,4 +1,3 @@ -from __future__ import print_function import os diff --git a/tests/compas/colors/test_color.py b/tests/compas/colors/test_color.py index ff1ac36a2750..790d5bc12eeb 100644 --- a/tests/compas/colors/test_color.py +++ b/tests/compas/colors/test_color.py @@ -1,6 +1,5 @@ import pytest import json -import compas from random import random from compas.colors import Color @@ -40,10 +39,6 @@ def test_color_data(): assert color == other - if not compas.IPY: - assert Color.validate_data(color.__data__) - assert Color.validate_data(other.__data__) - def test_color_predefined(): assert Color.red() == Color(1.0, 0.0, 0.0) diff --git a/tests/compas/compas_api.json b/tests/compas/compas_api.json index b4eeefeb4a2a..bd48e4bb3e48 100644 --- a/tests/compas/compas_api.json +++ b/tests/compas/compas_api.json @@ -203,11 +203,10 @@ "STLParser", "STLReader", "STLWriter", - "XML", - "XMLElement", - "XMLReader", - "XMLWriter", - "prettify_string" + "parse_xml", + "read_xml", + "write_xml", + "xml_to_string" ], "compas.geometry": [ "Arc", diff --git a/tests/compas/compas_api_ipy.json b/tests/compas/compas_api_ipy.json deleted file mode 100644 index f401da0cedfe..000000000000 --- a/tests/compas/compas_api_ipy.json +++ /dev/null @@ -1,582 +0,0 @@ -{ - "metadata": { - "compas_version": "1.17.5-31fce1b0", - "generated_on": "20230614" - }, - "modules": { - "compas": [ - "BLENDER", - "IPY", - "LINUX", - "MONO", - "OSX", - "PY2", - "PY3", - "RHINO", - "WINDOWS", - "get", - "is_blender", - "is_grasshopper", - "is_ironpython", - "is_linux", - "is_mono", - "is_osx", - "is_rhino", - "is_windows", - "json_dump", - "json_dumps", - "json_load", - "json_loads", - "set_precision" - ], - "compas.data": [ - "Data", - "DataDecoder", - "DataEncoder", - "DecoderError", - "is_float3", - "is_float4x4", - "is_int3", - "is_item_iterable", - "is_sequence_of_float", - "is_sequence_of_int", - "is_sequence_of_uint", - "json_dump", - "json_dumps", - "json_load", - "json_loads" - ], - "compas.datastructures": [ - "Assembly", - "AssemblyError", - "BaseMesh", - "BaseGraph", - "BaseVolMesh", - "Datastructure", - "Feature", - "FeatureError", - "GeometricFeature", - "Graph", - "HalfEdge", - "HalfFace", - "Mesh", - "Graph", - "ParametricFeature", - "Part", - "VolMesh", - "mesh_add_vertex_to_face_edge", - "mesh_bounding_box", - "mesh_bounding_box_xy", - "mesh_collapse_edge", - "mesh_connected_components", - "mesh_conway_ambo", - "mesh_conway_bevel", - "mesh_conway_dual", - "mesh_conway_expand", - "mesh_conway_gyro", - "mesh_conway_join", - "mesh_conway_kis", - "mesh_conway_meta", - "mesh_conway_needle", - "mesh_conway_ortho", - "mesh_conway_snub", - "mesh_conway_truncate", - "mesh_conway_zip", - "mesh_delete_duplicate_vertices", - "mesh_disconnected_faces", - "mesh_disconnected_vertices", - "mesh_dual", - "mesh_explode", - "mesh_face_adjacency", - "mesh_flatness", - "mesh_flip_cycles", - "mesh_insert_vertex_on_edge", - "mesh_is_connected", - "mesh_merge_faces", - "mesh_offset", - "mesh_planarize_faces", - "mesh_quads_to_triangles", - "mesh_slice_plane", - "mesh_smooth_area", - "mesh_smooth_centerofmass", - "mesh_smooth_centroid", - "mesh_split_edge", - "mesh_split_face", - "mesh_split_strip", - "mesh_subdivide", - "mesh_subdivide_catmullclark", - "mesh_subdivide_corner", - "mesh_subdivide_doosabin", - "mesh_subdivide_frames", - "mesh_subdivide_quad", - "mesh_subdivide_tri", - "mesh_substitute_vertex_in_faces", - "mesh_thicken", - "mesh_transform", - "mesh_transformed", - "mesh_unify_cycles", - "mesh_unweld_edges", - "mesh_unweld_vertices", - "mesh_weld", - "meshes_join", - "meshes_join_and_weld", - "graph_complement", - "graph_count_crossings", - "graph_disconnected_edges", - "graph_disconnected_nodes", - "graph_embed_in_plane", - "graph_embed_in_plane_proxy", - "graph_explode", - "graph_find_crossings", - "graph_find_cycles", - "graph_is_connected", - "graph_is_crossed", - "graph_is_planar", - "graph_is_planar_embedding", - "graph_is_xy", - "graph_join_edges", - "graph_polylines", - "graph_shortest_path", - "graph_smooth_centroid", - "graph_split_edge", - "graph_transform", - "graph_transformed", - "trimesh_collapse_edge", - "trimesh_face_circle", - "trimesh_gaussian_curvature", - "trimesh_mean_curvature", - "trimesh_remesh", - "trimesh_split_edge", - "trimesh_subdivide_loop", - "trimesh_swap_edge", - "volmesh_bounding_box", - "volmesh_transform", - "volmesh_transformed" - ], - "compas.files": [ - "DXF", - "DXFParser", - "DXFReader", - "GLTF", - "GLTFContent", - "GLTFExporter", - "GLTFMesh", - "GLTFParser", - "GLTFReader", - "LAS", - "LASParser", - "LASReader", - "OBJ", - "OBJParser", - "OBJReader", - "OBJWriter", - "OFF", - "OFFReader", - "OFFWriter", - "PLY", - "PLYParser", - "PLYReader", - "PLYWriter", - "STL", - "STLParser", - "STLReader", - "STLWriter", - "XML", - "XMLElement", - "XMLReader", - "XMLWriter", - "prettify_string" - ], - "compas.geometry": [ - "Arc", - "Box", - "Brep", - "BrepEdge", - "BrepError", - "BrepFace", - "BrepInvalidError", - "BrepLoop", - "BrepOrientation", - "BrepTrim", - "BrepTrimIsoStatus", - "BrepTrimmingError", - "BrepType", - "BrepVertex", - "Capsule", - "Circle", - "Cone", - "Curve", - "Cylinder", - "Ellipse", - "Frame", - "Geometry", - "KDTree", - "Line", - "NurbsCurve", - "NurbsSurface", - "Plane", - "Point", - "Pointcloud", - "Polygon", - "Polyhedron", - "Polyline", - "Projection", - "Quaternion", - "Reflection", - "Rotation", - "Scale", - "Shape", - "Shear", - "Sphere", - "Surface", - "Torus", - "Transformation", - "Translation", - "Vector", - "add_vectors", - "add_vectors_xy", - "allclose", - "angle_planes", - "angle_points", - "angle_points_xy", - "angle_vectors", - "angle_vectors_signed", - "angle_vectors_projected", - "angle_vectors_xy", - "angles_points", - "angles_points_xy", - "angles_vectors", - "angles_vectors", - "angles_vectors_xy", - "angles_vectors_xy", - "archimedean_spiral_evaluate", - "area_polygon", - "area_polygon_xy", - "area_triangle", - "area_triangle_xy", - "argmax", - "argmin", - "axis_and_angle_from_matrix", - "axis_angle_from_quaternion", - "axis_angle_vector_from_matrix", - "barycentric_coordinates", - "basis_vectors_from_matrix", - "bestfit_plane", - "boolean_difference_mesh_mesh", - "boolean_intersection_mesh_mesh", - "boolean_union_mesh_mesh", - "bounding_box", - "bounding_box_xy", - "centroid_points", - "centroid_points_weighted", - "centroid_points_xy", - "centroid_polygon", - "centroid_polygon_edges", - "centroid_polygon_edges_xy", - "centroid_polygon_vertices", - "centroid_polygon_vertices_xy", - "centroid_polygon_xy", - "centroid_polyhedron", - "circle_evaluate", - "circle_from_points", - "circle_from_points_xy", - "close", - "closest_line_to_point", - "closest_point_in_cloud", - "closest_point_in_cloud_xy", - "closest_point_on_line", - "closest_point_on_line_xy", - "closest_point_on_plane", - "closest_point_on_polyline", - "closest_point_on_polyline_xy", - "closest_point_on_segment", - "closest_point_on_segment_xy", - "compose_matrix", - "conforming_delaunay_triangulation", - "constrained_delaunay_triangulation", - "convex_hull", - "convex_hull_xy", - "cross_vectors", - "cross_vectors_xy", - "decompose_matrix", - "dehomogenize_vectors", - "delaunay_from_points", - "delaunay_from_points", - "delaunay_triangulation", - "discrete_coons_patch", - "distance_line_line", - "distance_point_line", - "distance_point_line_sqrd", - "distance_point_line_sqrd_xy", - "distance_point_line_xy", - "distance_point_plane", - "distance_point_plane_signed", - "distance_point_point", - "distance_point_point_sqrd", - "distance_point_point_sqrd_xy", - "distance_point_point_xy", - "divide_vectors", - "divide_vectors_xy", - "dot_vectors", - "dot_vectors_xy", - "ellipse_evaluate", - "euler_angles_from_matrix", - "euler_angles_from_quaternion", - "helix_evaluate", - "homogenize_vectors", - "identity_matrix", - "intersection_circle_circle_xy", - "intersection_ellipse_line_xy", - "intersection_line_box_xy", - "intersection_line_line", - "intersection_line_line_xy", - "intersection_line_plane", - "intersection_line_segment", - "intersection_line_segment_xy", - "intersection_line_triangle", - "intersection_mesh_mesh", - "intersection_plane_circle", - "intersection_plane_plane", - "intersection_plane_plane_plane", - "intersection_polyline_plane", - "intersection_ray_mesh", - "intersection_segment_plane", - "intersection_segment_polyline", - "intersection_segment_polyline_xy", - "intersection_segment_segment", - "intersection_segment_segment_xy", - "intersection_sphere_line", - "intersection_sphere_sphere", - "is_ccw_xy", - "is_colinear", - "is_colinear_line_line", - "is_colinear_xy", - "is_coplanar", - "is_intersection_line_line", - "is_intersection_line_line_xy", - "is_intersection_line_plane", - "is_intersection_line_triangle", - "is_intersection_plane_plane", - "is_intersection_segment_plane", - "is_intersection_segment_segment", - "is_intersection_segment_segment_xy", - "is_point_behind_plane", - "is_point_in_circle", - "is_point_in_circle_xy", - "is_point_in_convex_polygon_xy", - "is_point_in_halfspace", - "is_point_in_polygon_xy", - "is_point_in_polyhedron", - "is_point_in_triangle", - "is_point_in_triangle_xy", - "is_point_infrontof_plane", - "is_point_on_line", - "is_point_on_line_xy", - "is_point_on_plane", - "is_point_on_polyline", - "is_point_on_polyline_xy", - "is_point_on_segment", - "is_point_on_segment_xy", - "is_polygon_convex", - "is_polygon_convex_xy", - "is_polygon_in_polygon_xy", - "length_vector", - "length_vector_sqrd", - "length_vector_sqrd_xy", - "length_vector_xy", - "local_axes", - "local_to_world_coordinates", - "logarithmic_spiral_evaluate", - "matrix_determinant", - "matrix_from_axis_and_angle", - "matrix_from_axis_angle_vector", - "matrix_from_basis_vectors", - "matrix_from_change_of_basis", - "matrix_from_euler_angles", - "matrix_from_frame", - "matrix_from_frame_to_frame", - "matrix_from_orthogonal_projection", - "matrix_from_parallel_projection", - "matrix_from_perspective_entries", - "matrix_from_perspective_projection", - "matrix_from_quaternion", - "matrix_from_scale_factors", - "matrix_from_shear", - "matrix_from_shear_entries", - "matrix_from_translation", - "matrix_inverse", - "midpoint_line", - "midpoint_line_xy", - "midpoint_point_point", - "midpoint_point_point_xy", - "mirror_point_plane", - "mirror_points_line", - "mirror_points_line_xy", - "mirror_points_plane", - "mirror_points_point", - "mirror_points_point_xy", - "mirror_vector_vector", - "multiply_matrices", - "multiply_matrix_vector", - "multiply_vectors", - "multiply_vectors_xy", - "norm_vector", - "norm_vectors", - "normal_polygon", - "normal_triangle", - "normal_triangle_xy", - "normalize_vector", - "normalize_vector_xy", - "normalize_vectors", - "normalize_vectors_xy", - "offset_line", - "offset_polygon", - "offset_polyline", - "orient_points", - "orthonormalize_axes", - "orthonormalize_vectors", - "power_vector", - "power_vectors", - "project_point_line", - "project_point_line_xy", - "project_point_plane", - "project_points_line", - "project_points_line_xy", - "project_points_plane", - "quadmesh_planarize", - "quaternion_canonize", - "quaternion_conjugate", - "quaternion_from_axis_angle", - "quaternion_from_euler_angles", - "quaternion_from_matrix", - "quaternion_is_unit", - "quaternion_multiply", - "quaternion_norm", - "quaternion_unitize", - "reflect_line_plane", - "reflect_line_triangle", - "rotate_points", - "rotate_points_xy", - "scale_points", - "scale_points_xy", - "scale_vector", - "scale_vector_xy", - "scale_vectors", - "scale_vectors_xy", - "square_vector", - "square_vectors", - "subtract_vectors", - "subtract_vectors_xy", - "sum_vectors", - "tangent_points_to_circle_xy", - "transform_frames", - "transform_points", - "transform_vectors", - "translate_points", - "translate_points_xy", - "translation_from_matrix", - "transpose_matrix", - "trimesh_gaussian_curvature", - "trimesh_geodistance", - "trimesh_harmonic", - "trimesh_isolines", - "trimesh_lscm", - "trimesh_massmatrix", - "trimesh_mean_curvature", - "trimesh_principal_curvature", - "trimesh_remesh", - "trimesh_remesh_along_isoline", - "trimesh_remesh_constrained", - "trimesh_slice", - "tween_points", - "tween_points_distance", - "vector_average", - "vector_component", - "vector_component_xy", - "vector_standard_deviation", - "vector_variance", - "volume_polyhedron", - "world_to_local_coordinates" - ], - "compas.numerical": [], - "compas.plugins": [ - "IncompletePluginImplError", - "PluginManager", - "PluginNotInstalledError", - "PluginValidator", - "pluggable", - "plugin", - "plugin_manager" - ], - "compas.rpc": [ - "Dispatcher", - "Proxy", - "RPCClientError", - "RPCServerError", - "Server", - "XFunc" - ], - "compas.topology": [ - "adjacency_from_edges", - "astar_lightest_path", - "astar_shortest_path", - "breadth_first_ordering", - "breadth_first_paths", - "breadth_first_traverse", - "connected_components", - "depth_first_ordering", - "dijkstra_distances", - "dijkstra_path", - "face_adjacency", - "shortest_path", - "unify_cycles", - "vertex_coloring" - ], - "compas.utilities": [ - "Colormap", - "SSH", - "abstractclassmethod", - "abstractstaticmethod", - "await_callback", - "black", - "blue", - "color_to_colordict", - "color_to_rgb", - "cyan", - "download_file_from_remote", - "flatten", - "geometric_key", - "geometric_key_xy", - "gif_from_images", - "green", - "grouper", - "hex_to_rgb", - "i_to_black", - "i_to_blue", - "i_to_green", - "i_to_red", - "i_to_rgb", - "i_to_white", - "is_color_hex", - "is_color_light", - "is_color_rgb", - "iterable_like", - "linspace", - "memoize", - "meshgrid", - "normalize_values", - "now", - "pairwise", - "print_profile", - "red", - "remap_values", - "reverse_geometric_key", - "rgb_to_hex", - "rgb_to_rgb", - "timestamp", - "white", - "window", - "yellow" - ] - } -} diff --git a/tests/compas/data/test_dataschema.py b/tests/compas/data/test_dataschema.py deleted file mode 100644 index 12e05bb7c351..000000000000 --- a/tests/compas/data/test_dataschema.py +++ /dev/null @@ -1,853 +0,0 @@ -import pytest -import compas - -from compas.geometry import Point -from compas.geometry import Vector -from compas.geometry import Line -from compas.geometry import Plane -from compas.geometry import Circle -from compas.geometry import Ellipse -from compas.geometry import Frame -from compas.geometry import Quaternion -from compas.geometry import Polygon -from compas.geometry import Polyline -from compas.geometry import Box -from compas.geometry import Capsule -from compas.geometry import Cone -from compas.geometry import Cylinder -from compas.geometry import Polyhedron -from compas.geometry import Sphere -from compas.geometry import Torus -from compas.geometry import Pointcloud - -from compas.datastructures import Graph -from compas.datastructures import Mesh - -if not compas.IPY: - import jsonschema.exceptions - - @pytest.mark.parametrize( - "point", - [ - [0, 0, 0], - [0.0, 0, 0], - [0.0, 0.0, 0.0], - ], - ) - def test_schema_point_valid(point): - Point.validate_data(point) - - @pytest.mark.parametrize( - "point", - [ - [0, 0], - [0, 0, 0, 0], - [0, 0, "0"], - ], - ) - def test_schema_point_invalid(point): - with pytest.raises(jsonschema.exceptions.ValidationError): - Point.validate_data(point) - - @pytest.mark.parametrize( - "vector", - [ - [0, 0, 0], - [0.0, 0, 0], - [0.0, 0.0, 0.0], - ], - ) - def test_schema_vector_valid(vector): - Vector.validate_data(vector) - - @pytest.mark.parametrize( - "vector", - [ - [0, 0], - [0, 0, 0, 0], - [0, 0, "0"], - ], - ) - def test_schema_vector_invalid(vector): - with pytest.raises(jsonschema.exceptions.ValidationError): - Vector.validate_data(vector) - - @pytest.mark.parametrize( - "line", - [ - {"start": [0, 0, 0], "end": [0, 0, 0]}, - {"start": [0, 0, 0], "end": [0, 0, 0], "extra": 0}, - ], - ) - def test_schema_line_valid(line): - Line.validate_data(line) - - @pytest.mark.parametrize( - "line", - [ - [[0, 0, 0], [0, 0, 0]], - {"START": [0, 0, 0], "END": [0, 0, 0]}, - ], - ) - def test_schema_line_invalid(line): - with pytest.raises(jsonschema.exceptions.ValidationError): - Line.validate_data(line) - - @pytest.mark.parametrize( - "plane", - [ - {"point": [0, 0, 0], "normal": [0, 0, 1]}, - ], - ) - def test_schema_plane_valid(plane): - Plane.validate_data(plane) - - @pytest.mark.parametrize( - "plane", - [ - [[0, 0, 0], [0, 0, 1]], - {"POINT": [0, 0, 0], "NORMAL": [0, 0, 1]}, - ], - ) - def test_schema_plane_invalid(plane): - with pytest.raises(jsonschema.exceptions.ValidationError): - Plane.validate_data(plane) - - @pytest.mark.parametrize( - "circle", - [ - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "radius": 0.0}, - ], - ) - def test_schema_circle_valid(circle): - Circle.validate_data(circle) - - @pytest.mark.parametrize( - "circle", - [ - {"plane": {"point": [0, 0, 0], "normal": [0, 0, 1]}, "radius": 0.0}, - {"plane": [[0, 0, 0], [0, 0, 1]], "radius": 1.0}, - {"PLANE": {"point": [0, 0, 0], "normal": [0, 0, 1]}, "RADIUS": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0]}, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "yaxis": [0, 1, 0]}, "radius": 1.0}, - {"frame": {"xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "radius": 1.0}, - {"FRAME": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "radius": -1.0}, - ], - ) - def test_schema_circle_invalid(circle): - with pytest.raises(jsonschema.exceptions.ValidationError): - Circle.validate_data(circle) - - @pytest.mark.parametrize( - "ellipse", - [ - { - "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "major": 1.0, - "minor": 0.5, - }, - { - "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "major": 1.0, - "minor": 0.0, - }, - { - "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "major": 0.0, - "minor": 0.5, - }, - { - "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "major": 0.0, - "minor": 0.0, - }, - ], - ) - def test_schema_ellipse_valid(ellipse): - Ellipse.validate_data(ellipse) - - @pytest.mark.parametrize( - "ellipse", - [ - { - "frame": {"point": [0, 0, 0], "yaxis": [0, 1, 0]}, - "major": 1.0, - "minor": 1.0, - }, - { - "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0]}, - "major": 1.0, - "minor": 1.0, - }, - { - "frame": {"xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "major": 1.0, - "minor": 1.0, - }, - { - "frame": [[0, 0, 0], [1, 0, 0], [0, 1, 0]], - "major": 1.0, - "minor": 0.5, - }, - { - "FRAME": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "MAJOR": 1.0, - "MINOR": 0.5, - }, - { - "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "major": 1.0, - "minor": -1.0, - }, - { - "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "major": -1.0, - "minor": 1.0, - }, - ], - ) - def test_schema_ellipse_invalid(ellipse): - with pytest.raises(jsonschema.exceptions.ValidationError): - Ellipse.validate_data(ellipse) - - @pytest.mark.parametrize( - "frame", - [ - {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - {"point": [0, 0, 0], "yaxis": [0, 1, 0], "xaxis": [1, 0, 0]}, - { - "point": [0, 0, 0], - "xaxis": [1, 0, 0], - "yaxis": [0, 1, 0], - "zaxis": [0, 0, 1], - }, - { - "point": [0, 0, 0], - "xaxis": [1, 0, 0], - "zaxis": [0, 0, 1], - "yaxis": [0, 1, 0], - }, - ], - ) - def test_schema_frame_valid(frame): - Frame.validate_data(frame) - - @pytest.mark.parametrize( - "frame", - [ - {"point": [0, 0, 0], "yaxis": [0, 1, 0], "zaxis": [0, 0, 1]}, - {"point": [0, 0, 0], "xaxis": [1, 0, 0], "zaxis": [0, 0, 1]}, - ], - ) - def test_schema_frame_invalid(frame): - with pytest.raises(jsonschema.exceptions.ValidationError): - Frame.validate_data(frame) - - @pytest.mark.parametrize( - "quaternion", - [ - {"x": 0, "y": 0, "z": 0, "w": 0}, - {"w": 0, "x": 0, "y": 0, "z": 0}, - {"x": 0, "z": 0, "y": 0, "w": 0}, - ], - ) - def test_schema_quaternion_valid(quaternion): - Quaternion.validate_data(quaternion) - - @pytest.mark.parametrize( - "quaternion", - [ - {"x": 0, "y": 0, "z": 0}, - {"x": 0, "y": 0, "w": 0}, - {"y": 0, "z": 0, "w": 0}, - {"X": 0, "Y": 0, "Z": 0, "W": 0}, - ], - ) - def test_schema_quaternion_invalid(quaternion): - with pytest.raises(jsonschema.exceptions.ValidationError): - Quaternion.validate_data(quaternion) - - @pytest.mark.parametrize( - "polygon", - [ - {"points": [[0, 0, 0], [1, 0, 0]]}, - {"points": [[0, 0, 0], [1, 0, 0], [1, 1, 0]]}, - ], - ) - def test_schema_polygon_valid(polygon): - Polygon.validate_data(polygon) - - @pytest.mark.parametrize( - "polygon", - [ - {"points": [[0, 0, 0]]}, - {"points": [0, 0, 0]}, - {"POINTS": [[0, 0, 0], [1, 0, 0]]}, - ], - ) - def test_schema_polygon_invalid(polygon): - with pytest.raises(jsonschema.exceptions.ValidationError): - Polygon.validate_data(polygon) - - @pytest.mark.parametrize( - "polyline", - [ - {"points": [[0, 0, 0], [1, 0, 0]]}, - {"points": [[0, 0, 0], [1, 0, 0], [1, 1, 0]]}, - ], - ) - def test_schema_polyline_valid(polyline): - Polyline.validate_data(polyline) - - @pytest.mark.parametrize( - "polyline", - [ - {"points": [[0, 0, 0]]}, - {"points": [0, 0, 0]}, - {"POINTS": [[0, 0, 0], [1, 0, 0]]}, - ], - ) - def test_schema_polyline_invalid(polyline): - with pytest.raises(jsonschema.exceptions.ValidationError): - Polyline.validate_data(polyline) - - @pytest.mark.parametrize( - "box", - [ - { - "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "xsize": 1, - "ysize": 1, - "zsize": 1, - } - ], - ) - def test_schema_box_valid(box): - Box.validate_data(box) - - @pytest.mark.parametrize( - "box", - [ - # { - # "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - # "xsize": 0, - # "ysize": 1, - # "zsize": 1, - # }, - # { - # "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - # "xsize": 1, - # "ysize": 1, - # "zsize": 0, - # }, - # { - # "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - # "xsize": 1, - # "ysize": 0, - # "zsize": 1, - # }, - { - "FRAME": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "XSIZE": 1, - "YSIZE": 1, - "ZSIZE": 1, - }, - ], - ) - def test_schema_box_invalid(box): - with pytest.raises(jsonschema.exceptions.ValidationError): - Box.validate_data(box) - - @pytest.mark.parametrize( - "capsule", - [ - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 1.0, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 0.0, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 1.0, "radius": 0.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 0.0, "radius": 0.0}, - ], - ) - def test_schema_capsule_valid(capsule): - Capsule.validate_data(capsule) - - @pytest.mark.parametrize( - "capsule", - [ - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": -1.0, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 1.0, "radius": -1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": -1.0, "radius": -1.0}, - {"FRAME": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 1.0, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "HEIGHT": 1.0, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 1.0, "RADIUS": 1.0}, - ], - ) - def test_schema_capsule_invalid(capsule): - with pytest.raises(jsonschema.exceptions.ValidationError): - Capsule.validate_data(capsule) - - @pytest.mark.parametrize( - "cone", - [ - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 1.0, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 0.0, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 1.0, "radius": 0.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 0.0, "radius": 0.0}, - ], - ) - def test_schema_cone_valid(cone): - Cone.validate_data(cone) - - @pytest.mark.parametrize( - "cone", - [ - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": -1.0, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 1.0, "radius": -1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": -1.0, "radius": -1.0}, - {"FRAME": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 1.0, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "HEIGHT": 1.0, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 1.0, "RADIUS": 1.0}, - ], - ) - def test_schema_cone_invalid(cone): - with pytest.raises(jsonschema.exceptions.ValidationError): - Cone.validate_data(cone) - - @pytest.mark.parametrize( - "cylinder", - [ - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 1.0, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 0.0, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 1.0, "radius": 0.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 0.0, "radius": 0.0}, - ], - ) - def test_schema_cylinder_valid(cylinder): - Cylinder.validate_data(cylinder) - - @pytest.mark.parametrize( - "cylinder", - [ - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": -1.0, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 1.0, "radius": -1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": -1.0, "radius": -1.0}, - {"FRAME": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 1.0, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "HEIGHT": 1.0, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "height": 1.0, "RADIUS": 1.0}, - ], - ) - def test_schema_cylinder_invalid(cylinder): - with pytest.raises(jsonschema.exceptions.ValidationError): - Cylinder.validate_data(cylinder) - - @pytest.mark.parametrize( - "polyhedron", - [ - { - "vertices": [[0, 0, 0], [1, 0, 0], [1, 1, 0], [1, 0, 1]], - "faces": [[0, 1, 3], [1, 2, 3], [2, 0, 3], [0, 2, 1]], - }, - ], - ) - def test_schema_polyhedron_valid(polyhedron): - Polyhedron.validate_data(polyhedron) - - @pytest.mark.parametrize( - "polyhedron", - [ - { - "vertices": [[0, 0, 0], [1, 0, 0], [1, 1, 0], [1, 0, 1]], - "faces": [[0, 1, 3], [1, 2, 3], [2, 0, 3]], - }, - { - "vertices": [[0, 0, 0], [1, 0, 0], [1, 1, 0]], - "faces": [[0, 1, 3], [1, 2, 3], [2, 0, 3], [0, 2, 1]], - }, - {"vertices": [[0, 0, 0], [1, 0, 0], [1, 1, 0], [1, 0, 1]], "faces": []}, - {"vertices": [], "faces": [[0, 1, 3], [1, 2, 3], [2, 0, 3], [0, 2, 1]]}, - {"vertices": [], "faces": []}, - ], - ) - def test_schema_polyhedron_invalid(polyhedron): - with pytest.raises(jsonschema.exceptions.ValidationError): - Polyhedron.validate_data(polyhedron) - - @pytest.mark.parametrize( - "sphere", - [ - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "radius": 0.0}, - ], - ) - def test_schema_sphere_valid(sphere): - Sphere.validate_data(sphere) - - @pytest.mark.parametrize( - "sphere", - [ - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "radius": -1.0}, - {"FRAME": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "radius": 1.0}, - {"frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, "RADIUS": 1.0}, - ], - ) - def test_schema_sphere_invalid(sphere): - with pytest.raises(jsonschema.exceptions.ValidationError): - Sphere.validate_data(sphere) - - @pytest.mark.parametrize( - "torus", - [ - { - "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "radius_axis": 1.0, - "radius_pipe": 1.0, - }, - { - "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "radius_axis": 0.0, - "radius_pipe": 1.0, - }, - { - "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "radius_axis": 1.0, - "radius_pipe": 0.0, - }, - { - "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "radius_axis": 0.0, - "radius_pipe": 0.0, - }, - ], - ) - def test_schema_torus_valid(torus): - Torus.validate_data(torus) - - @pytest.mark.parametrize( - "torus", - [ - { - "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "radius_axis": -1.0, - "radius_pipe": 1.0, - }, - { - "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "radius_axis": 1.0, - "radius_pipe": -1.0, - }, - { - "frame": {"point": [0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "radius_axis": 1.0, - "radius_pipe": 1.0, - }, - { - "frame": {"point": [0, 0, 0], "yaxis": [0, 1, 0]}, - "radius_axis": 1.0, - "radius_pipe": 1.0, - }, - { - "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0]}, - "radius_axis": 1.0, - "radius_pipe": 1.0, - }, - { - "FRAME": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "radius_axis": 1.0, - "radius_pipe": 1.0, - }, - { - "frame": {"point": [0, 0, 0], "xaxis": [1, 0, 0], "yaxis": [0, 1, 0]}, - "radius": 1.0, - "radius_pipe": 1.0, - }, - ], - ) - def test_schema_torus_invalid(torus): - with pytest.raises(jsonschema.exceptions.ValidationError): - Torus.validate_data(torus) - - @pytest.mark.parametrize( - "pointcloud", - [ - {"points": [[0, 0, 0]]}, - {"points": [[0, 0, 0], [0, 0, 1]]}, - {"points": [[0, 0, 0], [0, 0, 0]]}, - ], - ) - def test_schema_pointcloud_valid(pointcloud): - Pointcloud.validate_data(pointcloud) - - @pytest.mark.parametrize( - "pointcloud", - [ - {"points": []}, - {"points": [0, 0, 0]}, - {"points": [["0", 0, 0]]}, - {"POINTS": []}, - ], - ) - def test_schema_pointcloud_invalid(pointcloud): - with pytest.raises(jsonschema.exceptions.ValidationError): - Pointcloud.validate_data(pointcloud) - - @pytest.mark.parametrize( - "graph", - [ - { - "attributes": {}, - "default_node_attributes": {}, - "default_edge_attributes": {}, - "node": {}, - "edge": {}, - "max_node": -1, - }, - { - "attributes": {}, - "default_node_attributes": {}, - "default_edge_attributes": {}, - "node": {}, - "edge": {}, - "max_node": 0, - }, - { - "attributes": {}, - "default_node_attributes": {}, - "default_edge_attributes": {}, - "node": {}, - "edge": {}, - "max_node": 1000, - }, - ], - ) - def test_schema_graph_valid(graph): - Graph.validate_data(graph) - - @pytest.mark.parametrize( - "graph", - [ - { - "default_node_attributes": {}, - "default_edge_attributes": {}, - "node": {}, - "edge": {}, - "max_node": -2, - }, - { - "default_edge_attributes": {}, - "node": {}, - "edge": {}, - "max_node": -1, - }, - { - "default_node_attributes": {}, - "node": {}, - "edge": {}, - "max_node": -1, - }, - { - "default_node_attributes": {}, - "default_edge_attributes": {}, - "edge": {}, - "max_node": -1, - }, - { - "default_node_attributes": {}, - "default_edge_attributes": {}, - "node": {}, - "max_node": -1, - }, - { - "default_node_attributes": {}, - "default_edge_attributes": {}, - "node": {}, - "edge": {}, - }, - ], - ) - def test_schema_graph_invalid(graph): - with pytest.raises(jsonschema.exceptions.ValidationError): - Graph.validate_data(graph) - - @pytest.mark.parametrize( - "mesh", - [ - { - "attributes": {}, - "default_vertex_attributes": {}, - "default_edge_attributes": {}, - "default_face_attributes": {}, - "vertex": {}, - "face": {}, - "facedata": {}, - "edgedata": {}, - "max_vertex": -1, - "max_face": -1, - }, - { - "attributes": {}, - "default_vertex_attributes": {}, - "default_edge_attributes": {}, - "default_face_attributes": {}, - "vertex": {}, - "face": {}, - "facedata": {}, - "edgedata": {}, - "max_vertex": -1, - "max_face": 0, - }, - { - "attributes": {}, - "default_vertex_attributes": {}, - "default_edge_attributes": {}, - "default_face_attributes": {}, - "vertex": {}, - "face": {}, - "facedata": {}, - "edgedata": {}, - "max_vertex": 0, - "max_face": -1, - }, - { - "attributes": {}, - "default_vertex_attributes": {}, - "default_edge_attributes": {}, - "default_face_attributes": {}, - "vertex": {}, - "face": {}, - "facedata": {}, - "edgedata": {}, - "max_vertex": -1, - "max_face": 1000, - }, - { - "attributes": {}, - "default_vertex_attributes": {}, - "default_edge_attributes": {}, - "default_face_attributes": {}, - "vertex": {}, - "face": {}, - "facedata": {}, - "edgedata": {}, - "max_vertex": 1000, - "max_face": -1, - }, - { - "attributes": {}, - "default_vertex_attributes": {}, - "default_edge_attributes": {}, - "default_face_attributes": {}, - "vertex": {"0": {}, "1": {}, "2": {}}, - "face": {"0": [0, 1, 2]}, - "facedata": {}, - "edgedata": {}, - "max_vertex": -1, - "max_face": -1, - }, - { - "attributes": {}, - "default_vertex_attributes": {}, - "default_edge_attributes": {}, - "default_face_attributes": {}, - "vertex": {"0": {}, "1": {}, "2": {}}, - "face": {"0": [0, 1, 2]}, - "facedata": {"0": {}}, - "edgedata": {"(0, 1)": {}}, - "max_vertex": -1, - "max_face": -1, - }, - ], - ) - def test_schema_mesh_valid(mesh): - Mesh.validate_data(mesh) - - @pytest.mark.parametrize( - "mesh", - [ - { - "attributes": {}, - "default_vertex_attributes": {}, - "default_edge_attributes": {}, - "default_face_attributes": {}, - "vertex": {}, - "face": {}, - "facedata": {}, - "edgedata": {}, - "max_vertex": -1, - "max_face": -2, - }, - { - "attributes": {}, - "default_vertex_attributes": {}, - "default_edge_attributes": {}, - "default_face_attributes": {}, - "vertex": {}, - "face": {}, - "facedata": {}, - "edgedata": {}, - "max_vertex": -2, - "max_face": -1, - }, - { - "attributes": {}, - "default_vertex_attributes": {}, - "default_edge_attributes": {}, - "default_face_attributes": {}, - "vertex": {"0": {}, "1": {}, "2": {}}, - "face": {"0": [0, 1]}, - "facedata": {}, - "edgedata": {}, - "max_vertex": -1, - "max_face": -1, - }, - { - "attributes": {}, - "default_vertex_attributes": {}, - "default_edge_attributes": {}, - "default_face_attributes": {}, - "vertex": {"0": {}, "1": {}, "2": {}}, - "face": {"0": [0, 1, 2]}, - "facedata": {"0": {}}, - "edgedata": {"0": {}}, - "max_vertex": -1, - "max_face": -1, - }, - ], - ) - def test_schema_mesh_invalid(mesh): - with pytest.raises(jsonschema.exceptions.ValidationError): - Mesh.validate_data(mesh) - - @pytest.mark.parametrize( - "mesh", - [ - { - "attributes": {}, - "default_vertex_attributes": {}, - "default_edge_attributes": {}, - "default_face_attributes": {}, - "vertex": {0: {}, "1": {}, "2": {}}, - "face": {"0": [0, 1, 2]}, - "facedata": {}, - "edgedata": {}, - "max_vertex": -1, - "max_face": -1, - }, - { - "attributes": {}, - "default_vertex_attributes": {}, - "default_edge_attributes": {}, - "default_face_attributes": {}, - "vertex": {"0": {}, "1": {}, "2": {}}, - "face": {0: [0, 1, 2]}, - "facedata": {}, - "edgedata": {}, - "max_vertex": -1, - "max_face": -1, - }, - ], - ) - def test_schema_mesh_failing(mesh): - with pytest.raises(TypeError): - Mesh.validate_data(mesh) diff --git a/tests/compas/data/test_schema.py b/tests/compas/data/test_schema.py deleted file mode 100644 index d6719a75f785..000000000000 --- a/tests/compas/data/test_schema.py +++ /dev/null @@ -1,37 +0,0 @@ -import compas -from compas.data import Data -from compas.data import compas_dataclasses -from compas.data import dataclass_dataschema -from compas.data import dataclass_typeschema -from compas.data import dataclass_jsonschema - - -def test_schema_dataclasses(): - for cls in compas_dataclasses(): - assert issubclass(cls, Data) - - -def test_schema_dataclasses_typeschema(): - for cls in compas_dataclasses(): - __dtype__ = dataclass_typeschema(cls) - modefault_node_attributesme, clsname = __dtype__["const"].split("/") # type: ignore - assert cls.__name__ == clsname - # module = __import__(modefault_node_attributesme, fromlist=[clsname]) - # assert hasattr(module, clsname) - # assert getattr(module, clsname) == cls - - -def test_schema_dataclasses_dataschema(): - for cls in compas_dataclasses(): - assert dataclass_dataschema(cls) == cls.DATASCHEMA - - -def test_schema_dataclasses_jsonschema(): - for cls in compas_dataclasses(): - schema = dataclass_jsonschema(cls) - assert schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" - assert schema["$id"] == "{}.json".format(cls.__name__) - assert schema["$compas"] == "{}".format(compas.__version__) - assert schema["type"] == "object" - assert schema["properties"]["dtype"] == dataclass_typeschema(cls) - assert schema["properties"]["data"] == dataclass_dataschema(cls) diff --git a/tests/compas/data/test_validators.py b/tests/compas/data/test_validators.py index 0c10fd4ef730..1f11acd68448 100644 --- a/tests/compas/data/test_validators.py +++ b/tests/compas/data/test_validators.py @@ -1,135 +1,130 @@ -# import compas -# import pytest - -# from compas.data import is_sequence_of_int -# from compas.data import is_sequence_of_uint -# from compas.data import is_sequence_of_float - -# from compas.data import is_int3 -# from compas.data import is_float3 -# from compas.data import is_float4x4 - - -# @pytest.mark.parametrize( -# "sequence,result", -# [ -# (range(10), True), -# (range(+10, -10, -1), True), -# (list(range(10)), True), -# (list(range(+10, -10, -1)), True), -# ([1, 2, 3, 4.0], False), -# ([1, 2, "3"], False), -# ([], True), -# ], -# ) -# def test_is_sequence_of_int(sequence, result): -# assert is_sequence_of_int(sequence) is result - - -# @pytest.mark.parametrize( -# "sequence,result", -# [ -# (range(10), True), -# (range(0, -10, -1), False), -# (range(+10, -10, -1), False), -# ([1, 2, 3.0], False), -# ([1, 2, "3"], False), -# ([], True), -# ], -# ) -# def test_is_sequence_of_uint(sequence, result): -# assert is_sequence_of_uint(sequence) is result - - -# @pytest.mark.parametrize( -# "sequence,result", -# [ -# (range(10), False), -# (range(+10, -10, -1), False), -# ([1, 2, 3.0], False), -# ([1, 2, "3"], False), -# ([], True), -# (map(float, range(10)), True), -# (map(float, range(+10, -10, -1)), True), -# ], -# ) -# def test_is_sequence_of_float(sequence, result): -# assert is_sequence_of_float(sequence) is result - - -# @pytest.mark.parametrize( -# "sequence,result", -# [ -# (range(3), True), -# (range(+1, -2, -1), True), -# (range(4), False), -# (range(2), False), -# ([1, 2, 3.0], False), -# ([1, 2, "3"], False), -# ([], False), -# ], -# ) -# def test_is_int3(sequence, result): -# assert is_int3(sequence) is result - - -# @pytest.mark.parametrize( -# "sequence,result", -# [ -# (list(map(float, range(3))), True), -# (list(map(float, range(+1, -2, -1))), True), -# (list(map(float, range(4))), False), -# (list(map(float, range(2))), False), -# ([1, 2, 3.0], False), -# ([1, 2, "3"], False), -# ([], False), -# ], -# ) -# def test_is_float3(sequence, result): -# assert is_float3(sequence) is result - - -# if compas.PY3: - -# @pytest.mark.parametrize( -# "sequence,result", -# [ -# (map(float, range(3)), False), -# (map(float, range(+1, -2, -1)), False), -# ], -# ) -# def test_is_float3_invalid(sequence, result): -# with pytest.raises(TypeError): -# assert is_float3(sequence) is result - - -# @pytest.mark.parametrize( -# "sequence,result", -# [ -# ([list(map(float, range(4))) for _ in range(4)], True), -# ([list(map(float, range(+2, -2, -1))) for _ in range(4)], True), -# ([list(map(float, range(5))) for _ in range(4)], False), -# ([list(map(float, range(3))) for _ in range(4)], False), -# ([list(map(float, range(4))) for _ in range(5)], False), -# ([list(map(float, range(4))) for _ in range(3)], False), -# ([[1, 2, 3.0, 4.0] for _ in range(4)], False), -# ([[1, 2, "3", 4.0] for _ in range(4)], False), -# ([], False), -# ], -# ) -# def test_is_float4x4(sequence, result): -# assert is_float4x4(sequence) is result - - -# if compas.PY3: - -# @pytest.mark.parametrize( -# "sequence,result", -# [ -# ([map(float, range(4)) for _ in range(4)], True), -# ([map(float, range(+2, -2, -1)) for _ in range(4)], True), -# ], -# ) -# def test_is_float4x4_invalid(sequence, result): -# with pytest.raises(TypeError): -# assert is_float4x4(sequence) is result +import pytest + +from compas.data.validators import is_sequence_of_int +from compas.data.validators import is_sequence_of_uint +from compas.data.validators import is_sequence_of_float + +from compas.data.validators import is_int3 +from compas.data.validators import is_float3 +from compas.data.validators import is_float4x4 + + +@pytest.mark.parametrize( + "sequence,result", + [ + (range(10), True), + (range(+10, -10, -1), True), + (list(range(10)), True), + (list(range(+10, -10, -1)), True), + ([1, 2, 3, 4.0], False), + ([1, 2, "3"], False), + ([], True), + ], +) +def test_is_sequence_of_int(sequence, result): + assert is_sequence_of_int(sequence) is result + + +@pytest.mark.parametrize( + "sequence,result", + [ + (range(10), True), + (range(0, -10, -1), False), + (range(+10, -10, -1), False), + ([1, 2, 3.0], False), + ([1, 2, "3"], False), + ([], True), + ], +) +def test_is_sequence_of_uint(sequence, result): + assert is_sequence_of_uint(sequence) is result + + +@pytest.mark.parametrize( + "sequence,result", + [ + (range(10), False), + (range(+10, -10, -1), False), + ([1, 2, 3.0], False), + ([1, 2, "3"], False), + ([], True), + (map(float, range(10)), True), + (map(float, range(+10, -10, -1)), True), + ], +) +def test_is_sequence_of_float(sequence, result): + assert is_sequence_of_float(sequence) is result + + +@pytest.mark.parametrize( + "sequence,result", + [ + (range(3), True), + (range(+1, -2, -1), True), + (range(4), False), + (range(2), False), + ([1, 2, 3.0], False), + ([1, 2, "3"], False), + ([], False), + ], +) +def test_is_int3(sequence, result): + assert is_int3(sequence) is result + + +@pytest.mark.parametrize( + "sequence,result", + [ + (list(map(float, range(3))), True), + (list(map(float, range(+1, -2, -1))), True), + (list(map(float, range(4))), False), + (list(map(float, range(2))), False), + ([1, 2, 3.0], False), + ([1, 2, "3"], False), + ([], False), + ], +) +def test_is_float3(sequence, result): + assert is_float3(sequence) is result + + +@pytest.mark.parametrize( + "sequence,result", + [ + (map(float, range(3)), False), + (map(float, range(+1, -2, -1)), False), + ], +) +def test_is_float3_invalid(sequence, result): + with pytest.raises(TypeError): + assert is_float3(sequence) is result + + +@pytest.mark.parametrize( + "sequence,result", + [ + ([list(map(float, range(4))) for _ in range(4)], True), + ([list(map(float, range(+2, -2, -1))) for _ in range(4)], True), + ([list(map(float, range(5))) for _ in range(4)], False), + ([list(map(float, range(3))) for _ in range(4)], False), + ([list(map(float, range(4))) for _ in range(5)], False), + ([list(map(float, range(4))) for _ in range(3)], False), + ([[1, 2, 3.0, 4.0] for _ in range(4)], False), + ([[1, 2, "3", 4.0] for _ in range(4)], False), + ([], False), + ], +) +def test_is_float4x4(sequence, result): + assert is_float4x4(sequence) is result + + +@pytest.mark.parametrize( + "sequence,result", + [ + ([map(float, range(4)) for _ in range(4)], True), + ([map(float, range(+2, -2, -1)) for _ in range(4)], True), + ], +) +def test_is_float4x4_invalid(sequence, result): + with pytest.raises(TypeError): + assert is_float4x4(sequence) is result diff --git a/tests/compas/datastructures/test_assembly.py b/tests/compas/datastructures/test_assembly.py deleted file mode 100644 index df9755ca54c8..000000000000 --- a/tests/compas/datastructures/test_assembly.py +++ /dev/null @@ -1,123 +0,0 @@ -import pytest - -from compas.data import json_dumps -from compas.data import json_loads -from compas.datastructures import Assembly -from compas.datastructures import AssemblyError -from compas.datastructures import Part - - -def test_init(): - assembly = Assembly(name="abc") - assert assembly.name == "abc" - - # assembly = Assembly(attr1="value", attr2=3.14) - # assert assembly.attributes["attr1"] == "value" - # assert assembly.attributes["attr2"] == 3.14 - - -def test_add_parts(): - assembly = Assembly() - - for _ in range(3): - assembly.add_part(Part()) - - assert len(list(assembly.parts())) == 3 - - -def test_delete_part(): - assembly = Assembly() - part1 = Part() - part2 = Part() - part3 = Part() - - assembly.add_part(part1) - assembly.add_part(part2) - assembly.add_part(part3) - - assert len(list(assembly.parts())) == 3 - assembly.delete_part(part2) - assert len(list(assembly.parts())) == 2 - - -def test_add_duplicate_parts(): - assembly = Assembly() - - part = Part() - assembly.add_part(part) - - with pytest.raises(AssemblyError): - assembly.add_part(part) - - -def test_add_connections(): - assembly = Assembly() - parts = [Part() for i in range(3)] - - for part in parts: - assembly.add_part(part) - - assembly.add_connection(parts[0], parts[1]) - assembly.add_connection(parts[1], parts[2]) - assembly.add_connection(parts[2], parts[0]) - - assert list(assembly.connections()) == [(0, 1), (1, 2), (2, 0)] - - -def test_delete_connection(): - assembly = Assembly() - parts = [Part() for i in range(3)] - - for part in parts: - assembly.add_part(part) - - assembly.add_connection(parts[0], parts[1]) - assembly.add_connection(parts[1], parts[2]) - assembly.add_connection(parts[2], parts[0]) - - assert list(assembly.connections()) == [(0, 1), (1, 2), (2, 0)] - assembly.delete_connection((parts[0].key, parts[1].key)) - assert list(assembly.connections()) == [(1, 2), (2, 0)] - - -def test_find(): - assembly = Assembly() - part = Part() - assert assembly.find(part.guid) is None - - assembly.add_part(part) - assert assembly.find(part.guid) == part - - -def test_find_by_key(): - assembly = Assembly() - part = Part() - assembly.add_part(part, key=2) - assert assembly.find_by_key(2) == part - - part = Part() - assembly.add_part(part, key="6") - assert assembly.find_by_key("6") == part - - assert assembly.find_by_key("100") is None - - -def test_find_by_key_after__from_data__(): - assembly = Assembly() - part = Part() - assembly.add_part(part, key=2) - assembly = Assembly.__from_data__(assembly.__data__) - assert assembly.find_by_key(2) == part - - -def test_find_by_key_after_deserialization(): - assembly = Assembly() - part = Part(name="test_part") - assembly.add_part(part, key=2) - assembly = json_loads(json_dumps(assembly)) - - deserialized_part = assembly.find_by_key(2) - assert deserialized_part.name == part.name - assert deserialized_part.key == part.key - assert deserialized_part.guid == part.guid - assert deserialized_part.attributes == part.attributes diff --git a/tests/compas/datastructures/test_attributes.py b/tests/compas/datastructures/test_attributes.py new file mode 100644 index 000000000000..ef1f2f826cdc --- /dev/null +++ b/tests/compas/datastructures/test_attributes.py @@ -0,0 +1,58 @@ +import pytest + +from compas.datastructures.attributes import AttributeView +from compas.datastructures.attributes import CellAttributeView +from compas.datastructures.attributes import EdgeAttributeView +from compas.datastructures.attributes import FaceAttributeView +from compas.datastructures.attributes import NodeAttributeView +from compas.datastructures.attributes import VertexAttributeView + + +def test_attribute_view_combines_default_and_custom_attributes(): + defaults = {"color": "red", "size": 1} + custom = {"color": "blue", "name": "custom"} + view = AttributeView(defaults, custom) + + assert len(view) == 3 + assert dict(view) == {"color": "blue", "size": 1, "name": "custom"} + assert view["color"] == "blue" + assert view["size"] == 1 + + +def test_attribute_view_mutates_only_custom_attributes(): + defaults = {"color": "red"} + custom = {"color": "blue"} + view = AttributeView(defaults, custom) + + view["size"] = 1 + del view["color"] + + assert custom == {"size": 1} + assert view["color"] == "red" + with pytest.raises(KeyError, match="missing"): + view["missing"] + + +def test_custom_only_attribute_view_has_consistent_length(): + view = AttributeView({"color": "red", "size": 1}, {"name": "custom"}, custom_only=True) + + assert list(view) == ["name"] + assert len(view) == 1 + assert dict(view) == {"name": "custom"} + assert "color" not in view + with pytest.raises(KeyError, match="color"): + view["color"] + + +@pytest.mark.parametrize( + "view_type", + [NodeAttributeView, VertexAttributeView, EdgeAttributeView, FaceAttributeView, CellAttributeView], +) +def test_specific_attribute_views_inherit_behavior(view_type): + custom = {} + view = view_type({"default": True}, custom) + + view["custom"] = True + + assert dict(view) == {"default": True, "custom": True} + assert custom == {"custom": True} diff --git a/tests/compas/datastructures/test_cell_network.py b/tests/compas/datastructures/test_cell_network.py index 2f593f0c94d8..babdbf944980 100644 --- a/tests/compas/datastructures/test_cell_network.py +++ b/tests/compas/datastructures/test_cell_network.py @@ -66,6 +66,7 @@ def test_cell_network_data(example_cell_network): other = CellNetwork.__from_data__(ds.__data__) + assert other.__data__ == ds.__data__ assert other.number_of_vertices() is nv assert other.number_of_edges() is ne assert other.number_of_faces() is nf @@ -76,13 +77,273 @@ def test_cell_network_data(example_cell_network): assert other.face_attribute(11, "canopy") is True +def test_cell_network_data_preserves_subclass(example_cell_network): + class CustomCellNetwork(CellNetwork): + pass + + other = CustomCellNetwork.__from_data__(example_cell_network.__data__) + + assert type(other) is CustomCellNetwork + assert other.__data__ == example_cell_network.__data__ + + +def test_cell_network_clear_resets_all_data(example_cell_network): + network = example_cell_network + assert network._edge_data + + network.clear() + + assert network.number_of_vertices() == 0 + assert network.number_of_edges() == 0 + assert network.number_of_faces() == 0 + assert network.number_of_cells() == 0 + assert not network._edge_data + assert network.__data__["max_vertex"] == -1 + assert network.__data__["max_face"] == -1 + assert network.__data__["max_cell"] == -1 + + +def test_cell_network_samples_and_vertex_maps(example_cell_network): + network = example_cell_network + + assert len(network.vertex_sample(2)) == 2 + assert len(network.edge_sample(2)) == 2 + assert len(network.face_sample(2)) == 2 + assert len(network.cell_sample(2)) == 2 + assert network.vertex_index() == {vertex: index for index, vertex in network.index_vertex().items()} + assert set(network.vertex_gkey()) == set(network.vertices()) + assert set(network.gkey_vertex().values()) == set(network.vertices()) + + +def test_cell_network_builders_do_not_mutate_input_attributes(): + network = CellNetwork() + vertex_attributes = {"name": "vertex"} + edge_attributes = {"name": "edge"} + face_attributes = {"name": "face"} + for vertex in range(3): + network.add_vertex(vertex, attr_dict=vertex_attributes if vertex == 0 else None) + + network.add_edge(0, 1, attr_dict=edge_attributes, color="red") + network.add_face([0, 1, 2], attr_dict=face_attributes, color="blue") + + assert vertex_attributes == {"name": "vertex"} + assert edge_attributes == {"name": "edge"} + assert face_attributes == {"name": "face"} + + +def test_add_face_rejects_missing_vertices_without_modifying_network(): + network = CellNetwork() + network.add_vertex(0, x=0, y=0, z=0) + + with pytest.raises(ValueError, match=r"not part of the cell network: \[1, 2\]"): + network.add_face([0, 1, 2]) + + assert list(network.vertices()) == [0] + assert list(network.edges()) == [] + assert list(network.faces()) == [] + + +def test_add_face_rejects_fewer_than_three_vertices(): + network = CellNetwork() + network.add_vertex(0) + network.add_vertex(1) + + with pytest.raises(ValueError, match="at least 3 vertices"): + network.add_face([0, 1]) + + +def test_delete_edge_removes_edge_attributes(): + network = CellNetwork() + network.add_vertex(0) + network.add_vertex(1) + edge = network.add_edge(0, 1, label="edge") + key = tuple(sorted(edge)) + + network.delete_edge(edge) + + assert not network.has_edge(edge) + assert key not in network._edge_data + + +def test_cell_network_conversions(example_cell_network): + network = example_cell_network + + assert network.edges_to_graph().number_of_edges() == network.number_of_edges() + assert network.cells_to_graph().number_of_nodes() == network.number_of_cells() + vertices, faces = network.cell_to_vertices_and_faces(0) + mesh = network.cell_to_mesh(0) + assert mesh.number_of_vertices() == len(vertices) + assert mesh.number_of_faces() == len(faces) + assert mesh.is_closed() + assert network.faces_to_mesh(network.faces()).number_of_faces() == network.number_of_faces() + + +def test_cell_network_vertex_attributes_support_none_and_empty_selection(example_cell_network): + network = example_cell_network + network.vertex_attribute(0, "nullable", None) + + assert network.vertex_attribute(0, "nullable") is None + assert network.vertices_attribute("nullable", keys=[]) == [] + + network.vertices_attribute("selected", True, keys=[]) + assert all(network.vertex_attribute(vertex, "selected") is None for vertex in network.vertices()) + + +def test_cell_network_vertex_attribute_inputs_are_not_mutated(): + network = CellNetwork() + attributes = {"color": "red"} + + network.update_default_vertex_attributes(attributes, size=1) + + assert attributes == {"color": "red"} + + +def test_cell_network_vertex_neighborhood_rejects_invalid_ring(example_cell_network): + with pytest.raises(ValueError, match="at least 1"): + example_cell_network.vertex_neighborhood(0, ring=0) + + +def test_cell_network_vertex_queries(example_cell_network): + network = example_cell_network + network.vertex_attribute(0, "group", "support") + network.vertex_attribute(0, "tags", ["selected"]) + network.vertex_attribute(1, "tags", ["selected"]) + + assert list(network.vertices_where({"group": "support"})) == [0] + assert list(network.vertices_where({"tags": "selected", "group": "support"})) == [0] + assert list(network.vertices_where_predicate(lambda vertex, attr: vertex == 0)) == [0] + assert isinstance(network.vertex_neighbors(0), list) + assert network.vertex_degree(0) == len(network.vertex_neighbors(0)) + assert network.vertex_point(0) == Point(*network.vertex_coordinates(0)) + + +def test_cell_network_edge_attributes_support_none_and_empty_selection(example_cell_network): + network = example_cell_network + edge = next(network.edges()) + network.edge_attribute(edge, "nullable", None) + + assert network.edge_attribute(edge, "nullable") is None + assert network.edges_attribute("nullable", edges=[]) == [] + + network.edges_attribute("selected", True, edges=[]) + assert all(network.edge_attribute(item, "selected") is None for item in network.edges()) + + +def test_cell_network_edge_attribute_inputs_are_not_mutated(): + network = CellNetwork() + attributes = {"color": "red"} + + network.update_default_edge_attributes(attributes, size=1) + + assert attributes == {"color": "red"} + + +def test_cell_network_edge_queries_and_geometry(example_cell_network): + network = example_cell_network + edge = next(network.edges()) + network.edge_attributes(edge, ["tags", "group"], [["selected"], "frame"]) + + assert list(network.edges_where({"tags": "selected", "group": "frame"})) == [edge] + assert list(network.edges_where_predicate(lambda item, attr: item == edge)) == [edge] + assert network.edge_midpoint(edge) == network.edge_point(edge) + assert network.edge_line(edge).length == network.edge_length(edge) + + +def test_cell_network_edge_cells_are_independent_of_edge_direction(example_cell_network): + network = example_cell_network + + for edge in network.edges(): + assert set(network.edge_cells(edge)) == set(network.edge_cells(edge[::-1])) + + +def test_cell_network_face_attributes_support_none_and_empty_selection(example_cell_network): + network = example_cell_network + face = next(network.faces()) + network.face_attribute(face, "nullable", None) + + assert network.face_attribute(face, "nullable") is None + assert network.faces_attribute("nullable", faces=[]) == [] + + network.faces_attribute("selected", True, faces=[]) + assert all(network.face_attribute(item, "selected") is None for item in network.faces()) + + +def test_cell_network_face_attribute_inputs_are_not_mutated(): + network = CellNetwork() + attributes = {"color": "red"} + + network.update_default_face_attributes(attributes, size=1) + + assert attributes == {"color": "red"} + + +def test_cell_network_face_queries_and_geometry(example_cell_network): + network = example_cell_network + face = next(network.faces()) + network.face_attributes(face, ["tags", "group"], [["selected"], "wall"]) + + assert list(network.faces_where({"tags": "selected", "group": "wall"})) == [face] + assert list(network.faces_where_predicate(lambda item, attr: item == face)) == [face] + assert network.face_polygon(face).area == network.face_area(face) + assert network.face_plane(face).point == network.face_centroid(face) + assert len(network.face_edges(face)) == len(network.face_vertices(face)) + + +def test_cell_network_cell_attributes_support_none_and_empty_selection(example_cell_network): + network = example_cell_network + cell = next(network.cells()) + network.cell_attribute(cell, "nullable", None) + + assert network.cell_attribute(cell, "nullable") is None + assert network.cells_attribute("nullable", cells=[]) == [] + + network.cells_attribute("selected", True, cells=[]) + assert all(network.cell_attribute(item, "selected") is None for item in network.cells()) + + +def test_cell_network_cell_attribute_inputs_are_not_mutated(): + network = CellNetwork() + attributes = {"color": "red"} + + network.update_default_cell_attributes(attributes, size=1) + + assert attributes == {"color": "red"} + + +def test_cell_network_cell_topology(example_cell_network): + network = example_cell_network + cell = next(network.cells()) + + assert network.has_cell(cell) + assert not network.has_cell(100) + assert len(network.cell_edges(cell)) * 2 == len(network.cell_halfedges(cell)) + for vertex in network.cell_vertices(cell): + assert set(network.cell_vertex_neighbors(cell, vertex)) == set(network.vertex_neighbors(vertex)) & set( + network.cell_vertices(cell) + ) + for face in network.cell_faces(cell): + assert len(network.cell_face_neighbors(cell, face)) == len(network.face_edges(face)) + + +def test_cell_network_cell_queries_and_geometry(example_cell_network): + network = example_cell_network + cell = next(network.cells()) + network.cell_attributes(cell, ["tags", "group"], [["selected"], "volume"]) + + assert list(network.cells_where({"tags": "selected", "group": "volume"})) == [cell] + assert list(network.cells_where_predicate(lambda item, attr: item == cell)) == [cell] + assert len(network.cell_polyhedron(cell).vertices) == len(network.cell_vertices(cell)) + assert network.cell_volume(cell) > 0 + assert len(network.cell_points(cell)) == len(network.cell_vertices(cell)) + + def test_cell_network_boundary(example_cell_network): ds = example_cell_network assert set(ds.cells_on_boundaries()) == {0, 1} assert set(ds.faces_on_boundaries()) == {0, 1, 2, 3, 4, 6, 7, 8, 9, 10} assert set(ds.faces_without_cell()) == {11} - assert set(ds.edges_without_face()) == {(15, 13), (14, 12)} - assert set(ds.nonmanifold_edges()) == {(6, 7), (4, 5), (5, 6), (7, 4)} + assert set(ds.edges_without_face()) == {(13, 15), (12, 14)} + assert set(ds.nonmanifold_edges()) == {(6, 7), (4, 5), (5, 6), (4, 7)} # ============================================================================== diff --git a/tests/compas/datastructures/test_datastructure.py b/tests/compas/datastructures/test_datastructure.py index 9889ed57a753..0b8d73d03259 100644 --- a/tests/compas/datastructures/test_datastructure.py +++ b/tests/compas/datastructures/test_datastructure.py @@ -21,6 +21,19 @@ def __from_data__(cls, data): return obj +class TransformableDatastructure(Datastructure): + def __init__(self, attributes=None, name=None): + super().__init__(attributes=attributes, name=name) + self.transformations = [] + + @property + def __data__(self): + return {"attributes": self.attributes} + + def transform(self, transformation): + self.transformations.append(transformation) + + @pytest.fixture def level2(): # Level2 is a custom class that is not available outside of this local scope @@ -171,3 +184,37 @@ def test_custom_mesh(custom_mesh): assert loaded.__jsondump__()["dtype"] == "compas.datastructures/Mesh" assert loaded.__jsondump__()["inheritance"] == [] assert not hasattr(loaded, "custom_mesh_attr") + + +def test_datastructure_does_not_retain_input_attributes(): + attributes = {"name": "original"} + datastructure = Datastructure(attributes=attributes) + + datastructure.attributes["name"] = "changed" + + assert attributes == {"name": "original"} + + +def test_copy_transformations_preserve_subclass(): + datastructure = TransformableDatastructure() + + scaled = datastructure.scaled(2) + translated = datastructure.translated([1, 2, 3]) + rotated = datastructure.rotated(0.5) + + assert type(scaled) is TransformableDatastructure + assert type(translated) is TransformableDatastructure + assert type(rotated) is TransformableDatastructure + assert not datastructure.transformations + assert len(scaled.transformations) == 1 + assert len(translated.transformations) == 1 + assert len(rotated.transformations) == 1 + + +def test_unimplemented_transformations_raise(): + datastructure = Datastructure() + + with pytest.raises(NotImplementedError): + datastructure.transform(None) + with pytest.raises(NotImplementedError): + datastructure.transform_numpy(None) diff --git a/tests/compas/datastructures/test_graph.py b/tests/compas/datastructures/test_graph.py index 6829e071007f..9c8bb4e518bc 100644 --- a/tests/compas/datastructures/test_graph.py +++ b/tests/compas/datastructures/test_graph.py @@ -100,10 +100,6 @@ def test_graph_data1(graph): assert graph.number_of_nodes() == other.number_of_nodes() assert graph.number_of_edges() == other.number_of_edges() - if not compas.IPY: - assert Graph.validate_data(graph.__data__) - assert Graph.validate_data(other.__data__) - def test_graph_data2(): cloud = Pointcloud.from_bounds(random.random(), random.random(), random.random(), random.randint(10, 100)) @@ -112,10 +108,6 @@ def test_graph_data2(): assert graph.__data__ == other.__data__ - if not compas.IPY: - assert Graph.validate_data(graph.__data__) - assert Graph.validate_data(other.__data__) - def test_shortest_path(): graph = Graph() @@ -170,6 +162,48 @@ def test_add_node(): assert graph.add_node(0, x=1) == 0 +def test_automatic_node_keys_only_track_integer_keys(): + graph = Graph() + graph.add_node(5) + graph.add_node(10.5) + graph.add_node("20") + + assert graph.add_node() == 6 + + +def test_connected_edges_with_mixed_node_key_types(): + graph = Graph() + graph.add_edge(0, "a") + graph.add_edge((1, 2), "b") + + components = graph.connected_edges() + + assert len(components) == 2 + assert {frozenset(edges) for edges in components} == { + frozenset([(0, "a")]), + frozenset([((1, 2), "b")]), + } + + +def test_exploded_with_mixed_node_key_types(): + graph = Graph() + graph.add_node(0, label="zero") + graph.add_node("a", label="a") + graph.add_node((1, 2), label="tuple") + graph.add_node("b", label="b") + graph.add_edge(0, "a", weight=1.0) + graph.add_edge((1, 2), "b", weight=2.0) + + exploded = graph.exploded() + + assert len(exploded) == 2 + assert {frozenset(item.nodes()) for item in exploded} == { + frozenset([0, "a"]), + frozenset([(1, 2), "b"]), + } + assert sorted(item.edge_attribute(next(item.edges()), "weight") for item in exploded) == [1.0, 2.0] + + # ============================================================================== # Modifiers # ============================================================================== @@ -236,6 +270,43 @@ def test_graph_default_edge_attributes(): assert graph.edge_attribute(edge, name="a") == 3 +def test_node_attribute_can_be_set_to_none(): + graph = Graph() + graph.add_node(0) + + graph.node_attribute(0, "value", None) + + assert "value" in graph.node[0] + assert graph.node_attribute(0, "value") is None + + +def test_nodes_attribute_can_be_set_to_none(): + graph = Graph.from_edges([(0, 1)]) + + graph.nodes_attribute("value", None) + + assert all("value" in graph.node[key] for key in graph.nodes()) + assert graph.nodes_attribute("value") == [None, None] + + +def test_edge_attribute_can_be_set_to_none(): + graph = Graph.from_edges([(0, 1)]) + + graph.edge_attribute((0, 1), "value", None) + + assert "value" in graph.edge[0][1] + assert graph.edge_attribute((0, 1), "value") is None + + +def test_edges_attribute_can_be_set_to_none(): + graph = Graph.from_edges([(0, 1), (1, 2)]) + + graph.edges_attribute("value", None) + + assert all("value" in graph.edge[u][v] for u, v in graph.edges()) + assert graph.edges_attribute("value") == [None, None] + + # ============================================================================== # Conversion # ============================================================================== diff --git a/tests/compas/datastructures/test_graph_duality.py b/tests/compas/datastructures/test_graph_duality.py new file mode 100644 index 000000000000..b6d51e9aa9cc --- /dev/null +++ b/tests/compas/datastructures/test_graph_duality.py @@ -0,0 +1,110 @@ +from compas.datastructures import Graph +from compas.datastructures.graph.duality import graph_find_edge_cycle +from compas.datastructures.graph.duality import graph_sort_neighbors +from compas.datastructures.graph.duality import node_sort_neighbors + + +def square_with_diagonal(): + return Graph.from_lines( + [ + ([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]), + ([1.0, 0.0, 0.0], [1.0, 1.0, 0.0]), + ([1.0, 1.0, 0.0], [0.0, 1.0, 0.0]), + ([0.0, 1.0, 0.0], [0.0, 0.0, 0.0]), + ([0.0, 0.0, 0.0], [1.0, 1.0, 0.0]), + ] + ) + + +def test_find_cycles_empty_graph(): + assert Graph().find_cycles() == [] + + +def test_find_cycles_edgeless_graph(): + graph = Graph() + graph.add_node(0, x=0.0, y=0.0, z=0.0) + + assert graph.find_cycles() == [] + + +def test_find_cycles_triangle(): + graph = Graph.from_lines( + [ + ([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]), + ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), + ([0.0, 1.0, 0.0], [0.0, 0.0, 0.0]), + ] + ) + + cycles = graph.find_cycles() + + assert len(cycles) == 1 + assert cycles[0][0] == cycles[0][-1] + assert set(cycles[0]) == set(graph.nodes()) + + +def test_find_cycles_square_with_diagonal(): + graph = square_with_diagonal() + + cycles = graph.find_cycles() + + assert len(cycles) == 3 + assert sorted(len(cycle) for cycle in cycles) == [4, 4, 5] + assert all(cycle[0] == cycle[-1] for cycle in cycles) + + +def test_find_cycles_assigns_cycles_to_adjacency(): + graph = square_with_diagonal() + + graph.find_cycles() + + for u, v in graph.edges(): + assert isinstance(graph.adjacency[u][v], int) + assert isinstance(graph.adjacency[v][u], int) + + +def test_find_cycles_with_breakpoints(): + graph = square_with_diagonal() + + cycles = graph.find_cycles(breakpoints=[0, 1]) + + assert len(cycles) == 4 + assert [0, 1] in cycles + + +def test_sort_neighbors(): + graph = Graph.from_lines( + [ + ([0.0, 0.0, 0.0], [1.0, 0.0, 0.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]), + ] + ) + + sorted_neighbors = graph_sort_neighbors(graph) + + assert sorted_neighbors[0] == [3, 2, 1] + assert graph.node_attribute(0, "neighbors") == [1, 2, 3] + + +def test_sort_neighbors_clockwise(): + xyz = { + 0: [0.0, 0.0, 0.0], + 1: [1.0, 0.0, 0.0], + 2: [0.0, 1.0, 0.0], + 3: [-1.0, 0.0, 0.0], + } + + ccw = node_sort_neighbors(0, [1, 2, 3], xyz) + cw = node_sort_neighbors(0, [1, 2, 3], xyz, ccw=False) + + assert cw == ccw[::-1] + + +def test_find_edge_cycle(): + graph = square_with_diagonal() + graph_sort_neighbors(graph) + + cycle = graph_find_edge_cycle(graph, (0, 1)) + + assert cycle == [0, 1, 2] diff --git a/tests/compas/datastructures/test_graph_operations.py b/tests/compas/datastructures/test_graph_operations.py new file mode 100644 index 000000000000..d1c377b0a13a --- /dev/null +++ b/tests/compas/datastructures/test_graph_operations.py @@ -0,0 +1,201 @@ +import pytest + +from compas.datastructures import Graph +from compas.datastructures.graph.operations.join import graph_polylines + + +def line_graph(points): + return Graph.from_lines(list(zip(points, points[1:]))) + + +def normalized_polyline(polyline): + points = tuple(tuple(point) for point in polyline) + reverse = tuple(reversed(points)) + return min(points, reverse) + + +def normalized_polylines(polylines): + return sorted(normalized_polyline(polyline) for polyline in polylines) + + +def test_split_edge(): + graph = line_graph([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]]) + edge = next(graph.edges()) + + node = graph.split_edge(edge) + + assert node in graph.node + assert graph.node_coordinates(node) == [1.0, 0.0, 0.0] + assert not graph.has_edge(edge) + assert graph.has_edge((edge[0], node)) + assert graph.has_edge((node, edge[1])) + assert graph.number_of_nodes() == 3 + assert graph.number_of_edges() == 2 + + +def test_split_edge_at_parameter(): + graph = line_graph([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]]) + + node = graph.split_edge(next(graph.edges()), t=0.25) + + assert graph.node_coordinates(node) == [0.5, 0.0, 0.0] + + +@pytest.mark.parametrize("t", [0.0, -0.1, 1.0, 1.1]) +def test_split_edge_invalid_parameter(t): + graph = line_graph([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + + with pytest.raises(ValueError): + graph.split_edge(next(graph.edges()), t=t) + + +def test_split_edge_missing(): + graph = Graph.from_edges([(0, 1)]) + data = graph.__data__ + + node = graph.split_edge((1, 2)) + + assert node is None + assert graph.__data__ == data + + +def test_split_edge_reverse_direction_is_missing(): + graph = Graph.from_edges([(0, 1)]) + + node = graph.split_edge((1, 0)) + + assert node is None + assert list(graph.edges()) == [(0, 1)] + + +def test_split_edge_uses_default_attributes(): + graph = Graph(default_edge_attributes={"color": "red"}) + graph.add_node(0, x=0.0, y=0.0, z=0.0) + graph.add_node(1, x=2.0, y=0.0, z=0.0) + graph.add_edge(0, 1, color="blue") + + node = graph.split_edge((0, 1)) + + assert graph.edge_attribute((0, node), "color") == "red" + assert graph.edge_attribute((node, 1), "color") == "red" + + +def test_join_edges(): + graph = Graph.from_edges([(0, 1), (1, 2)]) + + graph.join_edges(1) + + assert set(graph.nodes()) == {0, 2} + assert graph.has_edge((0, 2)) + assert graph.number_of_edges() == 1 + assert graph.neighbors(0) == [2] + assert graph.neighbors(2) == [0] + + +@pytest.mark.parametrize("key", [0, 3]) +def test_join_edges_requires_degree_two(key): + graph = Graph.from_edges([(0, 1), (1, 2), (1, 3)]) + data = graph.__data__ + + graph.join_edges(key) + + assert graph.__data__ == data + + +def test_join_edges_missing_node(): + graph = Graph.from_edges([(0, 1)]) + + with pytest.raises(KeyError): + graph.join_edges(2) + + +def test_join_edges_uses_default_attributes(): + graph = Graph(default_edge_attributes={"color": "red"}) + graph.add_edge(0, 1, color="blue") + graph.add_edge(1, 2, color="green") + + graph.join_edges(1) + + assert graph.edge_attribute((0, 2), "color") == "red" + + +def test_join_edges_preserves_remaining_node_attributes(): + graph = Graph() + graph.add_node(0, label="start") + graph.add_node(1, label="middle") + graph.add_node(2, label="end") + graph.add_edge(0, 1) + graph.add_edge(1, 2) + + graph.join_edges(1) + + assert graph.node_attribute(0, "label") == "start" + assert graph.node_attribute(2, "label") == "end" + + +def test_graph_polylines_empty_graph(): + assert graph_polylines(Graph()) == [] + + +def test_graph_polylines_single_edge(): + graph = line_graph([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + + assert normalized_polylines(graph_polylines(graph)) == [ + ((0.0, 0.0, 0.0), (1.0, 0.0, 0.0)), + ] + + +def test_graph_polylines_open_polyline(): + points = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [3.0, 0.0, 0.0]] + graph = line_graph(points) + + assert normalized_polylines(graph_polylines(graph)) == [tuple(tuple(point) for point in points)] + + +def test_graph_polylines_disconnected(): + graph = Graph.from_lines( + [ + ([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]), + ([2.0, 0.0, 0.0], [3.0, 0.0, 0.0]), + ] + ) + + assert normalized_polylines(graph_polylines(graph)) == [ + ((0.0, 0.0, 0.0), (1.0, 0.0, 0.0)), + ((2.0, 0.0, 0.0), (3.0, 0.0, 0.0)), + ] + + +def test_graph_polylines_branch(): + center = [0.0, 0.0, 0.0] + endpoints = [[-1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + graph = Graph.from_lines([(center, endpoint) for endpoint in endpoints]) + + polylines = graph_polylines(graph) + + assert len(polylines) == 3 + assert all(len(polyline) == 2 for polyline in polylines) + + +def test_graph_polylines_closed_cycle(): + points = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]] + graph = Graph.from_lines(list(zip(points, points[1:] + points[:1]))) + + polylines = graph_polylines(graph) + + assert len(polylines) == 1 + assert len(polylines[0]) == 5 + assert polylines[0][0] == polylines[0][-1] + assert {tuple(point) for point in polylines[0]} == {tuple(point) for point in points} + + +def test_graph_polylines_explicit_split(): + points = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]] + graph = line_graph(points) + + polylines = graph_polylines(graph, splits=[points[1]]) + + assert normalized_polylines(polylines) == [ + ((0.0, 0.0, 0.0), (1.0, 0.0, 0.0)), + ((1.0, 0.0, 0.0), (2.0, 0.0, 0.0)), + ] diff --git a/tests/compas/datastructures/test_graph_planarity.py b/tests/compas/datastructures/test_graph_planarity.py new file mode 100644 index 000000000000..a31a95a927bb --- /dev/null +++ b/tests/compas/datastructures/test_graph_planarity.py @@ -0,0 +1,123 @@ +import networkx + +from compas.datastructures import Graph + + +def crossing_graph(): + graph = Graph() + graph.add_node(0, x=0.0, y=0.0, z=0.0) + graph.add_node(1, x=1.0, y=1.0, z=0.0) + graph.add_node(2, x=0.0, y=1.0, z=0.0) + graph.add_node(3, x=1.0, y=0.0, z=0.0) + graph.add_edge(0, 1) + graph.add_edge(2, 3) + return graph + + +def square_graph(): + graph = Graph() + graph.add_node(0, x=0.0, y=0.0, z=0.0) + graph.add_node(1, x=1.0, y=0.0, z=0.0) + graph.add_node(2, x=1.0, y=1.0, z=0.0) + graph.add_node(3, x=0.0, y=1.0, z=0.0) + graph.add_edge(0, 1) + graph.add_edge(1, 2) + graph.add_edge(2, 3) + graph.add_edge(3, 0) + return graph + + +def test_is_crossed(): + assert crossing_graph().is_crossed() + assert not square_graph().is_crossed() + + +def test_find_crossings(): + graph = crossing_graph() + + crossings = graph.find_crossings() + + assert len(crossings) == 1 + assert {frozenset(edge) for edge in crossings[0]} == {frozenset((0, 1)), frozenset((2, 3))} + + +def test_count_crossings(): + assert crossing_graph().count_crossings() == 1 + assert square_graph().count_crossings() == 0 + + +def test_edges_with_shared_node_do_not_cross(): + graph = Graph() + graph.add_node(0, x=0.0, y=0.0) + graph.add_node(1, x=1.0, y=1.0) + graph.add_node(2, x=2.0, y=0.0) + graph.add_edge(0, 1) + graph.add_edge(1, 2) + + assert not graph.is_crossed() + assert graph.find_crossings() == [] + + +def test_is_xy_empty_graph(): + assert Graph().is_xy() + + +def test_is_xy_with_default_coordinates(): + graph = Graph.from_edges([(0, 1)]) + + assert graph.is_xy() + + +def test_is_xy_constant_elevation(): + graph = Graph() + graph.add_node(0, z=5.0) + graph.add_node(1, z=5.0) + + assert graph.is_xy() + + +def test_is_xy_different_elevations(): + graph = Graph() + graph.add_node(0, z=0.0) + graph.add_node(1, z=1.0) + + assert not graph.is_xy() + + +def test_is_planar_embedding(): + assert square_graph().is_planar_embedding() + assert not crossing_graph().is_planar_embedding() + + +def test_embed_in_plane_empty_graph(): + assert not Graph().embed_in_plane() + + +def test_embed_in_plane(monkeypatch): + graph = square_graph() + positions = { + 0: [0.0, 0.0], + 1: [2.0, 0.0], + 2: [2.0, 2.0], + 3: [0.0, 2.0], + } + monkeypatch.setattr(networkx, "spring_layout", lambda *args, **kwargs: positions) + + assert graph.embed_in_plane() + assert graph.node_attributes(0, "xy") == [0.0, 0.0] + assert graph.node_attributes(2, "xy") == [2.0, 2.0] + + +def test_embed_in_plane_with_fixed_nodes(monkeypatch): + graph = square_graph() + positions = { + 0: [0.0, 0.0], + 1: [2.0, 0.0], + 2: [2.0, 2.0], + 3: [0.0, 2.0], + } + monkeypatch.setattr(networkx, "spring_layout", lambda *args, **kwargs: positions) + + assert graph.embed_in_plane(fixed=[0, 1]) + assert graph.node_attributes(0, "xy") == [0.0, 0.0] + assert graph.node_attributes(1, "xy") == [1.0, 0.0] diff --git a/tests/compas/datastructures/test_graph_smoothing.py b/tests/compas/datastructures/test_graph_smoothing.py new file mode 100644 index 000000000000..788675036887 --- /dev/null +++ b/tests/compas/datastructures/test_graph_smoothing.py @@ -0,0 +1,92 @@ +import pytest + +from compas.datastructures import Graph + + +def three_node_graph(): + graph = Graph() + graph.add_node(0, x=0.0, y=0.0, z=0.0) + graph.add_node(1, x=1.0, y=1.0, z=0.0) + graph.add_node(2, x=2.0, y=0.0, z=0.0) + graph.add_edge(0, 1) + graph.add_edge(1, 2) + return graph + + +def test_smooth_centroid(): + graph = three_node_graph() + + graph.smooth(fixed=[0, 2], kmax=1, damping=1.0) + + assert graph.node_coordinates(1) == [1.0, 0.0, 0.0] + + +def test_smooth_centroid_with_damping(): + graph = three_node_graph() + + graph.smooth(fixed=[0, 2], kmax=1, damping=0.25) + + assert graph.node_coordinates(1) == [1.0, 0.75, 0.0] + + +def test_smooth_centroid_fixed_nodes(): + graph = three_node_graph() + before = {key: graph.node_coordinates(key) for key in graph.nodes()} + + graph.smooth(fixed=graph.nodes(), kmax=3) + + assert {key: graph.node_coordinates(key) for key in graph.nodes()} == before + + +def test_smooth_centroid_zero_iterations(): + graph = three_node_graph() + before = {key: graph.node_coordinates(key) for key in graph.nodes()} + + graph.smooth(kmax=0) + + assert {key: graph.node_coordinates(key) for key in graph.nodes()} == before + + +def test_smooth_centroid_uses_iteration_snapshot(): + graph = Graph() + graph.add_node(0, x=0.0, y=0.0, z=0.0) + graph.add_node(1, x=0.0, y=0.0, z=0.0) + graph.add_node(2, x=3.0, y=0.0, z=0.0) + graph.add_node(3, x=4.0, y=0.0, z=0.0) + graph.add_edge(0, 1) + graph.add_edge(1, 2) + graph.add_edge(2, 3) + + graph.smooth(fixed=[0, 3], kmax=1, damping=1.0) + + assert graph.node_coordinates(1) == [1.5, 0.0, 0.0] + assert graph.node_coordinates(2) == [2.0, 0.0, 0.0] + + +def test_smooth_centroid_isolated_node(): + graph = Graph() + graph.add_node(0, x=1.0, y=2.0, z=3.0) + + graph.smooth(kmax=1) + + assert graph.node_coordinates(0) == [1.0, 2.0, 3.0] + + +def test_smooth_centroid_callback(): + graph = three_node_graph() + calls = [] + callback_args = {"name": "test"} + + def callback(iteration, args): + calls.append((iteration, args)) + + graph.smooth(kmax=3, callback=callback, callback_args=callback_args) + + assert calls == [(0, callback_args), (1, callback_args), (2, callback_args)] + + +def test_smooth_centroid_invalid_callback(): + graph = three_node_graph() + + with pytest.raises(TypeError): + graph.smooth(callback="not callable") diff --git a/tests/compas/datastructures/test_hashtree.py b/tests/compas/datastructures/test_hashtree.py index a215f1edcf37..19529eac857a 100644 --- a/tests/compas/datastructures/test_hashtree.py +++ b/tests/compas/datastructures/test_hashtree.py @@ -1,5 +1,12 @@ +import pytest + +from compas.data import json_dumps +from compas.data import json_loads +from compas.datastructures import HashNode from compas.datastructures import HashTree from compas.datastructures import Mesh +from compas.datastructures import Tree +from compas.datastructures import TreeNode def test_hashtree_from_dict(): @@ -23,3 +30,100 @@ def test_hashtree_from_mesh(): assert diff["added"] == [] assert diff["removed"] == [{"path": ".face.3", "value": [1, 3, 2]}, {"path": ".facedata.3", "value": None}] assert diff["modified"] == [{"path": ".vertex.0.x", "old": -0.8164965809277261, "new": 1.0}] + + +def test_hashtree_serialization(): + tree = HashTree.from_dict({"a": {"b": 1}, "c": [1, 2, 3]}) + + other = json_loads(json_dumps(tree)) + + assert isinstance(other, HashTree) + assert all(isinstance(node, HashNode) for node in other.nodes) + assert other.diff(tree) == {"added": [], "removed": [], "modified": []} + assert other.signatures == tree.signatures + + +def test_empty_hashtree_serialization(): + tree = json_loads(json_dumps(HashTree())) + + assert isinstance(tree, HashTree) + assert tree.root is None + assert tree.signatures == {} + + +def test_hashnode_repr_computes_signature(): + node = HashNode(".a", value=1) + + assert repr(node) == ".a:1 @ {}".format(node.signature[:5]) + + +def test_hashtree_from_object_requires_data(): + with pytest.raises(TypeError): + HashTree.from_object({"a": 1}) + + +def test_hashtree_diff_requires_roots(): + with pytest.raises(ValueError): + HashTree().diff(HashTree()) + + +def test_hashtree_signature_is_independent_of_dictionary_order(): + tree1 = HashTree.from_dict({"a": 1, "b": 2}) + tree2 = HashTree.from_dict({"b": 2, "a": 1}) + + assert tree1.root.signature == tree2.root.signature + + +def test_hashtree_does_not_inherit_tree(): + assert not isinstance(HashTree(), Tree) + assert not isinstance(HashNode(""), TreeNode) + + +def test_hashnode_children_are_immutable(): + child = HashNode(".a", value=1) + root = HashNode("", children=[child]) + + assert root.children == (child,) + assert not hasattr(root, "add") + + with pytest.raises(AttributeError): + root.path = ".changed" + + +def test_hashnode_value_is_defensively_copied(): + value = [1, 2] + node = HashNode(".a", value=value) + signature = node.signature + + value.append(3) + returned_value = node.value + returned_value.append(4) + + assert node.value == [1, 2] + assert node.signature == signature + + +def test_hashnode_explicit_none_is_a_value(): + value_node = HashNode(".a", value=None) + branch_node = HashNode(".a") + + assert value_node.is_value + assert not branch_node.is_value + assert value_node.signature != branch_node.signature + + +def test_hashnode_rejects_values_with_children(): + with pytest.raises(ValueError): + HashNode("", value=1, children=[HashNode(".a", value=2)]) + + +def test_hashnode_rejects_duplicate_child_paths(): + with pytest.raises(ValueError): + HashNode("", children=[HashNode(".a", value=1), HashNode(".a", value=2)]) + + +def test_hashtree_to_graph_rejects_duplicate_keys(): + tree = HashTree.from_dict({"a": 1, "b": 2}) + + with pytest.raises(ValueError): + tree.to_graph(lambda node: "duplicate") diff --git a/tests/compas/datastructures/test_mesh.py b/tests/compas/datastructures/test_mesh.py index 319756f284b8..ef1127c9fea0 100644 --- a/tests/compas/datastructures/test_mesh.py +++ b/tests/compas/datastructures/test_mesh.py @@ -286,10 +286,6 @@ def test_mesh_data(halfedge): assert halfedge.number_of_edges() == other.number_of_edges() assert halfedge.number_of_faces() == other.number_of_faces() - if not compas.IPY: - assert Mesh.validate_data(halfedge.__data__) - assert Mesh.validate_data(other.__data__) - # -------------------------------------------------------------------------- # converters @@ -467,6 +463,20 @@ def test_cull_vertices(): assert mesh.number_of_vertices() == v - 1 +def test_quads_to_triangles_preserves_face_attributes(): + mesh = Mesh.from_vertices_and_faces( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]], + [[0, 1, 2, 3]], + ) + mesh.face_attribute(0, "name", "quad") + + mesh.quads_to_triangles() + + assert mesh.number_of_faces() == 2 + assert mesh.number_of_edges() == 5 + assert all(mesh.face_attribute(face, "name") == "quad" for face in mesh.faces()) + + # -------------------------------------------------------------------------- # info # -------------------------------------------------------------------------- @@ -535,6 +545,32 @@ def test_genus(): # -------------------------------------------------------------------------- +def test_filtered_accessors_with_and_without_data(): + mesh = Mesh.from_vertices_and_faces( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[0, 1, 2]], + ) + mesh.vertex_attribute(0, "selected", True) + mesh.edge_attribute((0, 1), "selected", True) + mesh.face_attribute(0, "selected", True) + + assert list(mesh.vertices_where(selected=True)) == [0] + assert list(mesh.edges_where(selected=True)) == [(0, 1)] + assert list(mesh.faces_where(selected=True)) == [0] + + vertex, vertex_data = next(mesh.vertices_where(data=True, selected=True)) + edge, edge_data = next(mesh.edges_where(data=True, selected=True)) + face, face_data = next(mesh.faces_where(data=True, selected=True)) + + assert vertex == 0 and vertex_data["selected"] is True + assert edge == (0, 1) and edge_data["selected"] is True + assert face == 0 and face_data["selected"] is True + + assert list(mesh.vertices_where_predicate(lambda key, attr: key == 0)) == [0] + assert list(mesh.edges_where_predicate(lambda edge, attr: edge == (0, 1))) == [(0, 1)] + assert list(mesh.faces_where_predicate(lambda face, attr: face == 0)) == [0] + + def test_default_vertex_attributes(): he = Mesh(name="test", default_vertex_attributes={"a": 1, "b": 2}) for vertex in he.vertices(): @@ -681,30 +717,44 @@ def test_del_edge_attribute_in_view(halfedge, edge_key): attrs["foo"] +def test_attributes_can_be_set_to_none(halfedge, vertex_key, face_key, edge_key): + halfedge.vertex_attribute(vertex_key, "nullable", None) + halfedge.face_attribute(face_key, "nullable", None) + halfedge.edge_attribute(edge_key, "nullable", None) + + assert "nullable" in halfedge.vertex[vertex_key] + assert "nullable" in halfedge.facedata[face_key] + assert "nullable" in halfedge.edgedata[str(tuple(sorted(edge_key)))] + assert halfedge.vertex_attribute(vertex_key, "nullable") is None + assert halfedge.face_attribute(face_key, "nullable") is None + assert halfedge.edge_attribute(edge_key, "nullable") is None + + +def test_bulk_attributes_can_be_set_to_none(halfedge): + halfedge.vertices_attribute("nullable", None) + halfedge.faces_attribute("nullable", None) + halfedge.edges_attribute("nullable", None) + + assert halfedge.vertices_attribute("nullable") == [None] * halfedge.number_of_vertices() + assert halfedge.faces_attribute("nullable") == [None] * halfedge.number_of_faces() + assert halfedge.edges_attribute("nullable") == [None] * halfedge.number_of_edges() + + # -------------------------------------------------------------------------- # accessors # -------------------------------------------------------------------------- def test_vertices(cube): - if compas.PY3: - assert hasattr(cube.vertices(), "__next__") - else: - assert hasattr(cube.vertices(), "__iter__") + assert hasattr(cube.vertices(), "__next__") def test_faces(cube): - if compas.PY3: - assert hasattr(cube.faces(), "__next__") - else: - assert hasattr(cube.faces(), "__iter__") + assert hasattr(cube.faces(), "__next__") def test_edges(cube): - if compas.PY3: - assert hasattr(cube.edges(), "__next__") - else: - assert hasattr(cube.edges(), "__iter__") + assert hasattr(cube.edges(), "__next__") # -------------------------------------------------------------------------- @@ -1331,12 +1381,50 @@ def test_face_attributes_includes_all_defaults(box): assert box.face_attribute(random_fkey, "attr3") == "value3" +# -------------------------------------------------------------------------- +# matrices +# -------------------------------------------------------------------------- + + +def test_connectivity_and_laplacian_matrices_use_mesh_edges(): + numpy = pytest.importorskip("numpy") + mesh = Mesh.from_vertices_and_faces( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]], + [[0, 1, 2], [0, 2, 3]], + ) + + connectivity = mesh.connectivity_matrix() + laplacian = mesh.laplacian_matrix() + + assert connectivity.shape == (mesh.number_of_edges(), mesh.number_of_vertices()) + assert numpy.all(numpy.count_nonzero(connectivity, axis=1) == 2) + assert numpy.allclose(laplacian, connectivity.T.dot(connectivity)) + + +def test_matrix_types_with_noncontiguous_vertex_keys(): + mesh = Mesh.from_vertices_and_faces( + {2: [0.0, 0.0, 0.0], 4: [1.0, 0.0, 0.0], 8: [0.0, 1.0, 0.0]}, + [[2, 4, 8]], + ) + + assert isinstance(mesh.adjacency_matrix(rtype="list"), list) + assert mesh.connectivity_matrix(rtype="csr").shape == (3, 3) + assert mesh.degree_matrix(rtype="coo").shape == (3, 3) + assert mesh.face_matrix(rtype="csc").shape == (1, 3) + assert mesh.laplacian_matrix(rtype="array").shape == (3, 3) + + # -------------------------------------------------------------------------- # bounding volumes # -------------------------------------------------------------------------- if not compas.IPY: + def test_aabb_and_obb_are_properties(cube): + assert isinstance(Mesh.aabb, property) + assert isinstance(Mesh.obb, property) + assert isinstance(cube.aabb, Box) + def test_compute_aabb(): mesh = Mesh.from_obj(compas.get("tubemesh.obj")) aabb = mesh.compute_aabb() @@ -1352,3 +1440,53 @@ def test_compute_obb(): assert isinstance(obb, Box) assert len(obb.points) == 8 assert obb.contains_points(mesh.to_points()) + + +# -------------------------------------------------------------------------- +# derived meshes +# -------------------------------------------------------------------------- + + +def test_offset_returns_same_mesh_type(): + mesh = Mesh.from_vertices_and_faces( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]], + [[0, 1, 2, 3]], + ) + + offset = mesh.offset(2.0) + + assert type(offset) is type(mesh) + assert all(TOL.is_close(offset.vertex_coordinates(vertex)[2], 2.0) for vertex in offset.vertices()) + + +def test_thickened_closes_open_mesh(): + mesh = Mesh.from_vertices_and_faces( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]], + [[0, 1, 2, 3]], + ) + + thickened = mesh.thickened(1.0) + + assert thickened.number_of_vertices() == 8 + assert thickened.number_of_faces() == 6 + assert thickened.is_closed() + + +def test_exploded_returns_one_mesh_per_component(): + mesh = Mesh.from_vertices_and_faces( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [2.0, 0.0, 0.0], + [3.0, 0.0, 0.0], + [2.0, 1.0, 0.0], + ], + [[0, 1, 2], [3, 4, 5]], + ) + + parts = mesh.exploded() + + assert len(parts) == 2 + assert all(type(part) is type(mesh) for part in parts) + assert sorted(part.number_of_faces() for part in parts) == [1, 1] diff --git a/tests/compas/datastructures/test_mesh_conway.py b/tests/compas/datastructures/test_mesh_conway.py new file mode 100644 index 000000000000..194884e74d63 --- /dev/null +++ b/tests/compas/datastructures/test_mesh_conway.py @@ -0,0 +1,156 @@ +import pytest + +from compas.datastructures import Mesh +from compas.datastructures import mesh_conway_ambo +from compas.datastructures import mesh_conway_bevel +from compas.datastructures import mesh_conway_dual +from compas.datastructures import mesh_conway_expand +from compas.datastructures import mesh_conway_gyro +from compas.datastructures import mesh_conway_join +from compas.datastructures import mesh_conway_kis +from compas.datastructures import mesh_conway_meta +from compas.datastructures import mesh_conway_needle +from compas.datastructures import mesh_conway_ortho +from compas.datastructures import mesh_conway_snub +from compas.datastructures import mesh_conway_truncate +from compas.datastructures import mesh_conway_zip +from compas.tolerance import TOL + + +OPERATORS_AND_COUNTS = [ + (mesh_conway_dual, lambda V, E, F: (F, E, V)), + (mesh_conway_join, lambda V, E, F: (V + F, 2 * E, E)), + (mesh_conway_ambo, lambda V, E, F: (E, 2 * E, V + F)), + (mesh_conway_kis, lambda V, E, F: (V + F, 3 * E, 2 * E)), + (mesh_conway_needle, lambda V, E, F: (V + F, 3 * E, 2 * E)), + (mesh_conway_zip, lambda V, E, F: (2 * E, 3 * E, V + F)), + (mesh_conway_truncate, lambda V, E, F: (2 * E, 3 * E, V + F)), + (mesh_conway_ortho, lambda V, E, F: (V + E + F, 4 * E, 2 * E)), + (mesh_conway_expand, lambda V, E, F: (2 * E, 4 * E, V + E + F)), + (mesh_conway_gyro, lambda V, E, F: (V + F + 2 * E, 5 * E, 2 * E)), + (mesh_conway_snub, lambda V, E, F: (2 * E, 5 * E, V + F + 2 * E)), + (mesh_conway_meta, lambda V, E, F: (V + E + F, 6 * E, 4 * E)), + (mesh_conway_bevel, lambda V, E, F: (4 * E, 6 * E, V + E + F)), +] + + +@pytest.fixture +def irregular_tetrahedron(): + vertices = [[0, 0, 0], [2, 0, 0], [0, 3, 0], [0, 0, 5]] + faces = [[0, 2, 1], [0, 1, 3], [1, 2, 3], [2, 0, 3]] + return Mesh.from_vertices_and_faces(vertices, faces) + + +def assert_vertex_coordinates(mesh, expected): + assert mesh.number_of_vertices() == len(expected) + for vertex, xyz in zip(mesh.vertices(), expected): + assert TOL.is_allclose(mesh.vertex_coordinates(vertex), xyz) + + +@pytest.mark.parametrize( + ("operator", "counts"), + OPERATORS_AND_COUNTS, +) +@pytest.mark.parametrize("number_of_faces", [4, 6]) +def test_conway_operator_counts_and_topology(operator, counts, number_of_faces): + mesh = Mesh.from_polyhedron(number_of_faces) + V = mesh.number_of_vertices() + E = mesh.number_of_edges() + F = mesh.number_of_faces() + + result = operator(mesh) + + assert (result.number_of_vertices(), result.number_of_edges(), result.number_of_faces()) == counts(V, E, F) + assert result.is_valid() + assert result.is_closed() + + +@pytest.mark.parametrize("operator", [operator for operator, _ in OPERATORS_AND_COUNTS]) +def test_conway_operator_preserves_mesh_subclass(operator): + class CustomMesh(Mesh): + pass + + mesh = CustomMesh.from_polyhedron(6) + + assert type(operator(mesh)) is CustomMesh + + +def test_conway_dual_geometry_and_oriented_connectivity(irregular_tetrahedron): + mesh = irregular_tetrahedron + result = mesh_conway_dual(mesh) + faces = list(mesh.faces()) + face_vertex = {face: index for index, face in enumerate(faces)} + + assert_vertex_coordinates(result, [mesh.face_centroid(face) for face in faces]) + assert [result.face_vertices(face) for face in result.faces()] == [ + [face_vertex[face] for face in reversed(mesh.vertex_faces(vertex, ordered=True))] + for vertex in mesh.vertices() + ] + + +def test_conway_join_geometry_and_oriented_connectivity(irregular_tetrahedron): + mesh = irregular_tetrahedron + result = mesh_conway_join(mesh) + vertices = list(mesh.vertices()) + faces = list(mesh.faces()) + vertex_index = {vertex: index for index, vertex in enumerate(vertices)} + face_vertex = {face: len(vertices) + index for index, face in enumerate(faces)} + + assert_vertex_coordinates( + result, + [mesh.vertex_coordinates(vertex) for vertex in vertices] + [mesh.face_centroid(face) for face in faces], + ) + assert [result.face_vertices(face) for face in result.faces()] == [ + [vertex_index[u], face_vertex[mesh.halfedge[v][u]], vertex_index[v], face_vertex[mesh.halfedge[u][v]]] + for u, v in mesh.edges() + ] + + +def test_conway_kis_geometry_and_oriented_connectivity(irregular_tetrahedron): + mesh = irregular_tetrahedron + result = mesh_conway_kis(mesh) + vertices = list(mesh.vertices()) + faces = list(mesh.faces()) + vertex_index = {vertex: index for index, vertex in enumerate(vertices)} + face_vertex = {face: len(vertices) + index for index, face in enumerate(faces)} + + assert_vertex_coordinates( + result, + [mesh.vertex_coordinates(vertex) for vertex in vertices] + [mesh.face_centroid(face) for face in faces], + ) + assert [result.face_vertices(face) for face in result.faces()] == [ + [vertex_index[u], vertex_index[v], face_vertex[face]] + for face in faces + for u, v in mesh.face_halfedges(face) + ] + + +def test_conway_gyro_geometry_and_oriented_connectivity(irregular_tetrahedron): + mesh = irregular_tetrahedron + result = mesh_conway_gyro(mesh) + vertices = list(mesh.vertices()) + faces = list(mesh.faces()) + halfedges = [(u, v) for u in vertices for v in mesh.halfedge[u]] + vertex_index = {vertex: index for index, vertex in enumerate(vertices)} + face_vertex = {face: len(vertices) + index for index, face in enumerate(faces)} + halfedge_vertex = { + halfedge: len(vertices) + len(faces) + index for index, halfedge in enumerate(halfedges) + } + + assert_vertex_coordinates( + result, + [mesh.vertex_coordinates(vertex) for vertex in vertices] + + [mesh.face_centroid(face) for face in faces] + + [mesh.edge_point(halfedge, t=0.33) for halfedge in halfedges], + ) + assert [result.face_vertices(face) for face in result.faces()] == [ + [ + halfedge_vertex[u, v], + halfedge_vertex[v, u], + vertex_index[v], + halfedge_vertex[v, mesh.face_vertex_descendant(face, v)], + face_vertex[face], + ] + for face in faces + for u, v in mesh.face_halfedges(face) + ] diff --git a/tests/compas/datastructures/test_mesh_duality.py b/tests/compas/datastructures/test_mesh_duality.py new file mode 100644 index 000000000000..50cf1fa0b79a --- /dev/null +++ b/tests/compas/datastructures/test_mesh_duality.py @@ -0,0 +1,149 @@ +import pytest + +from compas.datastructures import Mesh +from compas.datastructures.mesh.duality import mesh_dual +from compas.tolerance import TOL + + +@pytest.fixture +def irregular_tetrahedron(): + vertices = [[0, 0, 0], [2, 0, 0], [0, 3, 0], [0, 0, 5]] + faces = [[0, 2, 1], [0, 1, 3], [1, 2, 3], [2, 0, 3]] + return Mesh.from_vertices_and_faces(vertices, faces) + + +@pytest.fixture +def irregular_fan(): + vertices = [[0, 0, 0], [3, 0, 0], [4, 2, 0], [0, 4, 0], [1, 1, 1]] + faces = [[0, 1, 4], [1, 2, 4], [2, 3, 4], [3, 0, 4]] + return Mesh.from_vertices_and_faces(vertices, faces) + + +def assert_point_equal(actual, expected): + assert TOL.is_allclose(actual, expected) + + +@pytest.mark.parametrize("number_of_faces", [4, 6, 12]) +def test_closed_mesh_dual_has_theoretical_counts_and_topology(number_of_faces): + mesh = Mesh.from_polyhedron(number_of_faces) + dual = mesh_dual(mesh) + + assert dual.number_of_vertices() == mesh.number_of_faces() + assert dual.number_of_edges() == mesh.number_of_edges() + assert dual.number_of_faces() == mesh.number_of_vertices() + assert dual.is_valid() + assert dual.is_closed() + + +def test_closed_mesh_dual_geometry_and_oriented_connectivity(irregular_tetrahedron): + mesh = irregular_tetrahedron + dual = mesh_dual(mesh) + + assert set(dual.vertices()) == set(mesh.faces()) + assert set(dual.faces()) == set(mesh.vertices()) + + for face in mesh.faces(): + assert_point_equal(dual.vertex_coordinates(face), mesh.face_centroid(face)) + + for vertex in mesh.vertices(): + assert dual.face_vertices(vertex) == mesh.vertex_faces(vertex, ordered=True) + + expected_edges = { + frozenset((mesh.halfedge[u][v], mesh.halfedge[v][u])) + for u, v in mesh.edges() + } + assert {frozenset(edge) for edge in dual.edges()} == expected_edges + + +def test_include_boundary_does_not_change_closed_mesh_dual(irregular_tetrahedron): + mesh = irregular_tetrahedron + dual = mesh_dual(mesh) + dual_with_boundary = mesh_dual(mesh, include_boundary=True) + + assert dual_with_boundary.__data__ == dual.__data__ + + +def test_open_mesh_dual_excludes_boundary_vertices(irregular_fan): + mesh = irregular_fan + dual = mesh_dual(mesh) + interior_vertex = 4 + + assert set(dual.vertices()) == set(mesh.faces()) + assert list(dual.faces()) == [interior_vertex] + assert dual.face_vertices(interior_vertex) == mesh.vertex_faces(interior_vertex, ordered=True) + assert dual.number_of_edges() == mesh.number_of_faces() + assert dual.is_valid() + assert not dual.is_closed() + + for face in mesh.faces(): + assert_point_equal(dual.vertex_coordinates(face), mesh.face_centroid(face)) + + +def test_open_mesh_dual_includes_theoretical_boundary_elements(irregular_fan): + mesh = irregular_fan + dual = mesh_dual(mesh, include_boundary=True) + boundary = mesh.vertices_on_boundary() + if boundary[0] == boundary[-1]: + boundary = boundary[:-1] + boundary_edges = mesh.edges_on_boundary() + + assert dual.number_of_vertices() == mesh.number_of_faces() + len(boundary_edges) + len(boundary) + assert dual.number_of_faces() == mesh.number_of_vertices() + assert dual.number_of_edges() == dual.number_of_vertices() + dual.number_of_faces() - 1 + assert dual.is_valid() + assert not dual.is_closed() + + for face in mesh.faces(): + assert_point_equal(dual.vertex_coordinates(face), mesh.face_centroid(face)) + + generated_vertices = list(set(dual.vertices()) - set(mesh.faces())) + edge_vertex = {} + vertex_vertex = {} + for edge in boundary_edges: + midpoint = mesh.edge_midpoint(edge) + matches = [ + vertex + for vertex in generated_vertices + if TOL.is_allclose(dual.vertex_coordinates(vertex), midpoint) + ] + assert len(matches) == 1 + u, v = edge + edge_vertex[u, v] = edge_vertex[v, u] = matches[0] + for vertex in boundary: + point = mesh.vertex_coordinates(vertex) + matches = [ + candidate + for candidate in generated_vertices + if TOL.is_allclose(dual.vertex_coordinates(candidate), point) + ] + assert len(matches) == 1 + vertex_vertex[vertex] = matches[0] + + interior = set(mesh.vertices()) - set(boundary) + for vertex in interior: + assert dual.face_vertices(vertex) == mesh.vertex_faces(vertex, ordered=True) + + boundary_faces = [face for face in dual.faces() if face not in interior] + for vertex in boundary: + face = next(face for face in boundary_faces if vertex_vertex[vertex] in dual.face_vertices(face)) + neighbors = mesh.vertex_neighbors(vertex, ordered=True)[::-1] + expected = [vertex_vertex[vertex], edge_vertex[vertex, neighbors[0]]] + expected.extend(mesh.halfedge_face((vertex, neighbor)) for neighbor in neighbors[:-1]) + expected.append(edge_vertex[vertex, neighbors[-1]]) + assert dual.face_vertices(face) == expected[::-1] + + +def test_mesh_dual_preserves_or_overrides_mesh_type(irregular_tetrahedron): + class CustomMesh(Mesh): + pass + + class OtherMesh(Mesh): + pass + + mesh = CustomMesh.from_vertices_and_faces( + [irregular_tetrahedron.vertex_coordinates(vertex) for vertex in irregular_tetrahedron.vertices()], + [irregular_tetrahedron.face_vertices(face) for face in irregular_tetrahedron.faces()], + ) + + assert type(mesh_dual(mesh)) is CustomMesh + assert type(mesh_dual(mesh, cls=OtherMesh)) is OtherMesh diff --git a/tests/compas/datastructures/test_mesh_operations.py b/tests/compas/datastructures/test_mesh_operations.py index 50e0e75a9b2e..da0e119ab4a8 100644 --- a/tests/compas/datastructures/test_mesh_operations.py +++ b/tests/compas/datastructures/test_mesh_operations.py @@ -1,8 +1,20 @@ import pytest from compas.datastructures import Mesh +from compas.datastructures.mesh.operations.collapse import is_collapse_legal +from compas.datastructures.mesh.operations.collapse import trimesh_collapse_edge +from compas.datastructures.mesh.operations.insert import mesh_add_vertex_to_face_edge +from compas.datastructures.mesh.operations.insert import mesh_insert_vertex_on_edge +from compas.datastructures.mesh.operations.merge import mesh_merge_faces +from compas.datastructures.mesh.operations.split import mesh_split_edge +from compas.datastructures.mesh.operations.split import mesh_split_strip +from compas.datastructures.mesh.operations.split import trimesh_split_edge +from compas.datastructures.mesh.operations.substitute import mesh_substitute_vertex_in_faces +from compas.datastructures.mesh.operations.swap import trimesh_swap_edge +from compas.datastructures.mesh.operations.weld import mesh_unweld_edges +from compas.datastructures.mesh.operations.weld import mesh_unweld_vertices +from compas.tolerance import TOL -# from compas.datastructures import mesh_insert_vertex_on_edge # from compas.datastructures import mesh_substitute_vertex_in_faces @@ -35,16 +47,43 @@ def mesh_quads(): return Mesh.from_vertices_and_faces(vertices, faces) -# def test_insert_vertex_on_edge(mesh_0): -# mesh_insert_vertex_on_edge(mesh_0, (0, 1)) -# assert len(mesh_0.face_vertices(0)) == 4 -# assert len(mesh_0.face_vertices(1)) == 4 -# assert mesh_0.face_vertex_descendant(0, 0) == 5 -# assert mesh_0.face_vertex_descendant(1, 1) == 5 +def test_add_existing_vertex_to_face_edge(): + mesh = Mesh.from_vertices_and_faces( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]], + [[0, 1, 2]], + ) + key = mesh.add_vertex(x=0.5, y=0.0, z=0.0) + mesh.edge_attribute((0, 1), "name", "edge") -# mesh_insert_vertex_on_edge(mesh_0, (0, 2), 4) -# assert len(mesh_0.face_vertices(0)) == 5 -# assert mesh_0.face_vertex_descendant(0, 2) == 4 + mesh_add_vertex_to_face_edge(mesh, key, 0, 1) + + assert mesh.face_vertices(0) == [0, key, 1, 2] + assert mesh.face_halfedges(0) == [(0, key), (key, 1), (1, 2), (2, 0)] + assert not mesh.edgedata + assert mesh.is_valid() + + +def test_insert_new_vertex_on_shared_edge(mesh_0): + point = mesh_0.edge_midpoint((0, 1)) + + key = mesh_insert_vertex_on_edge(mesh_0, (0, 1)) + + assert key == 5 + assert mesh_0.face_vertices(0) == [0, key, 1, 2] + assert mesh_0.face_vertices(1) == [key, 0, 3, 1] + assert TOL.is_allclose(mesh_0.vertex_coordinates(key), point) + assert mesh_0.is_valid() + + +def test_insert_existing_vertex_preserves_position(mesh_0): + point = mesh_0.vertex_coordinates(4) + + key = mesh_insert_vertex_on_edge(mesh_0, (0, 2), vkey=4) + + assert key == 4 + assert mesh_0.face_vertex_descendant(0, 2) == 4 + assert mesh_0.vertex_coordinates(4) == point + assert mesh_0.is_valid() # def test_mesh_substitute_vertex_in_faces(mesh_0): @@ -73,3 +112,179 @@ def test__split_face_vertex_not_in_face(mesh_quads): def test_mesh_split_face_vertex_nbors(mesh_quads): with pytest.raises(ValueError): mesh_quads.split_face(0, 0, 1) + + +# -------------------------------------------------------------------------- +# collapse +# -------------------------------------------------------------------------- + + +def test_is_collapse_legal_rejects_missing_and_boundary_edges(mesh_quads): + assert not is_collapse_legal(mesh_quads, (0, 99)) + assert not is_collapse_legal(mesh_quads, (0, 1)) + + +def test_mesh_collapse_edge_validates_edge_and_parameter(): + mesh = Mesh.from_polyhedron(20) + edge = next(mesh.edges()) + + with pytest.raises(ValueError): + mesh.collapse_edge((edge[0], 99)) + with pytest.raises(ValueError): + mesh.collapse_edge(edge, t=-0.1) + with pytest.raises(ValueError): + mesh.collapse_edge(edge, t=1.1) + + +def test_mesh_collapse_edge_respects_fixed_vertices(): + mesh = Mesh.from_polyhedron(20) + edge = next(mesh.edges()) + vertices = mesh.number_of_vertices() + + assert mesh.collapse_edge(edge, fixed=[edge[0]]) is False + assert mesh.number_of_vertices() == vertices + assert mesh.has_vertex(edge[0]) + assert mesh.has_vertex(edge[1]) + + +def test_mesh_collapse_edge_updates_topology_and_position(): + mesh = Mesh.from_polyhedron(20) + edge = next(mesh.edges()) + point = mesh.edge_point(edge, t=0.25) + vertices = mesh.number_of_vertices() + + assert mesh.collapse_edge(edge, t=0.25) is None + assert mesh.number_of_vertices() == vertices - 1 + assert mesh.has_vertex(edge[0]) + assert not mesh.has_vertex(edge[1]) + assert TOL.is_allclose(mesh.vertex_coordinates(edge[0]), point) + assert mesh.is_valid() + + +def test_trimesh_collapse_edge_returns_success_status(): + mesh = Mesh.from_polyhedron(20) + edge = next(mesh.edges()) + + assert trimesh_collapse_edge(mesh, edge) + assert mesh.is_valid() + assert mesh.is_manifold() + + +# -------------------------------------------------------------------------- +# remaining operations +# -------------------------------------------------------------------------- + + +def test_merge_adjacent_faces(): + mesh = Mesh.from_vertices_and_faces( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]], + [[0, 1, 2], [0, 2, 3]], + ) + + face = mesh_merge_faces(mesh, [0, 1]) + + assert face is not None + assert mesh.face_vertices(face) == [0, 1, 2, 3] + assert mesh.number_of_faces() == 1 + assert mesh.is_valid() + + +def test_merge_requires_two_adjacent_faces(): + mesh = Mesh.from_vertices_and_faces( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [2.0, 0.0, 0.0], [3.0, 0.0, 0.0], [2.0, 1.0, 0.0]], + [[0, 1, 2], [3, 4, 5]], + ) + + assert mesh_merge_faces(mesh, [0, 1]) is None + with pytest.raises(ValueError): + mesh_merge_faces(mesh, [0]) + + +def test_split_edge_boundary_policy(mesh_quads): + assert mesh_split_edge(mesh_quads, (0, 1)) is None + + vertex = mesh_split_edge(mesh_quads, (0, 1), allow_boundary=True) + + assert vertex is not None + assert mesh_quads.has_vertex(vertex) + assert mesh_quads.is_valid() + + +def test_trimesh_split_edge_preserves_validity(): + mesh = Mesh.from_polyhedron(20) + edge = next(mesh.edges()) + vertices = mesh.number_of_vertices() + faces = mesh.number_of_faces() + + vertex = trimesh_split_edge(mesh, edge) + + assert vertex is not None + assert mesh.number_of_vertices() == vertices + 1 + assert mesh.number_of_faces() == faces + 2 + assert mesh.is_valid() + + +def test_split_face_rejects_cyclic_neighbors(mesh_quads): + with pytest.raises(ValueError): + mesh_quads.split_face(0, 0, 3) + + +def test_split_strip(mesh_quads): + vertices = mesh_split_strip(mesh_quads, (1, 2)) + + assert len(vertices) == 3 + assert mesh_quads.number_of_vertices() == 9 + assert mesh_quads.number_of_faces() == 4 + assert mesh_quads.is_valid() + + +def test_substitute_vertex_accepts_face_iterables(mesh_0): + faces = mesh_substitute_vertex_in_faces(mesh_0, 0, 4, (face for face in [0])) + + assert faces == [0] + assert 4 in mesh_0.face_vertices(0) + assert 0 not in mesh_0.face_vertices(0) + assert 0 in mesh_0.face_vertices(1) + assert mesh_0.is_valid() + + +def test_swap_edge_preserves_trimesh_topology(): + mesh = Mesh.from_polyhedron(20) + edge = next(mesh.edges()) + faces = mesh.number_of_faces() + + result = trimesh_swap_edge(mesh, edge) + + assert result is not False + assert mesh.number_of_faces() == faces + assert mesh.is_valid() + assert mesh.is_manifold() + + +def test_unweld_face_vertices_preserves_attributes(): + mesh = Mesh.from_polyhedron(6) + face = next(mesh.faces()) + mesh.face_attribute(face, "name", "face") + for vertex in mesh.face_vertices(face): + mesh.vertex_attribute(vertex, "name", "vertex") + vertices = mesh.number_of_vertices() + + unwelded = mesh_unweld_vertices(mesh, face) + + assert mesh.number_of_vertices() == vertices + 4 + assert mesh.face_vertices(face) == unwelded + assert mesh.face_attribute(face, "name") == "face" + assert all(mesh.vertex_attribute(vertex, "name") == "vertex" for vertex in unwelded) + assert mesh.is_valid() + + +def test_unweld_edge_loop_opens_closed_mesh(): + mesh = Mesh.from_polyhedron(6) + face = next(mesh.faces()) + edges = mesh.face_halfedges(face) + + mesh_unweld_edges(mesh, edges) + + assert not mesh.is_closed() + assert mesh.edges_on_boundary() + assert mesh.is_valid() diff --git a/tests/compas/datastructures/test_mesh_remesh.py b/tests/compas/datastructures/test_mesh_remesh.py new file mode 100644 index 000000000000..e5c387846146 --- /dev/null +++ b/tests/compas/datastructures/test_mesh_remesh.py @@ -0,0 +1,55 @@ +import pytest + +from compas.datastructures import Mesh +from compas.datastructures.mesh.remesh import trimesh_remesh + + +@pytest.fixture +def triangle(): + return Mesh.from_vertices_and_faces( + [[0, 0, 0], [2, 0, 0], [0, 2, 0]], + [[0, 1, 2]], + ) + + +@pytest.mark.parametrize("target", [0.0, -1.0]) +def test_remesh_rejects_nonpositive_target_length(triangle, target): + with pytest.raises(ValueError, match="greater than zero"): + trimesh_remesh(triangle, target) + + +def test_remesh_splits_long_boundary_edge(triangle): + trimesh_remesh( + triangle, + target=0.5, + kmax=1, + allow_boundary_split=True, + smooth=False, + ) + + assert triangle.number_of_vertices() == 4 + assert triangle.number_of_edges() == 5 + assert triangle.number_of_faces() == 2 + assert triangle.is_valid() + + +def test_remesh_calls_callback_after_iteration(): + mesh = Mesh.from_polyhedron(4) + target = max(mesh.edge_length(edge) for edge in mesh.edges()) + events = [] + callback_args = {"name": "iteration"} + + def callback(current_mesh, iteration, args): + events.append((current_mesh, iteration, args)) + + trimesh_remesh( + mesh, + target=target, + kmax=1, + smooth=False, + callback=callback, + callback_args=callback_args, + ) + + assert events == [(mesh, 0, callback_args)] + assert mesh.is_valid() diff --git a/tests/compas/datastructures/test_mesh_slice.py b/tests/compas/datastructures/test_mesh_slice.py new file mode 100644 index 000000000000..3ebbefb807f2 --- /dev/null +++ b/tests/compas/datastructures/test_mesh_slice.py @@ -0,0 +1,36 @@ +from compas.datastructures import Mesh +from compas.datastructures.mesh.slice import mesh_slice_plane +from compas.geometry import Box +from compas.geometry import Plane + + +def test_slice_closed_mesh_constructs_two_closed_valid_meshes(): + mesh = Mesh.from_shape(Box.from_width_height_depth(2, 2, 2)) + + result = mesh_slice_plane(mesh, Plane((0, 0, 0), (1, 0, 0))) + + assert result is not None + positive, negative = result + assert positive.is_valid() and positive.is_closed() + assert negative.is_valid() and negative.is_closed() + assert positive.number_of_vertices() == 8 + assert negative.number_of_vertices() == 8 + assert positive.number_of_faces() == 6 + assert negative.number_of_faces() == 6 + + +def test_slice_returns_none_without_polygonal_intersection(): + mesh = Mesh.from_shape(Box.from_width_height_depth(2, 2, 2)) + + assert mesh_slice_plane(mesh, Plane((5, 0, 0), (1, 0, 0))) is None + + +def test_slice_preserves_mesh_type(): + class CustomMesh(Mesh): + pass + + mesh = CustomMesh.from_shape(Box.from_width_height_depth(2, 2, 2)) + result = mesh_slice_plane(mesh, Plane((0, 0, 0), (1, 0, 0))) + + assert result is not None + assert all(type(part) is CustomMesh for part in result) diff --git a/tests/compas/datastructures/test_mesh_smoothing.py b/tests/compas/datastructures/test_mesh_smoothing.py new file mode 100644 index 000000000000..14286bc45eef --- /dev/null +++ b/tests/compas/datastructures/test_mesh_smoothing.py @@ -0,0 +1,65 @@ +from typing import Any + +import pytest + +from compas.datastructures import Mesh +from compas.datastructures.mesh.smoothing import mesh_smooth_area +from compas.datastructures.mesh.smoothing import mesh_smooth_centerofmass +from compas.datastructures.mesh.smoothing import mesh_smooth_centroid +from compas.tolerance import TOL + + +SMOOTHING_FUNCTIONS = [ + mesh_smooth_centroid, + mesh_smooth_centerofmass, + mesh_smooth_area, +] + + +@pytest.fixture +def fan(): + return Mesh.from_vertices_and_faces( + [[0, 0, 0], [2, 0, 0], [2, 2, 0], [0, 2, 0], [1, 1, 1]], + [[0, 1, 4], [1, 2, 4], [2, 3, 4], [3, 0, 4]], + ) + + +@pytest.mark.parametrize( + ("smooth", "expected"), + [ + (mesh_smooth_centroid, [1, 1, 0]), + (mesh_smooth_centerofmass, [1, 1, 0]), + (mesh_smooth_area, [1, 1, 1.0 / 3.0]), + ], +) +def test_smoothing_moves_free_vertex_and_preserves_fixed_vertices(fan, smooth, expected): + fixed = [0, 1, 2, 3] + before = {vertex: fan.vertex_coordinates(vertex) for vertex in fixed} + + smooth(fan, fixed=fixed, kmax=1, damping=1.0) + + assert TOL.is_allclose(fan.vertex_coordinates(4), expected) + assert all(TOL.is_allclose(fan.vertex_coordinates(vertex), before[vertex]) for vertex in fixed) + + +@pytest.mark.parametrize("smooth", SMOOTHING_FUNCTIONS) +def test_smoothing_calls_callback_after_each_iteration(fan, smooth): + events = [] + callback_args = {"name": "smoothing"} + + smooth( + fan, + fixed=list(fan.vertices()), + kmax=2, + callback=lambda iteration, args: events.append((iteration, args)), + callback_args=callback_args, + ) + + assert events == [(0, callback_args), (1, callback_args)] + + +@pytest.mark.parametrize("smooth", SMOOTHING_FUNCTIONS) +def test_smoothing_rejects_noncallable_callback(fan, smooth): + callback: Any = 1 + with pytest.raises(TypeError, match="Callback is not callable"): + smooth(fan, callback=callback) diff --git a/tests/compas/datastructures/test_mesh_subd.py b/tests/compas/datastructures/test_mesh_subd.py index 2ee5947800b8..ad64876e54c0 100644 --- a/tests/compas/datastructures/test_mesh_subd.py +++ b/tests/compas/datastructures/test_mesh_subd.py @@ -1,6 +1,9 @@ +from typing import Any + import pytest from compas.datastructures import Mesh +from compas.datastructures.mesh.subdivision import mesh_subdivide @pytest.fixture @@ -50,3 +53,69 @@ def test_tris_subdivide_quad(mesh_tris): subd = mesh_tris.subdivided(scheme="quad") assert subd.number_of_faces() == 3 * mesh_tris.number_of_faces() assert subd.number_of_vertices() == (mesh_tris.number_of_vertices() + mesh_tris.number_of_edges() + mesh_tris.number_of_faces()) + + +@pytest.mark.parametrize( + ("scheme", "options", "vertices", "faces"), + [ + ("tri", {}, 14, 24), + ("quad", {}, 26, 24), + ("corner", {}, 20, 30), + ("catmullclark", {}, 26, 24), + ("doosabin", {}, 24, 26), + ("frames", {"offset": 0.1}, 32, 24), + ("frames", {"offset": 0.1, "add_windows": True}, 32, 30), + ], +) +def test_subdivision_schemes_have_expected_counts_and_preserve_source(mesh_quads, scheme, options, vertices, faces): + source_data = mesh_quads.__data__ + + subd = mesh_subdivide(mesh_quads, scheme=scheme, **options) + + assert subd.number_of_vertices() == vertices + assert subd.number_of_faces() == faces + assert subd.is_valid() + assert mesh_quads.__data__ == source_data + + +def test_loop_subdivision_has_expected_counts_and_preserves_source(mesh_tris): + source_data = mesh_tris.__data__ + + subd = mesh_subdivide(mesh_tris, scheme="loop") + + assert subd.number_of_vertices() == 26 + assert subd.number_of_faces() == 48 + assert subd.is_valid() + assert mesh_tris.__data__ == source_data + + +@pytest.mark.parametrize("scheme", ["tri", "quad", "corner", "catmullclark", "doosabin"]) +def test_zero_subdivision_levels_returns_independent_copy(mesh_quads, scheme): + subd = mesh_subdivide(mesh_quads, scheme=scheme, k=0) + + assert subd is not mesh_quads + assert type(subd) is type(mesh_quads) + assert subd.__data__ == mesh_quads.__data__ + + +def test_zero_loop_subdivision_levels_returns_independent_copy(mesh_tris): + subd = mesh_subdivide(mesh_tris, scheme="loop", k=0) + + assert subd is not mesh_tris + assert type(subd) is type(mesh_tris) + assert subd.__data__ == mesh_tris.__data__ + + +def test_subdivision_preserves_mesh_type(mesh_quads): + class CustomMesh(Mesh): + pass + + mesh = CustomMesh.__from_data__(mesh_quads.__data__) + + assert type(mesh_subdivide(mesh, scheme="quad")) is CustomMesh + + +def test_subdivision_rejects_unsupported_scheme(mesh_quads): + scheme: Any = "invalid" + with pytest.raises(ValueError, match="not supported: invalid"): + mesh_subdivide(mesh_quads, scheme=scheme) diff --git a/tests/compas/datastructures/test_mutablemapping.py b/tests/compas/datastructures/test_mutablemapping.py new file mode 100644 index 000000000000..b7405c854d68 --- /dev/null +++ b/tests/compas/datastructures/test_mutablemapping.py @@ -0,0 +1,55 @@ +from compas.datastructures._mutablemapping import MutableMapping + + +class CustomMapping(MutableMapping[str, object]): + def __init__(self): + self.data = {} + + def __getitem__(self, key): + return self.data[key] + + def __setitem__(self, key, value): + self.data[key] = value + + def __delitem__(self, key): + del self.data[key] + + def __iter__(self): + return iter(self.data) + + def __len__(self): + return len(self.data) + + +def test_mutablemapping_views_and_defaults(): + mapping = CustomMapping() + + assert mapping.get("missing") is None + assert mapping.get("missing", 1) == 1 + assert mapping.setdefault("key", 1) == 1 + assert list(mapping.keys()) == ["key"] + assert list(mapping.items()) == [("key", 1)] + assert list(mapping.values()) == [1] + + +def test_mutablemapping_update(): + mapping = CustomMapping() + + mapping.update({"a": 1}) + mapping.update([("b", 2)]) + mapping.update(c=3) + + assert mapping == {"a": 1, "b": 2, "c": 3} + + +def test_mutablemapping_removal(): + mapping = CustomMapping() + mapping.update(a=1, b=2) + + assert mapping.pop("a") == 1 + assert mapping.pop("missing", None) is None + assert mapping.popitem() == ("b", 2) + + mapping.update(a=1, b=2) + mapping.clear() + assert not mapping diff --git a/tests/compas/datastructures/test_tree.py b/tests/compas/datastructures/test_tree.py index b04a42fa5663..1e0f1c0f0cdc 100644 --- a/tests/compas/datastructures/test_tree.py +++ b/tests/compas/datastructures/test_tree.py @@ -1,6 +1,4 @@ import pytest -import compas -import json from compas.datastructures import Tree, TreeNode from compas.data import json_dumps, json_loads @@ -129,6 +127,22 @@ def test_tree_traversal(simple_tree): assert nodes == ["root", "branch1", "branch2", "leaf1_1", "leaf1_2", "leaf2_1", "leaf2_2"] +def test_tree_invalid_traversal(simple_tree): + with pytest.raises(ValueError): + list(simple_tree.traverse(strategy="unknown")) + + with pytest.raises(ValueError): + list(simple_tree.traverse(order="unknown")) + + +def test_treenode_ancestors_and_descendants(simple_tree): + branch1 = simple_tree.get_node_by_name("branch1") + leaf1_1 = simple_tree.get_node_by_name("leaf1_1") + + assert [node.name for node in leaf1_1.ancestors] == ["branch1", "root"] + assert [node.name for node in branch1.descendants] == ["leaf1_1", "leaf1_2"] + + # ============================================================================= # Tree Manipulation # ============================================================================= @@ -173,9 +187,30 @@ def test_tree_serialization(simple_tree): test_tree_add_node(deserialized) test_tree_remove_node(json_loads(serialized)) - if not compas.IPY: - data = json.loads(serialized)["data"] - assert Tree.validate_data(data) + +def test_empty_tree_from_data(): + tree = Tree.__from_data__(Tree().__data__) + + assert tree.root is None + assert list(tree.nodes) == [] + + +def test_get_nodes_by_name(simple_tree): + branch1 = simple_tree.get_node_by_name("branch1") + branch1.add(TreeNode(name="duplicate")) + branch1.add(TreeNode(name="duplicate")) + + assert len(simple_tree.get_nodes_by_name("duplicate")) == 2 + assert simple_tree.get_node_by_name("missing") is None + + +def test_hierarchy_max_depth(simple_tree): + hierarchy = simple_tree.get_hierarchy_string(max_depth=1) + + assert "root" in hierarchy + assert "branch1" in hierarchy + assert "branch2" in hierarchy + assert "leaf1_1" not in hierarchy # ============================================================================= diff --git a/tests/compas/datastructures/test_volmesh.py b/tests/compas/datastructures/test_volmesh.py index 60147a1572e9..80dc980b2502 100644 --- a/tests/compas/datastructures/test_volmesh.py +++ b/tests/compas/datastructures/test_volmesh.py @@ -2,6 +2,7 @@ import json import compas from compas.datastructures import VolMesh +from compas.geometry import Scale # ============================================================================== # Fixtures @@ -40,10 +41,6 @@ def test_halfface_data(halfface): assert halfface.number_of_faces() == other.number_of_faces() assert halfface.number_of_cells() == other.number_of_cells() - if not compas.IPY: - assert VolMesh.validate_data(halfface.__data__) - assert VolMesh.validate_data(other.__data__) - def test_volmesh_data(): vmesh = VolMesh.from_obj(compas.get("boxes.obj")) @@ -55,23 +52,142 @@ def test_volmesh_data(): assert vmesh.number_of_faces() == other.number_of_faces() assert vmesh.number_of_cells() == other.number_of_cells() - if not compas.IPY: - assert VolMesh.validate_data(vmesh.__data__) - assert VolMesh.validate_data(other.__data__) + +def test_volmesh_data_preserves_cell_attributes(): + volmesh = VolMesh.from_meshgrid(1, nx=2, ny=1, nz=1) + volmesh.cell_attribute(0, "name", "first") + volmesh.cell_attribute(1, "name", "second") + + data = json.loads(json.dumps(volmesh.__data__)) + other = VolMesh.__from_data__(data) + + assert other.cell_attribute(0, "name") == "first" + assert other.cell_attribute(1, "name") == "second" + assert other.__data__ == data + + +def test_volmesh_constructors_preserve_subclass(): + class CustomVolMesh(VolMesh): + pass + + volmesh = CustomVolMesh.from_meshgrid(1, nx=1, ny=1, nz=1) + other = CustomVolMesh.__from_data__(json.loads(json.dumps(volmesh.__data__))) + + assert type(volmesh) is CustomVolMesh + assert type(other) is CustomVolMesh + + +def test_volmesh_vertices_and_cells_roundtrip(): + volmesh = VolMesh.from_meshgrid(1, 2, 3, 2, 1, 1) + + vertices, cells = volmesh.to_vertices_and_cells() + other = VolMesh.from_vertices_and_cells(vertices, cells) + + assert other.number_of_vertices() == volmesh.number_of_vertices() + assert other.number_of_edges() == volmesh.number_of_edges() + assert other.number_of_faces() == volmesh.number_of_faces() + assert other.number_of_cells() == volmesh.number_of_cells() + + +def test_volmesh_clear_resets_topology(halfface): + halfface.clear() + + assert halfface.number_of_vertices() == 0 + assert halfface.number_of_edges() == 0 + assert halfface.number_of_faces() == 0 + assert halfface.number_of_cells() == 0 + assert halfface.__data__["max_vertex"] == -1 + assert halfface.__data__["max_face"] == -1 + assert halfface.__data__["max_cell"] == -1 # ============================================================================== # Builders # ============================================================================== + +def test_add_halfface_preserves_input_attributes(): + volmesh = VolMesh() + for vertex in range(3): + volmesh.add_vertex(vertex, x=vertex) + attributes = {"name": "triangle"} + + face = volmesh.add_halfface([0, 1, 2], attr_dict=attributes, color="red") + + assert set(volmesh.vertices()) == {0, 1, 2} + assert volmesh.face_attribute(face, "name") == "triangle" + assert volmesh.face_attribute(face, "color") == "red" + assert attributes == {"name": "triangle"} + + +def test_add_halfface_rejects_missing_vertices(): + volmesh = VolMesh() + volmesh.add_vertex(0, x=0, y=0, z=0) + + with pytest.raises(ValueError, match=r"not part of the volmesh: \[1, 2\]"): + volmesh.add_halfface([0, 1, 2]) + + assert list(volmesh.vertices()) == [0] + assert list(volmesh.halffaces()) == [] + + +def test_builders_do_not_mutate_input_attributes(): + volmesh = VolMesh() + vertex_attributes = {"name": "vertex"} + cell_attributes = {"name": "cell"} + + volmesh.add_vertex(0, attr_dict=vertex_attributes, color="red") + for vertex in (1, 2, 3): + volmesh.add_vertex(vertex) + volmesh.add_cell( + [[0, 2, 1], [0, 1, 3], [1, 2, 3], [2, 0, 3]], + attr_dict=cell_attributes, + color="blue", + ) + + assert vertex_attributes == {"name": "vertex"} + assert cell_attributes == {"name": "cell"} + + # ============================================================================== # Modifiers # ============================================================================== + +def test_delete_vertex_removes_vertex_and_incident_cells(): + volmesh = VolMesh.from_meshgrid(1, nx=1, ny=1, nz=1) + + volmesh.delete_vertex(0) + + assert not volmesh.has_vertex(0) + assert volmesh.number_of_vertices() == 7 + assert volmesh.number_of_edges() == 0 + assert volmesh.number_of_faces() == 0 + assert volmesh.number_of_cells() == 0 + + # ============================================================================== # Samples # ============================================================================== + +def test_samples_and_vertex_maps(): + volmesh = VolMesh.from_meshgrid(1, nx=2, ny=1, nz=1) + + assert len(volmesh.vertex_sample(2)) == 2 + assert len(volmesh.edge_sample(2)) == 2 + assert len(volmesh.face_sample(2)) == 2 + assert len(volmesh.cell_sample(2)) == 2 + assert volmesh.vertex_index() == {vertex: index for index, vertex in volmesh.index_vertex().items()} + assert set(volmesh.vertex_gkey()) == set(volmesh.vertices()) + assert set(volmesh.gkey_vertex().values()) == set(volmesh.vertices()) + + +def test_volmesh_validity_check_is_not_implemented(halfface): + with pytest.raises(NotImplementedError): + halfface.is_valid() + + # ============================================================================== # Topology # ============================================================================== @@ -191,6 +307,27 @@ def test_default_vertex_attributes(): assert he.vertex_attribute(vertex, name="a") == 3 +def test_vertex_attribute_can_be_set_to_none(halfface): + vertex = next(iter(halfface.vertices())) + + halfface.vertex_attribute(vertex, "nullable", None) + halfface.vertices_attribute("other", None, keys=[vertex]) + + assert "nullable" in halfface._vertex[vertex] + assert "other" in halfface._vertex[vertex] + assert halfface.vertex_attribute(vertex, "nullable") is None + assert halfface.vertex_attribute(vertex, "other") is None + + +def test_vertex_attribute_updates_do_not_mutate_inputs(halfface): + attributes = {"color": "red"} + + halfface.update_default_vertex_attributes(attributes, weight=1.0) + + assert attributes == {"color": "red"} + assert halfface.default_vertex_attributes["weight"] == 1.0 + + # ============================================================================== # Face Attributes # ============================================================================== @@ -205,6 +342,27 @@ def test_default_face_attributes(): assert he.face_attribute(face, name="a") == 3 +def test_face_attribute_can_be_set_to_none(halfface): + face = next(iter(halfface.faces())) + + halfface.face_attribute(face, "nullable", None) + halfface.faces_attribute("other", None, faces=[face]) + + assert "nullable" in halfface.face_attributes(face) + assert "other" in halfface.face_attributes(face) + assert halfface.face_attribute(face, "nullable") is None + assert halfface.face_attribute(face, "other") is None + + +def test_face_attribute_updates_do_not_mutate_inputs(halfface): + attributes = {"color": "red"} + + halfface.update_default_face_attributes(attributes, weight=1.0) + + assert attributes == {"color": "red"} + assert halfface.default_face_attributes["weight"] == 1.0 + + # ============================================================================== # Edge Attributes # ============================================================================== @@ -219,6 +377,27 @@ def test_default_edge_attributes(): assert he.edge_attribute(edge, name="a") == 3 +def test_edge_attribute_can_be_set_to_none(halfface): + edge = next(iter(halfface.edges())) + + halfface.edge_attribute(edge, "nullable", None) + halfface.edges_attribute("other", None, edges=[edge]) + + assert "nullable" in halfface.edge_attributes(edge) + assert "other" in halfface.edge_attributes(edge) + assert halfface.edge_attribute(edge[::-1], "nullable") is None + assert halfface.edge_attribute(edge[::-1], "other") is None + + +def test_edge_attribute_updates_do_not_mutate_inputs(halfface): + attributes = {"color": "red"} + + halfface.update_default_edge_attributes(attributes, weight=1.0) + + assert attributes == {"color": "red"} + assert halfface.default_edge_attributes["weight"] == 1.0 + + # ============================================================================== # Cell Attributes # ============================================================================== @@ -233,6 +412,27 @@ def test_default_cell_attributes(): assert he.cell_attribute(cell, name="a") == 3 +def test_cell_attribute_can_be_set_to_none(halfface): + cell = next(iter(halfface.cells())) + + halfface.cell_attribute(cell, "nullable", None) + halfface.cells_attribute("other", None, cells=[cell]) + + assert "nullable" in halfface.cell_attributes(cell) + assert "other" in halfface.cell_attributes(cell) + assert halfface.cell_attribute(cell, "nullable") is None + assert halfface.cell_attribute(cell, "other") is None + + +def test_cell_attribute_updates_do_not_mutate_inputs(halfface): + attributes = {"color": "red"} + + halfface.update_default_cell_attributes(attributes, weight=1.0) + + assert attributes == {"color": "red"} + assert halfface.default_cell_attributes["weight"] == 1.0 + + # ============================================================================== # Vertex Queries # ============================================================================== @@ -247,6 +447,16 @@ def test_vertices_where(): assert list(hf.vertices_where({"a": 1, "b": 2}))[0] == 0 +def test_vertices_where_does_not_mutate_conditions(): + volmesh = VolMesh.from_meshgrid(1, nx=1, ny=1, nz=1) + conditions = {"x": (0, 0)} + + vertices = list(volmesh.vertices_where(conditions)) + + assert len(vertices) == 4 + assert conditions == {"x": (0, 0)} + + def test_vertices_where_predicate(): hf = VolMesh(default_vertex_attributes={"a": 1, "b": 2}) hf.add_vertex(0) @@ -255,6 +465,24 @@ def test_vertices_where_predicate(): assert list(hf.vertices_where_predicate(lambda v, attr: attr["b"] - attr["a"] == 5)) == [1, 2] +def test_vertex_neighborhood_rejects_nonpositive_ring(halfface): + vertex = next(iter(halfface.vertices())) + + with pytest.raises(ValueError, match="greater than or equal to 1"): + halfface.vertex_neighborhood(vertex, ring=0) + + +def test_vertex_accessors_and_geometry(halfface): + vertex = next(iter(halfface.vertices())) + + assert len(list(halfface.vertices(data=True))) == halfface.number_of_vertices() + assert halfface.vertex_degree(vertex) == len(halfface.vertex_neighbors(vertex)) + assert set(halfface.vertex_edges(vertex)) == {(vertex, neighbor) for neighbor in halfface.vertex_neighbors(vertex)} + assert halfface.vertex_point(vertex) == halfface.vertex_coordinates(vertex) + assert len(halfface.vertex_laplacian(vertex)) == 3 + assert len(halfface.vertex_neighborhood_centroid(vertex)) == 3 + + # ============================================================================== # Edge Queries # ============================================================================== @@ -269,6 +497,16 @@ def test_edges_where(): assert list(hf.edges_where({"a": 1})) == [(0, 2), (1, 2)] +def test_edges_where_does_not_mutate_conditions(): + volmesh = VolMesh.from_meshgrid(1, nx=1, ny=1, nz=1) + conditions = {"edge_length": (0.9, 1.1)} + + edges = list(volmesh.edges_where(conditions)) + + assert len(edges) == 12 + assert conditions == {"edge_length": (0.9, 1.1)} + + def test_edges_where_predicate(): hf = VolMesh(default_edge_attributes={"a": 1, "b": 2}) for vkey in range(3): @@ -278,6 +516,39 @@ def test_edges_where_predicate(): assert list(hf.edges_where_predicate(lambda e, attr: attr["a"] - attr["b"] == 3))[0] == (0, 1) +def test_edge_topology_and_geometry(): + volmesh = VolMesh.from_meshgrid(1, nx=1, ny=1, nz=1) + edge = next(iter(volmesh.edges())) + + assert volmesh.has_edge(edge) + assert volmesh.has_edge(edge[::-1]) + assert len(volmesh.edge_halffaces(edge)) == 1 + assert len(volmesh.edge_cells(edge)) == 1 + assert volmesh.is_edge_on_boundary(edge) + assert volmesh.edge_start(edge) == volmesh.edge_coordinates(edge)[0] + assert volmesh.edge_end(edge) == volmesh.edge_coordinates(edge)[1] + assert volmesh.edge_point(edge, 0.5) == volmesh.edge_midpoint(edge) + assert len(volmesh.edge_vector(edge)) == 3 + assert len(volmesh.edge_direction(edge)) == 3 + assert volmesh.edge_line(edge).length == volmesh.edge_length(edge) + + +def test_edge_attributes_survive_while_edge_remains(): + volmesh = VolMesh.from_meshgrid(1, nx=2, ny=1, nz=1) + edge = next(edge for edge in volmesh.edges() if len(volmesh.edge_cells(edge)) == 2) + volmesh.edge_attribute(edge, "keep", True) + + volmesh.delete_cell(0) + + assert volmesh.has_edge(edge) + assert volmesh.edge_attribute(edge, "keep") is True + + volmesh.delete_cell(1) + + assert not volmesh.has_edge(edge) + assert not volmesh._edge_data + + # ============================================================================== # Face Queries # ============================================================================== @@ -293,6 +564,16 @@ def test_faces_where(): assert list(hf.faces_where({"a": 1})) == [0, 2] +def test_faces_where_does_not_mutate_conditions(): + volmesh = VolMesh.from_meshgrid(1, nx=2, ny=2, nz=1) + conditions = {"face_area": (0.2, 0.6)} + + faces = list(volmesh.faces_where(conditions)) + + assert faces + assert conditions == {"face_area": (0.2, 0.6)} + + def test_faces_where_predicate(): hf = VolMesh(default_face_attributes={"a": 1, "b": 2}) for vkey in range(5): @@ -303,6 +584,64 @@ def test_faces_where_predicate(): assert list(hf.faces_where_predicate(lambda e, attr: attr["a"] - attr["b"] == 3))[0] == 1 +def test_halfface_topology_and_face_geometry(): + volmesh = VolMesh.from_meshgrid(1, nx=2, ny=2, nz=1) + face = next(iter(volmesh.faces())) + vertices = volmesh.face_vertices(face) + + assert volmesh.has_halfface(face) + assert volmesh.halfface_vertices(face) == vertices + assert len(volmesh.halfface_halfedges(face)) == len(vertices) + assert volmesh.halfface_vertex_ancestor(face, vertices[0]) == vertices[-1] + assert volmesh.halfface_vertex_descendent(face, vertices[-1]) == vertices[0] + assert volmesh.halfface_cell(face) is not None + for halfface in volmesh.halffaces(): + assert all(volmesh.has_halfface(neighbor) for neighbor in volmesh.halfface_manifold_neighbors(halfface)) + assert len(volmesh.face_coordinates(face)) == len(vertices) + assert len(volmesh.face_points(face)) == len(vertices) + assert len(volmesh.face_polygon(face).points) == len(vertices) + assert len(volmesh.face_normal(face)) == 3 + assert len(volmesh.face_centroid(face)) == 3 + assert len(volmesh.face_center(face)) == 3 + assert volmesh.face_area(face) > 0 + assert volmesh.face_flatness(face) == 0 + assert volmesh.face_aspect_ratio(face) >= 1 + + +def test_halfface_manifold_neighbors_support_unattached_halfface(): + volmesh = VolMesh.from_vertices_and_cells([[0, 0, 0], [1, 0, 0], [0, 1, 0]], []) + face = volmesh.add_halfface([0, 1, 2]) + + assert volmesh.halfface_cell(face) is None + assert volmesh.halfface_manifold_neighbors(face) == [] + + +def test_halfface_manifold_neighborhood_rejects_nonpositive_ring(halfface): + face = next(iter(halfface.halffaces())) + + with pytest.raises(ValueError, match="greater than or equal to 1"): + halfface.halfface_manifold_neighborhood(face, ring=0) + + +def test_face_attributes_survive_while_shared_face_remains(): + volmesh = VolMesh.from_meshgrid(1, nx=2, ny=1, nz=1) + face = next(face for face in volmesh.faces() if volmesh.halfface_opposite_cell(face) is not None) + vertices = set(volmesh.face_vertices(face)) + cell = volmesh.halfface_cell(face) + opposite = volmesh.halfface_opposite_cell(face) + assert cell is not None and opposite is not None + volmesh.face_attribute(face, "keep", True) + + volmesh.delete_cell(cell) + + remaining = next(face for face in volmesh.faces() if set(volmesh.face_vertices(face)) == vertices) + assert volmesh.face_attribute(remaining, "keep") is True + + volmesh.delete_cell(opposite) + + assert all("keep" not in attributes for attributes in volmesh._face_data.values()) + + # ============================================================================== # Cell Queries # ============================================================================== @@ -325,6 +664,17 @@ def test_cells_where(): assert list(hf.cells_where({"a": 1})) == [0, 2] +def test_cells_where_does_not_mutate_conditions(): + volmesh = VolMesh.from_meshgrid(1, nx=2, ny=1, nz=1) + volmesh.cells_attribute("group", "cell") + conditions = {"group": "cell"} + + cells = list(volmesh.cells_where(conditions)) + + assert cells == list(volmesh.cells()) + assert conditions == {"group": "cell"} + + def test_cells_where_predicate(): hf = VolMesh(default_cell_attributes={"a": 1, "b": 2}) for vkey in range(6): @@ -342,6 +692,51 @@ def test_cells_where_predicate(): assert list(hf.cells_where_predicate(lambda e, attr: attr["a"] - attr["b"] == 3))[0] == 1 +def test_cell_topology_and_geometry(): + volmesh = VolMesh.from_meshgrid(3, 1, 1, nx=3, ny=1, nz=1) + + assert [volmesh.cell_neighbors(cell) for cell in volmesh.cells()] == [[1], [2, 0], [1]] + for cell in volmesh.cells(): + assert volmesh.has_cell(cell) + assert len(volmesh.cell_vertices(cell)) == 8 + assert len(volmesh.cell_halfedges(cell)) == 24 + assert len(volmesh.cell_edges(cell)) == 12 + assert len(volmesh.cell_faces(cell)) == 6 + for vertex in volmesh.cell_vertices(cell): + assert len(volmesh.cell_vertex_neighbors(cell, vertex)) == 3 + assert len(volmesh.cell_vertex_faces(cell, vertex)) == 3 + for face in volmesh.cell_faces(cell): + assert len(volmesh.cell_face_neighbors(cell, face)) == 4 + + cell = 0 + vertex = volmesh.cell_vertices(cell)[0] + assert len(volmesh.cell_points(cell)) == 8 + assert len(volmesh.cell_lines(cell)) == 12 + assert len(volmesh.cell_polygons(cell)) == 6 + assert len(volmesh.cell_centroid(cell)) == 3 + assert len(volmesh.cell_center(cell)) == 3 + assert len(volmesh.cell_vertex_normal(cell, vertex)) == 3 + assert len(volmesh.cell_polyhedron(cell).vertices) == 8 + + +def test_boundary_collections(): + volmesh = VolMesh.from_meshgrid(1, nx=3, ny=3, nz=3) + + assert len(volmesh.vertices_on_boundaries()) == 56 + assert len(volmesh.halffaces_on_boundaries()) == 54 + assert len(volmesh.cells_on_boundaries()) == 26 + + +def test_volmesh_transform(): + volmesh = VolMesh.from_meshgrid(1, nx=1, ny=1, nz=1) + original = {vertex: volmesh.vertex_coordinates(vertex) for vertex in volmesh.vertices()} + + volmesh.transform(Scale.from_factors([2, 2, 2])) + + for vertex, xyz in original.items(): + assert volmesh.vertex_coordinates(vertex) == [2 * value for value in xyz] + + # ============================================================================== # Conversion # ============================================================================== diff --git a/tests/compas/files/test_gltf.py b/tests/compas/files/test_gltf.py index b921ad5adb88..ab5d6615709c 100644 --- a/tests/compas/files/test_gltf.py +++ b/tests/compas/files/test_gltf.py @@ -1,182 +1,63 @@ -import json -import os -import pytest -from compas.files import GLTF -from compas.files import GLTFContent -from compas.tolerance import TOL - -# Temporary change the global precision in the TOL class to 12 -TOL.precision = 12 - -BASE_FOLDER = os.path.dirname(__file__) - - -@pytest.fixture -def simple_gltf(): - return os.path.join(BASE_FOLDER, "fixtures", "gltf", "SimpleMeshes.gltf") - - -@pytest.fixture -def embedded_gltf(): - return os.path.join(BASE_FOLDER, "fixtures", "gltf", "SimpleMeshesEmbedded.gltf") - - -@pytest.fixture -def interleaved_glb(): - return os.path.join(BASE_FOLDER, "fixtures", "gltf", "BoxInterleaved.glb") - - -@pytest.fixture -def indexless_gltf(): - return os.path.join(BASE_FOLDER, "fixtures", "gltf", "TriangleWithoutIndices.gltf") - - -@pytest.fixture -def morph_gltf(): - return os.path.join(BASE_FOLDER, "fixtures", "gltf", "SimpleMorph.gltf") - - -@pytest.fixture -def sparse_gltf(): - return os.path.join(BASE_FOLDER, "fixtures", "gltf", "SimpleSparseAccessor.gltf") - - -@pytest.fixture -def animated_gltf(): - return os.path.join(BASE_FOLDER, "fixtures", "gltf", "AnimatedMorphCube.glb") - - -@pytest.fixture -def textured_gltf(): - return os.path.join(BASE_FOLDER, "fixtures", "gltf", "BoxTextured.glb") - - -@pytest.fixture -def specglossmetalrough_gltf(): - return os.path.join(BASE_FOLDER, "fixtures", "gltf", "SpecGlossVsMetalRough.glb") - - -@pytest.fixture -def specular_gltf(): - return os.path.join(BASE_FOLDER, "fixtures", "gltf", "SpecularTest.glb") - - -def test_simple_gltf(simple_gltf): - gltf = GLTF(simple_gltf) - gltf.read() - assert len(gltf.content.scenes[0].children) == 2 - - exporter = gltf.exporter - json.dumps(exporter._gltf_dict) - assert len(exporter._gltf_dict["nodes"]) == 2 - assert len(exporter._gltf_dict["meshes"]) == 1 - assert len(exporter._buffer) == exporter._gltf_dict["buffers"][0]["byteLength"] - - -def test_embedded_gltf(embedded_gltf): - gltf = GLTF(embedded_gltf) - gltf.read() - assert len(gltf.content.scenes[0].children) > 1 - - exporter = gltf.exporter - exporter.embed_data = True - exporter.load() - json.dumps(exporter._gltf_dict) - assert exporter._gltf_dict["buffers"][0]["uri"].startswith("data") - assert "animations" not in exporter._gltf_dict - assert "materials" not in exporter._gltf_dict - - -def test_interleaved_glb(interleaved_glb): - gltf = GLTF(interleaved_glb) - gltf.read() - assert len(gltf.content.nodes) == 2 - assert len(gltf.content.meshes[0].vertices) == 24 - - -def test_indexless_gltf(indexless_gltf): - gltf = GLTF(indexless_gltf) - gltf.read() - assert len(gltf.content.meshes[0].vertices) > 0 - - -def test_morph_gltf(morph_gltf): - gltf = GLTF(morph_gltf) - gltf.read() - assert (0.5, 1.5, 0.0) in gltf.content.meshes[0].vertices +from pathlib import Path +import pytest -def test_sparse_gltf(sparse_gltf): - gltf = GLTF(sparse_gltf) - gltf.read() - assert (5.0, 4.0, 0.0) in gltf.content.meshes[0].vertices - assert len(gltf.content.meshes[0].faces) > 0 - assert (5.0, 4.0, 0.0) in gltf.content.nodes[0].vertices +from compas.files import GLTFDocument +from compas.files import GLTFEncoder +from compas.files import read_gltf -def test_animated_gltf(animated_gltf): - gltf = GLTF(animated_gltf) - gltf.read() - assert len(gltf.content.animations) > 0 +FIXTURES = Path(__file__).parent / "fixtures" / "gltf" - exporter = gltf.exporter - json.dumps(exporter._gltf_dict) - assert len(exporter._gltf_dict["animations"]) > 0 - assert "images" not in exporter._gltf_dict +@pytest.mark.parametrize( + ("filename", "collection"), + [ + ("SimpleMeshes.gltf", "meshes"), + ("SimpleMeshesEmbedded.gltf", "scenes"), + ("BoxInterleaved.glb", "nodes"), + ("TriangleWithoutIndices.gltf", "meshes"), + ("SimpleMorph.gltf", "meshes"), + ("SimpleSparseAccessor.gltf", "meshes"), + ("AnimatedMorphCube.glb", "animations"), + ("BoxTextured.glb", "materials"), + ("SpecGlossVsMetalRough.glb", "materials"), + ("SpecularTest.glb", "materials"), + ], +) +def test_gltf_fixtures_parse_and_encode(filename, collection): + document = read_gltf(FIXTURES / filename) + payload = GLTFEncoder(format="glb").encode(document) -def test_textured_gltf(textured_gltf): - gltf = GLTF(textured_gltf) - gltf.read() - assert len(gltf.content.materials) > 0 + assert getattr(document, collection) + assert payload.json[collection] - exporter = gltf.exporter - json.dumps(exporter._gltf_dict) - assert len(exporter._gltf_dict["materials"]) > 0 - assert len(exporter._gltf_dict["samplers"]) > 0 - assert len(exporter._gltf_dict["images"]) > 0 - assert len(exporter._gltf_dict["textures"]) > 0 - assert "animations" not in exporter._gltf_dict +def test_interleaved_vertices(): + document = read_gltf(FIXTURES / "BoxInterleaved.glb") -def test_specglossmetalrough(specglossmetalrough_gltf): - gltf = GLTF(specglossmetalrough_gltf) - gltf.read() - exporter = gltf.exporter - json.dumps(exporter._gltf_dict) - assert exporter._content.extensions_used == ["KHR_materials_pbrSpecularGlossiness"] + assert len(document.meshes[0].vertices) == 24 -def test_specular(specular_gltf): - gltf = GLTF(specular_gltf) - gltf.read() - exporter = gltf.exporter - json.dumps(exporter._gltf_dict) - assert exporter._content.extensions_used == ["KHR_materials_specular"] +def test_morph_and_sparse_vertices(): + morph = read_gltf(FIXTURES / "SimpleMorph.gltf") + sparse = read_gltf(FIXTURES / "SimpleSparseAccessor.gltf") + assert (0.5, 1.5, 0.0) in morph.meshes[0].vertices + assert (5.0, 4.0, 0.0) in sparse.meshes[0].vertices + assert (5.0, 4.0, 0.0) in sparse.nodes[0].vertices -def test_gltf_content(): - content = GLTFContent() - scene = content.add_scene() - assert len(content.scenes) == 1 - node_0 = scene.add_child() - assert len(content.nodes) == 1 - assert len(scene.children) == 1 - assert len(node_0.children) == 0 +def test_document_scene_editing(): + document = GLTFDocument() + scene = document.add_scene() + node = scene.add_child() + node.add_child() - node_0.add_child() - assert len(content.nodes) == 2 - assert len(node_0.children) == 1 + assert len(document.nodes) == 2 assert len(scene.nodes) == 2 - assert len(scene.positions_and_edges[0]) == 3 - - node_0.children = [] - content.remove_orphans() - assert len(node_0.children) == 0 - assert len(content.nodes) == 1 - assert len(scene.nodes) == 1 + node.children = [] + document.remove_orphans() -# Reset the precision to its default value -TOL.precision = TOL.PRECISION + assert len(document.nodes) == 1 diff --git a/tests/compas/files/test_gltf_container.py b/tests/compas/files/test_gltf_container.py new file mode 100644 index 000000000000..e81255be3ae2 --- /dev/null +++ b/tests/compas/files/test_gltf_container.py @@ -0,0 +1,45 @@ +import json +import struct + +import pytest + +from compas.files.gltf.gltf_container import GLTFParseError +from compas.files.gltf.gltf_container import parse_container + + +def glb(json_document, binary=b""): + json_data = json.dumps(json_document).encode("utf-8") + json_data += b" " * (-len(json_data) % 4) + binary += b"\0" * (-len(binary) % 4) + length = 20 + len(json_data) + (8 + len(binary) if binary else 0) + data = b"glTF" + struct.pack(" 0 - - stl = STL(binary_stl) - assert len(stl.parser.vertices) > 0 - - stl = STL(binary_stl_with_ascii_header) - assert len(stl.parser.vertices) > 0 - - -def test_binary_read_write_fidelity(): - mesh = Mesh.from_stl(compas.get("cube_binary.stl")) - fp = compas.get("cube_binary_2.stl") - mesh.to_stl(fp, binary=True) - mesh_2 = Mesh.from_stl(fp) - assert mesh.adjacency == mesh_2.adjacency - assert mesh.vertex == mesh_2.vertex - - -# Reset the precision to its default value -TOL.precision = TOL.PRECISION +from compas.datastructures import Mesh +from compas.files import STLDocument +from compas.files import STLFacet +from compas.files import STLSolid +from compas.files import read_stl +from compas.files import stl_data +from compas.files import weld_stl_data +from compas.files import write_stl + + +STL_WITH_SHARED_COORDINATES = """\ +solid square +facet normal 0 0 1 +outer loop +vertex 0 0 0 +vertex 1 0 0 +vertex 0 1 0 +endloop +endfacet +facet normal 0 0 1 +outer loop +vertex 1 0 0 +vertex 1 1 0 +vertex 0 1 0 +endloop +endfacet +endsolid square +""" + + +def test_stl_projection_only_welds_when_requested(): + document = read_stl(StringIO(STL_WITH_SHARED_COORDINATES)) + + unwelded = stl_data(document) + welded = weld_stl_data(document) + + assert len(unwelded.vertices) == 6 + assert len(unwelded.faces) == 2 + assert len(welded.vertices) == 4 + assert len(welded.faces) == 2 + + +def test_mesh_ascii_stl_roundtrip_welds_facet_vertices(): + mesh = Mesh.from_stl(StringIO(STL_WITH_SHARED_COORDINATES)) + stream = StringIO() + + mesh.to_stl(stream) + stream.seek(0) + restored = Mesh.from_stl(stream) + + assert mesh.number_of_vertices() == 4 + assert mesh.number_of_faces() == 2 + assert restored.number_of_vertices() == 4 + assert restored.number_of_faces() == 2 + + +def test_mesh_binary_stl_roundtrip(): + mesh = Mesh.from_stl(StringIO(STL_WITH_SHARED_COORDINATES)) + stream = BytesIO() + + mesh.to_stl(stream, binary=True) + stream.seek(0) + restored = Mesh.from_stl(stream) + + assert restored.number_of_vertices() == mesh.number_of_vertices() + assert restored.number_of_faces() == mesh.number_of_faces() + + +def test_write_stl_retains_document_format_and_does_not_mutate_document(): + document = STLDocument( + format="binary", + header=b"example", + solids=[STLSolid("part", [STLFacet([0, 0, 1], [[-1, 0, 0], [0, 0, 0], [-1, 1, 0]], 7)])], + ) + stream = BytesIO() + + write_stl(stream, document) + stream.seek(0) + restored = read_stl(stream) + + assert document.header == b"example" + assert restored.format == "binary" + assert restored.header.startswith(b"example") + assert restored.solids[0].facets[0].attribute == 7 + assert restored.solids[0].facets[0].vertices[0][0] == -1 diff --git a/tests/compas/files/test_stl_document.py b/tests/compas/files/test_stl_document.py new file mode 100644 index 000000000000..a9558ce1b687 --- /dev/null +++ b/tests/compas/files/test_stl_document.py @@ -0,0 +1,22 @@ +import pytest + +from compas.files import STLDocument +from compas.files import STLFacet +from compas.files import STLSolid + + +@pytest.mark.parametrize( + "document", + [ + STLDocument(format="invalid"), + STLDocument(header=b"x" * 81), + STLDocument(solids=[STLSolid(facets=[STLFacet([0, 1], [[0, 0, 0]] * 3)])]), + STLDocument(solids=[STLSolid(facets=[STLFacet([0, 0, 1], [[0, 0, 0]] * 2)])]), + STLDocument(solids=[STLSolid(facets=[STLFacet([0, 0, 1], [[0, 0], [0, 0, 0], [0, 0, 0]])])]), + STLDocument(solids=[STLSolid(facets=[STLFacet([0, 0, 1], [[0, 0, 0]] * 3, -1)])]), + STLDocument(solids=[STLSolid(facets=[STLFacet([0, 0, 1], [[0, 0, 0]] * 3, 65536)])]), + ], +) +def test_stl_document_rejects_invalid_data(document): + with pytest.raises(ValueError): + document.validate() diff --git a/tests/compas/files/test_stl_parser.py b/tests/compas/files/test_stl_parser.py new file mode 100644 index 000000000000..acf4ea4fe94a --- /dev/null +++ b/tests/compas/files/test_stl_parser.py @@ -0,0 +1,62 @@ +import struct + +import pytest + +from compas.files import STLParser +from compas.files.stl_parser import STLParseError + + +ASCII_STL = b"""solid first +facet normal 0 0 1 +outer loop +vertex 0 0 0 +vertex 1 0 0 +vertex 0 1 0 +endloop +endfacet +endsolid first +solid second +endsolid second +""" + + +def test_stl_parser_parses_multiple_ascii_solids(): + document = STLParser(ASCII_STL).parse() + + assert document.format == "ascii" + assert [solid.name for solid in document.solids] == ["first", "second"] + assert len(document.solids[0].facets) == 1 + + +def test_stl_parser_detects_binary_with_solid_header(): + header = b"solid deceptive binary header".ljust(80, b"\0") + facet = struct.pack("<12fH", *([0.0] * 12), 23) + + document = STLParser(header + struct.pack("") +def test_read_xml_from_stream(basic_xml): + root = read_xml(StringIO(basic_xml)) + assert root.tag == "Tests" -def test_xml_to_pretty_string(basic_xml): - xml = XML.from_string(basic_xml) - prettyxml = xml.to_string(prettify=True) - assert b"\n " in prettyxml +def test_parse_xml_from_string(basic_xml): + root = parse_xml(basic_xml) -def test_namespaces_to_string(): - xml = XML.from_string("""""") - xml_string = xml.to_string(prettify=True) - assert b'xmlns:xacro="http://www.ros.org/wiki/xacro"' in xml_string - assert b"""" - ) - xml_string = xml.to_string(prettify=True) - assert b'xmlns="https://default.org/namespace"' in xml_string - assert b" -
- - -
""" - ) + assert isinstance(result, str) + assert result.startswith("") - assert xml.root.attrib["xmlns"] == "https://ita.arch.ethz.ch/" - assert xml.root.attrib["xmlns:xsi"] == "http://www.w3.org/2001/XMLSchema-instance" - # first element redefines default namespace - assert list(xml.root)[1].attrib["xmlns"] == "https://ethz.ch" - assert list(xml.root)[1].attrib["name"] == "item2" +def test_xml_to_string_can_return_bytes(basic_xml): + root = parse_xml(basic_xml) + result = xml_to_string(root, encoding="utf-8") -# This is the same test as above, but the code paths of loading from file vs from string are different on pre-3.8 cpython -def test_nested_default_namespaces_from_file(default_nested_namespace_file): - xml = XML.from_file(default_nested_namespace_file) + assert isinstance(result, bytes) + assert result.startswith(b"") - assert xml.root.attrib["xmlns"] == "https://ita.arch.ethz.ch/" - assert xml.root.attrib["xmlns:xsi"] == "http://www.w3.org/2001/XMLSchema-instance" - # first element redefines default namespace - assert list(xml.root)[1].attrib["xmlns"] == "https://ethz.ch" - assert list(xml.root)[1].attrib["name"] == "item2" +def test_xml_to_string_pretty_prints_without_mutating_root(basic_xml): + root = parse_xml(basic_xml) + result = xml_to_string(root, pretty=True) -def test_no_root_default_namespace(): - xml = XML.from_string( - """ -
- - -
""" - ) + assert "\n " in result + assert root[0].tail is None + + +def test_write_xml_supports_binary_streams(basic_xml): + stream = BytesIO() + + write_xml(stream, parse_xml(basic_xml), pretty=True) + + assert stream.getvalue().startswith(b"") - assert not xml.root.attrib.get("xmlns") - assert list(xml.root)[1].attrib["xmlns"] == "https://ethz.ch" - assert list(xml.root)[1].attrib["name"] == "item2" +def test_write_xml_supports_text_streams(basic_xml): + stream = StringIO() -def test_no_default_namespace(): - xml = XML.from_string("""
""") + write_xml(stream, parse_xml(basic_xml)) - assert not xml.root.attrib.get("xmlns") - assert xml.root.attrib["name"] == "test-xml" + assert stream.getvalue().startswith("") -def test_namespace_expansion(): - xml = XML.from_string( +def test_standard_namespace_expansion(): + root = parse_xml( """ -
- - - - - +
+
""" ) - assert xml.root.tag == "{https://ethz.ch}main" - assert list(xml.root)[0].tag == "{https://ethz.ch}item" - assert list(list(xml.root)[0])[0].tag == "{https://ethz.ch}subitem" - assert list(list(list(xml.root)[0])[0])[0].tag == "{https://sub.ethz.ch}cat" + assert root.tag == "{https://ethz.ch}main" + assert root[0].tag == "{https://ethz.ch}item" + assert root[0][0].tag == "{https://ethz.ch}subitem" + assert root[0][0][0].tag == "{https://sub.ethz.ch}cat" + assert "xmlns" not in root.attrib -def test_namespace_expansion_from_file(namespaces_file): - xml = XML.from_file(namespaces_file) +def test_namespace_semantics_survive_roundtrip(namespaces_file): + root = read_xml(namespaces_file) + restored = ET.fromstring(xml_to_string(root, encoding="utf-8")) - assert xml.root.tag == "{https://ethz.ch}main" - assert list(xml.root)[0].tag == "{https://ethz.ch}item" - assert list(list(xml.root)[0])[0].tag == "{https://ethz.ch}subitem" - assert list(list(list(xml.root)[0])[0])[0].tag == "{https://sub.ethz.ch}cat" + assert restored.tag == "{https://ethz.ch}main" + assert restored[0].tag == "{https://ethz.ch}item" + assert restored[0][0][0].tag == "{https://sub.ethz.ch}cat" diff --git a/tests/compas/geometry/test_core.py b/tests/compas/geometry/test_core.py index 1c8b69b004a7..a15805153827 100644 --- a/tests/compas/geometry/test_core.py +++ b/tests/compas/geometry/test_core.py @@ -11,16 +11,33 @@ from compas.geometry import angles_vectors from compas.geometry import angle_vectors_signed from compas.geometry import angle_vectors_projected +from compas.geometry import angle_vectors_xy +from compas.geometry import angles_vectors_xy from compas.geometry import centroid_points +from compas.geometry import centroid_points_weighted +from compas.geometry import centroid_points_xy +from compas.geometry import centroid_polygon +from compas.geometry import centroid_polygon_edges +from compas.geometry import centroid_polygon_edges_xy +from compas.geometry import centroid_polygon_xy from compas.geometry import centroid_polyhedron -from compas.geometry import length_vector -from compas.geometry import subtract_vectors +from compas.linalg import length_vector +from compas.linalg import subtract_vectors +from compas.linalg import square_vectors from compas.geometry import volume_polyhedron from compas.geometry import area_polygon from compas.geometry import area_polygon_xy from compas.geometry import area_triangle from compas.geometry import area_triangle_xy from compas.geometry import normal_polygon +from compas.geometry import normal_triangle +from compas.geometry import normal_triangle_xy +from compas.linalg import normalize_vector +from compas.linalg import normalize_vector_xy +from compas.geometry import midpoint_line +from compas.geometry import midpoint_line_xy +from compas.geometry import midpoint_point_point +from compas.geometry import midpoint_point_point_xy @pytest.fixture @@ -61,6 +78,28 @@ def test_angle_vectors(u, v, angle): assert TOL.is_close(angle_vectors(u, v), angle) +@pytest.mark.parametrize( + ("u", "v"), + [ + ([0.0, 0.0, 0.0], [0.0, 0.0, 0.0]), + ([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]), + ([1.0, 0.0, 0.0], [0.0, 0.0, 0.0]), + ], +) +def test_angle_vectors_returns_zero_for_zero_length_input(u, v): + assert angle_vectors(u, v) == 0 + + +def test_angle_vectors_opposite_and_degrees(): + assert TOL.is_close(angle_vectors([1, 0, 0], [-1, 0, 0]), pi) + assert TOL.is_close(angle_vectors([1, 0, 0], [0, 1, 0], deg=True), 90.0) + + +def test_angle_vectors_xy_ignores_z_and_supports_degrees(): + assert TOL.is_close(angle_vectors_xy([1, 0, 100], [0, 1, -100]), pi / 2) + assert TOL.is_close(angle_vectors_xy([1, 0], [-1, 0], deg=True), 180.0) + + # @pytest.mark.parametrize( # ("u", "v"), # [ @@ -93,6 +132,10 @@ def test_angles_vectors(u, v, angles): assert TOL.is_allclose(angles_vectors(u, v), (a, b)) +def test_angles_vectors_xy_degrees(): + assert TOL.is_allclose(angles_vectors_xy([1, 0], [0, 1], deg=True), (90.0, 270.0)) + + # @pytest.mark.parametrize( # ("u", "v"), # [ @@ -138,6 +181,11 @@ def test_angle_vectors_signed(u, v, normal, result): assert TOL.is_close(angle_vectors_signed(u, v, normal), result) +def test_angle_vectors_signed_parallel_and_antiparallel(): + assert angle_vectors_signed([1, 0, 0], [1, 0, 0], [0, 0, 1]) == 0 + assert TOL.is_close(angle_vectors_signed([1, 0, 0], [-1, 0, 0], [0, 0, 1]), pi) + + @pytest.mark.parametrize( "u,v,normal,result", [ @@ -154,6 +202,27 @@ def test_angle_vectors_projected(u, v, normal, result): # ============================================================================== +def test_square_vectors(): + assert square_vectors([[1.0, -2.0, 3.0], [0.0, 4.0, -5.0]]) == [[1.0, 4.0, 9.0], [0.0, 16.0, 25.0]] + + +@pytest.mark.parametrize("normalize", [normalize_vector, normalize_vector_xy]) +def test_normalize_zero_vector_preserves_input_container(normalize): + vector_list = [0.0, 0.0, 0.0] + vector_tuple = (0.0, 0.0, 0.0) + + assert normalize(vector_list) is vector_list + assert normalize(vector_tuple) is vector_tuple + + +@pytest.mark.parametrize("normalize", [normalize_vector, normalize_vector_xy]) +def test_normalize_nonzero_vector_returns_list(normalize): + result = normalize((2.0, 0.0, 0.0)) + + assert isinstance(result, list) + assert result == [1.0, 0.0, 0.0] + + @pytest.mark.parametrize( ("points", "centroid"), [ @@ -204,6 +273,52 @@ def test_centroid_points_fails_when_input_is_not_complete_points(points): centroid_points(points) +def test_midpoint_functions_accept_raw_coordinate_sequences(): + line = ((0.0, 2.0, 4.0), (2.0, 4.0, 8.0)) + + assert midpoint_point_point(*line) == [1.0, 3.0, 6.0] + assert midpoint_line(line) == [1.0, 3.0, 6.0] + assert midpoint_point_point_xy(*line) == [1.0, 3.0, 0.0] + assert midpoint_line_xy(line) == [1.0, 3.0, 0.0] + + +def test_centroid_points_xy_ignores_z_coordinates(): + points = ((0.0, 0.0, 100.0), (2.0, 4.0, -100.0)) + + assert centroid_points_xy(points) == [1.0, 2.0, 0.0] + + +def test_centroid_points_weighted_supports_negative_weights(): + points = ((0.0, 0.0, 0.0), (2.0, 4.0, 6.0)) + + assert centroid_points_weighted(points, (-1.0, 2.0)) == [4.0, 8.0, 12.0] + + +@pytest.mark.parametrize("centroid", [centroid_polygon, centroid_polygon_xy]) +def test_centroid_polygon_requires_three_points(centroid): + with pytest.raises(ValueError, match="At least three points required"): + centroid(((0.0, 0.0, 0.0), (1.0, 0.0, 0.0))) + + +@pytest.mark.parametrize("centroid", [centroid_polygon, centroid_polygon_xy]) +def test_centroid_polygon_is_independent_of_winding(centroid): + polygon = [(0.0, 0.0, 3.0), (2.0, 0.0, 3.0), (2.0, 2.0, 3.0), (0.0, 2.0, 3.0)] + + assert TOL.is_allclose(centroid(polygon), centroid(list(reversed(polygon)))) + + +def test_centroid_polygon_degenerate_input_preserves_first_vertex(): + polygon = ((1.0, 2.0, 3.0), (1.0, 2.0, 3.0), (1.0, 2.0, 3.0), (1.0, 2.0, 3.0)) + + assert centroid_polygon(polygon) is polygon[0] + + +@pytest.mark.parametrize("centroid", [centroid_polygon_edges, centroid_polygon_edges_xy]) +def test_centroid_polygon_edges_degenerate_boundary_preserves_zero_division_error(centroid): + with pytest.raises(ZeroDivisionError): + centroid(((1.0, 2.0, 3.0), (1.0, 2.0, 3.0), (1.0, 2.0, 3.0))) + + @pytest.mark.parametrize( ("polyhedron", "centroid"), [ @@ -263,6 +378,12 @@ def test_area_polygon(): assert area_polygon(polygon_) >= 0 +def test_polygon_area_is_independent_of_winding(): + polygon = [[0, 0, 0], [2, 0, 0], [2, 1, 0], [1, 0.5, 0], [0, 1, 0]] + assert TOL.is_close(area_polygon(polygon), area_polygon(list(reversed(polygon)))) + assert TOL.is_close(area_polygon_xy(polygon), area_polygon_xy(list(reversed(polygon)))) + + # ============================================================================== # normals # ==============================================================================\ @@ -274,3 +395,28 @@ def test_normal_polygon(): area = area_polygon(polygon) assert TOL.is_close(area, 100.0) assert TOL.is_close(area, length_vector(normal)) + + +def test_polygon_normal_changes_sign_with_winding(): + polygon = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]] + normal = normal_polygon(polygon) + reversed_normal = normal_polygon(list(reversed(polygon))) + assert TOL.is_allclose(reversed_normal, [-value for value in normal]) + + +def test_normal_polygon_requires_three_points(): + with pytest.raises(ValueError, match="At least three points required"): + normal_polygon([[0, 0, 0], [1, 0, 0]]) + + +@pytest.mark.parametrize("normal", [normal_triangle, normal_triangle_xy]) +@pytest.mark.parametrize("triangle", [[], [[0, 0, 0]], [[0, 0, 0], [1, 0, 0]], [[0, 0, 0]] * 4]) +def test_normal_triangle_requires_exactly_three_points(normal, triangle): + with pytest.raises(ValueError, match="Three points are required"): + normal(triangle) + + +@pytest.mark.parametrize("normal", [normal_triangle, normal_triangle_xy]) +def test_normal_triangle_degenerate_input_preserves_zero_division_error(normal): + with pytest.raises(ZeroDivisionError): + normal([[0, 0, 0], [1, 0, 0], [2, 0, 0]]) diff --git a/tests/compas/geometry/test_core_distance.py b/tests/compas/geometry/test_core_distance.py index 359e8ccc8743..d624e01aadc4 100644 --- a/tests/compas/geometry/test_core_distance.py +++ b/tests/compas/geometry/test_core_distance.py @@ -1,8 +1,24 @@ import pytest -from compas.geometry import Point from compas.geometry import Line +from compas.geometry import Point +from compas.geometry import closest_point_in_cloud +from compas.geometry import closest_point_on_plane +from compas.geometry import closest_point_on_segment from compas.geometry import closest_point_on_segment_xy +from compas.geometry import distance_line_line +from compas.geometry import distance_point_line +from compas.geometry import distance_point_line_sqrd +from compas.geometry import distance_point_line_sqrd_xy +from compas.geometry import distance_point_line_xy +from compas.geometry import distance_point_plane +from compas.geometry import distance_point_plane_signed +from compas.geometry import distance_point_point +from compas.geometry import distance_point_point_sqrd +from compas.geometry import distance_point_point_sqrd_xy +from compas.geometry import distance_point_point_xy +from compas.geometry import sort_points +from compas.tolerance import TOL @pytest.mark.parametrize("point,point_on_line", [[[0, 0, -10], [1, 1, 0]], [[5, 4, 80], [4.5, 4.5, 0]]]) @@ -10,3 +26,81 @@ def test_closest_point_segment_xy(point, point_on_line): line = Line([1, 1, -15], [10, 10, 20]) ponl = closest_point_on_segment_xy(Point(*point), line) assert ponl == Point(*point_on_line) + + +def test_point_point_distances_accept_raw_sequences_and_are_symmetric(): + a = (1.0, 2.0, 3.0) + b = [4.0, 6.0, 3.0] + + assert distance_point_point(a, b) == distance_point_point(b, a) == 5.0 + assert distance_point_point_sqrd(a, b) == distance_point_point_sqrd(b, a) == 25.0 + + +def test_xy_point_distances_ignore_z_coordinates(): + a = (1.0, 2.0, -100.0) + b = (4.0, 6.0, 100.0) + + assert distance_point_point_xy(a, b) == 5.0 + assert distance_point_point_sqrd_xy(a, b) == 25.0 + + +@pytest.mark.parametrize( + ("distance", "expected"), + [ + (distance_point_line, 73.0**0.5), + (distance_point_line_sqrd, 73.0), + (distance_point_line_xy, 3.0), + (distance_point_line_sqrd_xy, 9.0), + ], +) +def test_point_line_distances(distance, expected): + assert distance((2.0, 3.0, 8.0), ((0.0, 0.0, 0.0), (4.0, 0.0, 0.0))) == expected + + +@pytest.mark.parametrize( + "distance", + [distance_point_line, distance_point_line_sqrd, distance_point_line_xy, distance_point_line_sqrd_xy], +) +def test_point_line_distances_preserve_degenerate_line_error(distance): + with pytest.raises(ZeroDivisionError): + distance((1.0, 2.0, 3.0), ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0))) + + +def test_signed_and_unsigned_point_plane_distances(): + plane = ((0.0, 0.0, 1.0), (0.0, 0.0, 1.0)) + + assert distance_point_plane_signed((0.0, 0.0, 4.0), plane) == 3.0 + assert distance_point_plane_signed((0.0, 0.0, -2.0), plane) == -3.0 + assert distance_point_plane((0.0, 0.0, -2.0), plane) == 3.0 + + +def test_line_line_distance_handles_skew_and_parallel_lines(): + x_axis = ((0.0, 0.0, 0.0), (1.0, 0.0, 0.0)) + skew = ((0.0, 1.0, 2.0), (0.0, 2.0, 2.0)) + parallel = ((0.0, 3.0, 0.0), (1.0, 3.0, 0.0)) + + assert TOL.is_close(distance_line_line(x_axis, skew), 2.0) + assert TOL.is_close(distance_line_line(x_axis, parallel), 3.0) + + +def test_sort_points_preserves_cloud_order_for_equal_distances(): + cloud = ((-1.0, 0.0, 0.0), (1.0, 0.0, 0.0)) + + assert [item[2] for item in sort_points((0.0, 0.0, 0.0), cloud)] == [0, 1] + + +def test_closest_point_in_empty_cloud_preserves_index_error(): + with pytest.raises(IndexError): + closest_point_in_cloud((0.0, 0.0, 0.0), ()) + + +def test_closest_point_on_segment_preserves_endpoint_container(): + segment = ((0.0, 0.0, 0.0), (1.0, 0.0, 0.0)) + + assert closest_point_on_segment((-1.0, 1.0, 0.0), segment) is segment[0] + + +def test_closest_point_on_plane_normalizes_the_plane_normal(): + result = closest_point_on_plane((1.0, 2.0, 5.0), ((0.0, 0.0, 1.0), (0.0, 0.0, 4.0))) + + assert TOL.is_allclose(result, (1.0, 2.0, 1.0)) diff --git a/tests/compas/geometry/test_core_nurbs.py b/tests/compas/geometry/test_core_nurbs.py new file mode 100644 index 000000000000..d21e1b55edab --- /dev/null +++ b/tests/compas/geometry/test_core_nurbs.py @@ -0,0 +1,55 @@ +import pytest + +from compas.geometry import compute_basisfuncs +from compas.geometry import compute_basisfuncsderivs +from compas.geometry import construct_knotvector +from compas.geometry import find_span +from compas.geometry import knots_and_mults_to_knotvector +from compas.geometry import knotvector_to_knots_and_mults + + +def test_construct_knotvector(): + knotvector = construct_knotvector(degree=2, pointcount=5) + + assert knotvector == pytest.approx([0, 0, 0, 1 / 3, 2 / 3, 1, 1, 1]) + + with pytest.raises(ValueError): + construct_knotvector(degree=3, pointcount=3) + + +def test_convert_knotvector_representation(): + knotvector = [0, 0, 0, 0.5, 1, 1, 1] + + knots, multiplicities = knotvector_to_knots_and_mults(knotvector) + + assert knots == [0, 0.5, 1] + assert multiplicities == [3, 1, 3] + assert knots_and_mults_to_knotvector(knots, multiplicities) == knotvector + + +def test_find_span_at_domain_boundaries(): + knotvector = construct_knotvector(degree=2, pointcount=5) + + assert find_span(4, 2, knotvector, 0.0) == 2 + assert find_span(4, 2, knotvector, 0.5) == 3 + assert find_span(4, 2, knotvector, 1.0) == 4 + + with pytest.raises(ValueError): + find_span(4, 2, knotvector, -0.1) + + with pytest.raises(ValueError): + find_span(4, 2, knotvector, 1.1) + + +def test_compute_basis_functions_and_derivatives(): + knotvector = construct_knotvector(degree=2, pointcount=5) + span = find_span(4, 2, knotvector, 0.5) + + basis = compute_basisfuncs(2, knotvector, span, 0.5) + derivatives = compute_basisfuncsderivs(2, knotvector, span, 0.5, 2) + + assert basis == pytest.approx([0.125, 0.75, 0.125]) + assert sum(basis) == pytest.approx(1.0) + assert derivatives[0] == pytest.approx(basis) + assert derivatives[1] == pytest.approx([-1.5, 0.0, 1.5]) + assert derivatives[2] == pytest.approx([9.0, -18.0, 9.0]) diff --git a/tests/compas/geometry/test_core_tangent.py b/tests/compas/geometry/test_core_tangent.py index 8e6ab4d779b2..e874cd61ea2c 100644 --- a/tests/compas/geometry/test_core_tangent.py +++ b/tests/compas/geometry/test_core_tangent.py @@ -1,3 +1,5 @@ +import pytest + from compas.tolerance import TOL from compas.geometry import tangent_points_to_circle_xy @@ -8,3 +10,29 @@ def test_tangent_points_to_circle_xy(): t1, t2 = tangent_points_to_circle_xy(circle, point) assert TOL.is_allclose(t1, (-0.772, 0.636, 0.000), atol=1e-3) assert TOL.is_allclose(t2, (0.972, -0.236, 0.000), atol=1e-3) + + +def test_tangent_points_accepts_two_coordinates_and_translated_circle(): + circle = ((1.0, 2.0, 0.0), (0.0, 0.0, 1.0)), 1.0 + t1, t2 = tangent_points_to_circle_xy(circle, (3.0, 2.0)) + assert TOL.is_allclose(t1, (1.5, 2.8660254, 0.0)) + assert TOL.is_allclose(t2, (1.5, 1.1339746, 0.0)) + + +def test_tangent_point_on_circle_returns_same_point_twice(): + circle = ((0.0, 0.0, 0.0), (0.0, 0.0, 1.0)), 1.0 + t1, t2 = tangent_points_to_circle_xy(circle, (1.0, 0.0)) + assert TOL.is_allclose(t1, (1.0, 0.0, 0.0)) + assert TOL.is_allclose(t2, (1.0, 0.0, 0.0)) + + +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"): + tangent_points_to_circle_xy(circle, (0.5, 0.0)) + + +def test_tangent_points_rejects_circle_center(): + circle = ((0.0, 0.0, 0.0), (0.0, 0.0, 1.0)), 1.0 + with pytest.raises(ZeroDivisionError): + tangent_points_to_circle_xy(circle, (0.0, 0.0)) diff --git a/tests/compas/geometry/test_core_transformations.py b/tests/compas/geometry/test_core_transformations.py index 599c2a6e7e1e..8bdb3fec276a 100644 --- a/tests/compas/geometry/test_core_transformations.py +++ b/tests/compas/geometry/test_core_transformations.py @@ -1,7 +1,6 @@ import pytest -# from compas.geometry import homogenize -# from compas.geometry import dehomogenize +from compas.geometry import local_axes from compas.geometry import Rotation from compas.geometry import Translation from compas.tolerance import TOL @@ -29,6 +28,8 @@ from compas.geometry import transform_vectors from compas.geometry import translate_points from compas.geometry import translate_points_xy +from compas.geometry._core.transformations import dehomogenize +from compas.geometry._core.transformations import homogenize @pytest.fixture @@ -58,16 +59,20 @@ def test_transform_vectors(R): assert TOL.is_allclose(a, b) -# def test_homogenize(): -# assert homogenize([[1, 2, 3]], 0.5) == [[0.5, 1.0, 1.5, 0.5]] +def test_homogenize(): + assert homogenize([[1, 2, 3]], 0.5) == [[0.5, 1.0, 1.5, 0.5]] -# def test_dehomogenize(): -# assert dehomogenize([[0.5, 1.0, 1.5, 0.5]]) == [[1, 2, 3]] +def test_dehomogenize(): + assert dehomogenize([[0.5, 1.0, 1.5, 0.5]]) == [[1.0, 2.0, 3.0]] -# def test_local_axes(): -# pass +def test_local_axes_accepts_raw_coordinate_sequences(): + xaxis, yaxis, zaxis = local_axes([0, 0, 0], [1, 0, 0], [0, 1, 0]) + + assert xaxis == [1.0, 0.0, 0.0] + assert yaxis == [0.0, 1.0, 0.0] + assert zaxis == [0.0, 0.0, 1.0] def test_translate_points(): diff --git a/tests/compas/geometry/test_core_transformations_numpy.py b/tests/compas/geometry/test_core_transformations_numpy.py index 21a492f614fe..3141fdfb9145 100644 --- a/tests/compas/geometry/test_core_transformations_numpy.py +++ b/tests/compas/geometry/test_core_transformations_numpy.py @@ -1 +1,70 @@ -# numpy helper will be created separated first +import numpy as np + +from compas.geometry import dehomogenize_and_unflatten_frames_numpy +from compas.geometry import dehomogenize_numpy +from compas.geometry import homogenize_and_flatten_frames_numpy +from compas.geometry import homogenize_numpy +from compas.geometry import local_to_world_coordinates_numpy +from compas.geometry import transform_frames_numpy +from compas.geometry import transform_points_numpy +from compas.geometry import transform_vectors_numpy +from compas.geometry import world_to_local_coordinates_numpy + + +TRANSLATION = [ + [1.0, 0.0, 0.0, 4.0], + [0.0, 1.0, 0.0, 5.0], + [0.0, 0.0, 1.0, 6.0], + [0.0, 0.0, 0.0, 1.0], +] + + +def test_transform_points_and_vectors_numpy(): + data = [[1.0, 2.0, 3.0], [-1.0, -2.0, -3.0]] + + assert np.allclose(transform_points_numpy(data, TRANSLATION), [[5, 7, 9], [3, 3, 3]]) + assert np.allclose(transform_vectors_numpy(data, TRANSLATION), data) + + +def test_transform_frames_numpy(): + frames = [[[1.0, 2.0, 3.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]] + + transformed = transform_frames_numpy(frames, TRANSLATION) + + assert transformed.shape == (1, 3, 3) + assert np.allclose(transformed, [[[5, 7, 9], [1, 0, 0], [0, 1, 0]]]) + + +def test_homogenize_and_dehomogenize_numpy(): + data = [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]] + + points = homogenize_numpy(data) + vectors = homogenize_numpy(data, w=0.0) + + assert np.allclose(points, [[1, 2, 3, 1], [4, 5, 6, 1]]) + assert np.allclose(vectors, [[1, 2, 3, 0], [4, 5, 6, 0]]) + assert np.allclose(dehomogenize_numpy(points), data) + assert np.allclose(dehomogenize_numpy(vectors), data) + + +def test_flatten_and_unflatten_frames_numpy(): + frames = [ + [[1.0, 2.0, 3.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[4.0, 5.0, 6.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + ] + + flattened = homogenize_and_flatten_frames_numpy(frames) + unflattened = dehomogenize_and_unflatten_frames_numpy(flattened) + + assert flattened.shape == (6, 4) + assert np.allclose(unflattened, frames) + + +def test_local_and_world_coordinates_numpy_roundtrip(): + frame = [[1.0, 2.0, 3.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + world = [[2.0, 4.0, 6.0], [-1.0, -2.0, -3.0]] + + local = world_to_local_coordinates_numpy(frame, world) + + assert np.allclose(local, [[1, 2, 3], [-2, -4, -6]]) + assert np.allclose(local_to_world_coordinates_numpy(frame, local), world) diff --git a/tests/compas/geometry/test_curves.py b/tests/compas/geometry/test_curves.py index a933c2194717..664adb487b72 100644 --- a/tests/compas/geometry/test_curves.py +++ b/tests/compas/geometry/test_curves.py @@ -6,6 +6,7 @@ from compas.geometry import Circle from compas.geometry import Ellipse from compas.geometry import Hyperbola +from compas.geometry import Parabola @pytest.mark.parametrize( @@ -45,3 +46,71 @@ def test_curve_geometry(curve): curve.point_at(1.0, world=True), curve.point_at(1.0, world=False).transformed(curve.transformation), ) + + +def test_curve_discretization(): + curve = Arc(radius=1, start_angle=0, end_angle=math.pi) + + points = curve.to_points(n=3, domain=(0.25, 0.75)) + polyline = curve.to_polyline(n=2) + + assert TOL.is_allclose(points[0], [math.sqrt(0.5), math.sqrt(0.5), 0]) + assert TOL.is_allclose(points[1], [0, 1, 0]) + assert TOL.is_allclose(points[2], [-math.sqrt(0.5), math.sqrt(0.5), 0]) + assert len(polyline.points) == 3 + assert polyline.start == curve.point_at(0) + assert polyline.end == curve.point_at(1) + + +@pytest.mark.parametrize( + "curve", + [ + Hyperbola(major=1.0, minor=0.5), + Parabola(focal=1.0), + ], +) +def test_unbounded_curve_discretization_requires_finite_domain(curve): + with pytest.raises(ValueError, match="finite domain"): + curve.to_points() + + with pytest.raises(ValueError, match="finite domain"): + curve.to_polyline() + + points = curve.to_points(n=3, domain=(-1.0, 1.0)) + polyline = curve.to_polyline(n=2, domain=(-1.0, 1.0)) + + assert len(points) == 3 + assert len(polyline.points) == 3 + assert all(math.isfinite(coordinate) for point in points for coordinate in point) + + +def test_curve_frame_requires_frame_object(): + representation = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + + with pytest.raises(TypeError): + Circle(radius=1.0, frame=representation) # type: ignore[arg-type] + + circle = Circle(radius=1.0) + with pytest.raises(TypeError): + circle.frame = representation # type: ignore[assignment] + + +def test_curve_transformation_reflects_frame_mutation(): + circle = Circle(radius=1.0) + _ = circle.transformation + + circle.center = [10.0, 0.0, 0.0] + assert TOL.is_allclose(circle.point_at(0.0), [11.0, 0.0, 0.0]) + + circle.frame.point = [20.0, 0.0, 0.0] + assert TOL.is_allclose(circle.point_at(0.0), [21.0, 0.0, 0.0]) + + +def test_curve_to_polygon(): + circle = Circle(radius=1) + polygon = circle.to_polygon(n=8) + + assert len(polygon.points) == 8 + + with pytest.raises(ValueError): + Arc(radius=1, start_angle=0, end_angle=math.pi).to_polygon() diff --git a/tests/compas/geometry/test_curves_arc.py b/tests/compas/geometry/test_curves_arc.py index c1491ac4e4d3..16360c7e480d 100644 --- a/tests/compas/geometry/test_curves_arc.py +++ b/tests/compas/geometry/test_curves_arc.py @@ -1,13 +1,11 @@ import math import json import pytest -import compas - from compas.geometry import Arc -from compas.geometry import Point # noqa: F401 -from compas.geometry import Vector # noqa: F401 from compas.geometry import Frame from compas.geometry import Circle +from compas.geometry import Point # noqa: F401 +from compas.geometry import Vector # noqa: F401 from compas.tolerance import TOL @@ -90,6 +88,90 @@ def test_arc_create_invalid(): Arc(radius=1.0, start_angle=0.2314, end_angle=7.14) +@pytest.mark.parametrize("radius", [0.0, -1.0]) +def test_arc_rejects_nonpositive_radius(radius): + with pytest.raises(ValueError, match="positive"): + Arc(radius=radius, start_angle=0.0, end_angle=math.pi) + + arc = Arc(radius=1.0, start_angle=0.0, end_angle=math.pi) + with pytest.raises(ValueError, match="positive"): + arc.radius = radius + + +def test_arc_comparison(): + arc = Arc(radius=1.0, start_angle=0.0, end_angle=math.pi) + + assert arc == Arc(radius=1.0, start_angle=0.0, end_angle=math.pi) + assert arc != Arc(radius=2.0, start_angle=0.0, end_angle=math.pi) + assert arc != object() + + +def test_arc_decreasing_angle_range_preserves_signed_sweep(): + arc = Arc(radius=2.0, start_angle=math.pi, end_angle=0.0) + + assert TOL.is_close(arc.angle, -math.pi) + assert TOL.is_close(arc.length, 2.0 * math.pi) + assert TOL.is_allclose(arc.point_at(0.5), [0.0, 2.0, 0.0]) + + +@pytest.mark.parametrize( + "arc, expected", + [ + (Arc(radius=2.0, start_angle=0.0, end_angle=math.pi), [0.0, 1.0, 0.0]), + (Arc(radius=2.0, start_angle=math.pi, end_angle=0.0), [0.0, 1.0, 0.0]), + ], +) +def test_arc_tangent_is_unitized_and_follows_parameter_direction(arc, expected): + tangent = arc.tangent_at(0.0) + + assert TOL.is_close(tangent.length, 1.0) + assert TOL.is_allclose(tangent, expected) + + +def test_full_circle_arc_is_closed_and_periodic(): + arc = Arc(radius=1.0, start_angle=0.0, end_angle=2.0 * math.pi) + + assert arc.is_circle + assert arc.is_closed + assert arc.is_periodic + + +def test_arc_reverse_preserves_geometry_and_reverses_parameter_direction(): + arc = Arc(radius=2.0, start_angle=0.0, end_angle=math.pi) + start = arc.point_at(0.0) + end = arc.point_at(1.0) + length = arc.length + + arc.reverse() + + assert arc.point_at(0.0) == end + assert arc.point_at(1.0) == start + assert TOL.is_close(arc.angle, -math.pi) + assert TOL.is_close(arc.length, length) + + +def test_full_circle_arc_remains_closed_after_reverse(): + arc = Arc(radius=1.0, start_angle=0.0, end_angle=2.0 * math.pi) + + arc.reverse() + + assert arc.is_circle + assert arc.is_closed + assert arc.is_periodic + + +def test_arc_from_circle_preserves_subclass(frame): + class CustomArc(Arc): + pass + + circle = Circle(radius=1.0, frame=frame) + arc = CustomArc.from_circle(circle, 0.0, math.pi) + + assert isinstance(arc, CustomArc) + assert arc.frame == circle.frame + assert arc.frame is not circle.frame + + # ============================================================================= # Data # ============================================================================= @@ -106,10 +188,6 @@ def test_arc_data(): assert TOL.is_allclose(arc.frame.xaxis, other.frame.xaxis) assert TOL.is_allclose(arc.frame.yaxis, other.frame.yaxis) - if not compas.IPY: - assert Arc.validate_data(arc.__data__) - assert Arc.validate_data(other.__data__) - # ============================================================================= # Constructors diff --git a/tests/compas/geometry/test_curves_bezier.py b/tests/compas/geometry/test_curves_bezier.py index 1becbe505e96..54b140422e1a 100644 --- a/tests/compas/geometry/test_curves_bezier.py +++ b/tests/compas/geometry/test_curves_bezier.py @@ -1,6 +1,7 @@ import pytest import json -import compas +from math import asinh +from math import sqrt from compas.tolerance import TOL from compas.geometry import Frame @@ -20,7 +21,7 @@ def test_bezier_create(): def test_bezier_create_with_frame(): - with pytest.raises(Exception): + with pytest.raises(TypeError): Bezier([[-1, 0, 0], [0, 1, 0], [+1, 0, 0]], frame=Frame.worldXY()) @@ -38,10 +39,6 @@ def test_bezier_data(): assert TOL.is_allclose(curve.frame.xaxis, other.frame.xaxis) assert TOL.is_allclose(curve.frame.yaxis, other.frame.yaxis) - if not compas.IPY: - assert Bezier.validate_data(curve.__data__) - assert Bezier.validate_data(other.__data__) - # ============================================================================= # Constructors @@ -57,9 +54,27 @@ def test_bezier_properties(): assert curve.frame == Frame.worldXY() - with pytest.raises(Exception): + with pytest.raises(AttributeError): curve.frame = Frame.worldXY() + assert not curve.is_closed + assert not curve.is_periodic + + +def test_bezier_length(): + line = Bezier([[0.0, 0.0, 0.0], [3.0, 4.0, 0.0]]) + quadratic = Bezier([[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]) + + assert TOL.is_close(line.length, 5.0) + assert TOL.is_close(quadratic.length, sqrt(2.0) + asinh(1.0)) + + +def test_closed_bezier_is_not_periodic(): + curve = Bezier([[0.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 0.0, 0.0]]) + + assert curve.is_closed + assert not curve.is_periodic + def test_bezier_geometry(): curve = Bezier([[-1, 0, 0], [0, 1, 0], [+1, 0, 0]]) @@ -71,6 +86,41 @@ def test_bezier_geometry(): assert TOL.is_allclose(curve.normal_at(0.5), [0, -1, 0]) +def test_bezier_comparison(): + curve = Bezier([[-1, 0, 0], [0, 1, 0], [+1, 0, 0]]) + + assert curve == Bezier([[-1, 0, 0], [0, 1, 0], [+1, 0, 0]]) + assert curve != Bezier([[-1, 0, 0], [0, 2, 0], [+1, 0, 0]]) + assert curve != object() + + +def test_bezier_control_points_are_independent_and_require_3d(): + points = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]] + curve = Bezier(points) + points[0][0] = 2.0 + + assert curve.points[0].x == 0.0 + with pytest.raises(ValueError): + curve.points = [] + with pytest.raises(IndexError): + Bezier([[0.0, 0.0], [1.0, 0.0, 0.0]]) + + +def test_bezier_parameter_domain(): + curve = Bezier([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + + with pytest.raises(ValueError): + curve.point_at(-0.1) + with pytest.raises(ValueError): + curve.tangent_at(1.1) + + +def test_linear_bezier_has_no_normal(): + curve = Bezier([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + + assert curve.normal_at(0.5) is None + + # ============================================================================= # Accessors # ============================================================================= diff --git a/tests/compas/geometry/test_curves_circle.py b/tests/compas/geometry/test_curves_circle.py index 12d05cd7ef2e..e0212115558f 100644 --- a/tests/compas/geometry/test_curves_circle.py +++ b/tests/compas/geometry/test_curves_circle.py @@ -1,5 +1,6 @@ import json -import compas + +import pytest from compas.tolerance import TOL from compas.geometry import Circle @@ -13,6 +14,7 @@ def test_circle_create(): assert TOL.is_close(circle.radius, 1.0) assert TOL.is_close(circle.area, 3.141592653589793) assert TOL.is_close(circle.circumference, 6.283185307179586) + assert TOL.is_close(circle.length, circle.circumference) assert TOL.is_close(circle.diameter, 2.0) assert circle.is_closed @@ -27,6 +29,16 @@ def test_circle_create(): assert TOL.is_allclose(circle.point_at(1.0), [1.0, 0.0, 0.0]) +@pytest.mark.parametrize("radius", [0.0, -1.0]) +def test_circle_rejects_nonpositive_radius(radius): + with pytest.raises(ValueError, match="positive"): + Circle(radius=radius) + + circle = Circle(radius=1.0) + with pytest.raises(ValueError, match="positive"): + circle.radius = radius + + def test_circle_create_with_frame(): circle = Circle(radius=1.0, frame=Frame.worldZX()) @@ -88,10 +100,6 @@ def test_circle_data(): assert TOL.is_allclose(circle.frame.xaxis, other.frame.xaxis) assert TOL.is_allclose(circle.frame.yaxis, other.frame.yaxis) - if not compas.IPY: - assert Circle.validate_data(circle.__data__) - assert Circle.validate_data(other.__data__) - # ============================================================================= # Constructors @@ -135,11 +143,27 @@ def test_circle_create_from_plane_and_radius(): def test_circle_create_from_three_points(): - pass + circle = Circle.from_three_points([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]) + + assert TOL.is_allclose(circle.center, [0.0, 0.0, 0.0]) + assert TOL.is_close(circle.radius, 1.0) def test_circle_create_from_points(): - pass + circle = Circle.from_points([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]]) + + assert TOL.is_allclose(circle.center, [0.0, 0.0, 0.0]) + assert TOL.is_close(circle.radius, 1.0) + + +def test_circle_constructors_preserve_subclass(): + class CustomCircle(Circle): + pass + + assert isinstance(CustomCircle.from_point_and_radius([0.0, 0.0, 0.0], 1.0), CustomCircle) + assert isinstance(CustomCircle.from_plane_and_radius(Plane.worldXY(), 1.0), CustomCircle) + assert isinstance(CustomCircle.from_three_points([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]), CustomCircle) + assert isinstance(CustomCircle.from_points([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]]), CustomCircle) # ============================================================================= @@ -148,11 +172,54 @@ def test_circle_create_from_points(): def test_circle_geometry(): - pass + circle = Circle(radius=2.0) + + assert TOL.is_allclose(circle.normal_at(0.0, world=False), [-1.0, 0.0, 0.0]) + assert TOL.is_close(circle.normal_at(0.0, world=False).length, 1.0) + assert TOL.is_close(circle.normal_at(0.0, world=True).length, 1.0) + assert TOL.is_close(circle.tangent_at(0.0, world=False).length, 1.0) def test_circle_properties(): - pass + circle = Circle(radius=1.0) + source = [1.0, 2.0, 3.0] + circle.center = source + source[0] = 4.0 + + assert TOL.is_allclose(circle.center, [1.0, 2.0, 3.0]) + assert circle.eccentricity == 0.0 + + +def test_circle_comparison(): + circle = Circle(radius=1.0) + + assert circle == Circle(radius=1.0) + assert circle != Circle(radius=2.0) + assert circle != object() + + +def test_circle_closest_point(): + circle = Circle(radius=1.0) + + point, parameter = circle.closest_point([0.0, 2.0, 3.0], return_parameter=True) + assert TOL.is_allclose(point, [0.0, 1.0, 0.0]) + assert TOL.is_close(parameter, 0.25) + assert TOL.is_allclose(circle.closest_point([0.0, 0.0, 0.0]), [1.0, 0.0, 0.0]) + + +def test_circle_contains_point(): + circle = Circle(radius=1.0) + + assert circle.contains_point([1.0, 0.0, 0.0]) + assert circle.contains_point([1.0, 0.0, 0.5e-6]) + assert not circle.contains_point([1.0, 0.0, 2e-6]) + assert not circle.contains_point([0.0, 0.0, 0.0]) + assert not circle.contains_point([2.0, 0.0, 0.0]) + + +def test_circle_rejects_2d_constructor_inputs(): + with pytest.raises(IndexError): + Circle.from_three_points([1.0, 0.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]) # ============================================================================= diff --git a/tests/compas/geometry/test_curves_ellipse.py b/tests/compas/geometry/test_curves_ellipse.py index df2ad0ea271e..147188ce9541 100644 --- a/tests/compas/geometry/test_curves_ellipse.py +++ b/tests/compas/geometry/test_curves_ellipse.py @@ -1,6 +1,5 @@ import pytest import json -import compas from compas.tolerance import TOL from compas.geometry import Frame @@ -14,6 +13,8 @@ def test_ellipse_create(): assert TOL.is_close(ellipse.major, 1.0) assert TOL.is_close(ellipse.minor, 0.5) assert TOL.is_close(ellipse.area, 1.5707963267948966) + assert TOL.is_close(ellipse.circumference, 4.844224110273838) + assert TOL.is_close(ellipse.length, ellipse.circumference) assert TOL.is_close(ellipse.semifocal, 0.8660254037844386) assert TOL.is_close(ellipse.eccentricity, 0.8660254037844386) assert TOL.is_close(ellipse.focal, 1.7320508075688772) @@ -100,10 +101,6 @@ def test_ellipse_data(): assert TOL.is_allclose(ellipse.frame.xaxis, other.frame.xaxis) assert TOL.is_allclose(ellipse.frame.yaxis, other.frame.yaxis) - if not compas.IPY: - assert Ellipse.validate_data(ellipse.__data__) - assert Ellipse.validate_data(other.__data__) - # ============================================================================= # Constructors @@ -167,6 +164,9 @@ def test_ellipse_major(): with pytest.raises(ValueError): ellipse.major = -1.0 + with pytest.raises(ValueError): + ellipse.major = 0.0 + def test_ellipse_minor(): ellipse = Ellipse(major=1.0, minor=0.5) @@ -180,6 +180,48 @@ def test_ellipse_minor(): with pytest.raises(ValueError): ellipse.minor = -1.0 + with pytest.raises(ValueError): + ellipse.minor = 0.0 + + with pytest.raises(ValueError): + ellipse.minor = 2.0 + + +def test_ellipse_major_cannot_be_smaller_than_minor(): + ellipse = Ellipse(major=1.0, minor=0.5) + + with pytest.raises(ValueError): + ellipse.major = 0.25 + + +@pytest.mark.parametrize("major, minor", [(0.0, 0.5), (1.0, 0.0)]) +def test_ellipse_rejects_degenerate_axes(major, minor): + with pytest.raises(ValueError, match="positive"): + Ellipse(major=major, minor=minor) + + +def test_circle_shaped_ellipse_circumference_is_exact(): + ellipse = Ellipse(major=2.0, minor=2.0) + + assert TOL.is_close(ellipse.circumference, 4.0 * 3.141592653589793) + + +def test_ellipse_comparison(): + ellipse = Ellipse(major=1.0, minor=0.5) + + assert ellipse == Ellipse(major=1.0, minor=0.5) + assert ellipse != Ellipse(major=2.0, minor=0.5) + assert ellipse != Ellipse(major=1.0, minor=0.25) + assert ellipse != object() + + +def test_ellipse_constructors_preserve_subclass(): + class CustomEllipse(Ellipse): + pass + + assert isinstance(CustomEllipse.from_point_major_minor([0.0, 0.0, 0.0], 2.0, 1.0), CustomEllipse) + assert isinstance(CustomEllipse.from_plane_major_minor(Plane.worldXY(), 2.0, 1.0), CustomEllipse) + # ============================================================================= # Accessors diff --git a/tests/compas/geometry/test_curves_hyperbola.py b/tests/compas/geometry/test_curves_hyperbola.py index 109ebdb3b5c4..4343b606aca9 100644 --- a/tests/compas/geometry/test_curves_hyperbola.py +++ b/tests/compas/geometry/test_curves_hyperbola.py @@ -1,6 +1,5 @@ import pytest import json -import compas from compas.tolerance import TOL from compas.geometry import Frame @@ -16,15 +15,13 @@ def test_hyperbola_create(): assert TOL.is_close(hyperbola.eccentricity, 1.118033988749895) assert TOL.is_close(hyperbola.focal, 2.23606797749979) - assert hyperbola.is_closed - assert hyperbola.is_periodic + assert not hyperbola.is_closed + assert not hyperbola.is_periodic assert hyperbola.frame == Frame.worldXY() assert TOL.is_allclose(hyperbola.point_at(0.0), hyperbola.point_at(0.0, world=False)) - assert TOL.is_allclose(hyperbola.point_at(0.25), hyperbola.point_at(0.25, world=False)) - assert TOL.is_allclose(hyperbola.point_at(0.5), hyperbola.point_at(0.5, world=False)) - assert TOL.is_allclose(hyperbola.point_at(0.75), hyperbola.point_at(0.75, world=False)) + assert TOL.is_allclose(hyperbola.point_at(-1.0), hyperbola.point_at(-1.0, world=False)) assert TOL.is_allclose(hyperbola.point_at(1.0), hyperbola.point_at(1.0, world=False)) @@ -37,8 +34,8 @@ def test_hyperbola_create_with_frame(): assert TOL.is_close(hyperbola.eccentricity, 1.118033988749895) assert TOL.is_close(hyperbola.focal, 2.23606797749979) - assert hyperbola.is_closed - assert hyperbola.is_periodic + assert not hyperbola.is_closed + assert not hyperbola.is_periodic assert hyperbola.frame == Frame.worldZX() @@ -79,10 +76,6 @@ def test_hyperbola_data(): assert TOL.is_allclose(hyperbola.frame.xaxis, other.frame.xaxis) assert TOL.is_allclose(hyperbola.frame.yaxis, other.frame.yaxis) - if not compas.IPY: - assert Hyperbola.validate_data(hyperbola.__data__) - assert Hyperbola.validate_data(other.__data__) - # ============================================================================= # Constructors @@ -119,6 +112,41 @@ def test_hyperbola_minor(): hyperbola.minor = -1.0 +def test_hyperbola_branches_and_derivatives(): + positive = Hyperbola(major=2.0, minor=1.0, branch=1) + negative = Hyperbola(major=2.0, minor=1.0, branch=-1) + + assert TOL.is_allclose(positive.point_at(0.0), [2.0, 0.0, 0.0]) + assert TOL.is_allclose(negative.point_at(0.0), [-2.0, 0.0, 0.0]) + assert TOL.is_allclose(positive.tangent_at(0.0), [0.0, 1.0, 0.0]) + assert TOL.is_allclose(positive.normal_at(0.0), [-1.0, 0.0, 0.0]) + assert TOL.is_close(positive.tangent_at(1.0).dot(positive.normal_at(1.0)), 0.0) + + +def test_hyperbola_asymptotes(): + hyperbola = Hyperbola(major=2.0, minor=1.0) + + assert TOL.is_allclose(hyperbola.asymptote1.closest_point([2.0, 1.0, 0.0]), [2.0, 1.0, 0.0]) + assert TOL.is_allclose(hyperbola.asymptote2.closest_point([2.0, -1.0, 0.0]), [2.0, -1.0, 0.0]) + + +def test_hyperbola_comparison(): + hyperbola = Hyperbola(major=2.0, minor=1.0) + + assert hyperbola == Hyperbola(major=2.0, minor=1.0) + assert hyperbola != Hyperbola(major=2.0, minor=1.0, branch=-1) + assert hyperbola != object() + + +def test_hyperbola_invalid_dimensions_and_branch(): + with pytest.raises(ValueError): + Hyperbola(major=0.0, minor=1.0) + with pytest.raises(ValueError): + Hyperbola(major=1.0, minor=0.0) + with pytest.raises(ValueError): + Hyperbola(major=1.0, minor=1.0, branch=0) + + # ============================================================================= # Accessors # ============================================================================= diff --git a/tests/compas/geometry/test_curves_line.py b/tests/compas/geometry/test_curves_line.py index fbc272b2fa4d..38907d86e9f7 100644 --- a/tests/compas/geometry/test_curves_line.py +++ b/tests/compas/geometry/test_curves_line.py @@ -1,17 +1,19 @@ from copy import deepcopy import pytest import json -import compas -from compas.geometry import add_vectors -from compas.geometry import scale_vector -from compas.geometry import normalize_vector -from compas.geometry import subtract_vectors +from compas.linalg import add_vectors +from compas.linalg import scale_vector +from compas.linalg import normalize_vector +from compas.linalg import subtract_vectors from compas.geometry import distance_point_point from compas.geometry import Point +from compas.geometry import Translation from compas.geometry import Vector from compas.geometry import Frame +from compas.geometry import Geometry from compas.geometry import Line +from compas.geometry import Curve from compas.tolerance import TOL @@ -37,7 +39,12 @@ def test_line_create(p1, p2): assert line.start == p1 assert line.end == p2 - assert line.frame == Frame.worldXY() + + +def test_line_is_primitive_geometry(): + assert issubclass(Line, Geometry) + assert not issubclass(Line, Curve) + assert Line.__module__ == "compas.geometry.line" def test_line_create_with_frame(): @@ -45,6 +52,14 @@ def test_line_create_with_frame(): Line([0, 0, 0], [1, 0, 0], frame=Frame.worldXY()) +def test_line_rejects_2d_coordinates(): + with pytest.raises(IndexError): + Line([0, 0], [1, 0, 0]) + + with pytest.raises(IndexError): + Line([0, 0, 0], [1, 0]) + + # ============================================================================= # Data # ============================================================================= @@ -57,10 +72,6 @@ def test_line_data(): assert line.start == other.start assert line.end == other.end - if not compas.IPY: - assert Line.validate_data(line.__data__) - assert Line.validate_data(other.__data__) - # ============================================================================= # Constructors @@ -134,11 +145,6 @@ def test_line_properties(p1, p2): assert line.direction == normalize_vector(subtract_vectors(p2, p1)) assert line.length == distance_point_point(p1, p2) - assert line.frame == Frame.worldXY() - - with pytest.raises(AttributeError): - line.frame = Frame.worldZX() - line._point = None with pytest.raises(ValueError): line.point @@ -257,6 +263,7 @@ def test_line_point_from_end(p1, p2): for distance in distances: line = Line(p1, p2) point = line.point_from_end(distance) + assert isinstance(point, Point) distance_to_start = distance_point_point(point, p1) distance_to_end = distance_point_point(point, p2) # Check that the distance is correct @@ -297,3 +304,73 @@ def test_line_copy_deepcopy(): assert line is not line_deepcopy assert line == line_deepcopy + + +def test_line_sequence_mutation_and_comparison(): + line = Line([0, 0, 0], [1, 0, 0]) + + line[0] = [1, 2, 3] + line[1] = [4, 5, 6] + + assert list(line) == [line.start, line.end] + assert line == [[1, 2, 3], [4, 5, 6]] + assert line != [[1, 2, 3]] + assert line != object() + + with pytest.raises(KeyError): + _ = line[2] + + with pytest.raises(KeyError): + line[2] = [0, 0, 0] + + +def test_line_midpoint_and_closest_point(): + line = Line([0, 0, 0], [2, 0, 0]) + point = Point(1, 2, 0) + + assert line.midpoint == [1, 0, 0] + assert line.closest_point(point) == [1, 0, 0] + assert line.closest_point([1, 2, 0]) == [1, 0, 0] + + with pytest.raises(IndexError): + line.closest_point([1, 2]) + + closest, parameter = line.closest_point(point, return_parameter=True) + assert closest == [1, 0, 0] + assert parameter == 0.5 + + +def test_line_transform(): + line = Line([0, 0, 0], [1, 0, 0]) + line.transform(Translation.from_vector([1, 2, 3])) + + assert line.start == [1, 2, 3] + assert line.end == [2, 2, 3] + + +def test_line_flipped_preserves_subclass(): + class CustomLine(Line): + pass + + line = CustomLine([0, 0, 0], [1, 2, 3]) + flipped = line.flipped() + + assert isinstance(flipped, CustomLine) + assert flipped.start == line.end + assert flipped.end == line.start + + +@pytest.mark.parametrize( + "operation", + [ + lambda line: line.direction, + lambda line: line.point_from_start(1), + lambda line: line.point_from_end(1), + lambda line: line.closest_point(Point(1, 0, 0)), + ], +) +def test_zero_length_line_raises_zero_division_error(operation): + line = Line([0, 0, 0], [0, 0, 0]) + + with pytest.raises(ZeroDivisionError): + operation(line) diff --git a/tests/compas/geometry/test_curves_nurbs.py b/tests/compas/geometry/test_curves_nurbs.py new file mode 100644 index 000000000000..d0d8b4f884ce --- /dev/null +++ b/tests/compas/geometry/test_curves_nurbs.py @@ -0,0 +1,90 @@ +from compas.geometry import Ellipse +from compas.geometry import Frame +from compas.geometry import NurbsCurve +from compas.tolerance import TOL + + +class StubNurbsCurve(NurbsCurve): + @property + def knots(self): + return [0.0, 0.5, 1.0] + + @property + def multiplicities(self): + return [3, 1, 3] + + +def test_nurbscurve_forwards_interpolation_precision(monkeypatch): + captured = {} + sentinel = object() + + def factory(cls, points, precision): + captured.update(cls=cls, points=points, precision=precision) + return sentinel + + monkeypatch.setattr("compas.geometry.curves.nurbs.nurbscurve_from_interpolation", factory) + points = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]] + + assert NurbsCurve.from_interpolation(points, precision=0.25) is sentinel + assert captured == {"cls": NurbsCurve, "points": points, "precision": 0.25} + + +def test_nurbscurve_forwards_periodicity(monkeypatch): + captured = {} + sentinel = object() + + def factory(cls, points, weights, knots, multiplicities, degree, is_periodic): + captured.update( + cls=cls, + points=points, + weights=weights, + knots=knots, + multiplicities=multiplicities, + degree=degree, + is_periodic=is_periodic, + ) + return sentinel + + monkeypatch.setattr("compas.geometry.curves.nurbs.nurbscurve_from_parameters", factory) + + result = NurbsCurve.from_parameters([[0.0, 0.0, 0.0]], [1.0], [0.0], [1], 1, is_periodic=True) + + assert result is sentinel + assert captured["is_periodic"] is True + + +def test_nurbscurve_from_points_forwards_periodicity(monkeypatch): + captured = {} + sentinel = object() + + def factory(cls, points, degree, is_periodic): + captured.update(cls=cls, points=points, degree=degree, is_periodic=is_periodic) + return sentinel + + monkeypatch.setattr("compas.geometry.curves.nurbs.nurbscurve_from_points", factory) + points = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]] + + assert NurbsCurve.from_points(points, degree=1, is_periodic=True) is sentinel + assert captured == {"cls": NurbsCurve, "points": points, "degree": 1, "is_periodic": True} + + +def test_nurbscurve_from_ellipse_preserves_frame(monkeypatch): + captured = {} + sentinel = object() + + def factory(cls, points, weights, knots, multiplicities, degree, is_periodic): + captured.update(points=points) + return sentinel + + monkeypatch.setattr("compas.geometry.curves.nurbs.nurbscurve_from_parameters", factory) + ellipse = Ellipse(major=2.0, minor=1.0, frame=Frame.worldZX()) + + assert NurbsCurve.from_ellipse(ellipse) is sentinel + assert all(TOL.is_zero(point.y) for point in captured["points"]) + assert TOL.is_allclose(captured["points"][2], ellipse.center - ellipse.frame.xaxis * ellipse.major) + + +def test_nurbscurve_knotvector_expands_multiplicities(): + curve = StubNurbsCurve() + + assert curve.knotvector == [0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0] diff --git a/tests/compas/geometry/test_curves_parabola.py b/tests/compas/geometry/test_curves_parabola.py index e2fe494a6512..7fdbcd7c7ec8 100644 --- a/tests/compas/geometry/test_curves_parabola.py +++ b/tests/compas/geometry/test_curves_parabola.py @@ -1,6 +1,5 @@ import pytest import json -import compas from compas.tolerance import TOL from compas.geometry import Frame @@ -57,10 +56,6 @@ def test_parabola_data(): assert TOL.is_allclose(parabola.frame.xaxis, other.frame.xaxis) assert TOL.is_allclose(parabola.frame.yaxis, other.frame.yaxis) - if not compas.IPY: - assert Parabola.validate_data(parabola.__data__) - assert Parabola.validate_data(other.__data__) - # ============================================================================= # Constructors @@ -81,6 +76,55 @@ def test_parabola_properties(): parabola.focal +def test_parabola_geometry(): + parabola = Parabola(focal=1.0) + + assert parabola.domain == (-float("inf"), float("inf")) + assert parabola.latus == 4.0 + assert parabola.eccentricity == 1.0 + assert TOL.is_allclose(parabola.focus, [0.0, 1.0, 0.0]) + assert TOL.is_allclose(parabola.vertex, [0.0, 0.0, 0.0]) + assert TOL.is_allclose(parabola.directix.closest_point([2.0, -1.0, 0.0]), [2.0, -1.0, 0.0]) + assert not parabola.is_closed + assert not parabola.is_periodic + + +def test_parabola_derivatives(): + parabola = Parabola(focal=1.0) + + assert TOL.is_allclose(parabola.tangent_at(0.0), [1.0, 0.0, 0.0]) + assert TOL.is_allclose(parabola.normal_at(0.0), [0.0, 1.0, 0.0]) + assert parabola.tangent_at(-1.0).x > 0.0 + assert TOL.is_close(parabola.tangent_at(2.0).dot(parabola.normal_at(2.0)), 0.0) + + +def test_parabola_invalid_focal_and_coefficient(): + with pytest.raises(ValueError): + Parabola(focal=0.0) + with pytest.raises(ValueError): + Parabola(focal=-1.0) + + parabola = Parabola(focal=1.0) + with pytest.raises(ValueError): + parabola.a = 0.0 + + +def test_parabola_comparison(): + parabola = Parabola(focal=1.0) + + assert parabola == Parabola(focal=1.0) + assert parabola != Parabola(focal=2.0) + assert parabola != object() + + +def test_parabola_from_data_preserves_subclass(): + class CustomParabola(Parabola): + pass + + parabola = CustomParabola.__from_data__(Parabola(focal=1.0).__data__) + assert isinstance(parabola, CustomParabola) + + # ============================================================================= # Accessors # ============================================================================= diff --git a/tests/compas/geometry/test_curves_polyline.py b/tests/compas/geometry/test_curves_polyline.py index 6525ed49a1d2..2097253250d7 100644 --- a/tests/compas/geometry/test_curves_polyline.py +++ b/tests/compas/geometry/test_curves_polyline.py @@ -1,10 +1,13 @@ import pytest import math import json -import compas from compas.geometry import Frame +from compas.geometry import Geometry +from compas.geometry import Point from compas.geometry import Polyline +from compas.geometry import Curve +from compas.tolerance import TOL @pytest.mark.parametrize( @@ -17,9 +20,13 @@ ], ) def test_polyline_create(points): - curve = Polyline(points) + Polyline(points) + - assert curve.frame == Frame.worldXY() +def test_polyline_is_primitive_geometry(): + assert issubclass(Polyline, Geometry) + assert not issubclass(Polyline, Curve) + assert Polyline.__module__ == "compas.geometry.polyline" def test_polyline_create_with_frame(): @@ -38,10 +45,6 @@ def test_polyline_data(): assert curve.points == other.points - if not compas.IPY: - assert Polyline.validate_data(curve.__data__) - assert Polyline.validate_data(other.__data__) - # ============================================================================= # Constructors @@ -97,6 +100,33 @@ def test_polyline_properties(points): assert curve.end == curve.lines[-1].end +def test_polyline_lines_reflect_direct_point_mutations(): + polyline = Polyline([[0, 0, 0], [1, 0, 0]]) + original_lines = polyline.lines + + polyline.points.append(Point(2, 0, 0)) + + assert len(polyline.lines) == 2 + assert polyline.lines[-1].end == [2, 0, 0] + assert polyline.lines is not original_lines + + +def test_polyline_mutations_and_queries_reject_2d_coordinates(): + polyline = Polyline([[0, 0, 0], [1, 0, 0]]) + + with pytest.raises(IndexError): + polyline.append([2, 0]) + + with pytest.raises(IndexError): + polyline.insert(1, [0.5, 0]) + + with pytest.raises(IndexError): + polyline.parameter_at([0.5, 0]) + + with pytest.raises(IndexError): + polyline.tangent_at_point([0.5, 0]) + + # ============================================================================= # Accessors # ============================================================================= @@ -325,7 +355,8 @@ def test_polyline_split(coords, segments_number, expected): if segments_number > 0: assert expected == Polyline(coords).split(segments_number) else: - pytest.raises(ValueError) + with pytest.raises(ValueError): + Polyline(coords).split(segments_number) @pytest.mark.parametrize( @@ -388,7 +419,19 @@ def test_polyline_split_by_length_strict1(coords, length, expected): if length > 0 and length < polyline.length: assert expected == polyline.split_by_length(length, strict=False) else: - pytest.raises(ValueError) + with pytest.raises(ValueError): + polyline.split_by_length(length, strict=False) + + +def test_polyline_splitting_preserves_subclass(): + class CustomPolyline(Polyline): + pass + + polyline = CustomPolyline([[0, 0, 0], [1, 0, 0], [1, 1, 0]]) + + assert all(isinstance(part, CustomPolyline) for part in polyline.split(2)) + assert all(isinstance(part, CustomPolyline) for part in polyline.split_by_length(1)) + assert all(isinstance(part, CustomPolyline) for part in polyline.split_at_corners(math.pi / 2)) @pytest.mark.parametrize( @@ -449,6 +492,60 @@ def test_polyline_tangent_at_point(coords, input, expected): assert expected == Polyline(coords).tangent_at_point(input) +@pytest.mark.parametrize("t", [0.0, 0.25, 1.0]) +def test_polyline_tangent_at_is_unitized(t): + polyline = Polyline([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [2.0, 3.0, 0.0]]) + + tangent = polyline.tangent_at(t) + + assert tangent is not None + assert TOL.is_close(tangent.length, 1.0) + + +@pytest.mark.parametrize("points", [[], [[0.0, 0.0, 0.0]], [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]]) +def test_zero_length_polyline_has_no_parameterization(points): + polyline = Polyline(points) + + with pytest.raises(ValueError, match="zero-length"): + polyline.point_at(0.5) + with pytest.raises(ValueError, match="zero-length"): + polyline.parameter_at([0.0, 0.0, 0.0]) + with pytest.raises(ValueError, match="zero-length"): + polyline.tangent_at(0.5) + with pytest.raises(ValueError, match="zero-length"): + polyline.tangent_at_point([0.0, 0.0, 0.0]) + + +def test_polyline_endpoint_tangents_skip_zero_length_segments(): + polyline = Polyline([[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [2.0, 0.0, 0.0]]) + + assert polyline.tangent_at(0.0) == [1.0, 0.0, 0.0] + assert polyline.tangent_at(1.0) == [1.0, 0.0, 0.0] + + +@pytest.mark.parametrize("num_segments", [0, -1]) +def test_polyline_divide_requires_positive_segment_count(num_segments): + polyline = Polyline([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + + with pytest.raises(ValueError, match="greater than or equal to 1"): + polyline.divide(num_segments) + + +@pytest.mark.parametrize("length", [0.0, -1.0]) +def test_polyline_divide_by_length_requires_positive_length(length): + polyline = Polyline([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + + with pytest.raises(ValueError, match="greater than zero"): + polyline.divide_by_length(length) + + +def test_polyline_divide_by_length_rejects_length_greater_than_polyline(): + polyline = Polyline([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + + with pytest.raises(ValueError, match="smaller than input length"): + polyline.divide_by_length(2.0) + + @pytest.mark.parametrize("input,expected", [((0, 0, 0), 0.0), ((1, 0, 0), 0.5), ((1, 1, 0), 1.0), ((2, 0, 0), None)]) def test_polyline_parameter_at(input, expected): polyline = Polyline(((0, 0, 0), (1, 0, 0), (1, 1, 0))) @@ -485,3 +582,18 @@ def test_polyline_extend(coords, input, expected, length): def test_polyline_shortened(coords, input, expected, length): polyline = Polyline(coords).shortened(input) assert expected == polyline and length == polyline.length + + +def test_polyline_copying_mutations_preserve_subclass_and_original(): + class CustomPolyline(Polyline): + pass + + polyline = CustomPolyline([[0, 0, 0], [1, 0, 0], [2, 0, 0]]) + extended = polyline.extended(1) + shortened = polyline.shortened(1) + + assert isinstance(extended, CustomPolyline) + assert isinstance(shortened, CustomPolyline) + assert extended == [[0, 0, 0], [1, 0, 0], [3, 0, 0]] + assert shortened == [[0, 0, 0], [1, 0, 0]] + assert polyline == [[0, 0, 0], [1, 0, 0], [2, 0, 0]] diff --git a/tests/compas/geometry/test_frame.py b/tests/compas/geometry/test_frame.py index 8b4b6f74beae..c7b14ab7afe0 100644 --- a/tests/compas/geometry/test_frame.py +++ b/tests/compas/geometry/test_frame.py @@ -1,15 +1,16 @@ -from __future__ import division - import math import pytest import json -import compas from random import random from compas.tolerance import TOL from compas.geometry import Point from compas.geometry import Vector from compas.geometry import Frame +from compas.geometry import Plane +from compas.geometry import Quaternion +from compas.geometry import Rotation +from compas.geometry import Transformation @pytest.mark.parametrize( @@ -38,6 +39,17 @@ def test_frame(point, xaxis, yaxis): assert TOL.is_allclose(frame.yaxis, other.yaxis) +def test_frame_rejects_2d_coordinates(): + with pytest.raises(IndexError): + Frame([0, 0], [1, 0, 0], [0, 1, 0]) + + with pytest.raises(IndexError): + Frame([0, 0, 0], [1, 0], [0, 1, 0]) + + with pytest.raises(IndexError): + Frame([0, 0, 0], [1, 0, 0], [0, 1]) + + def test_frame_data(): point = [random(), random(), random()] xaxis = [random(), random(), random()] @@ -50,10 +62,6 @@ def test_frame_data(): assert TOL.is_allclose(frame.yaxis, other.yaxis) assert frame.guid != other.guid - if not compas.IPY: - assert Frame.validate_data(frame.__data__) - assert Frame.validate_data(other.__data__) - def test_frame_predefined(): frame = Frame.worldXY() @@ -72,6 +80,103 @@ def test_frame_predefined(): assert frame.yaxis == Vector(1, 0, 0) +def test_frame_sequence_behaviour(): + frame = Frame.worldXY() + + assert len(frame) == 3 + assert list(frame) == [frame.point, frame.xaxis, frame.yaxis] + assert frame[0] is frame.point + assert frame[1] is frame.xaxis + assert frame[2] is frame.yaxis + assert frame != object() + assert frame != [frame.point, frame.xaxis] + + frame[0] = [1.0, 2.0, 3.0] + frame[2] = [0.0, 0.0, 1.0] + frame[1] = [0.0, 1.0, 0.0] + assert frame == [[1.0, 2.0, 3.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + + with pytest.raises(KeyError): + _ = frame[3] + with pytest.raises(KeyError): + frame[3] = [1.0, 0.0, 0.0] + + +def test_frame_xaxis_setter_preserves_orthonormality(): + frame = Frame.worldXY() + + frame.xaxis = [1.0, 1.0, 0.0] + + assert TOL.is_close(frame.xaxis.length, 1.0) + assert TOL.is_close(frame.yaxis.length, 1.0) + assert TOL.is_close(frame.xaxis.dot(frame.yaxis), 0.0) + assert TOL.is_close(frame.zaxis.length, 1.0) + + with pytest.raises(ValueError): + frame.xaxis = frame.yaxis + + +def test_frame_constructors(): + expected = Frame.worldXY() + point = [0.0, 0.0, 0.0] + + assert Frame.from_points(point, [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]) == expected + + rotation = Rotation.from_axis_and_angle([0.0, 0.0, 1.0], 0.0) + assert Frame.from_rotation(rotation) == expected + + transformation = Transformation.from_frame(expected) + assert Frame.from_transformation(transformation) == expected + assert Frame.from_matrix(transformation.matrix) == expected + + values = [value for row in transformation.matrix for value in row] + assert Frame.from_list(values) == expected + values12 = values[:12] + assert Frame.from_list(values12) == expected + assert len(values12) == 16 + + assert Frame.from_quaternion(Quaternion(1.0, 0.0, 0.0, 0.0), point) == expected + assert Frame.from_axis_angle_vector([0.0, 0.0, 0.0], point) == expected + assert Frame.from_euler_angles([0.0, 0.0, 0.0], point=point) == expected + from_plane = Frame.from_plane(Plane(point, [0.0, 0.0, 1.0])) + assert from_plane.point == point + assert from_plane.normal == [0.0, 0.0, 1.0] + + with pytest.raises(ValueError): + Frame.from_list([0.0] * 11) + + +def test_frame_conversions_and_transformations(): + frame = Frame([1.0, 2.0, 3.0]) + transformation = frame.to_transformation() + + assert Frame.from_transformation(transformation) == frame + assert frame.to_local_coordinates([1.0, 2.0, 3.0]) == Point(0.0, 0.0, 0.0) + assert frame.to_local_coordinates((2.0, 4.0, 6.0)) == Point(1.0, 2.0, 3.0) + assert frame.to_world_coordinates([0.0, 0.0, 0.0]) == Point(1.0, 2.0, 3.0) + assert frame.to_world_coordinates((1.0, 2.0, 3.0)) == Point(2.0, 4.0, 6.0) + + point = Point(2.0, 4.0, 6.0) + local = frame.to_local_coordinates(point) + assert isinstance(local, Point) + assert frame.to_world_coordinates(local) == point + + transformed = Frame.worldXY() + transformed.transform(transformation.matrix) + assert transformed == frame + + +def test_frame_interpolate_frames_and_euler_angles(): + frame1 = Frame.worldXY() + frame2 = Frame([1.0, 1.0, 1.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]) + + frames = frame1.interpolate_frames(frame2, 3) + assert len(frames) == 3 + assert frames[0] == frame1 + assert frames[-1] == frame2 + assert TOL.is_allclose(Frame.from_euler_angles(frame2.euler_angles()).xaxis, frame2.xaxis) + + def test_interpolate_frame_start_end(): frame1 = Frame(Point(0, 0, 0), Vector(1, 0, 0), Vector(0, 1, 0)) frame2 = Frame(Point(1, 1, 1), Vector(0, 0, 1), Vector(0, 1, 0)) diff --git a/tests/compas/geometry/test_intersection.py b/tests/compas/geometry/test_intersection.py new file mode 100644 index 000000000000..96e06e62e481 --- /dev/null +++ b/tests/compas/geometry/test_intersection.py @@ -0,0 +1,97 @@ +import pytest + +from compas.geometry import Circle +from compas.geometry import Geometry +from compas.geometry import Intersection +from compas.geometry import IntersectionResult +from compas.geometry import Line +from compas.geometry import Plane +from compas.geometry import Point +from compas.geometry import intersection + + +def test_line_line_intersection(): + a = Line([-1.0, 0.0, 0.0], [1.0, 0.0, 0.0]) + b = Line([0.0, -1.0, 0.0], [0.0, 1.0, 0.0]) + + result = intersection(a, b) + + assert isinstance(result, IntersectionResult) + assert result.number_of_intersections == 1 + assert result.points[0] == [0.0, 0.0, 0.0] + + +@pytest.mark.parametrize( + "a, b", + [ + (Line([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]), Line([0.0, 0.0, 1.0], [0.0, 1.0, 1.0])), + (Line([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]), Line([0.0, 1.0, 0.0], [1.0, 1.0, 0.0])), + ], +) +def test_nonintersecting_lines_return_empty_result(a, b): + assert not intersection(a, b) + + +def test_line_plane_dispatch_is_symmetric(): + line = Line([0.0, 0.0, -1.0], [0.0, 0.0, 1.0]) + plane = Plane.worldXY() + + forward = intersection(line, plane) + reverse = intersection(plane, line) + + assert forward == reverse + assert forward.points[0] == [0.0, 0.0, 0.0] + + +def test_plane_plane_intersection(): + result = intersection(Plane.worldXY(), Plane.worldYZ()) + + assert len(result.lines) == 1 + assert Plane.worldXY().contains_point(result.lines[0].start) + assert Plane.worldYZ().contains_point(result.lines[0].start) + + +def test_parallel_planes_return_empty_result_regardless_of_normal_orientation(): + assert not intersection(Plane.worldXY(), Plane([0.0, 0.0, 1.0], [0.0, 0.0, 1.0])) + assert not intersection(Plane.worldXY(), Plane([0.0, 0.0, 1.0], [0.0, 0.0, -1.0])) + + +def test_unsupported_intersection_combination(): + with pytest.raises(TypeError, match="Circle"): + intersection(Circle(1.0), Plane.worldXY()) + + +def test_custom_intersection_registration_is_symmetric_and_supports_subclasses(): + class A(Geometry): + pass + + class B(Geometry): + pass + + class SubA(A): + pass + + dispatcher = Intersection() + + @dispatcher.register(A, B) + def intersection_a_b(a, b, tol=None): + assert isinstance(a, A) + assert isinstance(b, B) + return IntersectionResult((Point(1.0, 2.0, 3.0),)) + + assert dispatcher(SubA(), B()).points[0] == [1.0, 2.0, 3.0] + assert dispatcher(B(), SubA()).points[0] == [1.0, 2.0, 3.0] + + +def test_duplicate_symmetric_registration_is_rejected(): + dispatcher = Intersection() + + @dispatcher.register(Line, Plane) + def intersection_line_plane(line, plane, tol=None): + return IntersectionResult() + + with pytest.raises(ValueError, match="already registered"): + + @dispatcher.register(Plane, Line) + def intersection_plane_line(plane, line, tol=None): + return IntersectionResult() diff --git a/tests/compas/geometry/test_intersections.py b/tests/compas/geometry/test_intersections.py index 352eb50180f7..e4e5034fe8dc 100644 --- a/tests/compas/geometry/test_intersections.py +++ b/tests/compas/geometry/test_intersections.py @@ -1,7 +1,56 @@ +import pytest + from compas.tolerance import TOL +from compas.geometry import intersection_line_line +from compas.geometry import intersection_line_line_xy +from compas.geometry import intersection_line_box_xy +from compas.geometry import intersection_line_plane +from compas.geometry import intersection_line_segment_xy +from compas.geometry import intersection_plane_plane +from compas.geometry import intersection_plane_plane_plane +from compas.geometry import intersection_polyline_plane +from compas.geometry import intersection_polyline_box_xy +from compas.geometry import intersection_segment_polyline +from compas.geometry import intersection_segment_polyline_xy +from compas.geometry import intersection_segment_segment_xy from compas.geometry import intersection_sphere_line +from compas.geometry import intersection_sphere_sphere from compas.geometry import intersection_plane_circle from compas.geometry import intersection_circle_circle_xy +from compas.geometry import intersection_ellipse_line_xy + + +def test_intersection_line_line_rejects_parallel_and_degenerate_lines(): + assert intersection_line_line(([0, 0, 0], [1, 0, 0]), ([0, 1, 0], [1, 1, 0])) == (None, None) + assert intersection_line_line(([0, 0, 0], [0, 0, 0]), ([0, 0, 0], [0, 1, 0])) == (None, None) + + +def test_intersection_line_plane_returns_origin_without_truthiness_ambiguity(): + assert intersection_line_plane(([0, 0, -1], [0, 0, 1]), ([0, 0, 0], [0, 0, 1])) == [0.0, 0.0, 0.0] + + +def test_intersection_polyline_plane_respects_zero_limit_and_rejects_negative_limit(): + polyline = [[0, 0, -1], [0, 0, 1], [1, 0, -1]] + plane = [0, 0, 0], [0, 0, 1] + + assert intersection_polyline_plane(polyline, plane, expected_number_of_intersections=0) == [] + with pytest.raises(ValueError, match="cannot be negative"): + intersection_polyline_plane(polyline, plane, expected_number_of_intersections=-1) + + +def test_intersection_plane_plane_accepts_nonunit_normals_and_detects_opposite_parallel_normals(): + line = intersection_plane_plane(([0, 0, 0], [0, 0, 2]), ([0, 0, 0], [0, 3, 0])) + + assert line is not None + assert TOL.is_allclose(line[0], [0, 0, 0]) + assert intersection_plane_plane(([0, 0, 0], [0, 0, 2]), ([0, 0, 1], [0, 0, -3])) is None + + +def test_intersection_plane_plane_plane_returns_unique_point(): + point = intersection_plane_plane_plane(([1, 0, 0], [1, 0, 0]), ([0, 2, 0], [0, 1, 0]), ([0, 0, 3], [0, 0, 1])) + + assert point is not None + assert TOL.is_allclose(point, [1, 2, 3]) def test_intersection_sphere_line(): @@ -12,6 +61,88 @@ def test_intersection_sphere_line(): assert TOL.is_allclose(ipt2, (-0.634, -1.634, 0.500), atol=1e-3) +def test_intersection_sphere_line_tangent_and_invalid_inputs(): + assert intersection_sphere_line(([0, 0, 0], 1.0), ([-1, 1, 0], [1, 1, 0])) == (0.0, 1.0, 0.0) + + with pytest.raises(ValueError, match="distinct"): + intersection_sphere_line(([0, 0, 0], 1.0), ([0, 0, 0], [0, 0, 0])) + with pytest.raises(ValueError, match="radius"): + intersection_sphere_line(([0, 0, 0], -1.0), ([-1, 0, 0], [1, 0, 0])) + + +def test_intersection_sphere_sphere_classifies_intersection_geometry(): + coincident = intersection_sphere_sphere(((0, 0, 0), 1.0), ((0, 0, 0), 1.0)) + tangent = intersection_sphere_sphere(((0, 0, 0), 1.0), ((2, 0, 0), 1.0)) + circle = intersection_sphere_sphere(((0, 0, 0), 2.0), ((2, 0, 0), 2.0)) + + assert coincident == ("sphere", ([0, 0, 0], 1.0)) + assert tangent == ("point", [1.0, 0.0, 0.0]) + assert circle is not None and circle[0] == "circle" + + +def test_intersection_segment_polyline_skips_nonintersecting_segments(): + segment = ([0.0, -1.0, 0.0], [0.0, 1.0, 0.0]) + polyline = [[-2.0, 2.0, 0.0], [-1.0, 2.0, 0.0], [-1.0, 0.0, 0.0], [1.0, 0.0, 0.0]] + + point1, point2 = intersection_segment_polyline(segment, polyline) + + assert point1 == [0.0, 0.0, 0.0] + assert point2 == [0.0, 0.0, 0.0] + + +def test_intersection_segment_polyline_returns_consistent_empty_pair(): + result = intersection_segment_polyline(([0, 0, 0], [1, 0, 0]), [[0, 1, 0], [1, 1, 0]]) + + assert result == (None, None) + + +def test_intersection_line_line_xy_accepts_2d_coordinates_and_returns_3d_point(): + point = intersection_line_line_xy(([0, 0], [1, 1]), ([0, 1], [1, 0])) + + assert point == [0.5, 0.5, 0.0] + + +def test_intersection_line_segment_xy_distinguishes_line_from_segment(): + line = ([0, 0], [1, 0]) + + assert intersection_line_segment_xy(line, ([0.5, -1], [0.5, 1])) == [0.5, 0.0, 0.0] + assert intersection_line_segment_xy(line, ([0.5, 1], [0.5, 2])) is None + + +def test_intersection_segment_segment_xy_requires_point_on_both_segments(): + assert intersection_segment_segment_xy(([0, 0], [1, 0]), ([0.5, -1], [0.5, 1])) == [0.5, 0.0, 0.0] + assert intersection_segment_segment_xy(([0, 0], [1, 0]), ([2, -1], [2, 1])) is None + + +def test_intersection_segment_polyline_xy_skips_nonintersecting_segments(): + segment = ([0, -1], [0, 1]) + polyline = [[-2, 2], [-1, 2], [-1, 0], [1, 0]] + + assert intersection_segment_polyline_xy(segment, polyline) == [0.0, 0.0, 0.0] + assert intersection_segment_polyline_xy(segment, [[1, 2], [2, 2]]) is None + + +def test_intersection_line_box_xy_returns_unique_points_consistently(): + box = [[0, 0], [1, 0], [1, 1], [0, 1]] + + assert intersection_line_box_xy(([-1, 0.5], [2, 0.5]), box) == [[1.0, 0.5, 0.0], [0.0, 0.5, 0.0]] + assert intersection_line_box_xy(([-1, 1], [0, 0]), box) == [[0.0, 0.0, 0.0]] + assert intersection_line_box_xy(([-1, 2], [2, 2]), box) == [] + + +def test_intersection_polyline_box_xy_deduplicates_corner_hits(): + box = [[0, 0], [1, 0], [1, 1], [0, 1]] + polyline = [[-1, -1], [0, 0], [2, 2]] + + assert intersection_polyline_box_xy(polyline, box) == [[0.0, 0.0, 0.0], [1.0, 1.0, 0.0]] + + +@pytest.mark.parametrize("function", [intersection_line_box_xy, intersection_polyline_box_xy]) +def test_xy_box_intersections_require_four_corners(function): + with pytest.raises(ValueError, match="four corners"): + function(([-1, 0], [2, 0]), [[0, 0], [1, 0], [1, 1]]) + + def test_intersection_plane_circle(): plane = (0, 0, 0), (0, 0, 1) circle = ((3.0, 7.0, 4.0), (0, 1, 0)), 10.0 @@ -26,3 +157,37 @@ def test_intersection_circle_circle_xy(): ipt1, ipt2 = intersection_circle_circle_xy(circle1, circle2) assert TOL.is_allclose(ipt1, (9.999, -0.142, 0.000), atol=1e-3) assert TOL.is_allclose(ipt2, (-6.999, 7.142, 0.000), atol=1e-3) + + +def test_intersection_circle_circle_xy_classifies_secant_tangent_and_disjoint_circles(): + circle1 = (([0, 0, 0], [0, 0, 1]), 1.0) + + secant = intersection_circle_circle_xy(circle1, (([1, 0, 0], [0, 0, 1]), 1.0)) + tangent = intersection_circle_circle_xy(circle1, (([2, 0, 0], [0, 0, 1]), 1.0)) + + assert secant is not None and not TOL.is_allclose(secant[0], secant[1]) + assert tangent == ((1.0, 0.0, 0), (1.0, 0.0, 0)) + assert intersection_circle_circle_xy(circle1, (([3, 0, 0], [0, 0, 1]), 1.0)) is None + assert intersection_circle_circle_xy(circle1, (([0, 0, 0], [0, 0, 1]), 1.0)) is None + + +def test_intersection_circle_circle_xy_rejects_negative_radius(): + with pytest.raises(ValueError, match="cannot be negative"): + intersection_circle_circle_xy((([0, 0, 0], [0, 0, 1]), -1.0), (([1, 0, 0], [0, 0, 1]), 1.0)) + + +def test_intersection_ellipse_line_xy_classifies_secant_tangent_and_disjoint_lines(): + assert intersection_ellipse_line_xy((2.0, 1.0), ([-3, 0], [3, 0])) == ((2.0, 0.0, 0.0), (-2.0, 0.0, 0.0)) + assert intersection_ellipse_line_xy((2.0, 1.0), ([-3, 1], [3, 1])) == (0.0, 1.0, 0.0) + assert intersection_ellipse_line_xy((2.0, 1.0), ([-3, 2], [3, 2])) is None + + +@pytest.mark.parametrize("ellipse", [(0.0, 1.0), (2.0, 0.0), (-2.0, 1.0)]) +def test_intersection_ellipse_line_xy_rejects_nonpositive_axes(ellipse): + with pytest.raises(ValueError, match="positive"): + intersection_ellipse_line_xy(ellipse, ([-3, 0], [3, 0])) + + +def test_intersection_ellipse_line_xy_rejects_degenerate_line(): + with pytest.raises(ValueError, match="distinct"): + intersection_ellipse_line_xy((2.0, 1.0), ([0, 0], [0, 0])) diff --git a/tests/compas/geometry/test_plane.py b/tests/compas/geometry/test_plane.py index fe8fca18b0ad..f041637f7ee8 100644 --- a/tests/compas/geometry/test_plane.py +++ b/tests/compas/geometry/test_plane.py @@ -1,11 +1,14 @@ -import pytest import json -import compas from random import random -from compas.tolerance import TOL + +import pytest + +from compas.geometry import Frame +from compas.geometry import Plane from compas.geometry import Point +from compas.geometry import Translation from compas.geometry import Vector -from compas.geometry import Plane +from compas.tolerance import TOL @pytest.mark.parametrize( @@ -31,6 +34,14 @@ def test_plane(point, vector): assert TOL.is_allclose(other.normal, plane.normal) +def test_plane_rejects_2d_coordinates(): + with pytest.raises(IndexError): + Plane([0, 0], [0, 0, 1]) + + with pytest.raises(IndexError): + Plane([0, 0, 0], [0, 1]) + + def test_plane_data(): point = Point(random(), random(), random()) vector = Vector(random(), random(), random()) @@ -41,10 +52,6 @@ def test_plane_data(): assert TOL.is_allclose(other.normal, plane.normal) assert plane.guid != other.guid - if not compas.IPY: - assert Plane.validate_data(plane.__data__) - assert Plane.validate_data(other.__data__) - def test_plane_predefined(): plane = Plane.worldXY() @@ -86,3 +93,92 @@ def test_plane_is_parallel(): plane1 = Plane.worldXY() plane2 = Plane([1.0, 1.0, 1.0], [0.0, 0.0, -1.0]) assert plane1.is_parallel(plane2) + + +def test_plane_sequence_behavior(): + plane = Plane.worldXY() + + assert len(plane) == 2 + assert list(plane) == [plane.point, plane.normal] + assert plane[0] is plane.point + assert plane[1] is plane.normal + + plane[0] = [1, 2, 3] + plane[1] = [0, 2, 0] + + assert plane.point == [1, 2, 3] + assert plane.normal == [0, 1, 0] + assert plane == [[1, 2, 3], [0, 1, 0]] + + with pytest.raises(KeyError): + _ = plane[2] + + with pytest.raises(KeyError): + plane[2] = [0, 0, 1] + + +def test_plane_equation_coefficients(): + plane = Plane([0, 0, 2], [0, 0, 1]) + + assert plane.d == -2 + assert plane.abcd == (0, 0, 1, -2) + + +def test_plane_from_abcd(): + plane = Plane.from_abcd([0, 0, 2, -4]) + + assert plane.point == [0, 0, 2] + assert plane.normal == [0, 0, 1] + assert TOL.is_zero(sum(coefficient * coordinate for coefficient, coordinate in zip(plane.abcd[:3], plane.point)) + plane.abcd[3]) + + +def test_plane_additional_constructors(): + frame = Frame([1, 2, 3], [1, 0, 0], [0, 1, 0]) + from_frame = Plane.from_frame(frame) + from_points = Plane.from_points([[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]]) + + assert from_frame.point == frame.point + assert from_frame.normal == frame.normal + assert from_points.contains_point([0, 0, 0]) + assert from_points.normal == [0, 0, 1] + + +def test_plane_transform(): + plane = Plane.worldXY() + plane.transform(Translation.from_vector([1, 2, 3])) + + assert plane.point == [1, 2, 3] + assert plane.normal == [0, 0, 1] + + +def test_plane_point_relationships(): + plane = Plane.worldXY() + + assert plane.contains_point([1, 2, 0]) + assert not plane.contains_point([1, 2, 1]) + assert plane.distance_to_point([1, 2, 3]) == 3 + assert plane.closest_point([1, 2, 3]) == [1, 2, 0] + assert plane.projected_point([1, 2, 3]) == [1, 2, 0] + assert plane.projected_point([1, 2, 3], [0, 0, -1]) == [1, 2, 0] + assert plane.projected_point([1, 2, 3], [1, 0, 0]) is None + assert plane.mirrored_point([1, 2, 3]) == [1, 2, -3] + + +def test_plane_relationships(): + plane = Plane.worldXY() + + assert plane.is_parallel(Plane([0, 0, 1], [0, 0, -1])) + assert plane.is_perpendicular(Plane.worldYZ()) + assert not plane.is_perpendicular(Plane.worldXY()) + + +def test_plane_offset_preserves_subclass(): + class CustomPlane(Plane): + pass + + plane = CustomPlane.worldXY() + offset = plane.offset(2) + + assert isinstance(offset, CustomPlane) + assert offset.point == [0, 0, 2] + assert offset.normal == plane.normal diff --git a/tests/compas/geometry/test_point.py b/tests/compas/geometry/test_point.py index 18557de694d6..6886ce7336d5 100644 --- a/tests/compas/geometry/test_point.py +++ b/tests/compas/geometry/test_point.py @@ -1,9 +1,15 @@ -from __future__ import division import pytest import json import compas from random import random +from compas.geometry import Circle +from compas.geometry import Line +from compas.geometry import Plane from compas.geometry import Point +from compas.geometry import Polygon +from compas.geometry import Polyhedron +from compas.geometry import Polyline +from compas.geometry import Translation from compas.tolerance import TOL @@ -63,6 +69,29 @@ def test_point_equality(): assert not (p1 != p2) assert p1 != p3 assert not (p1 == p3) + assert p1 != [1, 1] + assert p1 != [1, 1, 1, 1] + assert p1 != object() + assert p1 is not None + + +def test_geometry_copying_transform_helpers_preserve_subclass_and_original(): + point = Point(1.0, 0.0, 0.0) + + transformed = point.transformed(Translation.from_vector([1.0, 2.0, 3.0])) + translated = point.translated([1.0, 2.0, 3.0]) + scaled = point.scaled(2.0, 3.0, 4.0) + rotated = point.rotated(0.5 * 3.141592653589793, axis=[0.0, 0.0, 1.0]) + + assert isinstance(transformed, Point) + assert isinstance(translated, Point) + assert isinstance(scaled, Point) + assert isinstance(rotated, Point) + assert point == Point(1.0, 0.0, 0.0) + assert transformed == Point(2.0, 2.0, 3.0) + assert translated == Point(2.0, 2.0, 3.0) + assert scaled == Point(2.0, 0.0, 0.0) + assert TOL.is_allclose(rotated, [0.0, 1.0, 0.0]) def test_point_comparison_relative(): @@ -86,7 +115,41 @@ def test_point_comparison_absolute(): def test_point_inplace_operators(): - pass + point = Point(2.0, 4.0, 6.0) + identity = id(point) + + point += [1.0, 2.0, 3.0] + point -= [1.0, 1.0, 1.0] + point *= 2.0 + point /= 4.0 + point **= 2.0 + + assert id(point) == identity + assert point == [1.0, 6.25, 16.0] + + +def test_point_sequence_behaviour(): + point = Point(1.0, 2.0, 3.0) + + assert len(point) == 3 + assert list(point) == [1.0, 2.0, 3.0] + assert point[:] == [1.0, 2.0, 3.0] + assert point[-3] == 1.0 + assert point[-1] == 3.0 + + point[0] = 4.0 + point[-2] = 5.0 + point[-1] = 6.0 + assert point == [4.0, 5.0, 6.0] + + with pytest.raises(IndexError): + _ = point[3] + with pytest.raises(IndexError): + _ = point[-4] + with pytest.raises(IndexError): + point[3] = 7.0 + with pytest.raises(IndexError): + point[-4] = 7.0 def test_point_data(): @@ -97,50 +160,67 @@ def test_point_data(): assert point.__data__ == other.__data__ assert point.guid != other.guid - if not compas.IPY: - assert Point.validate_data(point.__data__) - assert Point.validate_data(other.__data__) - def test_point_distance_to_point(): - pass + assert Point(1.0, 2.0, 3.0).distance_to_point([4.0, 6.0, 3.0]) == 5.0 def test_point_distance_to_line(): - pass + line = Line([0.0, 0.0, 0.0], [0.0, 1.0, 0.0]) + assert Point(2.0, 0.0, 0.0).distance_to_line(line) == 2.0 def test_point_distance_to_plane(): - pass + plane = Plane([0.0, 0.0, 2.0], [0.0, 0.0, 1.0]) + assert Point(0.0, 0.0, -1.0).distance_to_plane(plane) == 3.0 def test_point_on_line(): - pass + line = Line([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]) + assert Point(2.0, 0.0, 0.0).on_line(line) + assert not Point(2.0, 1.0, 0.0).on_line(line) def test_point_on_segment(): - pass + segment = Line([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]) + assert Point(0.5, 0.0, 0.0).on_segment(segment) + assert not Point(2.0, 0.0, 0.0).on_segment(segment) def test_point_on_polyline(): - pass + polyline = Polyline([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]]) + assert Point(1.0, 0.5, 0.0).on_polyline(polyline) + assert not Point(0.5, 0.5, 0.0).on_polyline(polyline) def test_point_on_circle(): - pass + circle = Circle(1.0) + assert Point(1.0, 0.0, 0.0).on_circle(circle) + assert not Point(0.0, 0.0, 0.0).on_circle(circle) + assert not Point(1.0, 0.0, 1.0).on_circle(circle) def test_point_in_triangle(): - pass + triangle = Polygon([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 2.0, 0.0]]) + assert Point(0.5, 0.5, 0.0).in_triangle(triangle) + assert not Point(2.0, 2.0, 0.0).in_triangle(triangle) def test_point_in_polygon(): - pass + polygon = Polygon([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [2.0, 2.0, 0.0], [0.0, 2.0, 0.0]]) + assert Point(1.0, 1.0, 0.0).in_polygon(polygon) + assert Point(1.0, 1.0, 0.0).in_convex_polygon(polygon) + assert not Point(3.0, 1.0, 0.0).in_polygon(polygon) def test_point_in_circle(): - pass + circle = Circle(2.0) + assert Point(1.0, 0.0, 0.0).in_circle(circle) + assert not Point(3.0, 0.0, 0.0).in_circle(circle) def test_point_in_polyhedron(): - pass + tetrahedron = Polyhedron.from_platonicsolid(4) + tetrahedron.faces = [list(reversed(face)) for face in tetrahedron.faces] + assert Point(0.0, 0.0, 0.0).in_polyhedron(tetrahedron) + assert not Point(10.0, 10.0, 10.0).in_polyhedron(tetrahedron) diff --git a/tests/compas/geometry/test_pointcloud.py b/tests/compas/geometry/test_pointcloud.py index d0e2991f09fa..4cf06ee91fda 100644 --- a/tests/compas/geometry/test_pointcloud.py +++ b/tests/compas/geometry/test_pointcloud.py @@ -34,10 +34,6 @@ def test_pointcloud_data(): assert pointcloud.points == other.points assert pointcloud.__data__ == other.__data__ - if not compas.IPY: - assert Pointcloud.validate_data(pointcloud.__data__) - assert Pointcloud.validate_data(other.__data__) - def test_pointcloud__eq__(): a = Pointcloud.from_bounds(10, 10, 10, 10) diff --git a/tests/compas/geometry/test_polygon.py b/tests/compas/geometry/test_polygon.py index 322a59125838..d8b48f749359 100644 --- a/tests/compas/geometry/test_polygon.py +++ b/tests/compas/geometry/test_polygon.py @@ -47,10 +47,6 @@ def test_polygon_data(): assert polygon.points == other.points assert polygon.__data__ == other.__data__ - if not compas.IPY: - assert Polygon.validate_data(polygon.__data__) - assert Polygon.validate_data(other.__data__) - def test_polygon__eq__(): points1 = [[0, 0, x] for x in range(5)] diff --git a/tests/compas/geometry/test_quaternion.py b/tests/compas/geometry/test_quaternion.py index 70ccbabc09e1..21a8ae57f82e 100644 --- a/tests/compas/geometry/test_quaternion.py +++ b/tests/compas/geometry/test_quaternion.py @@ -6,6 +6,12 @@ from compas.geometry import Quaternion from compas.tolerance import TOL from compas.geometry import Frame +from compas.linalg import quaternion_canonize +from compas.linalg import quaternion_conjugate +from compas.linalg import quaternion_is_unit +from compas.linalg import quaternion_multiply +from compas.linalg import quaternion_norm +from compas.linalg import quaternion_unitize @pytest.mark.parametrize( @@ -59,10 +65,6 @@ def test_quaternion_data(): assert quaternion.y == other.y assert quaternion.z == other.z - if not compas.IPY: - assert Quaternion.validate_data(quaternion.__data__) - assert Quaternion.validate_data(other.__data__) - # ============================================================================= # Properties and Geometry @@ -151,3 +153,46 @@ def test_quaternion_other_methods(): value = TOL.format_number(0.5) assert str(canonized) == str("Quaternion(" + value + ", -" + value + ", -" + value + ", -" + value + ")") + + +# ============================================================================= +# Core helpers +# ============================================================================= + + +def test_quaternion_norm_and_unitize(): + assert TOL.is_close(quaternion_norm((1.0, 2.0, 3.0, 4.0)), 30**0.5) + assert TOL.is_allclose(quaternion_unitize((2.0, 0.0, 0.0, 0.0)), [1.0, 0.0, 0.0, 0.0]) + + +def test_quaternion_unitize_rejects_zero_length(): + with pytest.raises(ValueError, match="zero length"): + quaternion_unitize((0.0, 0.0, 0.0, 0.0)) + + +def test_quaternion_is_unit_respects_tolerance(): + quaternion = (1.0 + 1e-4, 0.0, 0.0, 0.0) + assert quaternion_is_unit(quaternion, tol=1e-3) + assert not quaternion_is_unit(quaternion, tol=1e-6) + + +def test_quaternion_multiply_identity(): + quaternion = [0.5, 0.5, 0.5, 0.5] + identity = [1.0, 0.0, 0.0, 0.0] + assert quaternion_multiply(identity, quaternion) == quaternion + assert quaternion_multiply(quaternion, identity) == quaternion + + +def test_quaternion_canonize_negates_negative_scalar(): + assert quaternion_canonize((-1.0, 2.0, -3.0, 4.0)) == [1.0, -2.0, 3.0, -4.0] + + +@pytest.mark.parametrize("quaternion", [(1.0, 2.0, 3.0, 4.0), [1.0, 2.0, 3.0, 4.0]]) +def test_quaternion_canonize_preserves_input_container_for_nonnegative_scalar(quaternion): + result = quaternion_canonize(quaternion) + assert result == quaternion + assert type(result) is type(quaternion) + + +def test_quaternion_conjugate(): + assert quaternion_conjugate((1.0, 2.0, 3.0, 4.0)) == [1.0, -2.0, -3.0, -4.0] diff --git a/tests/compas/geometry/test_surfaces.py b/tests/compas/geometry/test_surfaces.py index bb2250f01c56..46b4a0204291 100644 --- a/tests/compas/geometry/test_surfaces.py +++ b/tests/compas/geometry/test_surfaces.py @@ -1,3 +1,5 @@ +from math import inf + import pytest from compas.tolerance import TOL @@ -59,3 +61,69 @@ def test_surface_geometry(surface): surface.point_at(0, 0.5), surface.point_at(0, 0.5, world=False).transformed(surface.transformation), ) + + +def test_surface_frame_is_copied_and_requires_frame_object(): + source = Frame.worldYZ() + surface = PlanarSurface(frame=source) + + assert surface.frame == source + assert surface.frame is not source + + source.point = [10.0, 0.0, 0.0] + assert surface.frame.point == [0.0, 0.0, 0.0] + + with pytest.raises(TypeError): + surface.frame = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] # type: ignore[assignment] + + +def test_surface_transformation_reflects_direct_frame_mutation(): + surface = PlanarSurface() + _ = surface.transformation + + surface.frame.point = [10.0, 0.0, 0.0] + + assert surface.transformation.translation_vector == [10.0, 0.0, 0.0] + assert surface.point == [10.0, 0.0, 0.0] + + +def test_surface_preserves_geometry_bounding_box_property_contract(): + assert isinstance(PlanarSurface.aabb, property) + assert isinstance(PlanarSurface.obb, property) + + +@pytest.mark.parametrize( + "surface, periodic_u, periodic_v", + [ + (PlanarSurface(), False, False), + (SphericalSurface(1.0), True, False), + (CylindricalSurface(1.0), True, False), + (ConicalSurface(1.0, 1.0), True, False), + (ToroidalSurface(2.0, 0.5), True, True), + ], +) +def test_analytic_surface_periodicity(surface, periodic_u, periodic_v): + assert surface.is_periodic_u is periodic_u + assert surface.is_periodic_v is periodic_v + + +def test_surface_discretization_counts(): + surface = PlanarSurface(xsize=2.0, ysize=3.0) + + vertices, faces = surface.to_vertices_and_faces(nu=2, nv=3) + + assert len(vertices) == 12 + assert len(faces) == 6 + assert len(surface.to_quads(nu=2, nv=3)) == 6 + assert len(surface.to_triangles(nu=2, nv=3)) == 12 + + +@pytest.mark.parametrize("nu, nv", [(0, 1), (1, 0), (-1, 1), (1, -1)]) +def test_surface_discretization_requires_positive_face_counts(nu, nv): + with pytest.raises(ValueError, match="at least one face"): + PlanarSurface().to_vertices_and_faces(nu=nu, nv=nv) + + +def test_surface_discretization_requires_finite_domains(): + with pytest.raises(ValueError, match="finite parameter domains"): + PlanarSurface().to_vertices_and_faces(du=(-inf, inf)) diff --git a/tests/compas/geometry/test_surfaces_cone.py b/tests/compas/geometry/test_surfaces_cone.py index fbd9aa2aad7e..1596f2b012c2 100644 --- a/tests/compas/geometry/test_surfaces_cone.py +++ b/tests/compas/geometry/test_surfaces_cone.py @@ -1,11 +1,15 @@ import pytest import json -import compas +from math import pi +from math import sqrt from random import random +from compas.geometry import Circle from compas.geometry import Point # noqa: F401 from compas.geometry import Vector # noqa: F401 from compas.geometry import Frame +from compas.geometry import Line +from compas.geometry import Plane from compas.geometry import ConicalSurface from compas.tolerance import TOL from compas.itertools import linspace @@ -14,9 +18,6 @@ @pytest.mark.parametrize( "radius,height", [ - (0, 0), - (1, 0), - (0, 1), (1, 1), (random(), random()), ], @@ -83,19 +84,40 @@ def test_cone_data(): assert cone.height == height assert cone.frame == Frame.worldXY() - if not compas.IPY: - assert ConicalSurface.validate_data(cone.__data__) - assert ConicalSurface.validate_data(other.__data__) - # ============================================================================= # Constructors # ============================================================================= + +@pytest.mark.parametrize("radius, height", [(0.0, 1.0), (1.0, 0.0), (-1.0, 1.0), (1.0, -1.0)]) +def test_cone_requires_positive_radius_and_height(radius, height): + with pytest.raises(ValueError, match="larger than zero"): + ConicalSurface(radius, height) + + +def test_cone_from_plane_preserves_subclass(): + class CustomConicalSurface(ConicalSurface): + pass + + cone = CustomConicalSurface.from_plane_and_radius_height(Plane.worldYZ(), 2.0, 3.0) + + assert isinstance(cone, CustomConicalSurface) + assert cone.radius == 2.0 + assert cone.height == 3.0 + assert cone.frame == Frame.worldYZ() + # ============================================================================= # Properties and Geometry # ============================================================================= + +def test_cone_area_and_volume(): + cone = ConicalSurface(2.0, 3.0) + + assert TOL.is_close(cone.area, 2.0 * pi * sqrt(13.0)) + assert TOL.is_close(cone.volume, 4.0 * pi) + # ============================================================================= # Accessors # ============================================================================= @@ -107,3 +129,28 @@ def test_cone_data(): # ============================================================================= # Other Methods # ============================================================================= + + +def test_cone_isocurves_match_surface_parameterization(): + cone = ConicalSurface(2.0, 3.0, frame=Frame.worldYZ()) + generator = cone.isocurve_u(0.25) + circle = cone.isocurve_v(0.25) + + assert isinstance(generator, Line) + assert isinstance(circle, Circle) + for parameter in (0.0, 0.25, 0.5, 0.75, 1.0): + assert TOL.is_allclose(generator.point_at(parameter), cone.point_at(0.25, parameter)) + assert TOL.is_allclose(circle.point_at(parameter), cone.point_at(parameter, 0.25)) + + +def test_cone_isocurve_at_apex_is_degenerate(): + with pytest.raises(ValueError, match="degenerate"): + ConicalSurface(2.0, 3.0).isocurve_v(1.0) + + +def test_cone_frame_matches_point_and_normal_in_rotated_coordinates(): + cone = ConicalSurface(2.0, 3.0, frame=Frame.worldZX()) + frame = cone.frame_at(0.25, 0.75) + + assert TOL.is_allclose(frame.point, cone.point_at(0.25, 0.75)) + assert TOL.is_allclose(frame.zaxis, cone.normal_at(0.25, 0.75)) diff --git a/tests/compas/geometry/test_surfaces_cylinder.py b/tests/compas/geometry/test_surfaces_cylinder.py index 5cf3bc5daf43..d6ad01a9e2d5 100644 --- a/tests/compas/geometry/test_surfaces_cylinder.py +++ b/tests/compas/geometry/test_surfaces_cylinder.py @@ -1,11 +1,14 @@ import pytest import json -import compas +from math import pi from random import random +from compas.geometry import Circle from compas.geometry import Point # noqa: F401 from compas.geometry import Vector # noqa: F401 from compas.geometry import Frame +from compas.geometry import Line +from compas.geometry import Plane from compas.geometry import CylindricalSurface from compas.tolerance import TOL from compas.itertools import linspace @@ -14,7 +17,6 @@ @pytest.mark.parametrize( "radius", [ - 0, 1, random(), ], @@ -74,19 +76,38 @@ def test_cylinder_data(): assert cylinder.radius == radius assert cylinder.frame == Frame.worldXY() - if not compas.IPY: - assert CylindricalSurface.validate_data(cylinder.__data__) - assert CylindricalSurface.validate_data(other.__data__) - # ============================================================================= # Constructors # ============================================================================= + +def test_cylinder_requires_positive_radius(): + with pytest.raises(ValueError, match="larger than zero"): + CylindricalSurface(0.0) + + +def test_create_cylinder_from_plane_and_radius_preserves_subclass(): + class CustomCylindricalSurface(CylindricalSurface): + pass + + cylinder = CustomCylindricalSurface.from_plane_and_radius(Plane.worldYZ(), 2.0) + + assert isinstance(cylinder, CustomCylindricalSurface) + assert cylinder.radius == 2.0 + assert cylinder.frame == Frame.worldYZ() + # ============================================================================= # Properties and Geometry # ============================================================================= + +def test_cylinder_area_and_volume_over_v_domain(): + cylinder = CylindricalSurface(2.0) + + assert TOL.is_close(cylinder.area, 4.0 * pi) + assert TOL.is_close(cylinder.volume, 4.0 * pi) + # ============================================================================= # Accessors # ============================================================================= @@ -98,3 +119,23 @@ def test_cylinder_data(): # ============================================================================= # Other Methods # ============================================================================= + + +def test_cylinder_isocurves_match_surface_parameterization(): + cylinder = CylindricalSurface(2.0, frame=Frame.worldYZ()) + generator = cylinder.isocurve_u(0.25) + circle = cylinder.isocurve_v(0.25) + + assert isinstance(generator, Line) + assert isinstance(circle, Circle) + for parameter in (0.0, 0.25, 0.5, 0.75, 1.0): + assert TOL.is_allclose(generator.point_at(parameter), cylinder.point_at(0.25, parameter)) + assert TOL.is_allclose(circle.point_at(parameter), cylinder.point_at(parameter, 0.25)) + + +def test_cylinder_frame_matches_point_and_normal_in_rotated_coordinates(): + cylinder = CylindricalSurface(2.0, frame=Frame.worldZX()) + frame = cylinder.frame_at(0.25, 0.75) + + assert TOL.is_allclose(frame.point, cylinder.point_at(0.25, 0.75)) + assert TOL.is_allclose(frame.zaxis, cylinder.normal_at(0.25, 0.75)) diff --git a/tests/compas/geometry/test_surfaces_nurbs.py b/tests/compas/geometry/test_surfaces_nurbs.py new file mode 100644 index 000000000000..7a55d2838998 --- /dev/null +++ b/tests/compas/geometry/test_surfaces_nurbs.py @@ -0,0 +1,178 @@ +import pytest + +from compas.geometry import NurbsSurface +from compas.geometry import Point + + +class TestNurbsSurface(NurbsSurface): + __test__ = False + + def __init__( + self, + points, + weights, + knots_u, + knots_v, + mults_u, + mults_v, + degree_u, + degree_v, + is_periodic_u=False, + is_periodic_v=False, + ): + super().__init__() + self._points = [[Point(point[0], point[1], point[2]) for point in row] for row in points] + self._weights = [list(row) for row in weights] + self._knots_u = list(knots_u) + self._knots_v = list(knots_v) + self._mults_u = list(mults_u) + self._mults_v = list(mults_v) + self._degree_u = degree_u + self._degree_v = degree_v + self._is_periodic_u = is_periodic_u + self._is_periodic_v = is_periodic_v + + @classmethod + def from_parameters( + cls, + points, + weights, + knots_u, + knots_v, + mults_u, + mults_v, + degree_u, + degree_v, + is_periodic_u=False, + is_periodic_v=False, + ): + return cls( + points, + weights, + knots_u, + knots_v, + mults_u, + mults_v, + degree_u, + degree_v, + is_periodic_u, + is_periodic_v, + ) + + @classmethod + def from_points(cls, points, degree_u=3, degree_v=3): + count_u = len(points[0]) + count_v = len(points) + degree_u = min(degree_u, count_u - 1) + degree_v = min(degree_v, count_v - 1) + return cls.from_parameters( + points=points, + weights=[[1.0] * count_u for _ in range(count_v)], + knots_u=[0.0, 1.0], + knots_v=[0.0, 1.0], + mults_u=[degree_u + 1, degree_u + 1], + mults_v=[degree_v + 1, degree_v + 1], + degree_u=degree_u, + degree_v=degree_v, + ) + + @property + def points(self): + return self._points + + @property + def weights(self): + return self._weights + + @property + def knots_u(self): + return self._knots_u + + @property + def knots_v(self): + return self._knots_v + + @property + def mults_u(self): + return self._mults_u + + @property + def mults_v(self): + return self._mults_v + + @property + def degree_u(self): + return self._degree_u + + @property + def degree_v(self): + return self._degree_v + + @property + def domain_u(self): + return self.knots_u[0], self.knots_u[-1] + + @property + def domain_v(self): + return self.knots_v[0], self.knots_v[-1] + + @property + def is_periodic_u(self): + return self._is_periodic_u + + @property + def is_periodic_v(self): + return self._is_periodic_v + + +@pytest.fixture +def surface(): + return TestNurbsSurface.from_parameters( + points=[[[0, 0, 0], [1, 0, 0]], [[0, 1, 0], [1, 1, 0]]], + weights=[[1.0, 0.5], [1.0, 1.0]], + knots_u=[0.0, 1.0], + knots_v=[0.0, 1.0], + mults_u=[2, 2], + mults_v=[2, 2], + degree_u=1, + degree_v=1, + ) + + +def test_nurbs_surface_derived_properties(surface): + assert surface.knotvector_u == [0.0, 0.0, 1.0, 1.0] + assert surface.knotvector_v == [0.0, 0.0, 1.0, 1.0] + assert surface.order_u == 2 + assert surface.order_v == 2 + assert surface.is_rational + + +def test_nurbs_surface_data_roundtrip_preserves_concrete_class(surface): + other = TestNurbsSurface.__from_data__(surface.__data__) + + assert isinstance(other, TestNurbsSurface) + assert other.__data__ == surface.__data__ + + +def test_nurbs_surface_copy_preserves_concrete_class_and_is_independent(surface): + other = surface.copy() + + assert isinstance(other, TestNurbsSurface) + assert other.__data__ == surface.__data__ + assert other.points[0][0] is not surface.points[0][0] + + +def test_nurbs_surface_meshgrid_uses_requested_interval_counts(): + surface = TestNurbsSurface.from_meshgrid(nu=2, nv=3) + + assert len(surface.points) == 4 + assert len(surface.points[0]) == 3 + assert surface.degree_u == 2 + assert surface.degree_v == 3 + assert surface.points[1][2] == [2.0, 1.0, 0.0] + + +@pytest.mark.parametrize("nu, nv", [(0, 1), (1, 0), (-1, 1), (1, -1)]) +def test_nurbs_surface_meshgrid_requires_positive_interval_counts(nu, nv): + with pytest.raises(ValueError, match="at least one interval"): + TestNurbsSurface.from_meshgrid(nu=nu, nv=nv) diff --git a/tests/compas/geometry/test_surfaces_plane.py b/tests/compas/geometry/test_surfaces_plane.py index 1c852dd1d3c4..c0db848703e5 100644 --- a/tests/compas/geometry/test_surfaces_plane.py +++ b/tests/compas/geometry/test_surfaces_plane.py @@ -1,11 +1,11 @@ import pytest import json -import compas from random import random from compas.geometry import Point # noqa: F401 from compas.geometry import Vector # noqa: F401 from compas.geometry import Frame +from compas.geometry import Plane from compas.geometry import PlanarSurface from compas.tolerance import TOL from compas.itertools import linspace @@ -109,15 +109,21 @@ def test_plane_data(): assert plane.ysize == ysize assert plane.frame == Frame.worldXY() - if not compas.IPY: - assert PlanarSurface.validate_data(plane.__data__) - assert PlanarSurface.validate_data(other.__data__) - # ============================================================================= # Constructors # ============================================================================= + +def test_plane_from_plane_and_size_preserves_subclass(): + class CustomPlanarSurface(PlanarSurface): + pass + + surface = CustomPlanarSurface.from_plane_and_size(Plane.worldYZ(), 2.0, 3.0) + + assert isinstance(surface, CustomPlanarSurface) + assert surface.to_plane() == Plane.worldYZ() + # ============================================================================= # Properties and Geometry # ============================================================================= @@ -134,6 +140,17 @@ def test_plane_data(): # Other Methods # ============================================================================= + +def test_plane_frame_at_is_located_at_evaluated_point_and_is_independent(): + surface = PlanarSurface(xsize=2.0, ysize=3.0, frame=Frame.worldYZ()) + + frame = surface.frame_at(0.5, 0.5) + + assert frame.point == surface.point_at(0.5, 0.5) + assert frame.xaxis == surface.frame.xaxis + assert frame.yaxis == surface.frame.yaxis + assert frame is not surface.frame + # ============================================================================= # Conversions # ============================================================================= diff --git a/tests/compas/geometry/test_surfaces_sphere.py b/tests/compas/geometry/test_surfaces_sphere.py index 6fd1e94cc7dd..6af52990ec0d 100644 --- a/tests/compas/geometry/test_surfaces_sphere.py +++ b/tests/compas/geometry/test_surfaces_sphere.py @@ -1,12 +1,15 @@ import pytest import json -import compas +from math import pi from random import random from compas.itertools import linspace +from compas.geometry import Arc +from compas.geometry import Circle from compas.geometry import Point # noqa: F401 from compas.geometry import Vector # noqa: F401 from compas.geometry import Frame +from compas.geometry import Plane from compas.geometry import SphericalSurface from compas.tolerance import TOL @@ -73,10 +76,6 @@ def test_spherical_surface_data(): assert surf.radius == radius assert surf.frame == Frame.worldXY() - if not compas.IPY: - assert SphericalSurface.validate_data(surf.__data__) - assert SphericalSurface.validate_data(other.__data__) - # ============================================================================= # Constructors @@ -84,21 +83,51 @@ def test_spherical_surface_data(): def test_create_sphere_from_plane_and_radius(): - pass + class CustomSphericalSurface(SphericalSurface): + pass + + sphere = CustomSphericalSurface.from_plane_and_radius(Plane.worldYZ(), 2.0) + + assert isinstance(sphere, CustomSphericalSurface) + assert sphere.radius == 2.0 + assert sphere.frame == Frame.worldYZ() def test_create_sphere_from_three_points(): - pass + sphere = SphericalSurface.from_three_points([1, 0, 0], [0, 1, 0], [-1, 0, 0]) + + assert TOL.is_close(sphere.radius, 1.0) + assert sphere.center == [0, 0, 0] def test_create_sphere_from_points(): - pass + sphere = SphericalSurface.from_points([[1, 0, 0], [0, 1, 0], [-1, 0, 0]]) + + assert TOL.is_close(sphere.radius, 1.0) + assert sphere.center == [0, 0, 0] # ============================================================================= # Properties and Geometry # ============================================================================= + +def test_spherical_surface_area_and_volume(): + sphere = SphericalSurface(2.0) + + assert TOL.is_close(sphere.area, 16.0 * pi) + assert TOL.is_close(sphere.volume, 32.0 / 3.0 * pi) + + +def test_spherical_surface_center_assignment_creates_independent_point(): + source = Point(1, 2, 3) + sphere = SphericalSurface(1.0) + + sphere.center = source + source.x = 10 + + assert sphere.center == [1, 2, 3] + # ============================================================================= # Accessors # ============================================================================= @@ -110,3 +139,15 @@ def test_create_sphere_from_points(): # ============================================================================= # Other Methods # ============================================================================= + + +def test_spherical_surface_isocurves_match_surface_parameterization(): + sphere = SphericalSurface(2.0, frame=Frame.worldYZ()) + meridian = sphere.isocurve_u(0.25) + latitude = sphere.isocurve_v(0.25) + + assert isinstance(meridian, Arc) + assert isinstance(latitude, Circle) + for parameter in (0.0, 0.25, 0.5, 0.75, 1.0): + assert TOL.is_allclose(meridian.point_at(parameter), sphere.point_at(0.25, parameter)) + assert TOL.is_allclose(latitude.point_at(parameter), sphere.point_at(parameter, 0.25)) diff --git a/tests/compas/geometry/test_surfaces_torus.py b/tests/compas/geometry/test_surfaces_torus.py index cf8b4739886a..410aee5c5a9d 100644 --- a/tests/compas/geometry/test_surfaces_torus.py +++ b/tests/compas/geometry/test_surfaces_torus.py @@ -1,12 +1,14 @@ import pytest import json -import compas +from math import pi from random import random from compas.itertools import linspace +from compas.geometry import Circle from compas.geometry import Point # noqa: F401 from compas.geometry import Vector # noqa: F401 from compas.geometry import Frame +from compas.geometry import Plane from compas.geometry import ToroidalSurface from compas.tolerance import TOL @@ -14,11 +16,8 @@ @pytest.mark.parametrize( "radius_axis,radius_pipe", [ - (0, 0), - (1, 0), - (0, 1), - (1, 1), - (random(), random()), + (2.0, 1.0), + (1.0 + random(), 0.5), ], ) def test_torus(radius_axis, radius_pipe): @@ -48,10 +47,10 @@ def test_torus(radius_axis, radius_pipe): ], ) def test_torus_with_frame(frame): - torus = ToroidalSurface(radius_axis=1.0, radius_pipe=1.0, frame=frame) + torus = ToroidalSurface(radius_axis=2.0, radius_pipe=0.5, frame=frame) - assert torus.radius_axis == 1.0 - assert torus.radius_pipe == 1.0 + assert torus.radius_axis == 2.0 + assert torus.radius_pipe == 0.5 assert torus.frame == frame for u in linspace(0.0, 1.0, num=100): @@ -71,8 +70,8 @@ def test_torus_with_frame(frame): def test_torus_data(): - radius_axis = random() - radius_pipe = random() + radius_axis = 1.0 + random() + radius_pipe = 0.5 frame = Frame.worldXY() torus = ToroidalSurface(radius_axis=radius_axis, radius_pipe=radius_pipe, frame=frame) @@ -82,19 +81,38 @@ def test_torus_data(): assert torus.radius_pipe == other.radius_pipe assert torus.frame == frame - if not compas.IPY: - assert ToroidalSurface.validate_data(torus.__data__) - assert ToroidalSurface.validate_data(other.__data__) - # ============================================================================= # Constructors # ============================================================================= + +@pytest.mark.parametrize("radius_axis, radius_pipe", [(0.0, 0.5), (2.0, 0.0), (1.0, 1.0), (1.0, 2.0)]) +def test_torus_requires_regular_ring_radii(radius_axis, radius_pipe): + with pytest.raises(ValueError): + ToroidalSurface(radius_axis, radius_pipe) + + +def test_torus_from_plane_preserves_subclass(): + class CustomToroidalSurface(ToroidalSurface): + pass + + torus = CustomToroidalSurface.from_plane_and_radii(Plane.worldYZ(), 2.0, 0.5) + + assert isinstance(torus, CustomToroidalSurface) + assert torus.frame == Frame.worldYZ() + # ============================================================================= # Properties and Geometry # ============================================================================= + +def test_torus_area_and_volume(): + torus = ToroidalSurface(2.0, 0.5) + + assert TOL.is_close(torus.area, 4.0 * pi**2) + assert TOL.is_close(torus.volume, pi**2) + # ============================================================================= # Accessors # ============================================================================= @@ -106,3 +124,23 @@ def test_torus_data(): # ============================================================================= # Other Methods # ============================================================================= + + +def test_torus_isocurves_match_surface_parameterization(): + torus = ToroidalSurface(2.0, 0.5, frame=Frame.worldYZ()) + pipe = torus.isocurve_u(0.25) + ring = torus.isocurve_v(0.25) + + assert isinstance(pipe, Circle) + assert isinstance(ring, Circle) + for parameter in (0.0, 0.25, 0.5, 0.75, 1.0): + assert TOL.is_allclose(pipe.point_at(parameter), torus.point_at(0.25, parameter)) + assert TOL.is_allclose(ring.point_at(parameter), torus.point_at(parameter, 0.25)) + + +def test_torus_frame_matches_point_and_normal_in_rotated_coordinates(): + torus = ToroidalSurface(2.0, 0.5, frame=Frame.worldZX()) + frame = torus.frame_at(0.25, 0.75) + + assert TOL.is_allclose(frame.point, torus.point_at(0.25, 0.75)) + assert TOL.is_allclose(frame.zaxis, torus.normal_at(0.25, 0.75)) diff --git a/tests/compas/geometry/test_transformations/test_matrices.py b/tests/compas/geometry/test_transformations/test_matrices.py index 9d3d9cc0bec4..aa557949aee6 100644 --- a/tests/compas/geometry/test_transformations/test_matrices.py +++ b/tests/compas/geometry/test_transformations/test_matrices.py @@ -6,37 +6,44 @@ from compas.geometry import Rotation from compas.geometry import Translation from compas.tolerance import TOL -from compas.geometry import axis_and_angle_from_matrix -from compas.geometry import axis_angle_from_quaternion -from compas.geometry import axis_angle_vector_from_matrix -from compas.geometry import basis_vectors_from_matrix -from compas.geometry import compose_matrix -from compas.geometry import cross_vectors -from compas.geometry import decompose_matrix -from compas.geometry import euler_angles_from_matrix -from compas.geometry import euler_angles_from_quaternion -from compas.geometry import identity_matrix -from compas.geometry import matrix_determinant -from compas.geometry import matrix_from_axis_and_angle -from compas.geometry import matrix_from_axis_angle_vector -from compas.geometry import matrix_from_basis_vectors -from compas.geometry import matrix_from_euler_angles -from compas.geometry import matrix_from_frame -from compas.geometry import matrix_from_orthogonal_projection -from compas.geometry import matrix_from_parallel_projection -from compas.geometry import matrix_from_perspective_entries -from compas.geometry import matrix_from_perspective_projection -from compas.geometry import matrix_from_quaternion -from compas.geometry import matrix_from_scale_factors -from compas.geometry import matrix_from_shear -from compas.geometry import matrix_from_shear_entries -from compas.geometry import matrix_from_translation -from compas.geometry import matrix_inverse -from compas.geometry import normalize_vector -from compas.geometry import quaternion_from_axis_angle -from compas.geometry import quaternion_from_euler_angles -from compas.geometry import quaternion_from_matrix -from compas.geometry import translation_from_matrix +from compas.linalg import axis_and_angle_from_matrix +from compas.linalg import axis_angle_from_quaternion +from compas.linalg import axis_angle_vector_from_matrix +from compas.linalg import basis_vectors_from_matrix +from compas.linalg import compose_matrix +from compas.linalg import cross_vectors +from compas.linalg import decompose_matrix +from compas.linalg import dehomogenize_vectors +from compas.linalg import euler_angles_from_matrix +from compas.linalg import euler_angles_from_quaternion +from compas.linalg import identity_matrix +from compas.linalg import homogenize_vectors +from compas.linalg import is_matrix_square +from compas.linalg import matrix_determinant +from compas.linalg import matrix_from_axis_and_angle +from compas.linalg import matrix_from_axis_angle_vector +from compas.linalg import matrix_from_basis_vectors +from compas.linalg import matrix_from_euler_angles +from compas.linalg import matrix_from_frame +from compas.linalg import matrix_from_orthogonal_projection +from compas.linalg import matrix_from_parallel_projection +from compas.linalg import matrix_from_perspective_entries +from compas.linalg import matrix_from_perspective_projection +from compas.linalg import matrix_from_quaternion +from compas.linalg import matrix_from_scale_factors +from compas.linalg import matrix_from_shear +from compas.linalg import matrix_from_shear_entries +from compas.linalg import matrix_from_translation +from compas.linalg import matrix_inverse +from compas.linalg import matrix_minor +from compas.linalg import multiply_matrices +from compas.linalg import multiply_matrix_vector +from compas.linalg import normalize_vector +from compas.linalg import quaternion_from_axis_angle +from compas.linalg import quaternion_from_euler_angles +from compas.linalg import quaternion_from_matrix +from compas.linalg import translation_from_matrix +from compas.linalg import transpose_matrix @pytest.fixture @@ -75,6 +82,39 @@ def test_matrix_inverse(R, T): ) +def test_matrix_inverse_rejects_singular_matrix(): + with pytest.raises(ValueError, match="singular"): + matrix_inverse([[1.0, 2.0], [2.0, 4.0]]) + + +def test_decompose_matrix_rejects_singular_matrix(): + with pytest.raises(ValueError, match="singular"): + decompose_matrix([[0.0] * 4 for _ in range(4)]) + + +def test_general_matrix_operations_accept_tuple_data(): + matrix = ((1.0, 2.0), (3.0, 4.0)) + assert transpose_matrix(matrix) == [[1.0, 3.0], [2.0, 4.0]] + assert matrix_minor(matrix, 0, 0) == [[4.0]] + assert matrix_determinant(matrix) == -2.0 + assert multiply_matrices(matrix, ((1.0, 0.0), (0.0, 1.0))) == [[1.0, 2.0], [3.0, 4.0]] + assert multiply_matrix_vector(matrix, (1.0, 2.0)) == [5.0, 11.0] + + +def test_matrix_shape_validation(): + assert is_matrix_square(((1.0, 0.0), (0.0, 1.0))) + assert not is_matrix_square(((1.0, 0.0),)) + with pytest.raises(Exception, match="shapes are not compatible"): + multiply_matrices([[1.0, 2.0]], [[1.0, 2.0]]) + with pytest.raises(Exception, match="vector length"): + multiply_matrix_vector([[1.0, 2.0]], [1.0]) + + +def test_homogenize_dehomogenize_roundtrip(): + vectors = ((2.0, 4.0, 6.0), (-2.0, 0.0, 8.0)) + assert dehomogenize_vectors(homogenize_vectors(vectors, w=2.0)) == [[2.0, 4.0, 6.0], [-2.0, 0.0, 8.0]] + + def test_decompose_matrix(R, T): assert decompose_matrix(R.matrix) == ( [1.0, 1.0, 1.0], diff --git a/tests/compas/geometry/test_transformations/test_rotation.py b/tests/compas/geometry/test_transformations/test_rotation.py index 0cc80a0d04ea..3e11f83f1303 100644 --- a/tests/compas/geometry/test_transformations/test_rotation.py +++ b/tests/compas/geometry/test_transformations/test_rotation.py @@ -2,7 +2,7 @@ from compas.geometry import Rotation from compas.geometry import Transformation from compas.tolerance import TOL -from compas.geometry import normalize_vector +from compas.linalg import normalize_vector def test_from_basis_vectors(): diff --git a/tests/compas/geometry/test_vector.py b/tests/compas/geometry/test_vector.py index 720de65c6873..c1088ae34ce3 100644 --- a/tests/compas/geometry/test_vector.py +++ b/tests/compas/geometry/test_vector.py @@ -1,4 +1,3 @@ -from __future__ import division import pytest import json import compas @@ -94,6 +93,10 @@ def test_vector_equality(): assert not (p1 != p2) assert p1 != p3 assert not (p1 == p3) + assert p1 != [1, 1] + assert p1 != [1, 1, 1, 1] + assert p1 != object() + assert p1 is not None def test_vector_comparison_relative(): @@ -117,7 +120,51 @@ def test_vector_comparison_absolute(): def test_vector_inplace_operators(): - pass + vector = Vector(2.0, 4.0, 6.0) + identity = id(vector) + + vector += [1.0, 2.0, 3.0] + vector -= [1.0, 1.0, 1.0] + vector *= 2.0 + vector /= 4.0 + vector **= 2.0 + + assert id(vector) == identity + assert vector == [1.0, 6.25, 16.0] + + +def test_vector_sequence_behaviour(): + vector = Vector(1.0, 2.0, 3.0) + + assert len(vector) == 3 + assert list(vector) == [1.0, 2.0, 3.0] + assert vector[:] == [1.0, 2.0, 3.0] + assert vector[-3] == 1.0 + assert vector[-1] == 3.0 + + vector[0] = 4.0 + vector[-2] = 5.0 + vector[-1] = 6.0 + assert vector == [4.0, 5.0, 6.0] + + with pytest.raises(IndexError): + _ = vector[3] + with pytest.raises(IndexError): + _ = vector[-4] + with pytest.raises(IndexError): + vector[3] = 7.0 + with pytest.raises(IndexError): + vector[-4] = 7.0 + + +def test_vector_scale(): + vector = Vector(1.0, 2.0, 3.0) + vector.scale(2.0, 3.0, 4.0) + assert vector == [2.0, 6.0, 12.0] + + scaled = vector.scaled(0.5) + assert scaled == [1.0, 3.0, 6.0] + assert scaled is not vector def test_vector_data(): @@ -128,10 +175,6 @@ def test_vector_data(): assert vector.__data__ == other.__data__ assert vector.guid != other.guid - if not compas.IPY: - assert Vector.validate_data(vector.__data__) - assert Vector.validate_data(other.__data__) - def test_cross_vectors(): vec_list1 = [[1, 2, 3], [7, 8, 9]] diff --git a/tests/compas/test_linalg.py b/tests/compas/test_linalg.py new file mode 100644 index 000000000000..110dc75fe04b --- /dev/null +++ b/tests/compas/test_linalg.py @@ -0,0 +1,45 @@ +import numpy as np + +from compas.linalg import dof +from compas.linalg import nonpivots +from compas.linalg import normalizerow +from compas.linalg import normrow +from compas.linalg import nullspace +from compas.linalg import pivots +from compas.linalg import rank +from compas.linalg import rot90 + + +def test_matrix_rank_nullspace_and_degrees_of_freedom(): + matrix = [[1, 2, 1], [-2, -3, 1], [3, 5, 0]] + + assert rank(matrix) == 2 + assert nullspace(matrix).shape == (3, 1) + assert dof(matrix) == (1, 1) + assert len(dof(matrix, condition=True)) == 3 + + +def test_pivots_and_nonpivots(): + matrix = [[1, 0, 2], [0, 1, 3]] + + assert pivots(matrix) == [0, 1] + assert nonpivots(matrix) == [2] + + +def test_row_norms_and_normalization(): + matrix = [[3, 4, 0], [0, 0, 0]] + + assert np.allclose(normrow(matrix), [[5], [0]]) + with np.errstate(invalid="ignore"): + normalized = normalizerow(matrix) + assert np.allclose(normalized, [[0.6, 0.8, 0], [0, 0, 0]]) + + +def test_rot90_preserves_vector_lengths(): + vectors = [[1, 0, 0], [0, 2, 0]] + axes = [[0, 0, 1], [0, 0, 1]] + + rotated = rot90(vectors, axes) + + assert np.allclose(rotated, [[0, 1, 0], [-2, 0, 0]]) + assert np.allclose(normrow(rotated), normrow(vectors)) diff --git a/tests/compas/test_linalg_matrices.py b/tests/compas/test_linalg_matrices.py new file mode 100644 index 000000000000..8f862a19851c --- /dev/null +++ b/tests/compas/test_linalg_matrices.py @@ -0,0 +1,36 @@ +"""Tests for the matrix constructors in `compas.linalg`.""" + +import numpy as np +from scipy.sparse import spmatrix + +from compas.linalg import adjacency_matrix +from compas.linalg import connectivity_matrix +from compas.linalg import degree_matrix +from compas.linalg import face_matrix +from compas.linalg import laplacian_matrix + + +def test_matrix_return_formats(): + adjacency = [[1], [0]] + + assert isinstance(adjacency_matrix(adjacency, rtype="list"), list) + assert isinstance(adjacency_matrix(adjacency, rtype="array"), np.ndarray) + assert isinstance(adjacency_matrix(adjacency, rtype="csr"), spmatrix) + assert isinstance(adjacency_matrix(adjacency, rtype="csc"), spmatrix) + assert isinstance(adjacency_matrix(adjacency, rtype="coo"), spmatrix) + + +def test_graph_matrices(): + adjacency = [[1, 2], [0], [0]] + edges = [[0, 1], [0, 2]] + + assert np.allclose(adjacency_matrix(adjacency), [[0, 1, 1], [1, 0, 0], [1, 0, 0]]) + assert np.allclose(degree_matrix(adjacency), np.diag([2, 1, 1])) + assert np.allclose(connectivity_matrix(edges), [[-1, 1, 0], [-1, 0, 1]]) + assert np.allclose(laplacian_matrix(edges), [[2, -1, -1], [-1, 1, 0], [-1, 0, 1]]) + + +def test_normalized_face_matrix(): + matrix = face_matrix([[0, 1, 2], [0, 2, 3]], normalize=True) + + assert np.allclose(matrix, [[1 / 3, 1 / 3, 1 / 3, 0], [1 / 3, 0, 1 / 3, 1 / 3]]) diff --git a/tests/ipy_test_runner.py b/tests/ipy_test_runner.py deleted file mode 100644 index 21aae1ffe6ac..000000000000 --- a/tests/ipy_test_runner.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import print_function - -import os - -import pytest - -HERE = os.path.dirname(__file__) - -if __name__ == "__main__": - # Fake Rhino modules - pytest.load_fake_module("Rhino") - pytest.load_fake_module("Rhino.Geometry", fake_types=["RTree", "Sphere", "Point3d"]) - - pytest.run(HERE)