From c131c2896519f6bbf349dbebff00d585820613bb Mon Sep 17 00:00:00 2001 From: Helbert Gomes Date: Wed, 24 Jun 2026 18:00:16 -0400 Subject: [PATCH 1/7] docs: expand README and add contributing guidelines --- CONTRIBUTING.md | 264 ++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 187 +++++++++++++++++++++++++++++++++- 2 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7ea47a1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,264 @@ +# Contributing to OpenSpatial + +Thank you for your interest in contributing! This document explains how to set up your environment, follow the project conventions, and submit a pull request. + +--- + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Getting Started](#getting-started) +- [Project Structure](#project-structure) +- [Development Workflow](#development-workflow) +- [Code Style](#code-style) +- [Writing Tests](#writing-tests) +- [Documentation](#documentation) +- [Commit Messages](#commit-messages) +- [Submitting a Pull Request](#submitting-a-pull-request) +- [Reporting Issues](#reporting-issues) + +--- + +## Code of Conduct + +This project follows the [Swift open-source community standards](https://www.swift.org/code-of-conduct/). Be respectful, constructive, and welcoming to all contributors. + +--- + +## Getting Started + +### Prerequisites + +- Swift 6.0 or later ([swift.org/download](https://www.swift.org/download/)) +- Git + +### Fork and clone + +```bash +# Fork the repo on GitHub, then: +git clone https://github.com//OpenSpatial.git +cd OpenSpatial +``` + +### Build and test + +```bash +swift build +swift test +``` + +All tests must pass before you open a pull request. + +--- + +## Project Structure + +``` +OpenSpatial/ +├── Sources/OpenSpatial/ +│ ├── 2D primitives/ # Angle2D +│ ├── 3D primitives/ # Point3D, Pose3D, Ray3D, Rect3D, … +│ ├── Affine and projective transforms/ +│ ├── Converting between coordinate spaces/ +│ ├── Data structures/ # Axis3D, Vector3D +│ ├── Enumerations/ +│ ├── Protocols/ # Primitive3D, Rotatable3D, Translatable3D, … +│ └── Structures/ # EulerAngles +├── Tests/OpenSpatialTests/ # Mirror of Sources structure +├── Package.swift # Swift 6.3+ +├── Package@swift-6.1.swift # Swift 6.1 manifest +└── Package@swift-6.2.swift # Swift 6.2 manifest +``` + +New types should be placed in the directory that matches their category, mirroring how Apple's Spatial framework organises its API surface. + +--- + +## Development Workflow + +1. Create a branch from `main`: + ```bash + git checkout -b feat/my-new-type + ``` +2. Make your changes. +3. Run the formatter (see [Code Style](#code-style)). +4. Run the full test suite. +5. Push and open a pull request. + +--- + +## Code Style + +The project enforces style via [swift-format](https://github.com/apple/swift-format). The configuration lives in [`.swift-format`](.swift-format). Key rules: + +| Rule | Value | +|---|---| +| Indentation | 4 spaces | +| Line length | 100 characters | +| Doc comments | `///` triple-slash only (no `/* */` blocks) | +| Imports | Ordered alphabetically | +| Access level on extensions | Not allowed (`NoAccessLevelOnExtensionDeclaration`) | + +### Formatting your changes + +```bash +swift package plugin --allow-writing-to-package-directory format-source-code +``` + +> The CI pipeline treats warnings as errors (`-Xswiftc -warnings-as-errors`), so unformatted code will fail the build. + +### General guidelines + +- Prefer `@frozen` on value types that are unlikely to gain new stored properties. +- Mark all public API as `public` and give it a `///` documentation comment. +- Use `@inline(__always)` on small, performance-sensitive methods (arithmetic operators, simple getters). +- Add `Sendable` conformance to all new value types. +- Add `Codable`, `Hashable`, and `Equatable` where it makes sense for a mathematical primitive. + +--- + +## Writing Tests + +Tests live in `Tests/OpenSpatialTests/` and mirror the source structure. The project uses **Swift Testing** (`import Testing`). + +### Checklist for new types + +- [ ] Basic initialisation (`init()`, `init(x:y:z:)`, etc.) +- [ ] Identity / zero / infinity constants +- [ ] Arithmetic operators (`+`, `-`, `*`, `/`) +- [ ] `isApproximatelyEqual(to:tolerance:)` where applicable +- [ ] Protocol conformances (`Rotatable3D`, `Translatable3D`, `Scalable3D`, …) +- [ ] Edge cases: `NaN`, `infinity`, zero vectors + +### Example test structure + +```swift +import Testing +@testable import OpenSpatial + +@Suite("Point3D") +struct Point3DTests { + + @Test("Distance between two points") + func distance() { + let a = Point3D(x: 0, y: 0, z: 0) + let b = Point3D(x: 3, y: 4, z: 0) + #expect(a.distance(to: b) == 5.0) + } + + @Test("Approximate equality with tolerance") + func approximateEquality() { + let a = Point3D(x: 1.0, y: 2.0, z: 3.0) + let b = Point3D(x: 1.0 + 1e-15, y: 2.0, z: 3.0) + #expect(a.isApproximatelyEqual(to: b)) + } +} +``` + +Run tests with coverage locally: + +```bash +swift test --enable-code-coverage +``` + +--- + +## Documentation + +All `public` declarations must have a `///` documentation comment. Comments follow Apple's DocC conventions. + +### Format + +```swift +/// A short, single-line summary. +/// +/// An optional longer description that explains the behaviour in more detail. +/// +/// - Parameters: +/// - x: The x-coordinate. +/// - y: The y-coordinate. +/// - Returns: The resulting point. +/// - Complexity: O(1) +public func example(x: Double, y: Double) -> Point3D { … } +``` + +### Generating docs locally + +```bash +swift package --disable-sandbox preview-documentation --target OpenSpatial +``` + +Or to build a static site: + +```bash +swift package --allow-writing-to-directory ./docs \ + generate-documentation --target OpenSpatial \ + --output-path ./docs \ + --transform-for-static-hosting \ + --hosting-base-path OpenSpatial +``` + +--- + +## Commit Messages + +The project follows [Conventional Commits](https://www.conventionalcommits.org/). Every commit message must start with a type prefix: + +| Prefix | When to use | +|---|---| +| `feat:` | A new type, method, or feature | +| `fix:` | A bug fix | +| `docs:` | Documentation-only changes | +| `chore:` | Maintenance, formatting, tooling, dependency bumps | +| `test:` | Adding or fixing tests (no production code change) | +| `refactor:` | Code restructuring with no behaviour change | +| `perf:` | Performance improvements | + +**Examples:** + +``` +feat: add ScaledPose3D and Ray3D +fix: correct quaternion multiplication in Rotation3D +docs: document Clampable3D protocol requirements +chore: update swift-format to latest version +test: add edge-case tests for Rect3D.intersects +``` + +Keep the subject line under 72 characters and written in the imperative mood ("add", "fix", "update", not "added" or "fixes"). + +--- + +## Submitting a Pull Request + +1. Make sure the build passes with no warnings: + ```bash + swift build -Xswiftc -warnings-as-errors + ``` +2. Run the full test suite: + ```bash + swift test + ``` +3. Run the formatter: + ```bash + swift package plugin --allow-writing-to-package-directory format-source-code + ``` +4. Push your branch and open a PR against `main` on GitHub. +5. Fill in the PR description explaining *what* changed and *why*. +6. Wait for CI (GitHub Actions) to pass. Address any review feedback. + +### PR guidelines + +- Keep PRs focused — one feature or fix per PR. +- If the PR introduces a new public type, include tests and documentation. +- Reference any related issues with `Closes #` in the PR description. + +--- + +## Reporting Issues + +Please use [GitHub Issues](https://github.com/helbertgs/OpenSpatial/issues) to report bugs or request features. Include: + +- Swift version (`swift --version`) +- Platform and OS version +- A minimal code sample that reproduces the problem +- Expected vs. actual behaviour diff --git a/README.md b/README.md index c312e42..4a12920 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,187 @@ # OpenSpatial -Create and manipulate 3D mathematical primitives. + +![Swift](https://img.shields.io/badge/Swift-6.0%20%7C%206.1%20%7C%206.2%20%7C%206.3-orange.svg) +![Platforms](https://img.shields.io/badge/platforms-iOS%20%7C%20macOS%20%7C%20tvOS%20%7C%20watchOS%20%7C%20Linux%20%7C%20Windows%20%7C%20Wasm%20%7C%20Android-blue) +[![Build and Test on MacOS](https://github.com/helbertgs/OpenSpatial/actions/workflows/MacOS.yml/badge.svg)](https://github.com/helbertgs/OpenSpatial/actions/workflows/MacOS.yml) +[![Build and Test on Ubuntu](https://github.com/helbertgs/OpenSpatial/actions/workflows/Linux.yml/badge.svg)](https://github.com/helbertgs/OpenSpatial/actions/workflows/Linux.yml) +[![Build and Test on Windows](https://github.com/helbertgs/OpenSpatial/actions/workflows/Windows.yml/badge.svg)](https://github.com/helbertgs/OpenSpatial/actions/workflows/Windows.yml) +[![codecov](https://codecov.io/github/helbertgs/OpenSpatial/graph/badge.svg?token=7WrVJPx17w)](https://codecov.io/github/helbertgs/OpenSpatial) +![License](https://img.shields.io/badge/license-MIT-green) + +Open-source implementation inspired by Apple's [Spatial](https://developer.apple.com/documentation/spatial) framework. +`OpenSpatial` is not affiliated with, endorsed by, or maintained by Apple. + +## Overview + +The `OpenSpatial` module is a lightweight 3D mathematical library that provides a simple API for working with 3D primitives. +The API surface mirrors Apple's Spatial framework as closely as possible, so code written against `OpenSpatial` can be migrated to the official framework with minimal changes. + +## Goals + +OpenSpatial aims to: + +- Provide a fully open-source implementation of common spatial mathematics primitives. +- Remain API-compatible with Apple's Spatial framework whenever practical. +- Support Apple and non-Apple platforms equally. +- Serve as a foundation for graphics, simulation, robotics, game development, and spatial computing projects. + +## Features + +- Pure Swift 6, no platform-specific dependencies +- Full `Sendable` conformance for safe use in Swift Concurrency contexts +- `Codable`, `Hashable`, and `Equatable` on all value types +- `@frozen` structs where applicable for ABI stability +- Generic arithmetic operators and protocol-driven design (`Rotatable3D`, `Translatable3D`, `Scalable3D`, …) + +## Available Types + +### 2D Primitives +| Type | Description | +|---|---| +| `Angle2D` | A geometric angle | + +### 3D Primitives +| Type | Description | +|---|---| +| `Point3D` | A point in 3D space | +| `Size3D` | A 3D size with width, height, and depth | +| `Rect3D` | An axis-aligned 3D bounding box | +| `Pose3D` | A combined position and orientation | +| `ScaledPose3D` | A pose extended with a uniform scale factor | +| `Ray3D` | An origin and direction defining a ray | +| `Rotation3D` | A rotation in 3D space (quaternion-backed) | +| `RotationAxis3D` | A named axis for rotation | +| `Quaternion3D` | A quaternion for representing rotations | +| `SphericalCoordinates3D` | A point in spherical coordinates | + +### Transforms +| Type | Description | +|---|---| +| `AffineTransform3D` | A 4×4 affine transform (translation, rotation, scale, shear) | +| `ProjectiveTransform3D` | A full 4×4 projective transform | + +### Coordinate Spaces +| Type | Description | +|---|---| +| `CoordinateSpace3D` | A named 3D coordinate space | +| `CoordinateSpaceValue3D` | A value associated with a coordinate space | +| `WorldReferenceCoordinateSpace` | The world reference coordinate space | + +### Data Structures & Protocols +| Type | Description | +|---|---| +| `Vector3D` | A 3D vector | +| `Axis3D` | A named axis (x, y, z) | +| `EulerAngles` | Euler angle representation of a rotation | +| `Primitive3D` | Protocol for all 3D primitives | +| `Rotatable3D` | Protocol for types that support rotation | +| `Translatable3D` | Protocol for types that support translation | +| `Scalable3D` | Protocol for types that support scaling | +| `Shearable3D` | Protocol for types that support shearing | +| `Clampable3D` | Protocol for types that can be clamped to a `Rect3D` | +| `Volumetric` | Protocol for types with a volumetric extent | + +## Usage + +### Working with Points + +```swift +import OpenSpatial + +let origin = Point3D(x: 0, y: 0, z: 0) +let target = Point3D(x: 3, y: 4, z: 0) + +let distance = origin.distance(to: target) // 5.0 + +let moved = origin.translated(by: Vector3D(x: 1, y: 2, z: 3)) +let scaled = origin.uniformlyScaled(by: 2.0) +``` + +### Working with Rotations and Poses + +```swift +let rotation = Rotation3D(angle: Angle2D(radians: .pi / 4), axis: RotationAxis3D(x: 0, y: 1, z: 0)) +let pose = Pose3D(position: Point3D(x: 1, y: 0, z: 0), rotation: rotation) + +let rotatedPoint = Point3D(x: 1, y: 0, z: 0).rotated(by: rotation.quaternion) +let invertedPose = pose.inverse +``` + +### Applying Transforms + +```swift +var transform = AffineTransform3D.identity +transform = transform.concatenating(.init(translation: Vector3D(x: 5, y: 0, z: 0))) + +let transformedPoint = point.applying(transform) +``` + +### Raycasting + +```swift +let ray = Ray3D(origin: Point3D(x: 0, y: 0, z: -1), direction: Vector3D(x: 0, y: 0, z: 1)) +let box = Rect3D(origin: Point3D(x: -0.5, y: -0.5, z: 0), size: Size3D(width: 1, height: 1, depth: 1)) + +if ray.intersects(box) { + print("Hit!") +} +``` + +## Installation + +### Swift Package Manager + +Add the dependency to your `Package.swift`: + +```swift +dependencies: [ + .package( + url: "https://github.com/helbertgs/OpenSpatial.git", + from: "1.0.0" + ) +], +targets: [ + .target( + name: "MyTarget", + dependencies: [ + .product(name: "OpenSpatial", package: "OpenSpatial") + ] + ), +] +``` + +## Requirements + +Minimum supported toolchain: Swift 6.0 + +## Supported Platforms + +The project is continuously tested on: + +- macOS +- Ubuntu Linux +- Windows + +Additional support exists for: + +- iOS +- tvOS +- watchOS +- WebAssembly (Wasm) +- Android + +## Documentation + +Full API documentation is generated with [DocC](https://www.swift.org/documentation/docc/) and available in the `docs/` folder and also in this link: [OpenSpatial](https://helbertgs.github.io/OpenSpatial/documentation/openspatial). You can also browse it locally by running: + +```bash +swift package --disable-sandbox preview-documentation --target OpenSpatial +``` + +## Contributing + +Contributions are welcome! Read [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide covering setup, code style, testing, commit conventions, and how to submit a pull request. + +## License + +OpenSpatial is released under the [MIT License](LICENSE). From 65b3fe059bc3cf34237d52d688457b9c5ae642c8 Mon Sep 17 00:00:00 2001 From: Helbert Gomes Date: Wed, 24 Jun 2026 18:03:44 -0400 Subject: [PATCH 2/7] docs: expand README with project goals and platform support --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4a12920..291d075 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenSpatial -![Swift](https://img.shields.io/badge/Swift-6.0%20%7C%206.1%20%7C%206.2%20%7C%206.3-orange.svg) +![Swift](https://img.shields.io/badge/Swift-6.x-orange.svg) ![Platforms](https://img.shields.io/badge/platforms-iOS%20%7C%20macOS%20%7C%20tvOS%20%7C%20watchOS%20%7C%20Linux%20%7C%20Windows%20%7C%20Wasm%20%7C%20Android-blue) [![Build and Test on MacOS](https://github.com/helbertgs/OpenSpatial/actions/workflows/MacOS.yml/badge.svg)](https://github.com/helbertgs/OpenSpatial/actions/workflows/MacOS.yml) [![Build and Test on Ubuntu](https://github.com/helbertgs/OpenSpatial/actions/workflows/Linux.yml/badge.svg)](https://github.com/helbertgs/OpenSpatial/actions/workflows/Linux.yml) @@ -152,7 +152,9 @@ targets: [ ## Requirements -Minimum supported toolchain: Swift 6.0 +| Component | Version | +|------------|---------| +| Swift | 6.0+ | ## Supported Platforms @@ -162,7 +164,7 @@ The project is continuously tested on: - Ubuntu Linux - Windows -Additional support exists for: +Additional support is expected to work on: - iOS - tvOS From b94919b2b87444e827dfaae26be89521df8ca6ad Mon Sep 17 00:00:00 2001 From: Helbert Gomes Date: Wed, 24 Jun 2026 18:10:28 -0400 Subject: [PATCH 3/7] feat: add initial OpenSpatial implementation with test suite --- Package.swift | 26 + Package@swift-6.0.swift | 26 + Package@swift-6.1.swift | 26 + Package@swift-6.2.swift | 26 + .../OpenSpatial/2D primitives/Angle2D.swift | 270 +++++++++ .../OpenSpatial/3D primitives/Point3D.swift | 533 +++++++++++++++++ .../OpenSpatial/3D primitives/Pose3D.swift | 137 +++++ .../3D primitives/Quaterion3D.swift | 379 ++++++++++++ Sources/OpenSpatial/3D primitives/Ray3D.swift | 293 +++++++++ .../OpenSpatial/3D primitives/Rect3D.swift | 398 +++++++++++++ .../3D primitives/Rotation3D.swift | 208 +++++++ .../3D primitives/RotationAxis3D.swift | 123 ++++ .../3D primitives/ScaledPose3D.swift | 428 +++++++++++++ .../OpenSpatial/3D primitives/Size3D.swift | 377 ++++++++++++ .../SphericalCoordinates3D.swift | 103 ++++ .../AffineTransform3D.swift | 380 ++++++++++++ .../ProjectiveTransform3D.swift | 261 ++++++++ Sources/OpenSpatial/Common/Error.swift | 22 + .../CoordinateSpace3D.swift | 196 ++++++ .../CoordinateSpaceValue3D.swift | 24 + .../ProjectiveTransformable3D.swift | 18 + .../WorldReferenceCoordinateSpace.swift | 48 ++ .../OpenSpatial/Data structures/Axis3D.swift | 42 ++ .../Data structures/Vector3D.swift | 560 ++++++++++++++++++ .../Enumerations/AxisWithFactors.swift | 24 + .../OpenSpatial/Protocols/Clampable3D.swift | 36 ++ .../OpenSpatial/Protocols/Primitive3D.swift | 57 ++ .../OpenSpatial/Protocols/Rotatable3D.swift | 67 +++ .../OpenSpatial/Protocols/Scalable3D.swift | 77 +++ .../OpenSpatial/Protocols/Shearable3D.swift | 36 ++ .../Protocols/Translatable3D.swift | 42 ++ .../OpenSpatial/Protocols/Volumetric.swift | 101 ++++ .../OpenSpatial/Structures/EulerAngles.swift | 79 +++ .../2D primitives/Angle2DTests.swift | 172 ++++++ .../3D primitives/EulerAnglesTests.swift | 27 + .../3D primitives/Point3DTests.swift | 312 ++++++++++ .../3D primitives/Pose3DTests.swift | 137 +++++ .../3D primitives/Quaternion3DTests.swift | 169 ++++++ .../3D primitives/Ray3DTests.swift | 290 +++++++++ .../3D primitives/Rect3DTests.swift | 170 ++++++ .../3D primitives/Rotation3DTests.swift | 148 +++++ .../3D primitives/RotationAxis3DTestes.swift | 83 +++ .../3D primitives/ScaledPose3DTests.swift | 397 +++++++++++++ .../3D primitives/Size3DTests.swift | 291 +++++++++ .../SphericalCoordinates3DTests.swift | 154 +++++ .../AffineTransform3DTests.swift | 327 ++++++++++ .../ProjectiveTransform3DTests.swift | 210 +++++++ .../CoordinateSpace3DTests.swift | 130 ++++ .../Data structures/Axis3DTests.swift | 39 ++ .../Data structures/Vector3DTests.swift | 430 ++++++++++++++ .../OpenSpatialTests/Extensions/Double.swift | 8 + .../Protocols/Clampable3DTests.swift | 38 ++ .../ProjectiveTransformable3DTests.swift | 36 ++ .../Protocols/Shearable3DTests.swift | 56 ++ 54 files changed, 9047 insertions(+) create mode 100644 Package.swift create mode 100644 Package@swift-6.0.swift create mode 100644 Package@swift-6.1.swift create mode 100644 Package@swift-6.2.swift create mode 100644 Sources/OpenSpatial/2D primitives/Angle2D.swift create mode 100644 Sources/OpenSpatial/3D primitives/Point3D.swift create mode 100644 Sources/OpenSpatial/3D primitives/Pose3D.swift create mode 100644 Sources/OpenSpatial/3D primitives/Quaterion3D.swift create mode 100644 Sources/OpenSpatial/3D primitives/Ray3D.swift create mode 100644 Sources/OpenSpatial/3D primitives/Rect3D.swift create mode 100644 Sources/OpenSpatial/3D primitives/Rotation3D.swift create mode 100644 Sources/OpenSpatial/3D primitives/RotationAxis3D.swift create mode 100644 Sources/OpenSpatial/3D primitives/ScaledPose3D.swift create mode 100644 Sources/OpenSpatial/3D primitives/Size3D.swift create mode 100644 Sources/OpenSpatial/3D primitives/SphericalCoordinates3D.swift create mode 100644 Sources/OpenSpatial/Affine and projective transforms/AffineTransform3D.swift create mode 100644 Sources/OpenSpatial/Affine and projective transforms/ProjectiveTransform3D.swift create mode 100644 Sources/OpenSpatial/Common/Error.swift create mode 100644 Sources/OpenSpatial/Converting between coordinate spaces/CoordinateSpace3D.swift create mode 100644 Sources/OpenSpatial/Converting between coordinate spaces/CoordinateSpaceValue3D.swift create mode 100644 Sources/OpenSpatial/Converting between coordinate spaces/ProjectiveTransformable3D.swift create mode 100644 Sources/OpenSpatial/Converting between coordinate spaces/WorldReferenceCoordinateSpace.swift create mode 100644 Sources/OpenSpatial/Data structures/Axis3D.swift create mode 100644 Sources/OpenSpatial/Data structures/Vector3D.swift create mode 100644 Sources/OpenSpatial/Enumerations/AxisWithFactors.swift create mode 100644 Sources/OpenSpatial/Protocols/Clampable3D.swift create mode 100644 Sources/OpenSpatial/Protocols/Primitive3D.swift create mode 100644 Sources/OpenSpatial/Protocols/Rotatable3D.swift create mode 100644 Sources/OpenSpatial/Protocols/Scalable3D.swift create mode 100644 Sources/OpenSpatial/Protocols/Shearable3D.swift create mode 100644 Sources/OpenSpatial/Protocols/Translatable3D.swift create mode 100644 Sources/OpenSpatial/Protocols/Volumetric.swift create mode 100644 Sources/OpenSpatial/Structures/EulerAngles.swift create mode 100644 Tests/OpenSpatialTests/2D primitives/Angle2DTests.swift create mode 100644 Tests/OpenSpatialTests/3D primitives/EulerAnglesTests.swift create mode 100644 Tests/OpenSpatialTests/3D primitives/Point3DTests.swift create mode 100644 Tests/OpenSpatialTests/3D primitives/Pose3DTests.swift create mode 100644 Tests/OpenSpatialTests/3D primitives/Quaternion3DTests.swift create mode 100644 Tests/OpenSpatialTests/3D primitives/Ray3DTests.swift create mode 100644 Tests/OpenSpatialTests/3D primitives/Rect3DTests.swift create mode 100644 Tests/OpenSpatialTests/3D primitives/Rotation3DTests.swift create mode 100644 Tests/OpenSpatialTests/3D primitives/RotationAxis3DTestes.swift create mode 100644 Tests/OpenSpatialTests/3D primitives/ScaledPose3DTests.swift create mode 100644 Tests/OpenSpatialTests/3D primitives/Size3DTests.swift create mode 100644 Tests/OpenSpatialTests/3D primitives/SphericalCoordinates3DTests.swift create mode 100644 Tests/OpenSpatialTests/Affine and projective transforms/AffineTransform3DTests.swift create mode 100644 Tests/OpenSpatialTests/Affine and projective transforms/ProjectiveTransform3DTests.swift create mode 100644 Tests/OpenSpatialTests/Converting between coordinate spaces/CoordinateSpace3DTests.swift create mode 100644 Tests/OpenSpatialTests/Data structures/Axis3DTests.swift create mode 100644 Tests/OpenSpatialTests/Data structures/Vector3DTests.swift create mode 100644 Tests/OpenSpatialTests/Extensions/Double.swift create mode 100644 Tests/OpenSpatialTests/Protocols/Clampable3DTests.swift create mode 100644 Tests/OpenSpatialTests/Protocols/ProjectiveTransformable3DTests.swift create mode 100644 Tests/OpenSpatialTests/Protocols/Shearable3DTests.swift diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..618cf59 --- /dev/null +++ b/Package.swift @@ -0,0 +1,26 @@ +// swift-tools-version: 6.3 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "OpenSpatial", + products: [ + .library( + name: "OpenSpatial", + targets: ["OpenSpatial"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-docc-plugin", branch: "main"), + ], + targets: [ + .target( + name: "OpenSpatial" + ), + .testTarget( + name: "OpenSpatialTests", + dependencies: ["OpenSpatial"] + ), + ] +) diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift new file mode 100644 index 0000000..0f2b62d --- /dev/null +++ b/Package@swift-6.0.swift @@ -0,0 +1,26 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "OpenSpatial", + products: [ + .library( + name: "OpenSpatial", + targets: ["OpenSpatial"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-docc-plugin", branch: "main"), + ], + targets: [ + .target( + name: "OpenSpatial" + ), + .testTarget( + name: "OpenSpatialTests", + dependencies: ["OpenSpatial"] + ), + ] +) diff --git a/Package@swift-6.1.swift b/Package@swift-6.1.swift new file mode 100644 index 0000000..824f362 --- /dev/null +++ b/Package@swift-6.1.swift @@ -0,0 +1,26 @@ +// swift-tools-version: 6.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "OpenSpatial", + products: [ + .library( + name: "OpenSpatial", + targets: ["OpenSpatial"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-docc-plugin", branch: "main"), + ], + targets: [ + .target( + name: "OpenSpatial" + ), + .testTarget( + name: "OpenSpatialTests", + dependencies: ["OpenSpatial"] + ), + ] +) diff --git a/Package@swift-6.2.swift b/Package@swift-6.2.swift new file mode 100644 index 0000000..2ab47c9 --- /dev/null +++ b/Package@swift-6.2.swift @@ -0,0 +1,26 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "OpenSpatial", + products: [ + .library( + name: "OpenSpatial", + targets: ["OpenSpatial"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-docc-plugin", branch: "main"), + ], + targets: [ + .target( + name: "OpenSpatial" + ), + .testTarget( + name: "OpenSpatialTests", + dependencies: ["OpenSpatial"] + ), + ] +) diff --git a/Sources/OpenSpatial/2D primitives/Angle2D.swift b/Sources/OpenSpatial/2D primitives/Angle2D.swift new file mode 100644 index 0000000..688ab0a --- /dev/null +++ b/Sources/OpenSpatial/2D primitives/Angle2D.swift @@ -0,0 +1,270 @@ + +// Angle2D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A geometric angle with a value you access in either radians or degrees. +@frozen +public struct Angle2D: Copyable, Codable, Equatable, Hashable, Sendable { + + // MARK: - Inspecting an angle’s properties + + /// The angle in degrees. + public var degrees: Double { + radians * 180.0 / .pi + } + + /// The angle in radians. + public let radians: Double + + // MARK: - Creating an angle structure + + /// Creates an angle. + /// + /// - Complexity: O(1) + @inline(__always) + public init() { + radians = 0 + } + + /// Creates an angle with the specified double-precision radians. + /// + /// - Parameter radians: The angle in radians. + /// - Complexity: O(1) + @inline(__always) + public init(radians: Double) { + self.radians = radians + } + + /// Creates an angle with the specified floating-point radians. + /// + /// - Parameter radians: The angle in radians. + /// - Complexity: O(1) + @inline(__always) + public init(radians: T) where T: BinaryFloatingPoint { + self.radians = Double(radians) + } + + /// Creates an angle with the specified double-precision degrees. + /// + /// - Parameter degrees: The angle in degrees. + /// - Complexity: O(1) + @inline(__always) + public init(degrees: Double) { + self.radians = degrees * .pi / 180.0 + } + + /// Creates an angle with the specified floating-point degrees. + /// + /// - Parameter degrees: The angle in degrees. + /// - Complexity: O(1) + @inline(__always) + public init(degrees: T) where T: BinaryFloatingPoint { + self.radians = Double(degrees) * .pi / 180.0 + } + + /// Returns a new angle structure with the specified double-precision degrees. + /// + /// - Parameter degrees: The angle in degrees. + /// - Returns: A new angle structure. + /// - Complexity: O(1) + @inline(__always) + public static func degrees(_ degrees: Double) -> Angle2D { + Angle2D(degrees: degrees) + } + + /// Returns a new angle structure with the specified double-precision radians. + /// + /// - Parameter radians: The angle in radians. + /// - Returns: A new angle structure. + /// - Complexity: O(1) + @inline(__always) + public static func radians(_ radians: Double) -> Angle2D { + Angle2D(radians: radians) + } +} + +extension Angle2D: ExpressibleByFloatLiteral { + + /// Creates an angle with the specified floating-point literal value in radians. + /// + /// - Parameter value: The angle in radians. + @inline(__always) + public init(floatLiteral value: Double) { + self.radians = value + } +} + +extension Angle2D: ExpressibleByIntegerLiteral { + + /// Creates an angle with the specified integer literal value in radians. + /// + /// - Parameter value: The angle in radians. + @inline(__always) + public init(integerLiteral value: Int) { + self.radians = Double(value) + } +} + +extension Angle2D: AdditiveArithmetic { + + /// The zero angle. + public static var zero: Angle2D { + Angle2D(radians: 0) + } + + /// Adds two angles and returns the result. + /// + /// - Parameters: + /// - lhs: The first angle to add. + /// - rhs: The second angle to add. + /// - Returns: The sum of the two angles. + /// - Complexity: O(1) + @inline(__always) + public static func + (lhs: Angle2D, rhs: Angle2D) -> Angle2D { + Angle2D(radians: lhs.radians + rhs.radians) + } + + /// Adds an angle to another angle and stores the result in the left-hand side variable. + /// + /// - Parameters: + /// - lhs: The angle to add to. + /// - rhs: The angle to add. + /// - Complexity: O(1) + @inline(__always) + public static func += (lhs: inout Angle2D, rhs: Angle2D) { + lhs = lhs + rhs + } + + /// Subtracts one angle from another and returns the result. + /// + /// - Parameters: + /// - lhs: The angle to subtract from. + /// - rhs: The angle to subtract. + /// - Returns: The difference of the two angles. + /// - Complexity: O(1) + @inline(__always) + public static func - (lhs: Angle2D, rhs: Angle2D) -> Angle2D { + Angle2D(radians: lhs.radians - rhs.radians) + } + + /// Subtracts an angle from another angle and stores the result in the left-hand side variable. + /// + /// - Parameters: + /// - lhs: The angle to subtract from. + /// - rhs: The angle to subtract. + /// - Complexity: O(1) + @inline(__always) + public static func -= (lhs: inout Angle2D, rhs: Angle2D) { + lhs = lhs - rhs + } +} + +extension Angle2D: Comparable { + + /// Compares two angles to determine if the left-hand side angle is less than the right-hand side angle. + /// - Parameters: + /// - lhs: The left-hand side angle. + /// - rhs: The right-hand side angle. + /// - Returns: A Boolean value indicating whether the left-hand side angle is less than the right-hand side angle. + /// - Complexity: O(1) + public static func < (lhs: Angle2D, rhs: Angle2D) -> Bool { + lhs.radians < rhs.radians + } +} + +extension Angle2D { + + /// Returns true if this angle is approximately equal to another within the given tolerance. + /// + /// - Parameters: + /// - other: The angle to compare against. + /// - tolerance: The maximum allowed difference in radians. Defaults to `sqrt(.ulpOfOne)`. + /// - Returns: `true` if the absolute difference in radians is within the tolerance. + /// - Complexity: O(1) + public func isApproximatelyEqual( + to other: Angle2D, tolerance: Double = Foundation.sqrt(.ulpOfOne) + ) -> Bool { + abs(radians - other.radians) <= tolerance + } +} + +extension Angle2D { + + /// The value of π. + private static var pi: Double { + 3.141592653589793238462643383279502884 + } + + /// Returns the square root of the value. + private static func sqrt(_ value: Double) -> Double { + if value <= 0 { return 0 } + var guess = value > 1 ? value : 1.0 + for _ in 0..<8 { + guess = 0.5 * (guess + value / guess) + } + return guess + } + + /// Normalizes the angle. + private static func normalize(_ angle: Angle2D) -> Double { + self.normalize(angle.radians) + } + + /// Normalizes the angle. + private static func normalize(_ value: Double) -> Double { + var x = value.truncatingRemainder(dividingBy: 2 * pi) + + if x > pi { x -= 2 * pi } + if x <= -pi { x += 2 * pi } + + return x + } + + /// Returns the sine of the angle. + private static func sin(_ value: Double) -> Double { + + func taylor(_ x: Double) -> Double { + let x2 = x * x + let x3 = x2 * x + let x5 = x2 * x3 + let x7 = x2 * x5 + let x9 = x2 * x7 + + return x - x3 / 6.0 + x5 / 120.0 - x7 / 5040.0 + x9 / 362880.0 + } + + let x = normalize(value) + + if x > .pi / 2 { + return taylor(.pi - x) + } else if x < -(.pi / 2) { + return -taylor(-.pi - x) + } else { + return taylor(x) + } + } + + /// Returns the cosine of the angle. + private static func cos(_ value: Double) -> Double { + let x = normalize(value) + let x2 = x * x + let x4 = x2 * x2 + let x6 = x2 * x4 + let x8 = x4 * x4 + let x10 = x2 * x8 + return 1 - x2 / 2.0 + x4 / 24.0 - x6 / 720.0 + x8 / 40320.0 - x10 / 3628800.0 + } + + /// Returns the tangent of the angle. + private static func tan(_ value: Double) -> Double { + sin(value) / cos(value) + } +} diff --git a/Sources/OpenSpatial/3D primitives/Point3D.swift b/Sources/OpenSpatial/3D primitives/Point3D.swift new file mode 100644 index 0000000..45baf84 --- /dev/null +++ b/Sources/OpenSpatial/3D primitives/Point3D.swift @@ -0,0 +1,533 @@ + +// Point3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A point in a 3D coordinate system. +@frozen +public struct Point3D: Copyable, Codable, Equatable, Hashable, Sendable { + + // MARK: - Inspecting a 3D point’s properties + + /// The x-coordinate of the point. + public var x: Double + + /// The y-coordinate of the point. + public var y: Double + + /// The z-coordinate of the point. + public var z: Double + + /// The magnitude (length) of the point from the origin. + public var magnitudeSquared: Double { + return x * x + y * y + z * z + } + + /// The point expressed as an array of x, y, and z values. + public var vector: [Double] { [x, y, z] } + + // MARK: - Creating a 3D point structure + + /// Creates a point. + /// + /// - Complexity: O(1) + @inline(__always) + public init() { + self.x = 0.0 + self.y = 0.0 + self.z = 0.0 + } + + /// Creates a point with the specified coordinates. + /// + /// - Parameters: + /// - x: A double-precision value that specifies the x-coordinate of the point. + /// - y: A double-precision value that specifies the y-coordinate of the point. + /// - z: A double-precision value that specifies the z-coordinate of the point. + /// - Complexity: O(1) + @inline(__always) + public init(x: Double = 0, y: Double = 0, z: Double = 0) { + self.x = x + self.y = y + self.z = z + } + + /// Creates a point from the specified floating-point values. + /// + /// - Parameters: + /// - x: A floating-point value that specifies the x-coordinate value. + /// - y: A floating-point value that specifies the y-coordinate value. + /// - z: A floating-point value that specifies the z-coordinate value. + /// - Complexity: O(1) + @inline(__always) + public init(x: T, y: T, z: T) where T: BinaryFloatingPoint { + self.x = Double(x) + self.y = Double(y) + self.z = Double(z) + } + + /// Creates a point from the specified Spatial size structure. + /// + /// - Parameter size: A size structure that specifies the coordinates. + /// - Complexity: O(1) + @inline(__always) + public init(_ size: Size3D) { + self.x = size.width + self.y = size.height + self.z = size.depth + } + + /// Creates a point from the specified Spatial vector structure. + /// + /// - Parameter xyz: A vector structure that specifies the coordinates. + /// - Complexity: O(1) + @inline(__always) + public init(_ xyz: Vector3D) { + self.x = xyz.x + self.y = xyz.y + self.z = xyz.z + } + + /// Accesses the x, y, or z value at the specified index. + /// + /// - Parameter index: The index of the value to access. Valid indices are 0, 1, and 2. + /// - Returns: The x, y, or z value at the specified index. + /// - Complexity: O(1) + @inline(__always) + public subscript(index: Int) -> Double { + get throws { + switch index { + case 0: return x + case 1: return y + case 2: return z + default: + throw Error.outOfRage + } + } + } + + // MARK: - Checking characteristics + + /// Returns the distance between this point and another point. + /// + /// - Parameter other: The other point. + /// - Returns: The distance between the two points. + /// - Complexity: O(1) + @inline(__always) + public func distance(to other: Point3D) -> Double { + let dx = other.x - x + let dy = other.y - y + let dz = other.z - z + return (dx * dx + dy * dy + dz * dz).squareRoot() + } + + // MARK: - Comparing values + + /// Returns a Boolean value indicating whether this point is approximately equal to another point within a specified tolerance. + /// + /// - Parameters: + /// - other: The other point to compare. + /// - tolerance: The maximum allowable difference between the corresponding coordinates of the two points for them to be considered approximately equal. + /// The default value is the square root of the unit in the last place. + /// - Returns: `true` if the points are approximately equal within the specified tolerance; otherwise, `false`. + /// - Complexity: O(1) + @inline(__always) + public func isApproximatelyEqual(to other: Point3D, tolerance: Double = sqrt(.ulpOfOne)) -> Bool + { + abs(x - other.x) <= tolerance && abs(y - other.y) <= tolerance + && abs(z - other.z) <= tolerance + } +} + +extension Point3D: ExpressibleByArrayLiteral { + + /// Creates an instance initialized with the given elements. + /// + /// - Parameter elements: The elements of the array literal. + @inline(__always) + public init(arrayLiteral elements: Double...) { + precondition(elements.count == 3, "Array literal must contain exactly three elements.") + (x, y, z) = (elements[0], elements[1], elements[2]) + } +} + +extension Point3D: AdditiveArithmetic { + + // MARK: - Applying arithmetic operations + + /// The zero point. + public static let zero = Point3D() + + /// Returns a point that’s the product of a point and a scalar value. + /// + /// - Parameters: + /// - lhs: The left-hand-side value. + /// - rhs: The right-hand-side scalar value. + /// - Returns: The product of the point and the scalar. + /// - Complexity: O(1) + @inline(__always) + public static func * (lhs: Point3D, rhs: Double) -> Point3D { + .init(x: lhs.x * rhs, y: lhs.y * rhs, z: lhs.z * rhs) + } + + /// Returns a point that’s the product of a point and a scalar value. + /// + /// - Parameters: + /// - lhs: The left-hand-side scalar value. + /// - rhs: The right-hand-side value. + /// - Returns: The product of the point and the scalar. + /// - Complexity: O(1) + @inline(__always) + public static func * (lhs: Double, rhs: Point3D) -> Point3D { + .init(x: lhs * rhs.x, y: lhs * rhs.y, z: lhs * rhs.z) + } + + /// Returns the point that results from applying the affine transform to the point. + /// + /// - Parameters: + /// - lhs: The affine transform. + /// - rhs: The point to transform. + /// - Returns: The transformed point. + /// - Complexity: O(1) + @inline(__always) + public static func * (lhs: AffineTransform3D, rhs: Point3D) -> Point3D { + let x = lhs[0, 0] * rhs.x + lhs[1, 0] * rhs.y + lhs[2, 0] * rhs.z + lhs[3, 0] + let y = lhs[0, 1] * rhs.x + lhs[1, 1] * rhs.y + lhs[2, 1] * rhs.z + lhs[3, 1] + let z = lhs[0, 2] * rhs.x + lhs[1, 2] * rhs.y + lhs[2, 2] * rhs.z + lhs[3, 2] + return Point3D(x: x, y: y, z: z) + } + + /// Multiplies a point and a double-precision value, and stores the result in the left-hand-side variable. + /// + /// - Parameters: + /// - lhs: The left-hand-side value. + /// - rhs: The right-hand-side value. + /// - Complexity: O(1) + @inline(__always) + public static func *= (lhs: inout Point3D, rhs: Double) { + lhs = lhs * rhs + } + + /// Adds two points and returns the result. + /// + /// - Parameters: + /// - lhs: The first point. + /// - rhs: The second point. + /// - Returns: The sum of the two points. + /// - Complexity: O(1) + @inline(__always) + public static func + (lhs: Point3D, rhs: Point3D) -> Point3D { + .init(x: lhs.x + rhs.x, y: lhs.y + rhs.y, z: lhs.z + rhs.z) + } + + /// Adds a point to a size and returns the result. + /// + /// - Parameters: + /// - lhs: The point. + /// - rhs: The size. + /// - Returns: The sum of the point and the size. + /// - Complexity: O(1) + @inline(__always) + public static func + (lhs: Point3D, rhs: Size3D) -> Point3D { + .init(x: lhs.x + rhs.width, y: lhs.y + rhs.height, z: lhs.z + rhs.depth) + } + + /// Adds a size to a point and returns the result. + /// + /// - Parameters: + /// - lhs: The size. + /// - rhs: The point. + /// - Returns: The sum of the point and the size. + /// - Complexity: O(1) + @inline(__always) + public static func + (lhs: Size3D, rhs: Point3D) -> Point3D { + .init(x: lhs.width + rhs.x, y: lhs.height + rhs.y, z: lhs.depth + rhs.z) + } + + /// Adds the second point to the first point and stores the result in the first point. + /// + /// - Parameters: + /// - lhs: The first point. + /// - rhs: The second point. + /// - Complexity: O(1) + @inline(__always) + public static func += (lhs: inout Point3D, rhs: Point3D) { + lhs = lhs + rhs + } + + /// Adds a point and a vector, and stores the result in the left-hand-side variable. + /// + /// - Parameters: + /// - lhs: The point. + /// - rhs: The vector. + /// - Complexity: O(1) + @inline(__always) + public static func += (lhs: inout Point3D, rhs: Vector3D) { + lhs = lhs + rhs + } + + /// Subtracts one point from another and returns the result. + /// + /// - Parameters: + /// - lhs: The first point. + /// - rhs: The second point. + /// - Returns: The difference of the two points. + /// - Complexity: O(1) + @inline(__always) + public static func - (lhs: Point3D, rhs: Point3D) -> Point3D { + .init(x: lhs.x - rhs.x, y: lhs.y - rhs.y, z: lhs.z - rhs.z) + } + + /// Returns a point that’s the element-wise difference of a point and a size. + /// + /// - Parameters: + /// - lhs: The point. + /// - rhs: The size. + /// - Returns: The difference of the point and the size. + /// - Complexity: O(1) + @inline(__always) + public static func - (lhs: Point3D, rhs: Size3D) -> Point3D { + .init(x: lhs.x - rhs.width, y: lhs.y - rhs.height, z: lhs.z - rhs.depth) + } + + /// Returns a point that’s the element-wise difference of a point and a size. + /// + /// - Parameters: + /// - lhs: The size. + /// - rhs: The point. + /// - Returns: The difference of the size and the point. + /// - Complexity: O(1) + @inline(__always) + public static func - (lhs: Size3D, rhs: Point3D) -> Point3D { + .init(x: lhs.width - rhs.x, y: lhs.height - rhs.y, z: lhs.depth - rhs.z) + } + + /// Subtracts the second point from the first point and stores the result in the first point. + /// + /// - Parameters: + /// - lhs: The first point. + /// - rhs: The second point. + /// - Complexity: O(1) + @inline(__always) + public static func -= (lhs: inout Point3D, rhs: Point3D) { + lhs = lhs - rhs + } + + /// Subtracts a vector from a point, and stores the result in the left-hand-side variable. + /// + /// - Parameters: + /// - lhs: The point. + /// - rhs: The vector. + /// - Complexity: O(1) + @inline(__always) + public static func -= (lhs: inout Point3D, rhs: Vector3D) { + lhs = lhs - rhs + } + + /// Subtracts a size from a point, and stores the result in the left-hand-side variable. + /// + /// - Parameters: + /// - lhs: The point. + /// - rhs: The size. + /// - Complexity: O(1) + @inline(__always) + public static func -= (lhs: inout Point3D, rhs: Size3D) { + lhs = lhs - rhs + } + + /// Returns a point with each element divided by a scalar value. + /// + /// - Parameters: + /// - lhs: The left-hand-side value. + /// - rhs: The right-hand-side value. + /// - Returns: The resulting point. + /// - Complexity: O(1) + @inline(__always) + public static func / (lhs: Point3D, rhs: Double) -> Point3D { + .init(x: lhs.x / rhs, y: lhs.y / rhs, z: lhs.z / rhs) + } + + /// Divides each element of the point by a scalar value and stores the result in the left-hand-side variable. + /// + /// - Parameters: + /// - lhs: The left-hand-side value. + /// - rhs: The right-hand-side value. + /// - Complexity: O(1) + @inline(__always) + public static func /= (lhs: inout Point3D, rhs: Double) { + lhs = lhs / rhs + } +} + +extension Point3D: Primitive3D { + + // MARK: - Instance properties + + /// A Boolean value that indicates whether the vector is finite. + public var isFinite: Bool { + x.isFinite && y.isFinite && z.isFinite + } + + /// A Boolean value that indicates whether the vector contains any NaN values. + public var isNaN: Bool { + x.isNaN || y.isNaN || z.isNaN + } + + /// A Boolean value that indicates whether the vector is zero. + public var isZero: Bool { + x == 0 && y == 0 && z == 0 + } + + // MARK: - Type properties + + /// A vector with infinite values. + public static var infinity: Point3D { + .init(x: .infinity, y: .infinity, z: .infinity) + } + + // MARK: - Transforming primitives + + /// Applies an affine transform. + /// + /// - Parameter transform: The affine transform to apply. + /// - Returns: A new transformed vector. + /// - Complexity: O(1) + public func applying(_ transform: AffineTransform3D) -> Point3D { + let newX = + x * transform.matrix[0][0] + y * transform.matrix[1][0] + z * transform.matrix[2][0] + + transform.matrix[3][0] + let newY = + x * transform.matrix[0][1] + y * transform.matrix[1][1] + z * transform.matrix[2][1] + + transform.matrix[3][1] + let newZ = + x * transform.matrix[0][2] + y * transform.matrix[1][2] + z * transform.matrix[2][2] + + transform.matrix[3][2] + return Point3D(x: newX, y: newY, z: newZ) + } +} + +extension Point3D: Scalable3D { + + // MARK: - Instance methods + + /// Returns a new entity scaled by the specified size. + /// + /// - Parameter size: A size that contains the scale factors for each axis. + /// - Returns: A new scaled entity. + /// - Complexity: O(1) + @inline(__always) + public func scaled(by size: Size3D) -> Point3D { + .init(x: x * size.width, y: y * size.height, z: z * size.depth) + } + + /// Returns a new entity scaled uniformly by the specified factor. + /// + /// - Parameter scale: A double-precision value that specifies the uniform scale factor. + /// - Returns: A new scaled entity. + /// - Complexity: O(1) + @inline(__always) + public func uniformlyScaled(by scale: Double) -> Point3D { + .init(x: x * scale, y: y * scale, z: z * scale) + } +} + +extension Point3D: Translatable3D { + + // MARK: - Instance methods + + /// Returns a new entity translated by the specified vector. + /// + /// - Parameter vector: A vector that contains the translation distances for each axis. + /// - Returns: A new translated entity. + /// - Complexity: O(1) + @inline(__always) + public func translated(by vector: Vector3D) -> Point3D { + self + vector + } +} + +extension Point3D: CustomStringConvertible { + + // MARK: - CustomStringConvertible + + /// A textual representation of the point. + public var description: String { + "(x: \(x), y: \(y), z: \(z))" + } +} + +extension Point3D: ProjectiveTransformable3D { + + /// Returns a transformed copy of the point. + /// + /// Multiplies `[x, y, z, 1]` by the 4×4 projective matrix and divides + /// by the resulting homogeneous `w` component. + /// + /// - Parameter transform: A projective transform to apply. + /// - Returns: The transformed point. + /// - Complexity: O(1) + @inline(__always) + public func applying(_ transform: ProjectiveTransform3D) -> Point3D { + let m = transform.matrix + let tx = m[0][0] * x + m[1][0] * y + m[2][0] * z + m[3][0] + let ty = m[0][1] * x + m[1][1] * y + m[2][1] * z + m[3][1] + let tz = m[0][2] * x + m[1][2] * y + m[2][2] * z + m[3][2] + let tw = m[0][3] * x + m[1][3] * y + m[2][3] * z + m[3][3] + guard tw != 0 else { return Point3D(x: tx, y: ty, z: tz) } + return Point3D(x: tx / tw, y: ty / tw, z: tz / tw) + } +} + +extension Point3D: Clampable3D { + + /// Returns the point with coordinates clamped to the specified rectangle. + /// + /// - Parameter rect: The rectangle that defines the clamp volume. + /// - Returns: A point that's clamped to the specified rectangle. + /// - Complexity: O(1) + @inline(__always) + public func clamped(to rect: Rect3D) -> Point3D { + Point3D( + x: Swift.max(rect.min.x, Swift.min(rect.max.x, x)), + y: Swift.max(rect.min.y, Swift.min(rect.max.y, y)), + z: Swift.max(rect.min.z, Swift.min(rect.max.z, z)) + ) + } +} + +extension Point3D: Rotatable3D { + + // MARK: - Instance methods + + /// Returns a new entity rotated by the specified quaternion. + /// + /// - Parameter quaternion: A quaternion that defines the rotation. + /// - Returns: A new rotated entity. + /// - Complexity: O(1) + @inline(__always) + public func rotated(by quaternion: Quaternion3D) -> Point3D { + let qx = quaternion.x + let qy = quaternion.y + let qz = quaternion.z + let qw = quaternion.w + + // Calculate the rotated coordinates + let ix = qw * x + qy * z - qz * y + let iy = qw * y + qz * x - qx * z + let iz = qw * z + qx * y - qy * x + let iw = -qx * x - qy * y - qz * z + + let rx = ix * qw + iw * -qx + iy * -qz - iz * -qy + let ry = iy * qw + iw * -qy + iz * -qx - ix * -qz + let rz = iz * qw + iw * -qz + ix * -qy - iy * -qx + + return Point3D(x: rx, y: ry, z: rz) + } +} diff --git a/Sources/OpenSpatial/3D primitives/Pose3D.swift b/Sources/OpenSpatial/3D primitives/Pose3D.swift new file mode 100644 index 0000000..d8253d1 --- /dev/null +++ b/Sources/OpenSpatial/3D primitives/Pose3D.swift @@ -0,0 +1,137 @@ + +// Pose3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A type that combines a position and orientation in 3D space. +@frozen +public struct Pose3D: Codable, Equatable, Hashable, Sendable { + + // MARK: - Stored properties + + /// The position of the pose. + public var position: Point3D + + /// The rotation of the pose. + public var rotation: Rotation3D + + // MARK: - Creating a Pose3D + + /// Creates a pose at the origin with identity rotation. + @inline(__always) + public init() { + position = Point3D() + rotation = Rotation3D() + } + + /// Creates a pose with the specified position and rotation. + /// + /// - Parameters: + /// - position: The position of the pose. + /// - rotation: The rotation of the pose. + @inline(__always) + public init(position: Point3D, rotation: Rotation3D) { + self.position = position + self.rotation = rotation + } + + /// Creates a pose at the origin with a rotation derived from the specified forward and up vectors. + /// + /// - Parameters: + /// - forward: The forward direction of the pose. + /// - up: The up direction of the pose. + @inline(__always) + public init(forward: Vector3D, up: Vector3D) { + position = Point3D() + rotation = Rotation3D(forward: forward, up: up) + } + + // MARK: - Constants + + /// The identity pose: origin position and identity rotation. + public static let identity = Pose3D() + + // MARK: - Inspecting characteristics + + /// A Boolean value that indicates whether the pose is the identity pose. + public var isIdentity: Bool { + position == .zero && rotation.isIdentity + } + + /// Returns the inverse pose. + public var inverse: Pose3D { + let invRotation = rotation.inverse + let invPosition = Point3D(invRotation.act(Vector3D(position) * -1.0)) + return Pose3D(position: invPosition, rotation: invRotation) + } + + // MARK: - Transforming + + /// Returns a new pose with the specified affine transform applied. + /// + /// - Parameter transform: The affine transform to apply. + /// - Returns: A new transformed pose. + /// - Complexity: O(1) + public func applying(_ transform: AffineTransform3D) -> Pose3D { + let newPosition = position.applying(transform) + let newRotation = rotation.quaternion * transform.rotation.quaternion + return Pose3D(position: newPosition, rotation: Rotation3D(quaternion: newRotation)) + } +} + +extension Pose3D: Translatable3D { + + /// Returns a new pose translated by the specified vector. + /// + /// - Parameter vector: The translation vector. + /// - Returns: A new translated pose. + /// - Complexity: O(1) + @inline(__always) + public func translated(by vector: Vector3D) -> Pose3D { + Pose3D(position: position.translated(by: vector), rotation: rotation) + } +} + +extension Pose3D: Rotatable3D { + + /// Returns a new pose rotated by the specified quaternion. + /// + /// - Parameter quaternion: The quaternion to apply. + /// - Returns: A new rotated pose. + /// - Complexity: O(1) + @inline(__always) + public func rotated(by quaternion: Quaternion3D) -> Pose3D { + Pose3D( + position: position, rotation: Rotation3D(quaternion: rotation.quaternion * quaternion)) + } +} + +extension Pose3D: ProjectiveTransformable3D { + + /// Returns a transformed copy of the pose. + /// + /// - Parameter transform: A projective transform to apply. + /// - Returns: The transformed pose. + /// - Complexity: O(1) + public func applying(_ transform: ProjectiveTransform3D) -> Pose3D { + Pose3D( + position: position.applying(transform), + rotation: rotation.applying(transform) + ) + } +} + +extension Pose3D: CustomStringConvertible { + + /// A textual representation of the pose. + public var description: String { + "(position: \(position), rotation: \(rotation))" + } +} diff --git a/Sources/OpenSpatial/3D primitives/Quaterion3D.swift b/Sources/OpenSpatial/3D primitives/Quaterion3D.swift new file mode 100644 index 0000000..2af7366 --- /dev/null +++ b/Sources/OpenSpatial/3D primitives/Quaterion3D.swift @@ -0,0 +1,379 @@ + +// Quaterion3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// Quaternions are used to represent rotations. +/// +/// A quaternion is a four-tuple of real numbers {x,y,z,w}. +/// A quaternion is a mathematically convenient alternative to the euler angle representation. +/// You can interpolate a quaternion without experiencing gimbal lock. +/// You can also use a quaternion to concatenate a series of rotations into a single representation. +/// +/// OpenSpatial internally uses Quaternions to represent all rotations. +@frozen +public struct Quaternion3D: Copyable, Codable, Equatable, Hashable, Sendable { + + // MARK: - Initializers + + /// Creates a new quaternion + @inline(__always) + public init() { + self.init(x: 0, y: 0, z: 0, w: 1) + } + + /// Creates a new quaternion structure from the specified double-precision values. + /// + /// - Parameters: + /// - x: The x-coordinate value. + /// - y: The y-coordinate value. + /// - z: The z-coordinate value. + /// - w: The w-coordinate value. + @inline(__always) + public init(x: Double, y: Double, z: Double, w: Double) { + self.x = x + self.y = y + self.z = z + self.w = w + } + + /// Creates a new quaternion structure from the specified Euler angles. + /// + /// - Parameter eulerAngles: The Euler angles of the quaternion. + public init(_ angles: EulerAngles, order: EulerAngles.Order = .xyz) { + let x = angles.x.radians + let y = angles.y.radians + let z = angles.z.radians + + let cx = Foundation.cos(x) + let cy = Foundation.cos(y) + let cz = Foundation.cos(z) + let sx = Foundation.sin(x) + let sy = Foundation.sin(y) + let sz = Foundation.sin(z) + + switch order { + case .xyz: + self.init( + x: sx * cy * cz + cx * sy * sz, + y: cx * sy * cz - sx * cy * sz, + z: cx * cy * sz + sx * sy * cz, + w: cx * cy * cz - sx * sy * sz + ) + case .zxy: + self.init( + x: sx * cy * cz - cx * sy * sz, + y: cx * sy * cz + sx * cy * sz, + z: cx * cy * sz - sx * sy * cz, + w: cx * cy * cz + sx * sy * sz + ) + } + } + + public init(angle: Angle2D, axis: Vector3D) { + let n = axis.normalized + let x = angle.radians * 0.5 + let sx = Foundation.sin(x) + let cx = Foundation.cos(x) + self.init(x: n.x * sx, y: n.y * sx, z: n.z * sx, w: cx) + } + + /// Accesses the x, y, or z value at the specified index. + /// + /// - Parameter index: The index of the value to access. Valid indices are 0, 1, and 2. + /// - Returns: The x, y, or z value at the specified index. + /// - Complexity: O(1) + @inline(__always) + public subscript(index: Int) -> Double { + get throws { + switch index { + case 0: return x + case 1: return y + case 2: return z + case 3: return w + default: + throw Error.outOfRage + } + } + } + + /// Creates a quaternion representing the shortest-arc rotation from one vector to another. + /// + /// - Parameters: + /// - from: The starting unit vector. + /// - to: The ending unit vector. + public init(from: Vector3D, to: Vector3D) { + let a = from.normalized + let b = to.normalized + let dot = a.dot(b) + if dot >= 1.0 { + self.init() + return + } + if dot <= -1.0 { + // 180-degree rotation: find an orthogonal axis + let ortho = + abs(a.x) < 0.9 + ? Vector3D(x: 0, y: -a.z, z: a.y).normalized + : Vector3D(x: -a.z, y: 0, z: a.x).normalized + self.init(x: ortho.x, y: ortho.y, z: ortho.z, w: 0) + return + } + let c = a.cross(b) + self.init(x: c.x, y: c.y, z: c.z, w: 1.0 + dot) + let norm = self.normalized + self.init(x: norm.x, y: norm.y, z: norm.z, w: norm.w) + } + + // MARK: - Checking characteristics + + /// The x-coordinate value. + public var x: Double + + /// The y-coordinate value. + public var y: Double + + /// The z-coordinate value. + public var z: Double + + /// The w-coordinate value. + public var w: Double + + /// A simd four-element vector that contains the x-, y-, z-, and w-coordinate values. + public var vector: [Double] { [x, y, z, w] } + + /// The identity quaternion (0, 0, 0, 1). + public static let identity = Quaternion3D() + + /// Rotates the given vector by this quaternion. + /// + /// - Parameter vector: The vector to rotate. + /// - Returns: The rotated vector. + /// - Complexity: O(1) + @inline(__always) + public func act(_ vector: Vector3D) -> Vector3D { + vector.rotated(by: self) + } + + /// The Euler angles of the quaternion. + public var eulerAngles: EulerAngles { + let q = self.normalized + + let sinp = 2.0 * (q.w * q.x + q.y * q.z) + let cosp = 1.0 - 2.0 * (q.x * q.x + q.y * q.y) + let pitch = atan2(sinp, cosp) + + let siny = 2.0 * (q.w * q.y - q.z * q.x) + let yaw: Double + if abs(siny) >= 1 { + yaw = siny > 0 ? Double.pi / 2 : -Double.pi / 2 + } else { + yaw = asin(siny) + } + + let sinr = 2.0 * (q.w * q.z + q.x * q.y) + let cosr = 1.0 - 2.0 * (q.y * q.y + q.z * q.z) + let roll = atan2(sinr, cosr) + + return .init( + x: .init(radians: pitch), + y: .init(radians: yaw), + z: .init(radians: roll), + order: .xyz + ) + } + + /// The squared length of the quaternion. + public var lengthSquared: Double { + x * x + y * y + z * z + w * w + } + + /// The length of the quaternion. + public var length: Double { + Foundation.sqrt(lengthSquared) + } + + /// Returns the conjugated quaternion. + /// + /// - Complexity: O(1) + @inline(__always) + public func conjugated() -> Quaternion3D { + .init(x: -x, y: -y, z: -z, w: w) + } + + /// Returns the inverted quaternion. + /// + /// - Complexity: O(1) + @inline(__always) + public func inverted() -> Quaternion3D { + let lenSq = lengthSquared + guard lenSq > 0 else { return self } + let conjugate = conjugated() + let inverse = 1.0 / lenSq + return .init( + x: conjugate.x * inverse, + y: conjugate.y * inverse, + z: conjugate.z * inverse, + w: conjugate.w * inverse + ) + } + + /// Returns the normalized quaternion. + public var normalized: Quaternion3D { + let magnitude = length + guard magnitude > 0 else { return self } + let inverse = 1.0 / magnitude + return Quaternion3D( + x: x * inverse, + y: y * inverse, + z: z * inverse, + w: w * inverse + ) + } + + /// Returns the product of two quaternions. + /// + /// - Parameters: + /// - lhs: The left-hand-side quaternion. + /// - rhs: The right-hand-side quaternion. + /// - Returns: The product of the two quaternions. + /// - Complexity: O(1) + public static func * (lhs: Quaternion3D, rhs: Quaternion3D) -> Quaternion3D { + .init( + x: lhs.w * rhs.x + lhs.x * rhs.w + lhs.y * rhs.z - lhs.z * rhs.y, + y: lhs.w * rhs.y - lhs.x * rhs.z + lhs.y * rhs.w + lhs.z * rhs.x, + z: lhs.w * rhs.z + lhs.x * rhs.y - lhs.y * rhs.x + lhs.z * rhs.w, + w: lhs.w * rhs.w - lhs.x * rhs.x - lhs.y * rhs.y - lhs.z * rhs.z + ) + } +} + +extension Quaternion3D: CustomStringConvertible { + + /// A textual representation of the quaternion. + public var description: String { + return "(x: \(x), y: \(y), z: \(z), w: \(w))" + } +} + +extension Quaternion3D: ExpressibleByArrayLiteral { + + /// Creates a quaternion from the specified array literal. + /// + /// - Parameter elements: An array of double-precision values. + @inline(__always) + public init(arrayLiteral elements: Double...) { + precondition(elements.count == 4, "Invalid array literal for \(Self.self)") + self.init(x: elements[0], y: elements[1], z: elements[2], w: elements[3]) + } +} + +extension Quaternion3D: Primitive3D { + + /// Returns a Boolean value that indicates whether the quaternion is finite. + public var isFinite: Bool { + x.isFinite && y.isFinite && z.isFinite && w.isFinite + } + + /// Returns a Boolean value that indicates whether the quaternion contains any NaN values. + public var isNaN: Bool { + x.isNaN || y.isNaN || z.isNaN || w.isNaN + } + + /// Returns a Boolean value that indicates whether the quaternion is zero. + public var isZero: Bool { + x == 0 && y == 0 && z == 0 && w == 0 + } + + /// Returns a quaternion with infinite values. + public static var infinity: Quaternion3D { + .init(x: .infinity, y: .infinity, z: .infinity, w: .infinity) + } + + /// Returns a quaternion with zero values. + public static var zero: Quaternion3D { + .init(x: 0, y: 0, z: 0, w: 0) + } + + /// Returns a new quaternion created by applying an affine transform. + /// + /// - Parameter transform: The affine transform to apply. + /// - Returns: A new quaternion created by applying the affine transform. + /// - Complexity: O(1) + public func applying(_ transform: AffineTransform3D) -> Quaternion3D { + let w = + 0.5 + * Foundation.sqrt( + 1 + transform.matrix[0][0] + transform.matrix[1][1] + transform.matrix[2][2]) + let x = + 0.5 + * Foundation.sqrt( + 1 + transform.matrix[0][0] - transform.matrix[1][1] - transform.matrix[2][2]) + let y = + 0.5 + * Foundation.sqrt( + 1 + transform.matrix[1][1] - transform.matrix[0][0] - transform.matrix[2][2]) + let z = + 0.5 + * Foundation.sqrt( + 1 + transform.matrix[2][2] - transform.matrix[0][0] - transform.matrix[1][1]) + + return Quaternion3D(x: x, y: y, z: z, w: w) + } + + // MARK: - Transforing 3D Rotations structure + + @inline(__always) + public static func slerp(_ a: Quaternion3D, _ b: Quaternion3D, t: Double) -> Quaternion3D { + // Inline normalization for a — avoids calling .length (sqrt) twice via .normalized + let aLenSq = a.x * a.x + a.y * a.y + a.z * a.z + a.w * a.w + let aInv = aLenSq > 0 ? 1.0 / Foundation.sqrt(aLenSq) : 1.0 + let q1 = Quaternion3D(x: a.x * aInv, y: a.y * aInv, z: a.z * aInv, w: a.w * aInv) + + // Inline normalization for b + let bLenSq = b.x * b.x + b.y * b.y + b.z * b.z + b.w * b.w + let bInv = bLenSq > 0 ? 1.0 / Foundation.sqrt(bLenSq) : 1.0 + var q2 = Quaternion3D(x: b.x * bInv, y: b.y * bInv, z: b.z * bInv, w: b.w * bInv) + + var dot = q1.x * q2.x + q1.y * q2.y + q1.z * q2.z + q1.w * q2.w + + if dot < 0.0 { + q2 = Quaternion3D(x: -q2.x, y: -q2.y, z: -q2.z, w: -q2.w) + dot = -dot + } + + if dot > 0.9995 { + // Near-linear: lerp then renormalize inline + let rx = q1.x + (q2.x - q1.x) * t + let ry = q1.y + (q2.y - q1.y) * t + let rz = q1.z + (q2.z - q1.z) * t + let rw = q1.w + (q2.w - q1.w) * t + let rLenSq = rx * rx + ry * ry + rz * rz + rw * rw + let rInv = rLenSq > 0 ? 1.0 / Foundation.sqrt(rLenSq) : 1.0 + return Quaternion3D(x: rx * rInv, y: ry * rInv, z: rz * rInv, w: rw * rInv) + } + + let theta0 = acos(dot) + let theta = theta0 * t + + let sin_theta = sin(theta) + let sin_theta0 = sin(theta0) + + let s0 = cos(theta) - dot * sin_theta / sin_theta0 + let s1 = sin_theta / sin_theta0 + + return Quaternion3D( + x: q1.x * s0 + q2.x * s1, + y: q1.y * s0 + q2.y * s1, + z: q1.z * s0 + q2.z * s1, + w: q1.w * s0 + q2.w * s1 + ) + } +} diff --git a/Sources/OpenSpatial/3D primitives/Ray3D.swift b/Sources/OpenSpatial/3D primitives/Ray3D.swift new file mode 100644 index 0000000..9e67c95 --- /dev/null +++ b/Sources/OpenSpatial/3D primitives/Ray3D.swift @@ -0,0 +1,293 @@ + +// Ray3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A structure that contains the origin and direction of a 3D ray. +@frozen public struct Ray3D: Codable, Equatable, Hashable, Sendable { + + public init() { + self.origin = .zero + self.direction = .zero + } + + /// The origin of the ray. + public var origin: Point3D + + /// The direction of the ray. + public var direction: Vector3D +} + +extension Ray3D { + + /// Creates a ray from Spatial primitives that describe the origin and direction. + /// + /// - Parameter origin: The origin of the ray. + /// - Parameter direction: The direction of the ray. + /// - note: This function normalizes the direction vector. + public init(origin: Point3D = .zero, direction: Vector3D) { + self.origin = origin + self.direction = direction.normalized + } + + /// Returns `true` if the the ray intersects the specified rectangle. + /// + /// - Parameter rect: The second primitive. + /// - Complexity: O(1) + @inline(__always) + public func intersects(_ rect: Rect3D) -> Bool { + let invDirX = direction.x == 0 ? Double.infinity : 1.0 / direction.x + let invDirY = direction.y == 0 ? Double.infinity : 1.0 / direction.y + let invDirZ = direction.z == 0 ? Double.infinity : 1.0 / direction.z + + let tx1 = (rect.min.x - origin.x) * invDirX + let tx2 = (rect.max.x - origin.x) * invDirX + let ty1 = (rect.min.y - origin.y) * invDirY + let ty2 = (rect.max.y - origin.y) * invDirY + let tz1 = (rect.min.z - origin.z) * invDirZ + let tz2 = (rect.max.z - origin.z) * invDirZ + + let tmin = Swift.max( + Swift.max(Swift.min(tx1, tx2), Swift.min(ty1, ty2)), Swift.min(tz1, tz2)) + let tmax = Swift.min( + Swift.min(Swift.max(tx1, tx2), Swift.max(ty1, ty2)), Swift.max(tz1, tz2)) + + return tmax >= 0 && tmin <= tmax + } + + /// Returns a ray that's transformed by the specified pose. + /// + /// - Parameter pose: The pose. + /// - Returns The transformed ray. + /// - Complexity: O(1) + /// This function rotates the ray's direction by the pose's rotation and offsets the ray's origin by the pose's position. + public func applying(_ pose: Pose3D) -> Ray3D { + let newOrigin = origin + Vector3D(pose.position) + let newDirection = pose.rotation.act(direction) + return Ray3D(origin: newOrigin, direction: newDirection) + } + + /// Applies a pose. + /// + /// - Parameter pose: The pose. + /// This function rotate's the ray's direction by the pose's rotation and sets the ray's origin to the pose's position. + public mutating func apply(_ pose: Pose3D) { + self = applying(pose) + } +} + +extension Ray3D { + + /// Returns a ray that's transformed by the inverse of the specified scaled pose. + /// + /// - Parameter pose: The scaled pose. + /// - Returns The transformed ray. + /// - Complexity: O(1) + /// This function rotates the ray's direction by the pose's rotation and offsets the ray's origin by the pose's position. + public func unapplying(_ scaledPose: ScaledPose3D) -> Ray3D { + applying(scaledPose.inverse) + } + + /// Returns a ray that's transformed by the specified scaled pose. + /// + /// - Parameter pose: The scaled pose. + /// - Returns The transformed ray. + /// - Complexity: O(1) + /// This function rotates the ray's direction by the pose's rotation and offsets the ray's origin by the pose's position. + public func applying(_ scaledPose: ScaledPose3D) -> Ray3D { + let newOrigin = + scaledPose.rotation.act(Vector3D(origin) * scaledPose.scale) + + Vector3D(scaledPose.position) + let newDirection = scaledPose.rotation.act(direction) + return Ray3D(origin: Point3D(newOrigin), direction: newDirection) + } +} + +extension Ray3D: Primitive3D { + + /// Returns a ray that's transformed by the specified affine transform. + /// + /// - Parameter transform: The affine transform. + /// - Returns The transformed ray. + /// - Complexity: O(1) + /// + /// This function applies the transform to the ray's origin and direction. + public func applying(_ transform: AffineTransform3D) -> Ray3D { + let newOrigin = origin.applying(transform) + let newDirection = applyLinear(transform, to: direction) + return Ray3D(origin: newOrigin, direction: newDirection) + } + + /// Returns a ray that's transformed by the inverse of the specified affine transform. + /// + /// - Parameter transform: The affine transform. + /// - Returns The transformed ray. + /// - Complexity: O(1) + /// + /// This function applies the transform to the ray's origin and direction. + public func unapplying(_ transform: AffineTransform3D) -> Ray3D { + guard let inv = transform.inverse else { return self } + return applying(inv) + } + + /// Returns a ray that's transformed by the specified projective transform. + /// + /// - Parameter transform: The projective transform. + /// - Returns The transformed ray. + /// - Complexity: O(1) + /// + /// This function applies the transform to the ray's origin and direction. + public func applying(_ transform: ProjectiveTransform3D) -> Ray3D { + let newOrigin = origin.applying(transform) + let newDirection = direction.applying(transform) + return Ray3D(origin: newOrigin, direction: newDirection) + } + + /// Returns a ray that's transformed by the inverse of the specified projective transform. + /// + /// - Parameter transform: The projective transform. + /// - Returns The transformed ray. + /// - Complexity: O(1) + /// + /// This function applies the transform to the ray's origin and direction. + public func unapplying(_ transform: ProjectiveTransform3D) -> Ray3D { + guard let inv = transform.inverse else { return self } + return applying(inv) + } + + /// Returns a ray that's transformed by the inverse of the specified pose. + /// + /// - Parameter pose: The pose. + /// - Returns The transformed ray. + /// - Complexity: O(1) + /// This function rotates the ray's direction by the pose's rotation and offsets the ray's origin by the pose's position. + public func unapplying(_ pose: Pose3D) -> Ray3D { + applying(pose.inverse) + } + + /// Returns a ray that's rotated by the specified rotation around a specified pivot. + /// + /// - Parameter rotation: The rotation. + /// - Parameter pivot: The center of rotation. + /// - Complexity: O(1) + public func rotated(by rotation: Rotation3D, around pivot: Point3D) -> Ray3D { + rotated(by: rotation.quaternion, around: pivot) + } + + /// Returns a ray that's rotated by the specified rotation around a specified pivot. + /// + /// - Parameter quaternion: The quaternion that defines the rotation. + /// - Parameter pivot: The center of rotation. + /// - Complexity: O(1) + public func rotated(by quaternion: Quaternion3D, around pivot: Point3D) -> Ray3D { + let offset = Vector3D(origin) - Vector3D(pivot) + let rotatedOffset = offset.rotated(by: quaternion) + let newOrigin = Point3D(rotatedOffset) + Vector3D(pivot) + let newDirection = direction.rotated(by: quaternion) + return Ray3D(origin: newOrigin, direction: newDirection) + } + + /// A Boolean value that indicates whether all of the values of the ray are finite. + public var isNaN: Bool { + origin.isNaN || direction.isNaN + } + + /// A Boolean value that indicates whether all of the values of the ray are finite. + public var isFinite: Bool { + origin.isFinite && direction.isFinite + } + + /// A Boolean value that indicates whether all of the values of the ray are zero. + public var isZero: Bool { + origin.isZero && direction.isZero + } + + /// A ray with all-zero values. + public static var zero: Ray3D { + Ray3D() + } + + /// A ray with infinite origin and direction values. + public static var infinity: Ray3D { + Ray3D(origin: .infinity, direction: Vector3D(x: .infinity, y: .infinity, z: .infinity)) + } +} + +extension Ray3D: Translatable3D { + + /// Returns a ray translated by the specified vector. + /// + /// - Parameter vector: The translation vector. + /// - Complexity: O(1) + public func translated(by vector: Vector3D) -> Ray3D { + Ray3D(origin: origin + vector, direction: direction) + } +} + +extension Ray3D: Rotatable3D { + + /// Returns a ray that's rotated by the specified rotation. + /// + /// - Parameter rotation: The rotation. + /// - Returns A ray with a direction that's rotated by the specified rotation. + /// - Complexity: O(1) + public func rotated(by rotation: Rotation3D) -> Ray3D { + Ray3D(origin: origin, direction: rotation.act(direction)) + } + + /// Returns a ray that's rotated by the specified quaternion. + /// + /// - Parameter quaternion: The quaternion that defines the rotation. + /// - Returns A ray with a direction that's rotated by the specified quaternion. + /// - Complexity: O(1) + public func rotated(by quaternion: Quaternion3D) -> Ray3D { + Ray3D(origin: origin, direction: direction.rotated(by: quaternion)) + } +} + +extension Ray3D: CustomStringConvertible { + + /// A textual representation of this instance. + /// + /// Calling this property directly is discouraged. Instead, convert an + /// instance of any type to a string by using the `String(describing:)` + /// initializer. This initializer works with any type, and uses the custom + /// `description` property for types that conform to + /// `CustomStringConvertible`: + /// + /// struct Point: CustomStringConvertible { + /// let x: Int, y: Int + /// + /// var description: String { + /// return "(\(x), \(y))" + /// } + /// } + /// + /// let p = Point(x: 21, y: 30) + /// let s = String(describing: p) + /// print(s) + /// // Prints "(21, 30)" + /// + /// The conversion of `p` to a string in the assignment to `s` uses the + /// `Point` type's `description` property. + public var description: String { + "(origin: \(origin), direction: \(direction))" + } +} + +// MARK: - Private helpers + +private func applyLinear(_ transform: AffineTransform3D, to vector: Vector3D) -> Vector3D { + let m = transform.matrix + let x = vector.x * m[0][0] + vector.y * m[1][0] + vector.z * m[2][0] + let y = vector.x * m[0][1] + vector.y * m[1][1] + vector.z * m[2][1] + let z = vector.x * m[0][2] + vector.y * m[1][2] + vector.z * m[2][2] + return Vector3D(x: x, y: y, z: z) +} diff --git a/Sources/OpenSpatial/3D primitives/Rect3D.swift b/Sources/OpenSpatial/3D primitives/Rect3D.swift new file mode 100644 index 0000000..72a27dd --- /dev/null +++ b/Sources/OpenSpatial/3D primitives/Rect3D.swift @@ -0,0 +1,398 @@ + +// Rect3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A rectangle in a 3D coordinate system. +@frozen public struct Rect3D: Copyable, Codable, Equatable, Hashable, Sendable { + + // MARK: - Creating a 3D rectangle structure + + /// Creates a rectangle structure. + @inline(__always) + public init() { + self.init( + origin: Point3D.zero, + size: Size3D.zero + ) + } + + /// Creates a rectangle with the specified center and the specified size from Spatial structures. + /// + /// - Parameters: + /// - center: The center point of the rectangle. The default is the origin point + /// - size: The size of the rectangle. + @inline(__always) + public init(center: Point3D, size: Size3D) { + self.init( + origin: Point3D( + x: center.x - size.width / 2, + y: center.y - size.height / 2, + z: center.z - size.depth / 2 + ), + size: size + ) + } + + /// Creates a rectangle with the specified center and the specified size from Spatial vectors. + /// + /// - Parameters: + /// - center: The center point of the rectangle. The default is the origin point + /// - size: The size of the rectangle. + @inline(__always) + public init(center: Vector3D = .zero, size: Vector3D) { + self.init( + center: Point3D(x: center.x, y: center.y, z: center.z), + size: Size3D(width: size.x, height: size.y, depth: size.z) + ) + } + + /// Creates a rectangle with the specified origin and the specified size from Spatial vectors. + /// + /// - Parameters: + /// - origin: The origin point of the rectangle. The default is the origin point + /// - size: The size of the rectangle. + @inline(__always) + public init(origin: Vector3D = .zero, size: Vector3D) { + self.init( + origin: Point3D(x: origin.x, y: origin.y, z: origin.z), + size: Size3D(width: size.x, height: size.y, depth: size.z) + ) + } + + /// Creates a rectangle at the specified origin and the specified size from Spatial structures. + /// + /// - Parameters: + /// - origin: The origin point of the rectangle. The default is the origin point + /// - size: The size of the rectangle. + @inline(__always) + public init(origin: Point3D, size: Size3D) { + self.origin = origin + self.size = size + } + + // MARK: - Inspecting a 3D rectangle’s properties + + /// The center of the rectangle. + @inline(__always) + public var center: Point3D { + Point3D( + x: origin.x + size.width / 2, + y: origin.y + size.height / 2, + z: origin.z + size.depth / 2 + ) + } + + /// The corner points of the rectangle. + @inline(__always) + public var cornerPoints: [Point3D] { + [ + Point3D(x: origin.x, y: origin.y, z: origin.z), + Point3D(x: origin.x, y: origin.y + size.height, z: origin.z), + Point3D(x: origin.x + size.width, y: origin.y + size.height, z: origin.z), + Point3D(x: origin.x + size.width, y: origin.y, z: origin.z), + Point3D(x: origin.x, y: origin.y, z: origin.z + size.depth), + Point3D(x: origin.x, y: origin.y + size.height, z: origin.z + size.depth), + Point3D(x: origin.x + size.width, y: origin.y + size.height, z: origin.z + size.depth), + Point3D(x: origin.x + size.width, y: origin.y, z: origin.z + size.depth), + ] + } + + /// A point that represents the corner of the rectangle with the largest x-, y-, and z-coordinates. + @inline(__always) + public var max: Point3D { + origin + size + } + + /// A point that represents the corner of the rectangle with the smallest x-, y-, and z-coordinates. + @inline(__always) + public var min: Point3D { + origin + } + + /// The origin of the rectangle. + public private(set) var origin: Point3D + + /// The size of the rectangle. + public private(set) var size: Size3D + + // MARK: - Checking characteristics + + /// Returns a Boolean value that indicates whether two rectangles intersect. + /// + /// - Parameter other: The other rectangle to compare against. + /// - Returns: A Boolean value that indicates whether the two rectangles intersect. + /// - Complexity: O(1) + @inline(__always) + public func intersects(_ other: Rect3D) -> Bool { + !(other.min.x > max.x || other.max.x < min.x || other.min.y > max.y || other.max.y < min.y + || other.min.z > max.z || other.max.z < min.z) + } + + /// A Boolean value that indicates whether two or three of the dimensions are zero. + @inline(__always) + public var isEmpty: Bool { + size.width == 0 || size.height == 0 || size.depth == 0 + } + + // MARK: - Creating derived 3D rectangles + + @inline(__always) + public var integral: Rect3D { + .init( + origin: Point3D(x: floor(origin.x), y: floor(origin.y), z: floor(origin.z)), + size: Size3D( + width: ceil(size.width), height: ceil(size.height), depth: ceil(size.depth) + ) + ) + } + + /// A rectangle with positive dimensions. + @inline(__always) + public var standardized: Rect3D { + var newOrigin = origin + var newSize = size + + if size.width < 0 { + newOrigin.x += size.width + newSize.width = -size.width + } + + if size.height < 0 { + newOrigin.y += size.height + newSize.height = -size.height + } + + if size.depth < 0 { + newOrigin.z += size.depth + newSize.depth = -size.depth + } + + return Rect3D(origin: newOrigin, size: newSize) + } +} + +extension Rect3D: ProjectiveTransformable3D { + + /// Returns a transformed copy of the rectangle. + /// + /// Applies the transform to the origin and preserves the size. + /// + /// - Parameter transform: A projective transform to apply. + /// - Returns: The transformed rectangle. + /// - Complexity: O(1) + @inline(__always) + public func applying(_ transform: ProjectiveTransform3D) -> Rect3D { + Rect3D(origin: origin.applying(transform), size: size) + } +} + +extension Rect3D: Shearable3D { + + /// Returns a sheared rectangle. + /// + /// Computes the axis-aligned bounding box of all 8 sheared corner points. + /// + /// - Parameter shear: The axis and shear factors. + /// - Returns: The sheared rectangle. + public func sheared(_ shear: AxisWithFactors) -> Rect3D { + let corners = cornerPoints + func shearPoint(_ p: Point3D) -> Point3D { + switch shear { + case .xAxis(let ky, let kz): + return Point3D(x: p.x + ky * p.y + kz * p.z, y: p.y, z: p.z) + case .yAxis(let kx, let kz): + return Point3D(x: p.x, y: p.y + kx * p.x + kz * p.z, z: p.z) + case .zAxis(let kx, let ky): + return Point3D(x: p.x, y: p.y, z: p.z + kx * p.x + ky * p.y) + } + } + let sheared = corners.map(shearPoint) + let minX = sheared.min(by: { $0.x < $1.x })!.x + let minY = sheared.min(by: { $0.y < $1.y })!.y + let minZ = sheared.min(by: { $0.z < $1.z })!.z + let maxX = sheared.max(by: { $0.x < $1.x })!.x + let maxY = sheared.max(by: { $0.y < $1.y })!.y + let maxZ = sheared.max(by: { $0.z < $1.z })!.z + return Rect3D( + origin: Point3D(x: minX, y: minY, z: minZ), + size: Size3D(width: maxX - minX, height: maxY - minY, depth: maxZ - minZ) + ) + } +} + +extension Rect3D: CustomStringConvertible { + + // MARK: - CustomStringConvertible + + /// A textual representation of the rectangle. + public var description: String { + "(origin: \(origin), size: \(size))" + } +} + +extension Rect3D: Primitive3D { + + // MARK: - Primitive3D + + /// Returns a Boolean value that indicates whether the primitive is infinite. + public var isFinite: Bool { + origin.isFinite && size.isFinite + } + + /// Returns a Boolean value that indicates whether the primitive contains any NaN values. + public var isNaN: Bool { + origin.isNaN || size.isNaN + } + + /// Returns a Boolean value that indicates whether the primitive is zero. + public var isZero: Bool { + origin.isZero && size.isZero + } + + // MARK: - Type properties + + /// A primitive with infinite values. + public static var infinity: Rect3D { + .init(origin: Point3D.infinity, size: Size3D.infinity) + } + + /// A primitive with zero values. + public static var zero: Rect3D { + .init(origin: Point3D.zero, size: Size3D.zero) + } + + // MARK: - Transforming primitives + + /// Applies an affine transform. + /// + /// - Parameter transform: The affine transform to apply. + /// - Returns: A new transformed rectangle. + /// - Complexity: O(1) + @inline(__always) + public func applying(_ transform: AffineTransform3D) -> Rect3D { + .init(origin: origin.applying(transform), size: size.applying(transform)) + } +} + +extension Rect3D: Scalable3D { + + // MARK: - Scalable3D + + /// Returns a new rectangle with the scaled size. + /// + /// - Parameter size: The size to scale the rectangle by. + /// - Returns: A new scaled rectangle. + /// - Complexity: O(1) + @inline(__always) + public func scaled(by size: Size3D) -> Rect3D { + .init( + origin: origin.scaled(by: size), + size: self.size.scaled(by: size) + ) + } + + /// Returns a new rectangle with the uniformly scaled size. + /// + /// - Parameter scale: The scale factor. + /// - Returns: A new uniformly scaled rectangle. + /// - Complexity: O(1) + @inline(__always) + public func uniformlyScaled(by scale: Double) -> Rect3D { + .init(origin: origin.uniformlyScaled(by: scale), size: size.uniformlyScaled(by: scale)) + } +} + +extension Rect3D: Translatable3D { + + // MARK: - Translatable3D + + /// Returns a new rectangle with the translated origin. + /// + /// - Parameter vector: The translation vector. + /// - Returns: A new translated rectangle. + /// - Complexity: O(1) + @inline(__always) + public func translated(by vector: Vector3D) -> Rect3D { + .init(origin: origin.translated(by: vector), size: size) + } +} + +extension Rect3D: Volumetric { + + // MARK: - Instance methods + + /// Returns a Boolean value that indicates whether the entity contains the specified volumetric entity. + /// + /// - Parameter other: The volumetric entity that the function compares against. + /// - Returns: A Boolean value that indicates whether the entity contains the specified volumetric entity + /// - Complexity: O(1) + @inline(__always) + public func contains(_ other: Rect3D) -> Bool { + other.min.x >= min.x && other.max.x <= max.x && other.min.y >= min.y && other.max.y <= max.y + && other.min.z >= min.z && other.max.z <= max.z + } + + /// Returns a Boolean value that indicates whether this volume contains the specified point. + /// + /// - Parameter point: The point that the function compares against. + /// - Returns: A Boolean value that indicates whether this volume contains the specified point. + /// - Complexity: O(1) + @inline(__always) + public func contains(point: Point3D) -> Bool { + point.x >= min.x && point.x <= max.x && point.y >= min.y && point.y <= max.y + && point.z >= min.z + && point.z <= max.z + } + + /// Returns the intersection of this primitive and the specified primitive. + /// + /// - Parameter other: The rectangle to intersect with. + /// - Returns: The intersection of this primitive and the specified primitive, or `nil` if they do not intersect. + /// - Complexity: O(1) + @inline(__always) + public func intersection(_ other: Rect3D) -> Rect3D? { + let newMin = Point3D( + x: Swift.max(min.x, other.min.x), y: Swift.max(min.y, other.min.y), + z: Swift.max(min.z, other.min.z)) + let newMax = Point3D( + x: Swift.min(max.x, other.max.x), y: Swift.min(max.y, other.max.y), + z: Swift.min(max.z, other.max.z)) + + if newMin.x <= newMax.x && newMin.y <= newMax.y && newMin.z <= newMax.z { + return Rect3D( + origin: newMin, + size: Size3D( + width: newMax.x - newMin.x, height: newMax.y - newMin.y, + depth: newMax.z - newMin.z)) + } else { + return nil + } + } + + /// Returns the union of this rectangle and the specified rectangle. + /// + /// - Parameter other: The rectangle to union with. + /// - Returns: The union of this rectangle and the specified rectangle. + /// - Complexity: O(1) + @inline(__always) + public func union(_ other: Rect3D) -> Rect3D { + let newOrigin = Point3D( + x: Swift.min(min.x, other.min.x), y: Swift.min(min.y, other.min.y), + z: Swift.min(min.z, other.min.z)) + let newMax = Point3D( + x: Swift.max(max.x, other.max.x), y: Swift.max(max.y, other.max.y), + z: Swift.max(max.z, other.max.z)) + let newSize = Size3D( + width: newMax.x - newOrigin.x, height: newMax.y - newOrigin.y, + depth: newMax.z - newOrigin.z) + return Rect3D(origin: newOrigin, size: newSize) + } +} diff --git a/Sources/OpenSpatial/3D primitives/Rotation3D.swift b/Sources/OpenSpatial/3D primitives/Rotation3D.swift new file mode 100644 index 0000000..eee9c14 --- /dev/null +++ b/Sources/OpenSpatial/3D primitives/Rotation3D.swift @@ -0,0 +1,208 @@ + +// Rotation3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A rotation in three dimensions. +@frozen +public struct Rotation3D: Copyable, Codable, Equatable, Hashable, Sendable { + + // MARK: - Storage + + private let _quaternion: Quaternion3D + + // MARK: - Creating a 3D rotation structure + + /// Creates an identity rotation. + @inline(__always) + public init() { + _quaternion = Quaternion3D(x: 0, y: 0, z: 0, w: 1) + } + + /// Creates a rotation structure with the specified Euler angles. + /// + /// - Parameter eulerAngles: A structure that specifies the order and values of the Euler angles. + public init(eulerAngles: EulerAngles) { + // Quaternion3D(eulerAngles:) uses full angles, not half angles, which gives wrong results. + // Implement the standard half-angle Euler→quaternion conversion directly. + let hx = eulerAngles.x.radians * 0.5 + let hy = eulerAngles.y.radians * 0.5 + let hz = eulerAngles.z.radians * 0.5 + let cx = Foundation.cos(hx) + let sx = Foundation.sin(hx) + let cy = Foundation.cos(hy) + let sy = Foundation.sin(hy) + let cz = Foundation.cos(hz) + let sz = Foundation.sin(hz) + switch eulerAngles.order { + case .xyz: + _quaternion = Quaternion3D( + x: sx * cy * cz - cx * sy * sz, + y: cx * sy * cz + sx * cy * sz, + z: cx * cy * sz - sx * sy * cz, + w: cx * cy * cz + sx * sy * sz + ) + case .zxy: + _quaternion = Quaternion3D( + x: sx * cy * cz + cx * sy * sz, + y: cx * sy * cz - sx * cy * sz, + z: cx * cy * sz + sx * sy * cz, + w: cx * cy * cz - sx * sy * sz + ) + } + } + + /// Creates a rotation structure with the specified quaternion. + /// + /// - Parameter quaternion: A quaternion that represents the rotation. + @inline(__always) + public init(quaternion: Quaternion3D) { + _quaternion = quaternion.normalized + } + + /// Creates a rotation structure with the specified axis and angle. + /// + /// - Parameter angle: The angle of the rotation. + /// - Parameter axis: The axis of the rotation. + @inline(__always) + public init(angle: Angle2D, axis: RotationAxis3D) { + _quaternion = Quaternion3D(angle: angle, axis: Vector3D(x: axis.x, y: axis.y, z: axis.z)) + } + + // MARK: - Look-at and forward initialisers + + /// Creates a rotation structure with the specified position, target, and up vector. + /// + /// - Parameter position: The position of the rotation. + /// - Parameter target: The target of the rotation. + /// - Parameter up: The up vector of the rotation. + public init(position: Point3D, target: Point3D, up: Vector3D) { + let forward = Vector3D( + x: target.x - position.x, + y: target.y - position.y, + z: target.z - position.z + ).normalized + self.init(forward: forward, up: up) + } + + /// Creates a rotation structure with the specified forward vector and default up vector (0, 1, 0). + /// + /// - Parameter forward: The forward vector of the rotation. + public init(forward: Vector3D) { + self.init(forward: forward, up: Vector3D(x: 0, y: 1, z: 0)) + } + + /// Creates a rotation structure with the specified forward and up vectors. + /// + /// - Parameter forward: The forward vector of the rotation. + /// - Parameter up: The up vector of the rotation. + public init(forward: Vector3D, up: Vector3D) { + let f = forward.normalized + let r = up.normalized.cross(f).normalized + let u = f.cross(r) + // Build quaternion from rotation matrix columns [right, up, forward] + let trace = r.x + u.y + f.z + let q: Quaternion3D + if trace > 0 { + let s = 0.5 / Foundation.sqrt(trace + 1.0) + q = Quaternion3D( + x: (u.z - f.y) * s, y: (f.x - r.z) * s, z: (r.y - u.x) * s, w: 0.25 / s) + } else if r.x > u.y && r.x > f.z { + let s = 2.0 * Foundation.sqrt(1.0 + r.x - u.y - f.z) + q = Quaternion3D( + x: 0.25 * s, y: (u.x + r.y) / s, z: (f.x + r.z) / s, w: (u.z - f.y) / s) + } else if u.y > f.z { + let s = 2.0 * Foundation.sqrt(1.0 + u.y - r.x - f.z) + q = Quaternion3D( + x: (u.x + r.y) / s, y: 0.25 * s, z: (f.y + u.z) / s, w: (f.x - r.z) / s) + } else { + let s = 2.0 * Foundation.sqrt(1.0 + f.z - r.x - u.y) + q = Quaternion3D( + x: (f.x + r.z) / s, y: (f.y + u.z) / s, z: 0.25 * s, w: (r.y - u.x) / s) + } + _quaternion = q.normalized + } + + // MARK: - Inspecting a 3D rotation's properties + + /// The angle of the rotation derived from the quaternion. + public var angle: Angle2D { + let w = Swift.max(-1.0, Swift.min(1.0, _quaternion.w)) + return Angle2D(radians: 2.0 * Foundation.acos(w)) + } + + /// The normalised rotation axis derived from the quaternion xyz components. + public var axis: RotationAxis3D { + let sinHalfAngle = Foundation.sqrt(1.0 - _quaternion.w * _quaternion.w) + guard sinHalfAngle > 1e-10 else { + return RotationAxis3D(x: 0, y: 1, z: 0) + } + let inv = 1.0 / sinHalfAngle + return RotationAxis3D( + x: _quaternion.x * inv, y: _quaternion.y * inv, z: _quaternion.z * inv) + } + + /// A quaternion that represents the rotation. + public var quaternion: Quaternion3D { + _quaternion + } + + /// The underlying vector of the rotation: [x, y, z, w]. + public var vector: [Double] { + [_quaternion.x, _quaternion.y, _quaternion.z, _quaternion.w] + } + + /// A Boolean value that indicates whether the rotation is the identity rotation. + public var isIdentity: Bool { + (_quaternion.x == 0 && _quaternion.y == 0 && _quaternion.z == 0 && _quaternion.w == 1) + || (_quaternion.x == 0 && _quaternion.y == 0 && _quaternion.z == 0 + && _quaternion.w == -1) + } + + /// Returns the inverse of the rotation. + public var inverse: Rotation3D { + Rotation3D(quaternion: _quaternion.conjugated()) + } + + /// Rotates the given vector by this rotation. + /// + /// - Parameter vector: The vector to rotate. + /// - Returns: The rotated vector. + /// - Complexity: O(1) + @inline(__always) + public func act(_ vector: Vector3D) -> Vector3D { + _quaternion.act(vector) + } +} + +extension Rotation3D: ProjectiveTransformable3D { + + /// Returns a transformed copy of the rotation. + /// + /// When the transform is affine, extracts its rotation component and + /// composes it with this rotation. Otherwise returns self unchanged. + /// + /// - Parameter transform: A projective transform to apply. + /// - Returns: The transformed rotation. + /// - Complexity: O(1) + public func applying(_ transform: ProjectiveTransform3D) -> Rotation3D { + guard transform.isAffine else { return self } + let affineRotation = AffineTransform3D(matrix: transform.matrix).rotation + return Rotation3D(quaternion: quaternion * affineRotation.quaternion) + } +} + +extension Rotation3D: CustomStringConvertible { + + /// A textual representation of the rotation. + public var description: String { + "(angle: \(_quaternion.w), axis: \(axis))" + } +} diff --git a/Sources/OpenSpatial/3D primitives/RotationAxis3D.swift b/Sources/OpenSpatial/3D primitives/RotationAxis3D.swift new file mode 100644 index 0000000..263ba57 --- /dev/null +++ b/Sources/OpenSpatial/3D primitives/RotationAxis3D.swift @@ -0,0 +1,123 @@ + +// RotationAxis3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A 3D rotation axis. +@frozen +public struct RotationAxis3D: Copyable, Codable, Equatable, Hashable, Sendable { + + // MARK: - Creating a 3D rotation axis structure + + /// Creates a rotation axis. + @inline(__always) + public init() { + self.init(x: 0, y: 0, z: 0) + } + + /// Creates a rotation axis from the specified floating-point values. + /// + /// - Parameters: + /// - x: A floating-point value that specifies the x-coordinate value. + /// - y: A floating-point value that specifies the y-coordinate value. + /// - z: A floating-point value that specifies the z-coordinate value. + @inline(__always) + public init(x: T, y: T, z: T) where T: BinaryFloatingPoint { + self.init(x: Double(x), y: Double(y), z: Double(z)) + } + + /// Creates a rotation axis from the specified vector. + /// + /// - Parameter xyz: A Spatial vector that specifies the coordinates. + @inline(__always) + public init(_ xyz: Vector3D) { + self.init(x: xyz.x, y: xyz.y, z: xyz.z) + } + + /// Creates a rotation axis from the specified double-precision values. + /// + /// - Parameters: + /// - x: A double-precision value that specifies the x-coordinate value. + /// - y: A double-precision value that specifies the y-coordinate value. + /// - z: A double-precision value that specifies the z-coordinate value. + @inline(__always) + public init(x: Double, y: Double, z: Double) { + self.x = x + self.y = y + self.z = z + } + + // MARK: - Checking characteristics + + /// The x-coordinate value. + public let x: Double + + /// The y-coordinate value. + public let y: Double + + /// The z-coordinate value. + public let z: Double + + /// A simd three-element vector that contains the x-, y-, and z-coordinate values. + public var vector: [Double] { [x, y, z] } + + /// A Boolean value that indicates whether the rotation axis is zero. + public var isZero: Bool { x == 0 && y == 0 && z == 0 } + + // MARK: - Constants + + /// The zero rotation axis. + public static let zero = RotationAxis3D() + + /// The x-axis rotation axis. + public static let x = RotationAxis3D(x: 1, y: 0, z: 0) + + /// The y-axis rotation axis. + public static let y = RotationAxis3D(x: 0, y: 1, z: 0) + + /// The z-axis rotation axis. + public static let z = RotationAxis3D(x: 0, y: 0, z: 1) + + static let xy = RotationAxis3D(x: 1, y: 1, z: 0) + static let yz = RotationAxis3D(x: 0, y: 1, z: 1) + static let xz = RotationAxis3D(x: 1, y: 0, z: 1) + static let xyz = RotationAxis3D(x: 1, y: 1, z: 1) +} + +extension RotationAxis3D { + + /// - Complexity: O(1) + public func isApproximatelyEqual( + to other: RotationAxis3D, tolerance: Double = Foundation.sqrt(.ulpOfOne) + ) -> Bool { + abs(x - other.x) <= tolerance && abs(y - other.y) <= tolerance + && abs(z - other.z) <= tolerance + } +} + +extension RotationAxis3D: CustomStringConvertible { + + /// A textual representation of the rotation axis. + public var description: String { + return "(x: \(x), y: \(y), z: \(z))" + } +} + +extension RotationAxis3D: ExpressibleByArrayLiteral { + + /// Creates a rotation axis from the specified array literal. + /// + /// - Parameter elements: An array of double-precision values. + @inline(__always) + public init(arrayLiteral elements: Double...) { + precondition(elements.count == 3, "Invalid array literal for \(Self.self)") + self.init(x: elements[0], y: elements[1], z: elements[2]) + } +} diff --git a/Sources/OpenSpatial/3D primitives/ScaledPose3D.swift b/Sources/OpenSpatial/3D primitives/ScaledPose3D.swift new file mode 100644 index 0000000..671e913 --- /dev/null +++ b/Sources/OpenSpatial/3D primitives/ScaledPose3D.swift @@ -0,0 +1,428 @@ + +// ScaledPose3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A structure that contains a position, rotation, and scale. +@frozen +public struct ScaledPose3D: Codable, Equatable, Hashable, Sendable { + + // MARK: - Stored properties + + /// The position + public var position: Point3D + + /// The rotation + public var rotation: Rotation3D + + /// The uniform scale + public var scale: Double + + // MARK: - Creating a ScaledPose3D + + @inline(__always) + public init() { + self.position = .zero + self.rotation = Rotation3D() + self.scale = 1.0 + } + + /// Creates a scaled pose from Spatial primitives that describe the position, rotation, and scale. + /// + /// - Parameter position: The position of the scaled pose. + /// - Parameter rotation: The rotation of the pose. + /// - Parameter scale: The uniform scale of the scaled pose. + @inline(__always) + public init(position: Point3D, rotation: Rotation3D, scale: Double) { + self.position = position + self.rotation = rotation + self.scale = scale + } +} + +extension ScaledPose3D { + + /// Returns a new pose that's constructed by concatenating two existing poses. + /// + /// - Parameter lhs: The first value. + /// - Parameter rhs: The second value. + @inlinable public static func * (lhs: ScaledPose3D, rhs: Pose3D) -> ScaledPose3D { + lhs.concatenating(rhs) + } + + /// Returns a new scaled pose that's constructed by concatenating two existing poses. + /// + /// - Parameter lhs: The first value. + /// - Parameter rhs: The second value. + @inlinable public static func * (lhs: ScaledPose3D, rhs: ScaledPose3D) -> ScaledPose3D { + lhs.concatenating(rhs) + } + + /// Returns a new scaled pose that's constructed by concatenating a pose and a scaled pose. + /// + /// - Parameter lhs: The first value. + /// - Parameter rhs: The second value. + @inlinable public static func * (lhs: Pose3D, rhs: ScaledPose3D) -> ScaledPose3D { + ScaledPose3D(position: lhs.position, rotation: lhs.rotation, scale: 1.0).concatenating(rhs) + } + + /// Calculates the concatenation of scaled poses and stores the result in the left-hand-side variable. + /// + /// - Parameter lhs: The first value. + /// - Parameter rhs: The second value. + @inlinable public static func *= (lhs: inout ScaledPose3D, rhs: ScaledPose3D) { + lhs = lhs * rhs + } +} + +extension ScaledPose3D { + + /// Creates a pose from double-precision simd primitives that describe the position, rotation, and scale. + /// + /// - Parameter position: The position of the scaled pose. + /// - Parameter rotation: The rotation of the scaled pose. + /// - Parameter scale: The uniform scale of the scaled pose. + @inlinable public init(position: Point3D = .zero, rotation: Quaternion3D, scale: Double = 1) { + self.position = position + self.rotation = Rotation3D(quaternion: rotation) + self.scale = scale + } + + /// Creates a scaled pose at the specified position that's oriented towards a look at target. + /// + /// - Parameter position: The position of the scaled pose. + /// - Parameter target: The point that the scaled pose looks at. + /// - Parameter scale: The uniform scale of the scaled pose. + /// - Parameter up: The up direction. + /// + /// - note: This function creates a scaled pose where `+z` is forward. + @inlinable public init( + position: Point3D = .zero, target: Point3D, scale: Double = 1, + up: Vector3D = Vector3D(x: 0, y: 1, z: 0) + ) { + self.position = position + self.rotation = Rotation3D(position: position, target: target, up: up) + self.scale = scale + } + + /// Creates a scaled pose with the specified forward and up vectors. + /// + /// - Parameter forward: The forward direction. + /// - Parameter scale: The uniform scale of the scaled pose. + /// - Parameter up: The up direction. + /// + /// - note: This function creates a scaled pose where `+z` is forward. + @inlinable public init( + forward: Vector3D, scale: Double = 1, up: Vector3D = Vector3D(x: 0, y: 1, z: 0) + ) { + self.position = .zero + self.rotation = Rotation3D(forward: forward, up: up) + self.scale = scale + } + + /// Creates a scaled pose with with a position, rotation, and scale that are defined by an affine transform. + /// + /// - Parameter transform: The source transform. The function only considers the transform's + /// rotation and translation components. + /// - Returns: A pose with a position, rotation, and scale that are defined by an affine transform. + /// + /// - note: This function can't extract rotation from a non-scale-rotate-translate affine transform. In that case, + /// the function returns `nil`. If the specified ``AffineTransform3D`` doesn't have uniform + /// scale, the function returns `nil`. + public init?(transform: AffineTransform3D) { + self.init(transform.matrix) + } + + /// Creates a pose with with a position, rotation, and scale that are defined by a projective transform. + /// + /// - Parameter transform: The source transform. The function only considers the transform's + /// rotation and translation components. + /// - Returns: A pose with a position, rotation, and scale that are defined by a projective transform. + /// + /// - note: This function can't extract rotation from a non-scale-rotate-translate affine transform. In that case, + /// the function returns `nil`. If the specified ``ProjectiveTransform3D`` doesn't have uniform + /// scale, the function returns `nil`. + public init?(transform: ProjectiveTransform3D) { + self.init(transform.matrix) + } + + /// Creates a scaled pose with the specified double-precision 4 x 4 matrix + /// + /// - Parameter matrix: The source matrix + /// + /// - note: This function can't extract rotation from a non-scale-rotate-translate affine transform. In that case, + /// the function returns `nil`. If the specified matrix doesn't have uniform scale the function returns `nil`. + public init?(_ matrix: [[Double]]) { + guard let (position, rotation, scale) = decomposeSRT(matrix) else { return nil } + self.position = position + self.rotation = rotation + self.scale = scale + } + + /// Creates a scaled pose with the specified single-precision 4 x 4 matrix + /// + /// - Parameter matrix: The source matrix + /// + /// - note: This function can't extract rotation from a non-scale-rotate-translate affine transform. In that case, + /// the function returns `nil`. If the specified matrix doesn't have uniform scale the function returns `nil`. + public init?(_ matrix: [[Float]]) { + let doubleMatrix = matrix.map { $0.map { Double($0) } } + self.init(doubleMatrix) + } + + /// A 4 x 4 matrix that represents the scaled pose's scale, rotation, and translation. + @inlinable public var matrix: [[Double]] { + let q = rotation.quaternion + let s = scale + + let m00 = s * (1 - 2 * (q.y * q.y + q.z * q.z)) + let m01 = s * 2 * (q.x * q.y + q.z * q.w) + let m02 = s * 2 * (q.x * q.z - q.y * q.w) + + let m10 = s * 2 * (q.x * q.y - q.z * q.w) + let m11 = s * (1 - 2 * (q.x * q.x + q.z * q.z)) + let m12 = s * 2 * (q.y * q.z + q.x * q.w) + + let m20 = s * 2 * (q.x * q.z + q.y * q.w) + let m21 = s * 2 * (q.y * q.z - q.x * q.w) + let m22 = s * (1 - 2 * (q.x * q.x + q.y * q.y)) + + return [ + [m00, m01, m02, 0], + [m10, m11, m12, 0], + [m20, m21, m22, 0], + [position.x, position.y, position.z, 1], + ] + } + + /// The inverse of the scaled pose's underlying matrix. + @inlinable public var inverse: ScaledPose3D { + let invScale = scale == 0 ? 0 : 1.0 / scale + let invRotation = rotation.inverse + let invPos = invRotation.act(Vector3D(position) * (-invScale)) + return ScaledPose3D(position: Point3D(invPos), rotation: invRotation, scale: invScale) + } + + /// Returns a transform that's constructed by concatenating two existing scaled poses. + /// + /// - Parameter transform: The second scaled pose. + @inlinable public func concatenating(_ transform: ScaledPose3D) -> ScaledPose3D { + let newScale = scale * transform.scale + let combinedRotation = Rotation3D( + quaternion: rotation.quaternion * transform.rotation.quaternion) + let newPosition = Point3D( + rotation.act(Vector3D(transform.position) * scale) + Vector3D(position) + ) + return ScaledPose3D(position: newPosition, rotation: combinedRotation, scale: newScale) + } + + /// Returns a transform that's constructed by concatenating two a scaled pose and a pose. + /// + /// - Parameter transform: The second pose. + @inlinable public func concatenating(_ transform: Pose3D) -> ScaledPose3D { + let rhs = ScaledPose3D( + position: transform.position, rotation: transform.rotation, scale: 1.0) + return concatenating(rhs) + } + + /// The identity scaled pose. + @inlinable public static var identity: ScaledPose3D { + ScaledPose3D() + } + + /// Returns the scaled pose flipped along the specified axis. + /// + /// - Parameter axis: The axis of the flip operation. + /// - Complexity: O(1) + @inlinable public func flipped(along axis: Axis3D) -> ScaledPose3D { + var p = position + if axis.contains(.x) { p.x = -p.x } + if axis.contains(.y) { p.y = -p.y } + if axis.contains(.z) { p.z = -p.z } + return ScaledPose3D(position: p, rotation: rotation, scale: scale) + } + + /// Flips the scaled pose along the specified axis. + /// + /// - Parameter axis: The axis of the flip operation. + public mutating func flip(along axis: Axis3D) { + self = flipped(along: axis) + } +} + +extension ScaledPose3D: Translatable3D { + + /// Returns the entity translated by the specified vector. + /// + /// - Parameter vector: The vector that defines that translation. + /// - Complexity: O(1) + @inlinable public func translated(by offset: Vector3D) -> ScaledPose3D { + ScaledPose3D(position: position + offset, rotation: rotation, scale: scale) + } +} + +extension ScaledPose3D: Rotatable3D { + + /// Returns a pose with a rotation that's rotated by the specified rotation. + /// + /// - Parameter rotation: The rotation. + /// - Returns A scaled pose with a rotation that's rotated by the specified rotation. + /// - Complexity: O(1) + @inlinable public func rotated(by rotation: Rotation3D) -> ScaledPose3D { + ScaledPose3D( + position: position, + rotation: Rotation3D(quaternion: self.rotation.quaternion * rotation.quaternion), + scale: scale + ) + } + + /// Returns a scaled pose with a rotation that's rotated by the specified quaternion. + /// + /// - Parameter quaternion: The quaternion that defines the rotation. + /// - Returns A scaled pose with a rotation that's rotated by the specified quaternion. + /// - Complexity: O(1) + @inlinable public func rotated(by quaternion: Quaternion3D) -> ScaledPose3D { + ScaledPose3D( + position: position, + rotation: Rotation3D(quaternion: rotation.quaternion * quaternion), + scale: scale + ) + } +} + +extension ScaledPose3D { + + /// Returns a Boolean value that indicates whether two scaled poses are equal within a specified tolerance. + /// + /// - Parameter other: The second scaled pose. + /// - Parameter tolerance: The tolerance of the comparison. + /// - Complexity: O(1) + @inlinable public func isApproximatelyEqual( + to other: ScaledPose3D, tolerance: Double = sqrt(.ulpOfOne) + ) -> Bool { + let q1 = rotation.quaternion + let q2 = other.rotation.quaternion + let quatApprox = + (abs(q1.x - q2.x) <= tolerance && abs(q1.y - q2.y) <= tolerance + && abs(q1.z - q2.z) <= tolerance && abs(q1.w - q2.w) <= tolerance) + || (abs(q1.x + q2.x) <= tolerance && abs(q1.y + q2.y) <= tolerance + && abs(q1.z + q2.z) <= tolerance && abs(q1.w + q2.w) <= tolerance) + return position.isApproximatelyEqual(to: other.position, tolerance: tolerance) && quatApprox + && abs(scale - other.scale) <= tolerance + } + + /// Returns true if the scaled pose is the identity pose. + @inlinable public var isIdentity: Bool { + position == .zero && rotation.isIdentity && scale == 1.0 + } +} + +extension ScaledPose3D: CustomStringConvertible { + + /// A textual representation of this instance. + /// + /// Calling this property directly is discouraged. Instead, convert an + /// instance of any type to a string by using the `String(describing:)` + /// initializer. This initializer works with any type, and uses the custom + /// `description` property for types that conform to + /// `CustomStringConvertible`: + /// + /// struct Point: CustomStringConvertible { + /// let x: Int, y: Int + /// + /// var description: String { + /// return "(\(x), \(y))" + /// } + /// } + /// + /// let p = Point(x: 21, y: 30) + /// let s = String(describing: p) + /// print(s) + /// // Prints "(21, 30)" + /// + /// The conversion of `p` to a string in the assignment to `s` uses the + /// `Point` type's `description` property. + public var description: String { + "(position: \(position), rotation: \(rotation), scale: \(scale))" + } +} + +// MARK: - Private helpers + +/// Decomposes a 4×4 column-major SRT matrix into position, rotation, and uniform scale. +/// Returns nil if the matrix doesn't represent a uniform-scale SRT transform. +internal func decomposeSRT(_ m: [[Double]]) -> ( + position: Point3D, rotation: Rotation3D, scale: Double +)? { + guard m.count == 4 && m.allSatisfy({ $0.count == 4 }) else { return nil } + + let col0 = Vector3D(x: m[0][0], y: m[0][1], z: m[0][2]) + let col1 = Vector3D(x: m[1][0], y: m[1][1], z: m[1][2]) + let col2 = Vector3D(x: m[2][0], y: m[2][1], z: m[2][2]) + + let sx = col0.length + let sy = col1.length + let sz = col2.length + + let tolerance = Foundation.sqrt(.ulpOfOne) + guard abs(sx - sy) <= tolerance && abs(sy - sz) <= tolerance else { return nil } + + let scale = (sx + sy + sz) / 3.0 + + guard scale > 0 else { return nil } + + let invScale = 1.0 / scale + let rotMatrix = [ + [col0.x * invScale, col0.y * invScale, col0.z * invScale, 0.0], + [col1.x * invScale, col1.y * invScale, col1.z * invScale, 0.0], + [col2.x * invScale, col2.y * invScale, col2.z * invScale, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + + let trace = rotMatrix[0][0] + rotMatrix[1][1] + rotMatrix[2][2] + let q: Quaternion3D + if trace > 0 { + let s = 0.5 / Foundation.sqrt(trace + 1.0) + q = Quaternion3D( + x: (rotMatrix[1][2] - rotMatrix[2][1]) * s, + y: (rotMatrix[2][0] - rotMatrix[0][2]) * s, + z: (rotMatrix[0][1] - rotMatrix[1][0]) * s, + w: 0.25 / s + ) + } else if rotMatrix[0][0] > rotMatrix[1][1] && rotMatrix[0][0] > rotMatrix[2][2] { + let s = 2.0 * Foundation.sqrt(1.0 + rotMatrix[0][0] - rotMatrix[1][1] - rotMatrix[2][2]) + q = Quaternion3D( + x: 0.25 * s, + y: (rotMatrix[0][1] + rotMatrix[1][0]) / s, + z: (rotMatrix[2][0] + rotMatrix[0][2]) / s, + w: (rotMatrix[1][2] - rotMatrix[2][1]) / s + ) + } else if rotMatrix[1][1] > rotMatrix[2][2] { + let s = 2.0 * Foundation.sqrt(1.0 + rotMatrix[1][1] - rotMatrix[0][0] - rotMatrix[2][2]) + q = Quaternion3D( + x: (rotMatrix[0][1] + rotMatrix[1][0]) / s, + y: 0.25 * s, + z: (rotMatrix[1][2] + rotMatrix[2][1]) / s, + w: (rotMatrix[2][0] - rotMatrix[0][2]) / s + ) + } else { + let s = 2.0 * Foundation.sqrt(1.0 + rotMatrix[2][2] - rotMatrix[0][0] - rotMatrix[1][1]) + q = Quaternion3D( + x: (rotMatrix[2][0] + rotMatrix[0][2]) / s, + y: (rotMatrix[1][2] + rotMatrix[2][1]) / s, + z: 0.25 * s, + w: (rotMatrix[0][1] - rotMatrix[1][0]) / s + ) + } + + let position = Point3D(x: m[3][0], y: m[3][1], z: m[3][2]) + let rotation = Rotation3D(quaternion: q) + return (position, rotation, scale) +} diff --git a/Sources/OpenSpatial/3D primitives/Size3D.swift b/Sources/OpenSpatial/3D primitives/Size3D.swift new file mode 100644 index 0000000..3a5468e --- /dev/null +++ b/Sources/OpenSpatial/3D primitives/Size3D.swift @@ -0,0 +1,377 @@ + +// Size3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A size that describes width, height, and depth in a 3D coordinate system. +@frozen public struct Size3D: Codable, Copyable, Equatable, Hashable, Sendable { + + // MARK: - Creating a 3D size structure + + /// Creates a size structure. + @inline(__always) + public init() { + self.width = 0.0 + self.height = 0.0 + self.depth = 0.0 + } + + /// Creates a size structure from the specified double-precision values. + /// + /// - Parameters: + /// - width: A double-precision value that specifies the width. + /// - height: A double-precision value that specifies the height. + /// - depth: A double-precision value that specifies the depth. + @inline(__always) + public init(width: Double = 0, height: Double = 0, depth: Double = 0) { + self.width = width + self.height = height + self.depth = depth + } + + /// Creates a size structure from the specified floating-point values. + /// + /// - Parameters: + /// - width: A floating-point value that specifies the width. + /// - height: A floating-point value that specifies the height. + /// - depth: A floating-point value that specifies the depth. + @inline(__always) + public init(width: T, height: T, depth: T) where T: BinaryFloatingPoint { + self.width = Double(width) + self.height = Double(height) + self.depth = Double(depth) + } + + /// Creates a size structure from the specified vector. + /// + /// - Parameter xyz: A vector that contains the width, height, and depth values. + @inline(__always) + public init(_ xyz: Vector3D) { + (width, height, depth) = (xyz.x, xyz.y, xyz.z) + } + + // MARK: - Inspecting a 3D size’s properties + + /// The width value. + public var width: Double + + /// The height value. + public var height: Double + + /// The depth value. + public var depth: Double + + /// Accesses the width, height, or depth value at the specified index. + /// + /// - Parameter index: The index of the value to access. Valid indices are 0, 1, and 2. + /// - Returns: The width, height, or depth value at the specified index. + /// - Complexity: O(1) + @inline(__always) + public subscript(index: Int) -> Double { + get throws { + switch index { + case 0: return width + case 1: return height + case 2: return depth + default: + throw Error.outOfRage + } + } + } + + /// The size expressed as an array of width, height, and depth values. + public var vector: [Double] { [width, height, depth] } + + // MARK: - 3D size constants + + /// The size structure with width, height, and depth values of one. + public static let one = Size3D(width: 1, height: 1, depth: 1) +} + +extension Size3D: ExpressibleByArrayLiteral { + + /// Creates an instance initialized with the given elements. + /// + /// - Parameter elements: The elements of the array literal. + @inline(__always) + public init(arrayLiteral elements: Double...) { + precondition(elements.count == 3, "Array literal must contain exactly three elements.") + (width, height, depth) = (elements[0], elements[1], elements[2]) + } +} + +extension Size3D: AdditiveArithmetic { + + // MARK: - Applying arithmetic operations + + /// The zero size. + public static let zero = Size3D() + + /// Returns a size that’s the product of a size and a scalar value. + /// + /// - Parameters: + /// - lhs: The left-hand-side value. + /// - rhs: The right-hand-side scalar value. + /// - Returns: The product of the size and the scalar. + /// - Complexity: O(1) + @inline(__always) + public static func * (lhs: Size3D, rhs: Double) -> Size3D { + .init(width: lhs.width * rhs, height: lhs.height * rhs, depth: lhs.depth * rhs) + } + + /// Multiplies a size and a double-precision value, and stores the result in the left-hand-side variable. + /// + /// - Parameters: + /// - lhs: The left-hand-side value. + /// - rhs: The right-hand-side value. + /// - Complexity: O(1) + @inline(__always) + public static func *= (lhs: inout Size3D, rhs: Double) { + lhs = lhs * rhs + } + + /// Adds two sizes and returns the result. + /// + /// - Parameters: + /// - lhs: The first size. + /// - rhs: The second size. + /// - Returns: The sum of the two sizes. + /// - Complexity: O(1) + @inline(__always) + public static func + (lhs: Size3D, rhs: Size3D) -> Size3D { + .init( + width: lhs.width + rhs.width, height: lhs.height + rhs.height, + depth: lhs.depth + rhs.depth) + } + + /// Adds the second size to the first size and stores the result in the first size. + /// + /// - Parameters: + /// - lhs: The first size. + /// - rhs: The second size. + /// - Complexity: O(1) + @inline(__always) + public static func += (lhs: inout Size3D, rhs: Size3D) { + lhs = lhs + rhs + } + + /// Subtracts one size from another and returns the result. + /// + /// - Parameters: + /// - lhs: The first size. + /// - rhs: The second size. + /// - Returns: The difference of the two sizes. + /// - Complexity: O(1) + @inline(__always) + public static func - (lhs: Size3D, rhs: Size3D) -> Size3D { + .init( + width: lhs.width - rhs.width, height: lhs.height - rhs.height, + depth: lhs.depth - rhs.depth) + } + + /// Subtracts the second size from the first size and stores the result in the first size. + /// + /// - Parameters: + /// - lhs: The first size. + /// - rhs: The second size. + /// - Complexity: O(1) + @inline(__always) + public static func -= (lhs: inout Size3D, rhs: Size3D) { + lhs = lhs - rhs + } + + /// Returns a size with each element divided by a scalar value. + /// + /// - Parameters: + /// - lhs: The left-hand-side value. + /// - rhs: The right-hand-side value. + /// - Returns: The resulting size. + /// - Complexity: O(1) + @inline(__always) + public static func / (lhs: Size3D, rhs: Double) -> Size3D { + .init(width: lhs.width / rhs, height: lhs.height / rhs, depth: lhs.depth / rhs) + } + + /// Divides each element of the size by a scalar value and stores the result in the left-hand-side variable. + /// + /// - Parameters: + /// - lhs: The left-hand-side value. + /// - rhs: The right-hand-side value. + /// - Complexity: O(1) + @inline(__always) + public static func /= (lhs: inout Size3D, rhs: Double) { + lhs = lhs / rhs + } +} + +extension Size3D: Primitive3D { + // MARK: - Instance properties + + /// A Boolean value that indicates whether the vector is finite. + public var isFinite: Bool { + width.isFinite && height.isFinite && depth.isFinite + } + + /// A Boolean value that indicates whether the vector contains any NaN values. + public var isNaN: Bool { + width.isNaN || height.isNaN || depth.isNaN + } + + /// A Boolean value that indicates whether the vector is zero. + public var isZero: Bool { + width == 0 && height == 0 && depth == 0 + } + + // MARK: - Type properties + + /// A size with infinite values. + public static var infinity: Size3D { + .init(width: .infinity, height: .infinity, depth: .infinity) + } + + // MARK: - Transforming primitives + + /// Applies an affine transform. + /// + /// - Parameter transform: The affine transform to apply. + /// - Returns: A new transformed size. + /// - Complexity: O(1) + public func applying(_ transform: AffineTransform3D) -> Size3D { + Size3D( + width: width * transform[0, 0], + height: height * transform[1, 1], + depth: depth * transform[2, 2] + ) + } +} + +extension Size3D: Scalable3D { + + /// Returns a new entity scaled by the specified size. + /// + /// - Parameter size: A size that contains the scale factors for each axis. + /// - Returns: A new scaled entity. + /// - Complexity: O(1) + @inline(__always) + public func scaled(by size: Size3D) -> Size3D { + .init( + width: width * size.width, + height: height * size.height, + depth: depth * size.depth) + } + + /// Returns a new entity scaled uniformly by the specified factor. + /// + /// - Parameter scale: A double-precision value that specifies the uniform scale factor. + /// - Returns: A new scaled entity. + /// - Complexity: O(1) + @inline(__always) + public func uniformlyScaled(by scale: Double) -> Size3D { + self * scale + } +} + +extension Size3D: Volumetric { + + // MARK: - Instance methods + + /// The size of the volume. + @inline(__always) + public var size: Size3D { + self + } + + // MARK: - Checking Characteristics + + /// Returns a Boolean value that indicates whether the entity contains the specified volumetric entity. + /// + /// - Parameter other: The volumetric entity that the function compares against. + /// - Returns: A Boolean value that indicates whether the entity contains the specified volumetric entity + /// - Complexity: O(1) + @inline(__always) + public func contains(_ other: Size3D) -> Bool { + other.width <= width && other.height <= height && other.depth <= depth + } + + /// Returns a Boolean value that indicates whether this volume contains the specified point. + /// + /// - Parameter point: The point that the function compares against. + /// - Returns: A Boolean value that indicates whether this volume contains the specified point. + /// - Complexity: O(1) + @inline(__always) + public func contains(point: Point3D) -> Bool { + point.x >= 0 && point.x <= width && point.y >= 0 && point.y <= height && point.z >= 0 + && point.z <= depth + } + + // MARK: - Creating derived 3D sizes + + /// Returns the intersection of two sizes. + /// + /// - Parameter other: The size that the function compares against. + /// - Returns: A new size that is the intersection of two sizes. + /// - Complexity: O(1) + @inline(__always) + public func intersection(_ other: Size3D) -> Size3D? { + let newWidth = min(self.width, other.width) + let newHeight = min(self.height, other.height) + let newDepth = min(self.depth, other.depth) + + if newWidth >= 0 && newHeight >= 0 && newDepth >= 0 { + return Size3D(width: newWidth, height: newHeight, depth: newDepth) + } else { + return nil + } + } + + /// Returns the union of this primitive and the specified primitive. + /// + /// - Parameter other: The primitive to union with. + /// - Returns: The union of this primitive and the specified primitive. + /// - Complexity: O(1) + @inline(__always) + public func union(_ other: Size3D) -> Size3D { + let newWidth = max(self.width, other.width) + let newHeight = max(self.height, other.height) + let newDepth = max(self.depth, other.depth) + + return Size3D(width: newWidth, height: newHeight, depth: newDepth) + } +} + +extension Size3D: Shearable3D { + + /// Returns a sheared size. + /// + /// - Parameter shear: The axis and shear factors. + /// - Returns: The sheared size. + /// - Complexity: O(1) + @inline(__always) + public func sheared(_ shear: AxisWithFactors) -> Size3D { + switch shear { + case .xAxis(let ky, let kz): + return Size3D(width: width + ky * height + kz * depth, height: height, depth: depth) + case .yAxis(let kx, let kz): + return Size3D(width: width, height: height + kx * width + kz * depth, depth: depth) + case .zAxis(let kx, let ky): + return Size3D(width: width, height: height, depth: depth + kx * width + ky * height) + } + } +} + +extension Size3D: CustomStringConvertible { + + // MARK: - CustomStringConvertible + + /// A textual representation of the size. + public var description: String { + "(width: \(width), height: \(height), depth: \(depth))" + } +} diff --git a/Sources/OpenSpatial/3D primitives/SphericalCoordinates3D.swift b/Sources/OpenSpatial/3D primitives/SphericalCoordinates3D.swift new file mode 100644 index 0000000..1e0424b --- /dev/null +++ b/Sources/OpenSpatial/3D primitives/SphericalCoordinates3D.swift @@ -0,0 +1,103 @@ + +// SphericalCoordinates3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A position in 3D space expressed in spherical coordinates. +/// +/// The coordinate system uses: +/// - `radius`: distance from the origin +/// - `inclination`: polar angle from the +y axis, in the range `0…π` +/// - `azimuth`: azimuthal angle in the xz-plane from +x, in the range `-π…π` +@frozen +public struct SphericalCoordinates3D: Codable, Equatable, Hashable, Sendable { + + // MARK: - Stored properties + + /// The radial distance from the origin. + public var radius: Double + + /// The polar angle measured from the +y axis, in the range `0…π`. + public var inclination: Angle2D + + /// The azimuthal angle in the xz-plane measured from +x, in the range `-π…π`. + public var azimuth: Angle2D + + // MARK: - Creating SphericalCoordinates3D + + /// Creates a spherical coordinate with the specified components. + /// + /// - Parameters: + /// - radius: The radial distance from the origin. + /// - inclination: The polar angle from the +y axis. + /// - azimuth: The azimuthal angle from +x in the xz-plane. + public init(radius: Double, inclination: Angle2D, azimuth: Angle2D) { + self.radius = radius + self.inclination = inclination + self.azimuth = azimuth + } + + /// Creates a spherical coordinate from a Cartesian point. + /// + /// - Parameter point: The Cartesian point to convert. + @inline(__always) + public init(_ point: Point3D) { + let r = Foundation.sqrt(point.x * point.x + point.y * point.y + point.z * point.z) + self.radius = r + if r == 0 { + self.inclination = Angle2D(radians: 0) + self.azimuth = Angle2D(radians: 0) + } else { + self.inclination = Angle2D(radians: Foundation.acos(point.y / r)) + self.azimuth = Angle2D(radians: Foundation.atan2(point.z, point.x)) + } + } + + // MARK: - Converting to Cartesian + + /// The Cartesian representation of this spherical coordinate. + public var point: Point3D { + let sinInc = Foundation.sin(inclination.radians) + let cosInc = Foundation.cos(inclination.radians) + let sinAz = Foundation.sin(azimuth.radians) + let cosAz = Foundation.cos(azimuth.radians) + return Point3D( + x: radius * sinInc * cosAz, + y: radius * cosInc, + z: radius * sinInc * sinAz + ) + } + + // MARK: - Approximate equality + + /// Returns a Boolean value that indicates whether this coordinate is approximately + /// equal to another within a specified tolerance. + /// + /// - Parameters: + /// - other: The coordinate to compare. + /// - tolerance: The maximum allowed difference per component. + /// - Returns: `true` if all components are within tolerance. + /// - Complexity: O(1) + public func isApproximatelyEqual( + to other: SphericalCoordinates3D, tolerance: Double = Foundation.sqrt(.ulpOfOne) + ) -> Bool { + Swift.abs(radius - other.radius) <= tolerance + && inclination.isApproximatelyEqual(to: other.inclination, tolerance: tolerance) + && azimuth.isApproximatelyEqual(to: other.azimuth, tolerance: tolerance) + } +} + +extension SphericalCoordinates3D: CustomStringConvertible { + + /// A textual representation of the spherical coordinate. + public var description: String { + "(radius: \(radius), inclination: \(inclination.radians), azimuth: \(azimuth.radians))" + } +} diff --git a/Sources/OpenSpatial/Affine and projective transforms/AffineTransform3D.swift b/Sources/OpenSpatial/Affine and projective transforms/AffineTransform3D.swift new file mode 100644 index 0000000..48a0850 --- /dev/null +++ b/Sources/OpenSpatial/Affine and projective transforms/AffineTransform3D.swift @@ -0,0 +1,380 @@ + +// AffineTransform3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A 3D affine transformation matrix. +@frozen +public struct AffineTransform3D: Copyable, Equatable, Hashable, Sendable { + + // MARK: - Flat storage (row-major: _mRC = row R, column C) + + var _m00: Double; var _m01: Double; var _m02: Double; var _m03: Double + var _m10: Double; var _m11: Double; var _m12: Double; var _m13: Double + var _m20: Double; var _m21: Double; var _m22: Double; var _m23: Double + var _m30: Double; var _m31: Double; var _m32: Double; var _m33: Double + + // MARK: - Creating a 3D affine transform structure + + @inline(__always) + public static var identity: AffineTransform3D { .init() } + + /// Returns a new identity affine transform. + @inline(__always) + public init() { + _m00 = 1; _m01 = 0; _m02 = 0; _m03 = 0 + _m10 = 0; _m11 = 1; _m12 = 0; _m13 = 0 + _m20 = 0; _m21 = 0; _m22 = 1; _m23 = 0 + _m30 = 0; _m31 = 0; _m32 = 0; _m33 = 1 + } + + /// Creates an affine transform from the specified double-precision matrix. + /// + /// - Parameter matrix: The source double-precision matrix. + @inline(__always) + public init(matrix: [[Double]]) { + _m00 = matrix[0][0]; _m01 = matrix[0][1]; _m02 = matrix[0][2]; _m03 = matrix[0][3] + _m10 = matrix[1][0]; _m11 = matrix[1][1]; _m12 = matrix[1][2]; _m13 = matrix[1][3] + _m20 = matrix[2][0]; _m21 = matrix[2][1]; _m22 = matrix[2][2]; _m23 = matrix[2][3] + _m30 = matrix[3][0]; _m31 = matrix[3][1]; _m32 = matrix[3][2]; _m33 = matrix[3][3] + } + + /// Creates an affine transform that represents the specified rotation. + /// + /// - Parameter rotation: The rotation to encode. + public init(rotation: Rotation3D) { + let q = rotation.quaternion + let x = q.x; let y = q.y; let z = q.z; let w = q.w + _m00 = 1 - 2 * (y * y + z * z); _m01 = 2 * (x * y + w * z); _m02 = 2 * (x * z - w * y); _m03 = 0 + _m10 = 2 * (x * y - w * z); _m11 = 1 - 2 * (x * x + z * z); _m12 = 2 * (y * z + w * x); _m13 = 0 + _m20 = 2 * (x * z + w * y); _m21 = 2 * (y * z - w * x); _m22 = 1 - 2 * (x * x + y * y); _m23 = 0 + _m30 = 0; _m31 = 0; _m32 = 0; _m33 = 1 + } + + /// Creates an affine transform that represents the specified scale. + /// + /// - Parameter scale: The scale factors for each axis. + @inline(__always) + public init(scale: Size3D) { + _m00 = scale.width; _m01 = 0; _m02 = 0; _m03 = 0 + _m10 = 0; _m11 = scale.height; _m12 = 0; _m13 = 0 + _m20 = 0; _m21 = 0; _m22 = scale.depth; _m23 = 0 + _m30 = 0; _m31 = 0; _m32 = 0; _m33 = 1 + } + + /// Creates an affine transform that represents the specified translation. + /// + /// - Parameter translation: The translation vector. + @inline(__always) + public init(translation: Vector3D) { + _m00 = 1; _m01 = 0; _m02 = 0; _m03 = 0 + _m10 = 0; _m11 = 1; _m12 = 0; _m13 = 0 + _m20 = 0; _m21 = 0; _m22 = 1; _m23 = 0 + _m30 = translation.x; _m31 = translation.y; _m32 = translation.z; _m33 = 1 + } + + /// Creates an affine transform that composes rotation, scale, and translation. + /// + /// - Parameters: + /// - rotation: The rotation component. + /// - scale: The scale component. + /// - translation: The translation component. + public init(rotation: Rotation3D, scale: Size3D, translation: Vector3D) { + self = AffineTransform3D(rotation: rotation) + .scaled(by: scale) + .translated(by: translation) + } + + // MARK: - Checking characteristics + + /// The affine transform's underlying matrix (API-compatible with Apple Spatial framework). + public var matrix: [[Double]] { + [ + [_m00, _m01, _m02, _m03], + [_m10, _m11, _m12, _m13], + [_m20, _m21, _m22, _m23], + [_m30, _m31, _m32, _m33], + ] + } + + /// Accesses the element at the specified row and column. + /// + /// - Parameters: + /// - row: The row index. + /// - column: The column index. + /// - Returns: The element at the specified row and column. + public subscript(row: Int, column: Int) -> Double { + get { + switch (row, column) { + case (0, 0): return _m00; case (0, 1): return _m01 + case (0, 2): return _m02; case (0, 3): return _m03 + case (1, 0): return _m10; case (1, 1): return _m11 + case (1, 2): return _m12; case (1, 3): return _m13 + case (2, 0): return _m20; case (2, 1): return _m21 + case (2, 2): return _m22; case (2, 3): return _m23 + case (3, 0): return _m30; case (3, 1): return _m31 + case (3, 2): return _m32; case (3, 3): return _m33 + default: preconditionFailure("Index out of range") + } + } + set { + switch (row, column) { + case (0, 0): _m00 = newValue; case (0, 1): _m01 = newValue + case (0, 2): _m02 = newValue; case (0, 3): _m03 = newValue + case (1, 0): _m10 = newValue; case (1, 1): _m11 = newValue + case (1, 2): _m12 = newValue; case (1, 3): _m13 = newValue + case (2, 0): _m20 = newValue; case (2, 1): _m21 = newValue + case (2, 2): _m22 = newValue; case (2, 3): _m23 = newValue + case (3, 0): _m30 = newValue; case (3, 1): _m31 = newValue + case (3, 2): _m32 = newValue; case (3, 3): _m33 = newValue + default: preconditionFailure("Index out of range") + } + } + } + + /// A Boolean value that indicates whether this transform is the identity transform. + public var isIdentity: Bool { + self == AffineTransform3D() + } + + // MARK: - Decomposing a 3D affine transform + + /// The scale component extracted from the matrix (column magnitudes of the 3×3 upper-left block). + public var scale: Size3D { + let sx = Foundation.sqrt(_m00 * _m00 + _m10 * _m10 + _m20 * _m20) + let sy = Foundation.sqrt(_m01 * _m01 + _m11 * _m11 + _m21 * _m21) + let sz = Foundation.sqrt(_m02 * _m02 + _m12 * _m12 + _m22 * _m22) + return Size3D(width: sx, height: sy, depth: sz) + } + + /// The translation component extracted from the last row of the matrix. + public var translation: Vector3D { + Vector3D(x: _m30, y: _m31, z: _m32) + } + + /// The rotation component extracted from the matrix. + public var rotation: Rotation3D { + let s = scale + guard s.width > 0 && s.height > 0 && s.depth > 0 else { return Rotation3D() } + let r00 = _m00 / s.width; let r10 = _m10 / s.width; let r20 = _m20 / s.width + let r01 = _m01 / s.height; let r11 = _m11 / s.height; let r21 = _m21 / s.height + let r02 = _m02 / s.depth; let r12 = _m12 / s.depth; let r22 = _m22 / s.depth + let trace = r00 + r11 + r22 + let q: Quaternion3D + if trace > 0 { + let s2 = 0.5 / Foundation.sqrt(trace + 1.0) + q = Quaternion3D(x: (r12 - r21) * s2, y: (r20 - r02) * s2, z: (r01 - r10) * s2, w: 0.25 / s2) + } else if r00 > r11 && r00 > r22 { + let s2 = 2.0 * Foundation.sqrt(1.0 + r00 - r11 - r22) + q = Quaternion3D(x: 0.25 * s2, y: (r10 + r01) / s2, z: (r20 + r02) / s2, w: (r12 - r21) / s2) + } else if r11 > r22 { + let s2 = 2.0 * Foundation.sqrt(1.0 + r11 - r00 - r22) + q = Quaternion3D(x: (r10 + r01) / s2, y: 0.25 * s2, z: (r21 + r12) / s2, w: (r20 - r02) / s2) + } else { + let s2 = 2.0 * Foundation.sqrt(1.0 + r22 - r00 - r11) + q = Quaternion3D(x: (r20 + r02) / s2, y: (r21 + r12) / s2, z: 0.25 * s2, w: (r01 - r10) / s2) + } + return Rotation3D(quaternion: q) + } + + /// Returns the inverse of this transform, or nil if the matrix is singular. + /// + /// - Complexity: O(1) + public var inverse: AffineTransform3D? { + var m = matrix + var inv: [[Double]] = [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], + ] + for col in 0..<4 { + var pivotRow = col + var maxVal = Swift.abs(m[col][col]) + for row in (col + 1)..<4 { + let v = Swift.abs(m[row][col]) + if v > maxVal { maxVal = v; pivotRow = row } + } + guard maxVal > 1e-12 else { return nil } + if pivotRow != col { + m.swapAt(col, pivotRow) + inv.swapAt(col, pivotRow) + } + let pivot = m[col][col] + for j in 0..<4 { + m[col][j] /= pivot + inv[col][j] /= pivot + } + for row in 0..<4 where row != col { + let factor = m[row][col] + for j in 0..<4 { + m[row][j] -= factor * m[col][j] + inv[row][j] -= factor * inv[col][j] + } + } + } + return AffineTransform3D(matrix: inv) + } +} + +// MARK: - Codable + +extension AffineTransform3D: Codable { + + public init(from decoder: Decoder) throws { + let m = try [[Double]](from: decoder) + self.init(matrix: m) + } + + public func encode(to encoder: Encoder) throws { + try matrix.encode(to: encoder) + } +} + +extension AffineTransform3D: CustomStringConvertible { + + /// A textual representation of the affine transform. + public var description: String { + var rows: [String] = [] + for row in matrix { + let rowString = row.map { "\($0)" }.joined(separator: " ") + rows.append("[\(rowString)]") + } + return rows.joined(separator: "\n") + } +} + +extension AffineTransform3D { + + // MARK: - Applying arithmetic operations + + /// Returns the concatenation of two affine transforms. + /// + /// - Parameters: + /// - lhs: The left-hand-side value. + /// - rhs: The right-hand-side value. + /// - Returns: The concatenation of the two affine transforms. + /// - Note: The resulting transform is equivalent to applying `rhs` followed by `lhs`. + /// - Complexity: O(1) + public static func * (lhs: AffineTransform3D, rhs: AffineTransform3D) -> AffineTransform3D { + var result = AffineTransform3D() + result._m00 = lhs._m00 * rhs._m00 + lhs._m01 * rhs._m10 + lhs._m02 * rhs._m20 + lhs._m03 * rhs._m30 + result._m01 = lhs._m00 * rhs._m01 + lhs._m01 * rhs._m11 + lhs._m02 * rhs._m21 + lhs._m03 * rhs._m31 + result._m02 = lhs._m00 * rhs._m02 + lhs._m01 * rhs._m12 + lhs._m02 * rhs._m22 + lhs._m03 * rhs._m32 + result._m03 = lhs._m00 * rhs._m03 + lhs._m01 * rhs._m13 + lhs._m02 * rhs._m23 + lhs._m03 * rhs._m33 + result._m10 = lhs._m10 * rhs._m00 + lhs._m11 * rhs._m10 + lhs._m12 * rhs._m20 + lhs._m13 * rhs._m30 + result._m11 = lhs._m10 * rhs._m01 + lhs._m11 * rhs._m11 + lhs._m12 * rhs._m21 + lhs._m13 * rhs._m31 + result._m12 = lhs._m10 * rhs._m02 + lhs._m11 * rhs._m12 + lhs._m12 * rhs._m22 + lhs._m13 * rhs._m32 + result._m13 = lhs._m10 * rhs._m03 + lhs._m11 * rhs._m13 + lhs._m12 * rhs._m23 + lhs._m13 * rhs._m33 + result._m20 = lhs._m20 * rhs._m00 + lhs._m21 * rhs._m10 + lhs._m22 * rhs._m20 + lhs._m23 * rhs._m30 + result._m21 = lhs._m20 * rhs._m01 + lhs._m21 * rhs._m11 + lhs._m22 * rhs._m21 + lhs._m23 * rhs._m31 + result._m22 = lhs._m20 * rhs._m02 + lhs._m21 * rhs._m12 + lhs._m22 * rhs._m22 + lhs._m23 * rhs._m32 + result._m23 = lhs._m20 * rhs._m03 + lhs._m21 * rhs._m13 + lhs._m22 * rhs._m23 + lhs._m23 * rhs._m33 + result._m30 = lhs._m30 * rhs._m00 + lhs._m31 * rhs._m10 + lhs._m32 * rhs._m20 + lhs._m33 * rhs._m30 + result._m31 = lhs._m30 * rhs._m01 + lhs._m31 * rhs._m11 + lhs._m32 * rhs._m21 + lhs._m33 * rhs._m31 + result._m32 = lhs._m30 * rhs._m02 + lhs._m31 * rhs._m12 + lhs._m32 * rhs._m22 + lhs._m33 * rhs._m32 + result._m33 = lhs._m30 * rhs._m03 + lhs._m31 * rhs._m13 + lhs._m32 * rhs._m23 + lhs._m33 * rhs._m33 + return result + } + + /// Concatenates two affine transforms and stores the result in the left-hand-side variable. + /// + /// - Parameters: + /// - lhs: The left-hand-side value. + /// - rhs: The right-hand-side value. + public static func *= (lhs: inout AffineTransform3D, rhs: AffineTransform3D) { + lhs = lhs * rhs + } +} + +extension AffineTransform3D: Scalable3D { + + // MARK: - Scaling transforms + + /// Returns a new affine transform scaled by the specified size. + /// + /// - Parameter size: A size that contains the scale factors for each axis. + /// - Returns: A new scaled affine transform. + /// - Complexity: O(1) + @inline(__always) + public func scaled(by size: Size3D) -> AffineTransform3D { + var scaleTransform = AffineTransform3D() + scaleTransform[0, 0] = size.width + scaleTransform[1, 1] = size.height + scaleTransform[2, 2] = size.depth + return self * scaleTransform + } + + /// Returns a new entity scaled uniformly by the specified factor. + /// + /// - Parameter scale: A double-precision value that specifies the uniform scale factor. + /// - Returns: A new scaled entity. + /// - Complexity: O(1) + @inline(__always) + public func uniformlyScaled(by scale: Double) -> AffineTransform3D { + self.scaled(by: Size3D(width: scale, height: scale, depth: scale)) + } +} + +extension AffineTransform3D: Translatable3D { + + // MARK: - Translating transforms + + /// Returns a new affine transform translated by the specified vector. + /// + /// - Parameter vector: A vector that contains the translation distances for each axis. + /// - Returns: A new translated affine transform. + /// - Complexity: O(1) + @inline(__always) + public func translated(by vector: Vector3D) -> AffineTransform3D { + var translationTransform = AffineTransform3D() + translationTransform[3, 0] = vector.x + translationTransform[3, 1] = vector.y + translationTransform[3, 2] = vector.z + return self * translationTransform + } +} + +extension AffineTransform3D: Rotatable3D { + + // MARK: - Rotating transforms + + /// Returns a new affine transform rotated by the specified quaternion. + /// + /// - Parameter quaternion: The quaternion to apply. + /// - Returns: A new rotated affine transform. + /// - Complexity: O(1) + public func rotated(by quaternion: Quaternion3D) -> AffineTransform3D { + self * AffineTransform3D(rotation: Rotation3D(quaternion: quaternion)) + } +} + +extension AffineTransform3D: Shearable3D { + + // MARK: - Shearing transforms + + /// Returns a new affine transform sheared by the specified axis and factors. + /// + /// - Parameter shear: The axis and shear factors. + /// - Returns: A new sheared affine transform. + /// - Complexity: O(1) + public func sheared(_ shear: AxisWithFactors) -> AffineTransform3D { + var s = AffineTransform3D() + switch shear { + case .xAxis(let ky, let kz): + s._m10 = ky; s._m20 = kz + case .yAxis(let kx, let kz): + s._m01 = kx; s._m21 = kz + case .zAxis(let kx, let ky): + s._m02 = kx; s._m12 = ky + } + return self * s + } +} diff --git a/Sources/OpenSpatial/Affine and projective transforms/ProjectiveTransform3D.swift b/Sources/OpenSpatial/Affine and projective transforms/ProjectiveTransform3D.swift new file mode 100644 index 0000000..28e2885 --- /dev/null +++ b/Sources/OpenSpatial/Affine and projective transforms/ProjectiveTransform3D.swift @@ -0,0 +1,261 @@ + +// ProjectiveTransform3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A 3D projective transformation represented by a 4×4 matrix. +@frozen +public struct ProjectiveTransform3D: Equatable, Hashable, Sendable { + + // MARK: - Flat storage (row-major: _mRC = row R, column C) + + var _m00: Double; var _m01: Double; var _m02: Double; var _m03: Double + var _m10: Double; var _m11: Double; var _m12: Double; var _m13: Double + var _m20: Double; var _m21: Double; var _m22: Double; var _m23: Double + var _m30: Double; var _m31: Double; var _m32: Double; var _m33: Double + + // MARK: - Creating a ProjectiveTransform3D + + /// Returns the identity projective transform. + public static let identity = ProjectiveTransform3D() + + /// Creates a projective transform with the identity matrix. + @inline(__always) + public init() { + _m00 = 1; _m01 = 0; _m02 = 0; _m03 = 0 + _m10 = 0; _m11 = 1; _m12 = 0; _m13 = 0 + _m20 = 0; _m21 = 0; _m22 = 1; _m23 = 0 + _m30 = 0; _m31 = 0; _m32 = 0; _m33 = 1 + } + + /// Creates a projective transform from the specified 4×4 matrix. + /// + /// - Parameter matrix: The source matrix in row-major order. + @inline(__always) + public init(matrix: [[Double]]) { + _m00 = matrix[0][0]; _m01 = matrix[0][1]; _m02 = matrix[0][2]; _m03 = matrix[0][3] + _m10 = matrix[1][0]; _m11 = matrix[1][1]; _m12 = matrix[1][2]; _m13 = matrix[1][3] + _m20 = matrix[2][0]; _m21 = matrix[2][1]; _m22 = matrix[2][2]; _m23 = matrix[2][3] + _m30 = matrix[3][0]; _m31 = matrix[3][1]; _m32 = matrix[3][2]; _m33 = matrix[3][3] + } + + /// Creates a projective transform by upcasting an affine transform. + /// + /// - Parameter affine: The affine transform to upcast. + @inline(__always) + public init(_ affine: AffineTransform3D) { + _m00 = affine._m00; _m01 = affine._m01; _m02 = affine._m02; _m03 = affine._m03 + _m10 = affine._m10; _m11 = affine._m11; _m12 = affine._m12; _m13 = affine._m13 + _m20 = affine._m20; _m21 = affine._m21; _m22 = affine._m22; _m23 = affine._m23 + _m30 = affine._m30; _m31 = affine._m31; _m32 = affine._m32; _m33 = affine._m33 + } + + // MARK: - Public matrix property (API-compatible with Apple Spatial framework) + + /// The underlying 4×4 matrix in row-major order. + public var matrix: [[Double]] { + [ + [_m00, _m01, _m02, _m03], + [_m10, _m11, _m12, _m13], + [_m20, _m21, _m22, _m23], + [_m30, _m31, _m32, _m33], + ] + } + + // MARK: - Subscript + + /// Accesses the element at the specified row and column. + /// + /// - Parameters: + /// - row: The row index. + /// - column: The column index. + /// - Returns: The element at the specified position. + public subscript(row: Int, column: Int) -> Double { + get { + switch (row, column) { + case (0, 0): return _m00; case (0, 1): return _m01 + case (0, 2): return _m02; case (0, 3): return _m03 + case (1, 0): return _m10; case (1, 1): return _m11 + case (1, 2): return _m12; case (1, 3): return _m13 + case (2, 0): return _m20; case (2, 1): return _m21 + case (2, 2): return _m22; case (2, 3): return _m23 + case (3, 0): return _m30; case (3, 1): return _m31 + case (3, 2): return _m32; case (3, 3): return _m33 + default: preconditionFailure("Index out of range") + } + } + set { + switch (row, column) { + case (0, 0): _m00 = newValue; case (0, 1): _m01 = newValue + case (0, 2): _m02 = newValue; case (0, 3): _m03 = newValue + case (1, 0): _m10 = newValue; case (1, 1): _m11 = newValue + case (1, 2): _m12 = newValue; case (1, 3): _m13 = newValue + case (2, 0): _m20 = newValue; case (2, 1): _m21 = newValue + case (2, 2): _m22 = newValue; case (2, 3): _m23 = newValue + case (3, 0): _m30 = newValue; case (3, 1): _m31 = newValue + case (3, 2): _m32 = newValue; case (3, 3): _m33 = newValue + default: preconditionFailure("Index out of range") + } + } + } + + // MARK: - Characteristics + + /// A Boolean value that indicates whether the transform is an affine transform. + /// + /// Returns `true` when the bottom row is `[0, 0, 0, 1]`. + public var isAffine: Bool { + _m30 == 0 && _m31 == 0 && _m32 == 0 && _m33 == 1 + } + + /// A Boolean value that indicates whether the transform is the identity transform. + public var isIdentity: Bool { + self == ProjectiveTransform3D() + } + + // MARK: - Inverse + + /// Returns the inverse transform, or `nil` if the matrix is singular. + /// + /// - Complexity: O(1) + public var inverse: ProjectiveTransform3D? { + var m = matrix + var inv: [[Double]] = [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], + ] + for col in 0..<4 { + var pivotRow = col + var maxVal = Swift.abs(m[col][col]) + for row in (col + 1)..<4 { + let v = Swift.abs(m[row][col]) + if v > maxVal { maxVal = v; pivotRow = row } + } + guard maxVal > 1e-12 else { return nil } + if pivotRow != col { + m.swapAt(col, pivotRow) + inv.swapAt(col, pivotRow) + } + let pivot = m[col][col] + for j in 0..<4 { + m[col][j] /= pivot + inv[col][j] /= pivot + } + for row in 0..<4 where row != col { + let factor = m[row][col] + for j in 0..<4 { + m[row][j] -= factor * m[col][j] + inv[row][j] -= factor * inv[col][j] + } + } + } + return ProjectiveTransform3D(matrix: inv) + } + + // MARK: - Concatenation + + /// Returns the concatenation of this transform with another. + /// + /// - Parameter other: The transform to concatenate. + /// - Returns: The concatenated transform. + @inline(__always) + public func concatenating(_ other: ProjectiveTransform3D) -> ProjectiveTransform3D { + self * other + } + + // MARK: - Operators + + /// Returns the concatenation of two projective transforms. + /// + /// - Parameters: + /// - lhs: The left-hand-side value. + /// - rhs: The right-hand-side value. + /// - Returns: The concatenated transform. + public static func * (lhs: ProjectiveTransform3D, rhs: ProjectiveTransform3D) + -> ProjectiveTransform3D + { + var result = ProjectiveTransform3D() + result._m00 = lhs._m00 * rhs._m00 + lhs._m01 * rhs._m10 + lhs._m02 * rhs._m20 + lhs._m03 * rhs._m30 + result._m01 = lhs._m00 * rhs._m01 + lhs._m01 * rhs._m11 + lhs._m02 * rhs._m21 + lhs._m03 * rhs._m31 + result._m02 = lhs._m00 * rhs._m02 + lhs._m01 * rhs._m12 + lhs._m02 * rhs._m22 + lhs._m03 * rhs._m32 + result._m03 = lhs._m00 * rhs._m03 + lhs._m01 * rhs._m13 + lhs._m02 * rhs._m23 + lhs._m03 * rhs._m33 + result._m10 = lhs._m10 * rhs._m00 + lhs._m11 * rhs._m10 + lhs._m12 * rhs._m20 + lhs._m13 * rhs._m30 + result._m11 = lhs._m10 * rhs._m01 + lhs._m11 * rhs._m11 + lhs._m12 * rhs._m21 + lhs._m13 * rhs._m31 + result._m12 = lhs._m10 * rhs._m02 + lhs._m11 * rhs._m12 + lhs._m12 * rhs._m22 + lhs._m13 * rhs._m32 + result._m13 = lhs._m10 * rhs._m03 + lhs._m11 * rhs._m13 + lhs._m12 * rhs._m23 + lhs._m13 * rhs._m33 + result._m20 = lhs._m20 * rhs._m00 + lhs._m21 * rhs._m10 + lhs._m22 * rhs._m20 + lhs._m23 * rhs._m30 + result._m21 = lhs._m20 * rhs._m01 + lhs._m21 * rhs._m11 + lhs._m22 * rhs._m21 + lhs._m23 * rhs._m31 + result._m22 = lhs._m20 * rhs._m02 + lhs._m21 * rhs._m12 + lhs._m22 * rhs._m22 + lhs._m23 * rhs._m32 + result._m23 = lhs._m20 * rhs._m03 + lhs._m21 * rhs._m13 + lhs._m22 * rhs._m23 + lhs._m23 * rhs._m33 + result._m30 = lhs._m30 * rhs._m00 + lhs._m31 * rhs._m10 + lhs._m32 * rhs._m20 + lhs._m33 * rhs._m30 + result._m31 = lhs._m30 * rhs._m01 + lhs._m31 * rhs._m11 + lhs._m32 * rhs._m21 + lhs._m33 * rhs._m31 + result._m32 = lhs._m30 * rhs._m02 + lhs._m31 * rhs._m12 + lhs._m32 * rhs._m22 + lhs._m33 * rhs._m32 + result._m33 = lhs._m30 * rhs._m03 + lhs._m31 * rhs._m13 + lhs._m32 * rhs._m23 + lhs._m33 * rhs._m33 + return result + } + + /// Concatenates two projective transforms and stores the result in the left-hand-side variable. + /// + /// - Parameters: + /// - lhs: The left-hand-side value. + /// - rhs: The right-hand-side value. + public static func *= (lhs: inout ProjectiveTransform3D, rhs: ProjectiveTransform3D) { + lhs = lhs * rhs + } +} + +// MARK: - Codable + +extension ProjectiveTransform3D: Codable { + + public init(from decoder: Decoder) throws { + let m = try [[Double]](from: decoder) + self.init(matrix: m) + } + + public func encode(to encoder: Encoder) throws { + try matrix.encode(to: encoder) + } +} + +extension ProjectiveTransform3D: Shearable3D { + + // MARK: - Shearing transforms + + /// Returns a new projective transform sheared by the specified axis and factors. + /// + /// - Parameter shear: The axis and shear factors. + /// - Returns: A new sheared projective transform. + /// - Complexity: O(1) + public func sheared(_ shear: AxisWithFactors) -> ProjectiveTransform3D { + var s = ProjectiveTransform3D() + switch shear { + case .xAxis(let ky, let kz): + s._m10 = ky; s._m20 = kz + case .yAxis(let kx, let kz): + s._m01 = kx; s._m21 = kz + case .zAxis(let kx, let ky): + s._m02 = kx; s._m12 = ky + } + return self * s + } +} + +extension ProjectiveTransform3D: CustomStringConvertible { + + /// A textual representation of the projective transform. + public var description: String { + matrix.map { row in + "[" + row.map { "\($0)" }.joined(separator: " ") + "]" + }.joined(separator: "\n") + } +} diff --git a/Sources/OpenSpatial/Common/Error.swift b/Sources/OpenSpatial/Common/Error.swift new file mode 100644 index 0000000..5d3033a --- /dev/null +++ b/Sources/OpenSpatial/Common/Error.swift @@ -0,0 +1,22 @@ + +// Error.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +public enum Error: Swift.Error { + case outOfRage + case noAncestorSpace +} + +extension Error { + public var localizedDescription: String { + "Index out of range. Valid indices are 0, 1, and 2." + } +} diff --git a/Sources/OpenSpatial/Converting between coordinate spaces/CoordinateSpace3D.swift b/Sources/OpenSpatial/Converting between coordinate spaces/CoordinateSpace3D.swift new file mode 100644 index 0000000..1b36563 --- /dev/null +++ b/Sources/OpenSpatial/Converting between coordinate spaces/CoordinateSpace3D.swift @@ -0,0 +1,196 @@ + +// CoordinateSpace3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A type that represents a coordinate space which you can use to convert +/// values to and from other coordinate spaces. +public protocol CoordinateSpace3D: Sendable { + + associatedtype AncestorCoordinateSpace: CoordinateSpace3D + + /// An ancestor coordinate space. + /// + /// A `nil` ancestor indicates this space is a root. + var ancestorSpace: Self.AncestorCoordinateSpace? { get } + + /// This space's transform relative to its ancestor. + func ancestorFromSpaceTransform() throws -> ProjectiveTransform3D + + /// Returns a transform of this coordinate space from the target + /// coordinate space. + /// + /// - Parameters: + /// - targetCoordinateSpace: Another coordinate space. + /// + /// This method is dedicated for converting between coordinate spaces + /// of the same type. Implementations may be more efficient than the + /// general purpose convert functions, but results should be the same. + /// A default implementation is provided which uses root level conversions. + func transform(from targetCoordinateSpace: Self) throws -> ProjectiveTransform3D + + /// Returns the accumulated transform from this space up to the root, + /// expressed as the chain selfFromRoot = t1 * t2 * ... * tN where t1 is + /// the space's own ancestorFromSpaceTransform and tN is the root's. + func _transformToRoot() throws -> ProjectiveTransform3D +} + +extension CoordinateSpace3D { + + /// Default implementation: multiply this space's ancestorFromSpaceTransform + /// by the ancestor's chain to root. + public func _transformToRoot() throws -> ProjectiveTransform3D { + guard ancestorSpace != nil else { + return .identity + } + let t = try ancestorFromSpaceTransform() + let parentChain = try ancestorSpace!._transformToRoot() + return t * parentChain + } +} + +extension CoordinateSpace3D { + + /// Returns a modified version of the coordinate space. + /// + /// - Parameter baseFromMapTransform: A closure which takes in the base coordinate space + /// and returns a transform that represents the modification to that space. + public func transformSpace( + _ baseFromMapTransform: @escaping @Sendable (Self) -> ProjectiveTransform3D + ) -> some CoordinateSpace3D { + _MappedCoordinateSpace(base: self, transform: baseFromMapTransform) + } +} + +extension CoordinateSpace3D { + + /// Returns a transform of this coordinate space from the target coordinate space. + /// + /// Traverses both spaces to their respective roots and returns + /// `selfFromRoot.inverse * targetFromRoot`. + /// + /// - Parameter target: Another coordinate space. + public func transform(from target: Space) throws -> ProjectiveTransform3D + where Space: CoordinateSpace3D { + let selfFromRoot = try _transformToRoot() + let targetFromRoot = try target._transformToRoot() + guard let selfFromRootInverse = selfFromRoot.inverse else { + throw Error.noAncestorSpace + } + return selfFromRootInverse * targetFromRoot + } + + /// Converts a value from this coordinate space to another. + /// + /// - Parameters: + /// - value: The value to convert, expressed in this coordinate space. + /// - targetCoordinateSpace: The destination coordinate space. + /// - Returns: The value converted to the target coordinate space. + public func convert(value: T, to targetCoordinateSpace: Space) throws -> T + where T: ProjectiveTransformable3D, Space: CoordinateSpace3D { + let t = try transform(from: targetCoordinateSpace) + return value.applying(t) + } + + /// Converts a value from a source coordinate space to this one. + /// + /// - Parameters: + /// - value: The value to convert, expressed in the source coordinate space. + /// - sourceCoordinateSpace: The source coordinate space. + /// - Returns: The value converted to this coordinate space. + public func convert(value: T, from sourceCoordinateSpace: Space) throws -> T + where T: ProjectiveTransformable3D, Space: CoordinateSpace3D { + let t = try sourceCoordinateSpace.transform(from: self) + return value.applying(t) + } + + /// Converts a value from this coordinate space to another of the same type. + /// + /// - Parameters: + /// - value: The value to convert, expressed in this coordinate space. + /// - targetCoordinateSpace: The destination coordinate space. + /// - Returns: The value converted to the target coordinate space. + public func convert(value: T, to targetCoordinateSpace: Self) throws -> T + where T: ProjectiveTransformable3D { + let t = try transform(from: targetCoordinateSpace) + return value.applying(t) + } + + /// Converts a value from a source coordinate space of the same type to this one. + /// + /// - Parameters: + /// - value: The value to convert, expressed in the source coordinate space. + /// - sourceCoordinateSpace: The source coordinate space. + /// - Returns: The value converted to this coordinate space. + public func convert(value: T, from sourceCoordinateSpace: Self) throws -> T + where T: ProjectiveTransformable3D { + let t = try sourceCoordinateSpace.transform(from: self) + return value.applying(t) + } +} + +extension CoordinateSpace3D { + + /// Returns a transform of this coordinate space from the target coordinate space + /// when both spaces are of the same type. + /// + /// Delegates to the generic `transform(from:)` overload. + public func transform(from target: Self) throws -> ProjectiveTransform3D { + let selfFromRoot = try _transformToRoot() + let targetFromRoot = try target._transformToRoot() + guard let selfFromRootInverse = selfFromRoot.inverse else { + throw Error.noAncestorSpace + } + return selfFromRootInverse * targetFromRoot + } +} + +/// An internal wrapper that applies a transform closure on top of a base coordinate space. +struct _MappedCoordinateSpace: CoordinateSpace3D { + + typealias AncestorCoordinateSpace = Base + + let base: Base + let transform: @Sendable (Base) -> ProjectiveTransform3D + + var ancestorSpace: Base? { base } + + func ancestorFromSpaceTransform() throws -> ProjectiveTransform3D { + transform(base) + } + + func transform(from target: _MappedCoordinateSpace) throws -> ProjectiveTransform3D { + let selfFromRoot = try _transformToRoot() + let targetFromRoot = try target._transformToRoot() + guard let selfFromRootInverse = selfFromRoot.inverse else { + throw Error.noAncestorSpace + } + return selfFromRootInverse * targetFromRoot + } +} + +extension Never: CoordinateSpace3D { + + public typealias AncestorCoordinateSpace = Never + + public var ancestorSpace: Never? { nil } + + public func ancestorFromSpaceTransform() throws -> ProjectiveTransform3D { + fatalError("Never cannot be instantiated") + } + + public func transform(from targetCoordinateSpace: Never) throws -> ProjectiveTransform3D { + fatalError("Never cannot be instantiated") + } + + public func _transformToRoot() throws -> ProjectiveTransform3D { + fatalError("Never cannot be instantiated") + } +} diff --git a/Sources/OpenSpatial/Converting between coordinate spaces/CoordinateSpaceValue3D.swift b/Sources/OpenSpatial/Converting between coordinate spaces/CoordinateSpaceValue3D.swift new file mode 100644 index 0000000..cdef8d4 --- /dev/null +++ b/Sources/OpenSpatial/Converting between coordinate spaces/CoordinateSpaceValue3D.swift @@ -0,0 +1,24 @@ + +// CoordinateSpaceValue3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// An opaque value which can be resolved to a concrete value +/// in a `CoordinateSpace3D` +public protocol CoordinateSpaceValue3D { + + associatedtype Value: ProjectiveTransformable3D + + /// Resolves the associated value in the given coordinate space. + /// + /// - Parameter space: A coordinate space the function resolves the value in. + /// - Returns: A concrete value converted to the provided space. + func resolve(in otherSpace: Space) throws -> Self.Value where Space: CoordinateSpace3D +} diff --git a/Sources/OpenSpatial/Converting between coordinate spaces/ProjectiveTransformable3D.swift b/Sources/OpenSpatial/Converting between coordinate spaces/ProjectiveTransformable3D.swift new file mode 100644 index 0000000..8fd58cb --- /dev/null +++ b/Sources/OpenSpatial/Converting between coordinate spaces/ProjectiveTransformable3D.swift @@ -0,0 +1,18 @@ + +// ProjectiveTransformable3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +public protocol ProjectiveTransformable3D { + + /// Returns a transformed copy of the value. + /// - Parameter transform: A transform the function applies to the value. + func applying(_ transform: ProjectiveTransform3D) -> Self +} diff --git a/Sources/OpenSpatial/Converting between coordinate spaces/WorldReferenceCoordinateSpace.swift b/Sources/OpenSpatial/Converting between coordinate spaces/WorldReferenceCoordinateSpace.swift new file mode 100644 index 0000000..407ecdb --- /dev/null +++ b/Sources/OpenSpatial/Converting between coordinate spaces/WorldReferenceCoordinateSpace.swift @@ -0,0 +1,48 @@ + +// WorldReferenceCoordinateSpace.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A coordinate space that represents a world reference point. +public struct WorldReferenceCoordinateSpace: CoordinateSpace3D, Equatable, Hashable, Sendable { + + public typealias AncestorCoordinateSpace = Never + + public init() {} + + /// An ancestor coordinate space. + /// + /// Always `nil` — world reference is the root space with no ancestor. + public var ancestorSpace: Never? { + nil + } + + /// This space's transform relative to its ancestor. + /// + /// Always throws because the world reference space has no ancestor. + public func ancestorFromSpaceTransform() throws -> ProjectiveTransform3D { + throw Error.noAncestorSpace + } + + /// Returns the transform from this space to itself, which is always the identity. + public func transform(from target: WorldReferenceCoordinateSpace) throws + -> ProjectiveTransform3D + { + .identity + } +} + +extension CoordinateSpace3D where Self == WorldReferenceCoordinateSpace { + + /// A coordinate space that represents the world root for all other coordinate spaces. + public static var worldReference: WorldReferenceCoordinateSpace { + WorldReferenceCoordinateSpace() + } +} diff --git a/Sources/OpenSpatial/Data structures/Axis3D.swift b/Sources/OpenSpatial/Data structures/Axis3D.swift new file mode 100644 index 0000000..7c95c7f --- /dev/null +++ b/Sources/OpenSpatial/Data structures/Axis3D.swift @@ -0,0 +1,42 @@ + +// Axis3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +/// Constants that describe an axis. +@frozen +public struct Axis3D: Codable, Copyable, Equatable, Hashable, OptionSet, Sendable { + + // MARK: - Constants + + /// The operation is along the x-axis. + public static let x = Axis3D(rawValue: 1) + + /// The operation is along the y-axis. + public static let y = Axis3D(rawValue: 2) + + /// The operation is along the z-axis. + public static let z = Axis3D(rawValue: 4) + + /// All three axes combined. + public static let all: Axis3D = [.x, .y, .z] + + // MARK: - Inspecting the axis + + /// The raw value of the axis. + public var rawValue: Int + + // MARK: - Creating an axis + + /// Creates a new axis with the given raw value. + /// + /// - Parameter rawValue: The raw value of the axis. + public init(rawValue: Int) { + self.rawValue = rawValue + } +} diff --git a/Sources/OpenSpatial/Data structures/Vector3D.swift b/Sources/OpenSpatial/Data structures/Vector3D.swift new file mode 100644 index 0000000..44396e9 --- /dev/null +++ b/Sources/OpenSpatial/Data structures/Vector3D.swift @@ -0,0 +1,560 @@ + +// Vector3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A three-element vector. +@frozen public struct Vector3D: Codable, Equatable, Hashable, Sendable { + + // MARK: - Creating a vector + + /// Creates a vector. + @inline(__always) + public init() { + (x, y, z) = (0, 0, 0) + } + + /// Creates a vector from the specified double-precision values. + /// + /// - Parameters: + /// - x: A double-precision value that specifies the x value. The default is `0`. + /// - y: A double-precision value that specifies the y value. The default is `0`. + /// - z: A double-precision value that specifies the z value. The default is `0`. + @inline(__always) + public init(x: Double = 0, y: Double = 0, z: Double = 0) { + (self.x, self.y, self.z) = (x, y, z) + } + + /// Creates a vector from the specified floating-point values. + /// + /// - Parameters: + /// - x: A floating-point value that specifies the x value. + /// - y: A floating-point value that specifies the y value. + /// - z: A floating-point value that specifies the z value. + @inline(__always) + public init(x: T, y: T, z: T) where T: BinaryFloatingPoint { + (self.x, self.y, self.z) = (Double(x), Double(y), Double(z)) + } + + /// Creates a vector from the specified Spatial size structure. + /// + /// - Parameter size: A size structure that specifies the elememnt values. + @inline(__always) + public init(_ size: Size3D) { + (x, y, z) = (size.width, size.height, size.depth) + } + + /// Creates a vector from the specified Spatial point structure. + /// + /// - Parameter point: A point structure that specifies the element values. + @inline(__always) + public init(_ point: Point3D) { + (x, y, z) = (point.x, point.y, point.z) + } + + // MARK: - Inspecting a vector’s properties + + /// The x-element value. + public let x: Double + + /// The y-element value. + public let y: Double + + /// The z-element value. + public let z: Double + + /// Accesses the x, y, or z value at the specified index. + /// + /// - Parameter index: The index of the value to access. Valid indices are 0, 1, and 2. + /// - Returns: The x, y, or z value at the specified index. + /// - Complexity: O(1) + @inline(__always) + public subscript(index: Int) -> Double { + get throws { + switch index { + case 0: return x + case 1: return y + case 2: return z + default: + throw Error.outOfRage + } + } + } + + // MARK: - Geometry functions + + /// Returns the cross product of the vector and the specified vector. + /// + /// - Parameter other: The second vector. + /// - Returns: The cross product vector. + /// - Complexity: O(1) + @inline(__always) + public func cross(_ other: Vector3D) -> Vector3D { + .init( + x: y * other.z - z * other.y, + y: z * other.x - x * other.z, + z: x * other.y - y * other.x + ) + } + + /// Returns the dot product of the vector and the specified vector. + /// + /// - Parameter other: The second vector. + /// - Returns: The dot product value. + /// - Complexity: O(1) + @inline(__always) + public func dot(_ other: Vector3D) -> Double { + x * other.x + y * other.y + z * other.z + } + + /// The length (magnitude) of the vector. + public var length: Double { + sqrt(x * x + y * y + z * z) + } + + /// The squared length of the vector. + public var lengthSquared: Double { + x * x + y * y + z * z + } + + /// Returns a Boolean value indicating whether this vector is approximately equal to another vector within a specified tolerance. + /// + /// - Parameters: + /// - other: The other vector to compare. + /// - tolerance: The maximum allowable difference between corresponding elements. + /// - Returns: `true` if the vectors are approximately equal within the specified tolerance; otherwise, `false`. + /// - Complexity: O(1) + public func isApproximatelyEqual( + to other: Vector3D, tolerance: Double = Foundation.sqrt(.ulpOfOne) + ) -> Bool { + abs(x - other.x) <= tolerance && abs(y - other.y) <= tolerance + && abs(z - other.z) <= tolerance + } + + /// Normalizes the mutable vector. + /// + /// - Complexity: O(1) + public mutating func normalize() { + let len = length + guard len != 0 else { return } + self = Vector3D(x: x / len, y: y / len, z: z / len) + } + + /// A new vector that represents the normalized copy of the current vector. + public var normalized: Vector3D { + var vector = self + vector.normalize() + return vector + } + + /// Returns the vector projected onto the specified vector. + /// + /// - Parameter other: The second vector. + /// - Returns: The projected vector. + /// - Complexity: O(1) + @inline(__always) + public func projected(_ other: Vector3D) -> Vector3D { + let otherLengthSquared = other.lengthSquared + guard otherLengthSquared != 0 else { return .zero } + let scalar = dot(other) / otherLengthSquared + return Vector3D(x: other.x * scalar, y: other.y * scalar, z: other.z * scalar) + } + + /// Returns the reflection direction of the incident vector and a specified unit normal vector. + /// + /// - Parameter normal: The unit normal vector. + /// - Returns: The reflected vector. + /// - Complexity: O(1) + @inline(__always) + public func reflected(_ normal: Vector3D) -> Vector3D { + let dotProduct = self.dot(normal) + return Vector3D( + x: self.x - 2 * dotProduct * normal.x, + y: self.y - 2 * dotProduct * normal.y, + z: self.z - 2 * dotProduct * normal.z + ) + } + + // MARK: - Type properties + + /// A vector that contains the values 0, 0, -1. + public static let backward = Vector3D(x: 0, y: 0, z: -1) + + /// A vector that contains the values 0, 0, 1. + public static let forward = Vector3D(x: 0, y: 0, z: 1) + + /// A vector that contains the values -1, 0, 0. + public static let left = Vector3D(x: -1, y: 0, z: 0) + + /// A vector that contains the values 1, 0, 0. + public static let right = Vector3D(x: 1, y: 0, z: 0) + + /// A vector that contains the values 0, 1, 0. + public static let up = Vector3D(x: 0, y: 1, z: 0) + + /// A vector that contains the values 0, -1, 0. + public static let down = Vector3D(x: 0, y: -1, z: 0) +} + +extension Vector3D: ExpressibleByArrayLiteral { + + /// Creates an instance initialized with the given elements. + /// + /// - Parameter elements: The elements of the array literal. + @inline(__always) + public init(arrayLiteral elements: Double...) { + precondition(elements.count == 3, "Invalid array literal for \(Self.self)") + (x, y, z) = (elements[0], elements[1], elements[2]) + } +} + +extension Vector3D: AdditiveArithmetic { + + // MARK: - Applying arithmetic operations + + /// The zero vector. + public static let zero = Vector3D() + + /// Returns a vector that’s the product of a vector and a scalar value. + /// + /// - Parameters: + /// - lhs: The left-hand-side value. + /// - rhs: The right-hand-side scalar value. + /// - Returns: The product of the vector and the scalar. + /// - Complexity: O(1) + @inline(__always) + public static func * (lhs: Vector3D, rhs: Double) -> Vector3D { + .init(x: lhs.x * rhs, y: lhs.y * rhs, z: lhs.z * rhs) + } + + /// Multiplies a vector and a double-precision value, and stores the result in the left-hand-side variable. + /// + /// - Parameters: + /// - lhs: The left-hand-side value. + /// - rhs: The right-hand-side value. + /// - Complexity: O(1) + @inline(__always) + public static func *= (lhs: inout Vector3D, rhs: Double) { + lhs = lhs * rhs + } + + /// Adds two vectors and returns the result. + /// + /// - Parameters: + /// - lhs: The first vector. + /// - rhs: The second vector. + /// - Returns: The sum of the two vectors. + /// - Complexity: O(1) + @inline(__always) + public static func + (lhs: Vector3D, rhs: Vector3D) -> Vector3D { + .init(x: lhs.x + rhs.x, y: lhs.y + rhs.y, z: lhs.z + rhs.z) + } + + /// Adds a vector to a size and returns the result. + /// + /// - Parameters: + /// - lhs: The vector. + /// - rhs: The size. + /// - Returns: The sum of the vector and the size. + /// - Complexity: O(1) + @inline(__always) + public static func + (lhs: Vector3D, rhs: Size3D) -> Size3D { + .init(width: lhs.x + rhs.width, height: lhs.y + rhs.height, depth: lhs.z + rhs.depth) + } + + /// Adds a size to a vector and returns the result. + /// + /// - Parameters: + /// - lhs: The size. + /// - rhs: The vector. + /// - Returns: The sum of the vector and the size. + /// - Complexity: O(1) + @inline(__always) + public static func + (lhs: Size3D, rhs: Vector3D) -> Size3D { + .init(width: lhs.width + rhs.x, height: lhs.height + rhs.y, depth: lhs.depth + rhs.z) + } + + /// Adds a vector to a point and returns the result. + /// + /// - Parameters: + /// - lhs: The point. + /// - rhs: The vector. + /// - Returns: The sum of the point and the vector. + /// - Complexity: O(1) + @inline(__always) + public static func + (lhs: Point3D, rhs: Vector3D) -> Point3D { + .init(x: lhs.x + rhs.x, y: lhs.y + rhs.y, z: lhs.z + rhs.z) + } + + /// Adds a vector to a point and returns the result. + /// + /// - Parameters: + /// - lhs: The vector. + /// - rhs: The point. + /// - Returns: The sum of the point and the vector. + /// - Complexity: O(1) + @inline(__always) + public static func + (lhs: Vector3D, rhs: Point3D) -> Point3D { + .init(x: lhs.x + rhs.x, y: lhs.y + rhs.y, z: lhs.z + rhs.z) + } + + /// Adds the second vector to the first vector and stores the result in the first vector. + /// + /// - Parameters: + /// - lhs: The first vector. + /// - rhs: The second vector. + /// - Complexity: O(1) + @inline(__always) + public static func += (lhs: inout Vector3D, rhs: Vector3D) { + lhs = lhs + rhs + } + + /// Subtracts one vector from another and returns the result. + /// + /// - Parameters: + /// - lhs: The first vector. + /// - rhs: The second vector. + /// - Returns: The difference of the two vectors. + /// - Complexity: O(1) + @inline(__always) + public static func - (lhs: Vector3D, rhs: Vector3D) -> Vector3D { + .init(x: lhs.x - rhs.x, y: lhs.y - rhs.y, z: lhs.z - rhs.z) + } + + /// Subtracts the second vector from the first vector and stores the result in the first vector. + /// + /// - Parameters: + /// - lhs: The first vector. + /// - rhs: The second vector. + /// - Complexity: O(1) + @inline(__always) + public static func -= (lhs: inout Vector3D, rhs: Vector3D) { + lhs = lhs - rhs + } + + /// Subtracts a vector from a size and returns the result. + /// + /// - Parameters: + /// - lhs: The vector. + /// - rhs: The size. + /// - Returns: The difference of the vector and the size. + /// - Complexity: O(1) + @inline(__always) + public static func - (lhs: Vector3D, rhs: Size3D) -> Size3D { + .init(width: lhs.x - rhs.width, height: lhs.y - rhs.height, depth: lhs.z - rhs.depth) + } + + /// Subtracts a size from a vector and returns the result. + /// + /// - Parameters: + /// - lhs: The size. + /// - rhs: The vector. + /// - Returns: The difference of the size and the vector. + /// - Complexity: O(1) + @inline(__always) + public static func - (lhs: Size3D, rhs: Vector3D) -> Size3D { + .init(width: lhs.width - rhs.x, height: lhs.height - rhs.y, depth: lhs.depth - rhs.z) + } + + /// Subtracts a vector from a point and returns the result. + /// + /// - Parameters: + /// - lhs: The point. + /// - rhs: The vector. + /// - Returns: The difference of the point and the vector. + /// - Complexity: O(1) + @inline(__always) + public static func - (lhs: Point3D, rhs: Vector3D) -> Point3D { + .init(x: lhs.x - rhs.x, y: lhs.y - rhs.y, z: lhs.z - rhs.z) + } + + /// Subtracts a point from a vector and returns the result. + /// + /// - Parameters: + /// - lhs: The vector. + /// - rhs: The point. + /// - Returns: The difference of the vector and the point. + /// - Complexity: O(1) + @inline(__always) + public static func - (lhs: Vector3D, rhs: Point3D) -> Point3D { + .init(x: lhs.x - rhs.x, y: lhs.y - rhs.y, z: lhs.z - rhs.z) + } + + /// Returns a vector with each element divided by a scalar value. + /// + /// - Parameters: + /// - lhs: The left-hand-side value. + /// - rhs: The right-hand-side value. + /// - Returns: The resulting vector. + /// - Complexity: O(1) + @inline(__always) + public static func / (lhs: Vector3D, rhs: Double) -> Vector3D { + .init(x: lhs.x / rhs, y: lhs.y / rhs, z: lhs.z / rhs) + } + + /// Divides each element of the vector by a scalar value and stores the result in the left-hand-side variable. + /// + /// - Parameters: + /// - lhs: The left-hand-side value. + /// - rhs: The right-hand-side value. + /// - Complexity: O(1) + @inline(__always) + public static func /= (lhs: inout Vector3D, rhs: Double) { + lhs = lhs / rhs + } +} + +extension Vector3D: Primitive3D { + + // MARK: - Instance properties + + /// A Boolean value that indicates whether the vector is finite. + public var isFinite: Bool { + x.isFinite && y.isFinite && z.isFinite + } + + /// A Boolean value that indicates whether the vector contains any NaN values. + public var isNaN: Bool { + x.isNaN || y.isNaN || z.isNaN + } + + /// A Boolean value that indicates whether the vector is zero. + public var isZero: Bool { + x == 0 && y == 0 && z == 0 + } + + // MARK: - Type properties + + /// A vector with infinite values. + public static var infinity: Vector3D { + .init(x: .infinity, y: .infinity, z: .infinity) + } + + // MARK: - Transforming primitives + + /// Applies an affine transform. + /// + /// - Parameter transform: The affine transform to apply. + /// - Returns: A new transformed vector. + /// - Complexity: O(1) + public func applying(_ transform: AffineTransform3D) -> Vector3D { + let newX = + x * transform.matrix[0][0] + y * transform.matrix[1][0] + z * transform.matrix[2][0] + + transform.matrix[3][0] + let newY = + x * transform.matrix[0][1] + y * transform.matrix[1][1] + z * transform.matrix[2][1] + + transform.matrix[3][1] + let newZ = + x * transform.matrix[0][2] + y * transform.matrix[1][2] + z * transform.matrix[2][2] + + transform.matrix[3][2] + return Vector3D(x: newX, y: newY, z: newZ) + } +} + +extension Vector3D: Scalable3D { + + // MARK: - Instance methods + + /// Returns a new entity scaled by the specified size. + /// + /// - Parameter size: A size that contains the scale factors for each axis. + /// - Returns: A new scaled entity. + /// - Complexity: O(1) + @inline(__always) + public func scaled(by size: Size3D) -> Vector3D { + .init(x: x * size.width, y: y * size.height, z: z * size.depth) + } + + /// Returns a new entity scaled uniformly by the specified factor. + /// + /// - Parameter scale: A double-precision value that specifies the uniform scale factor. + /// - Returns: A new scaled entity. + /// - Complexity: O(1) + @inline(__always) + public func uniformlyScaled(by scale: Double) -> Vector3D { + .init(x: x * scale, y: y * scale, z: z * scale) + } +} + +extension Vector3D: Rotatable3D { + + // MARK: - Rotatable3D + + /// Returns a new entity rotated by the specified quaternion. + /// + /// - Parameter quaternion: The quaternion to rotate the entity by. + /// - Returns: A new rotated entity. + /// - Complexity: O(1) + public func rotated(by quaternion: Quaternion3D) -> Vector3D { + let q = quaternion.normalized + // Optimised sandwich product v' = q * v * q⁻¹ via the cross-product form + // t = 2 * (q.xyz × v), v' = v + q.w * t + (q.xyz × t) + let tx = 2.0 * (q.y * z - q.z * y) + let ty = 2.0 * (q.z * x - q.x * z) + let tz = 2.0 * (q.x * y - q.y * x) + return Vector3D( + x: x + q.w * tx + (q.y * tz - q.z * ty), + y: y + q.w * ty + (q.z * tx - q.x * tz), + z: z + q.w * tz + (q.x * ty - q.y * tx) + ) + } +} + +extension Vector3D: Translatable3D { + + /// Returns a new entity translated by the specified vector. + /// + /// - Parameter vector: The vector by which to translate the entity. + /// - Returns: A new translated entity. + /// - Complexity: O(1) + public func translated(by vector: Vector3D) -> Vector3D { + .init(x: x + vector.x, y: y + vector.y, z: z + vector.z) + } +} + +extension Vector3D: ProjectiveTransformable3D { + + /// Returns a transformed copy of the vector. + /// + /// Multiplies `[x, y, z, 0]` by the 4×4 projective matrix (direction + /// vector — translation is excluded). Divides by `w` if `w != 0`. + /// + /// - Parameter transform: A projective transform to apply. + /// - Returns: The transformed vector. + /// - Complexity: O(1) + public func applying(_ transform: ProjectiveTransform3D) -> Vector3D { + let m = transform.matrix + let tx = m[0][0] * x + m[1][0] * y + m[2][0] * z + let ty = m[0][1] * x + m[1][1] * y + m[2][1] * z + let tz = m[0][2] * x + m[1][2] * y + m[2][2] * z + let tw = m[0][3] * x + m[1][3] * y + m[2][3] * z + guard tw != 0 else { return Vector3D(x: tx, y: ty, z: tz) } + return Vector3D(x: tx / tw, y: ty / tw, z: tz / tw) + } +} + +extension Vector3D: Shearable3D { + + /// Returns a sheared vector. + /// + /// - Parameter shear: The axis and shear factors. + /// - Returns: The sheared vector. + /// - Complexity: O(1) + public func sheared(_ shear: AxisWithFactors) -> Vector3D { + switch shear { + case .xAxis(let ky, let kz): + return Vector3D(x: x + ky * y + kz * z, y: y, z: z) + case .yAxis(let kx, let kz): + return Vector3D(x: x, y: y + kx * x + kz * z, z: z) + case .zAxis(let kx, let ky): + return Vector3D(x: x, y: y, z: z + kx * x + ky * y) + } + } +} diff --git a/Sources/OpenSpatial/Enumerations/AxisWithFactors.swift b/Sources/OpenSpatial/Enumerations/AxisWithFactors.swift new file mode 100644 index 0000000..bfb5df3 --- /dev/null +++ b/Sources/OpenSpatial/Enumerations/AxisWithFactors.swift @@ -0,0 +1,24 @@ + +// AxisWithFactors.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// The axis of a shear transform. +public enum AxisWithFactors { + + /// The shear is on the _x_ axis using the _y_ and _z_ shear factors. + case xAxis(yShearFactor: Double, zShearFactor: Double) + + /// The shear is on the _y_ axis using the _x_ and _z_ shear factors. + case yAxis(xShearFactor: Double, zShearFactor: Double) + + /// The shear is on the _z_ axis using the _x_ and _y_ shear factors. + case zAxis(xShearFactor: Double, yShearFactor: Double) +} diff --git a/Sources/OpenSpatial/Protocols/Clampable3D.swift b/Sources/OpenSpatial/Protocols/Clampable3D.swift new file mode 100644 index 0000000..1c72dde --- /dev/null +++ b/Sources/OpenSpatial/Protocols/Clampable3D.swift @@ -0,0 +1,36 @@ + +// Clampable3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A set of methods that defines the interface for Spatial entities that can be clamped to a volume. +public protocol Clampable3D { + + /// Returns the entity with coordinates clamped to the specified rectangle. + /// + /// - Parameter rect: The rectangle that defines the clamp volume. + /// - Returns An entity that's clamped to the specified rectangle. + func clamped(to rect: Rect3D) -> Self + + /// Clamps the mutable entity to the specified rectangle. + /// + /// - Parameter rect: The rectangle that defines the clamp volume. + mutating func clamp(to rect: Rect3D) +} + +extension Clampable3D { + + /// Clamps the mutable entity to the specified rectangle. + /// + /// - Parameter rect: The rectangle that defines the clamp volume. + @inlinable public mutating func clamp(to rect: Rect3D) { + self = clamped(to: rect) + } +} diff --git a/Sources/OpenSpatial/Protocols/Primitive3D.swift b/Sources/OpenSpatial/Protocols/Primitive3D.swift new file mode 100644 index 0000000..4d27230 --- /dev/null +++ b/Sources/OpenSpatial/Protocols/Primitive3D.swift @@ -0,0 +1,57 @@ + +// Primitive3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A set of methods common to Spatial primitives. +public protocol Primitive3D: Codable, Equatable { + + // MARK: - Instance properties + + /// Returns a Boolean value that indicates whether the primitive is infinite. + var isFinite: Bool { get } + + /// Returns a Boolean value that indicates whether the primitive contains any NaN values. + var isNaN: Bool { get } + + /// Returns a Boolean value that indicates whether the primitive is zero. + var isZero: Bool { get } + + // MARK: - Type properties + + /// A primitive with infinite values. + static var infinity: Self { get } + + /// A primitive with zero values. + static var zero: Self { get } + + // MARK: - Transforming primitives + + /// Applies an affine transform. + /// + /// - Parameter transform: The affine transform to apply. + mutating func apply(_ transform: AffineTransform3D) + + /// Returns a new primitive created by applying an affine transform. + /// + /// - Parameter transform: The affine transform to apply. + /// - Returns: A new transformed primitive. + func applying(_ transform: AffineTransform3D) -> Self +} + +extension Primitive3D { + + /// Applies an affine transform. + /// + /// - Parameter transform: The affine transform to apply. + public mutating func apply(_ transform: AffineTransform3D) { + self = applying(transform) + } +} diff --git a/Sources/OpenSpatial/Protocols/Rotatable3D.swift b/Sources/OpenSpatial/Protocols/Rotatable3D.swift new file mode 100644 index 0000000..1b5603f --- /dev/null +++ b/Sources/OpenSpatial/Protocols/Rotatable3D.swift @@ -0,0 +1,67 @@ + +// Rotatable3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A set of methods that defines the interface to rotate Spatial entities. +public protocol Rotatable3D { + + /// Rotates the entity by a quaternion. + /// + /// - Parameter quaternion: The quaternion to apply. + mutating func rotate(by quaternion: Quaternion3D) + + /// Returns the entity that results from applying the specified quaternion. + /// + /// - Parameter quaternion: The quaternion to apply. + /// - Returns: A new rotated entity. + func rotated(by quaternion: Quaternion3D) -> Self + + /// Rotates the entity by a rotation. + /// + /// - Parameter rotation: The rotation to apply. + mutating func rotate(by rotation: Rotation3D) + + /// Returns the entity that results from applying the specified rotation. + /// + /// - Parameter rotation: The rotation to apply. + /// - Returns: A new rotated entity. + func rotated(by rotation: Rotation3D) -> Self +} + +extension Rotatable3D { + + // MARK: - Instance methods + + /// Rotates the entity by a quaternion. + /// + /// - Parameter quaternion: The quaternion to apply. + @inline(__always) + public mutating func rotate(by quaternion: Quaternion3D) { + self = rotated(by: quaternion) + } + + /// Rotates the entity by a rotation. + /// + /// - Parameter rotation: The rotation to apply. + @inline(__always) + public mutating func rotate(by rotation: Rotation3D) { + self = rotated(by: rotation) + } + + /// Returns the entity that results from applying the specified rotation. + /// + /// - Parameter rotation: The rotation to apply. + /// - Returns: A new rotated entity. + @inline(__always) + public func rotated(by rotation: Rotation3D) -> Self { + rotated(by: rotation.quaternion) + } +} diff --git a/Sources/OpenSpatial/Protocols/Scalable3D.swift b/Sources/OpenSpatial/Protocols/Scalable3D.swift new file mode 100644 index 0000000..ad6e109 --- /dev/null +++ b/Sources/OpenSpatial/Protocols/Scalable3D.swift @@ -0,0 +1,77 @@ + +// Scalable3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A protocol that defines scalable 3D types. +public protocol Scalable3D { + + // MARK: - Instance methods + + /// Scales the entity by the specified size. + /// + /// - Parameter size: A size that contains the scale factors for each axis. + mutating func scale(by size: Size3D) + + /// Scales the entity by the specified factors. + /// + /// - Parameters: + /// - x: A double-precision value that specifies the scale factor for the x-axis. + /// - y: A double-precision value that specifies the scale factor for the y-axis. + /// - z: A double-precision value that specifies the scale factor for the z-axis. + mutating func scaleBy(x: Double, y: Double, z: Double) + + /// Returns a new entity scaled by the specified size. + /// + /// - Parameter size: A size that contains the scale factors for each axis. + /// - Returns: A new scaled entity. + func scaled(by size: Size3D) -> Self + + /// Scales the entity uniformly by the specified factor. + /// + /// - Parameter scale: A double-precision value that specifies the uniform scale factor. + mutating func uniformlyScale(by scale: Double) + + /// Returns a new entity scaled uniformly by the specified factor. + /// + /// - Parameter scale: A double-precision value that specifies the uniform scale factor. + /// - Returns: A new scaled entity. + func uniformlyScaled(by scale: Double) -> Self +} + +extension Scalable3D { + + /// Scales the entity by the specified size. + /// + /// - Parameter size: A size that contains the scale factors for each axis. + @inline(__always) + public mutating func scale(by size: Size3D) { + self = self.scaled(by: size) + } + + /// Scales the entity by the specified factors. + /// + /// - Parameters: + /// - x: A double-precision value that specifies the scale factor for the x-axis. + /// - y: A double-precision value that specifies the scale factor for the y-axis. + /// - z: A double-precision value that specifies the scale factor for the z-axis. + @inline(__always) + public mutating func scaleBy(x: Double, y: Double, z: Double) { + self.scale(by: Size3D(width: x, height: y, depth: z)) + } + + /// Scales the entity uniformly by the specified factor. + /// + /// - Parameter scale: A double-precision value that specifies the uniform scale factor. + @inline(__always) + public mutating func uniformlyScale(by scale: Double) { + self.scale(by: Size3D(width: scale, height: scale, depth: scale)) + } +} diff --git a/Sources/OpenSpatial/Protocols/Shearable3D.swift b/Sources/OpenSpatial/Protocols/Shearable3D.swift new file mode 100644 index 0000000..c39c685 --- /dev/null +++ b/Sources/OpenSpatial/Protocols/Shearable3D.swift @@ -0,0 +1,36 @@ + +// Shearable3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A set of methods that defines the interface for Spatial entities that can shear. +public protocol Shearable3D { + + /// Returns a sheared entity. + /// + /// - Parameter shear: The axis and shear factors. + /// - Returns The sheared entity. + func sheared(_ shear: AxisWithFactors) -> Self + + /// Shears the entity. + /// + /// - Parameter shear: The axis and shear factors. + mutating func shear(_ shear: AxisWithFactors) +} + +extension Shearable3D { + + /// Shears the entity. + /// + /// - Parameter shear: The axis and shear factors. + public mutating func shear(_ shear: AxisWithFactors) { + self = sheared(shear) + } +} diff --git a/Sources/OpenSpatial/Protocols/Translatable3D.swift b/Sources/OpenSpatial/Protocols/Translatable3D.swift new file mode 100644 index 0000000..b0ba6b2 --- /dev/null +++ b/Sources/OpenSpatial/Protocols/Translatable3D.swift @@ -0,0 +1,42 @@ + +// Translatable3D.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A set of methods that defines the interface to translate Spatial entities. +public protocol Translatable3D: Equatable { + + // MARK: - Instance methods + + /// Translates the entity by the specified vector. + /// + /// - Parameter vector: The vector by which to translate the entity. + mutating func translate(by vector: Vector3D) + + /// Returns a new entity translated by the specified vector. + /// + /// - Parameter vector: The vector by which to translate the entity. + /// - Returns: A new translated entity. + func translated(by vector: Vector3D) -> Self +} + +extension Translatable3D { + + // MARK: - Instance methods + + /// Translates the entity by the specified vector. + /// + /// - Parameter vector: The vector by which to translate the entity. + /// - Complexity: O(1) + @inline(__always) + public mutating func translate(by vector: Vector3D) { + self = translated(by: vector) + } +} diff --git a/Sources/OpenSpatial/Protocols/Volumetric.swift b/Sources/OpenSpatial/Protocols/Volumetric.swift new file mode 100644 index 0000000..dba0bda --- /dev/null +++ b/Sources/OpenSpatial/Protocols/Volumetric.swift @@ -0,0 +1,101 @@ + +// Volumetric.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A set of methods for working with Spatial primitives with volume. +public protocol Volumetric { + + // MARK: - Instance properties + + /// The size of the volume. + var size: Size3D { get } + + // MARK: - Instance methods + + /// Returns a Boolean value that indicates whether the entity contains the specified volumetric entity. + /// + /// - Parameter other: The volumetric entity that the function compares against. + /// - Returns: A Boolean value that indicates whether the entity contains the specified volumetric entity + func contains(_ other: Self) -> Bool + + /// Returns a Boolean value that indicates whether this volume contains the specified point. + /// + /// - Parameter point: The point that the function compares against. + /// - Returns: A Boolean value that indicates whether this volume contains the specified point. + func contains(point: Point3D) -> Bool + + /// Returns a Boolean value that indicates whether this volume contains any of the specified points. + /// + /// - Parameter points: The array of points that the function compares against. + /// - Returns: A Boolean value that indicates whether this volume contains any of the specified points + func contains(anyOf points: [Point3D]) -> Bool + + /// Sets the primitive to the intersection of itself and the specified primitive. + /// + /// - Parameter other: The primitive to intersect with. + mutating func formIntersection(_ other: Self) + + /// Sets the primitive to the union of itself and the specified primitive. + /// + /// - Parameter other: The primitive to union with. + mutating func formUnion(_ other: Self) + + /// Returns the intersection of this primitive and the specified primitive. + /// + /// - Parameter other: The primitive to intersect with. + /// - Returns: The intersection of this primitive and the specified primitive, or `nil` if they do not intersect. + func intersection(_ other: Self) -> Self? + + /// Returns the union of this primitive and the specified primitive. + /// + /// - Parameter other: The primitive to union with. + /// - Returns: The union of this primitive and the specified primitive. + func union(_ other: Self) -> Self +} + +extension Volumetric { + + // MARK: - Instance methods + + /// Returns a Boolean value that indicates whether this volume contains any of the specified points. + /// + /// - Parameter points: The array of points that the function compares against. + /// - Returns: A Boolean value that indicates whether this volume contains any of the specified points + /// - Complexity: O(n), where n is the number of points. + @inline(__always) + public func contains(anyOf points: [Point3D]) -> Bool { + for point in points { + if contains(point: point) { + return true + } + } + + return false + } + + /// Sets the primitive to the intersection of itself and the specified primitive. + /// + /// - Parameter other: The primitive to intersect with. + @inline(__always) + public mutating func formIntersection(_ other: Self) { + if let intersection = intersection(other) { + self = intersection + } + } + + /// Sets the primitive to the union of itself and the specified primitive. + /// + /// - Parameter other: The primitive to union with. + @inline(__always) + public mutating func formUnion(_ other: Self) { + self = union(other) + } +} diff --git a/Sources/OpenSpatial/Structures/EulerAngles.swift b/Sources/OpenSpatial/Structures/EulerAngles.swift new file mode 100644 index 0000000..45345d1 --- /dev/null +++ b/Sources/OpenSpatial/Structures/EulerAngles.swift @@ -0,0 +1,79 @@ + +// EulerAngles.swift +// This source file is part of the OpenSpatial open source project +// +// Copyright (c) 2026 Helbert Gomes. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +import Foundation + +/// A vector that represents three Euler angles and specifies the angle ordering. +@frozen +public struct EulerAngles: Copyable, Codable, Equatable, Hashable, Sendable { + + // MARK: - Initializers + + /// Creates a new Euler angles structure. + @inline(__always) + public init() { + self.x = .zero + self.y = .zero + self.z = .zero + self.order = .xyz + } + + /// Creates a new Euler angles structure from the specified angle structures and order. + /// + /// - Parameters: + /// - x: The angle around the x-axis. + /// - y: The angle around the y-axis. + /// - z: The angle around the z-axis. + /// - order: The order of the angles. + @inline(__always) + public init(x: Angle2D, y: Angle2D, z: Angle2D, order: EulerAngles.Order) { + (self.x, self.y, self.z, self.order) = (x, y, z, order) + } + + // MARK: - Checking characteristics + + /// The angle around the x-axis (Pitch) + public var x: Angle2D + + /// The angle around the y-axis (Yaw). + public var y: Angle2D + + /// The angle around the z-axis (Roll). + public var z: Angle2D + + /// The order of the angles. + public var order: EulerAngles.Order + + /// A three-element vector that specifies the Euler angles. + public var angles: [Angle2D] { + [x, y, z] + } +} + +extension EulerAngles: CustomStringConvertible { + + /// A textual representation of the Euler angles. + public var description: String { + return "(x: \(x.radians), y: \(y.radians), z: \(z.radians), order: \(order.rawValue))" + } +} + +extension EulerAngles { + + /// The order of the Euler angles. + public enum Order: String, CaseIterable, Codable, Equatable, Hashable, Sendable { + + /// The angles are applied in the order of x, y, z. + case xyz + + /// The angles are applied in the order of z, x, y. + case zxy + } +} diff --git a/Tests/OpenSpatialTests/2D primitives/Angle2DTests.swift b/Tests/OpenSpatialTests/2D primitives/Angle2DTests.swift new file mode 100644 index 0000000..15b1737 --- /dev/null +++ b/Tests/OpenSpatialTests/2D primitives/Angle2DTests.swift @@ -0,0 +1,172 @@ +import Foundation +import Testing +@testable import OpenSpatial + +struct Angle2DTests { + + // MARK: - Creating an angle structure + + @Test func initializationWithRadians() { + let angle = Angle2D(radians: Double.pi / 4) + #expect(angle.radians == Double.pi / 4) + } + + @Test func initializationWithDegrees() { + let angle = Angle2D(degrees: 90.0) + #expect(angle.degrees == 90.0) + } + + @Test func testInitializationUsingRadiansFloatingPoint() { + let angle = Angle2D(radians: Float(Float.pi / 2)) + #expect(angle.radians == Double(Float.pi / 2)) + } + + @Test func testInitializationUsingDegreesFloatingPoint() { + let angle = Angle2D(degrees: Float(180.0)) + #expect(angle.degrees == 180.0) + } + + @Test func testInitializationUsingZero() { + let angle = Angle2D() + #expect(angle.radians == 0.0) + } + + @Test func testInitializationIntergerLiteral() { + let angle: Angle2D = 0 + #expect(angle.radians == 0.0) + #expect(angle.degrees == 0.0) + } + + @Test func testInitializationFloatingPointLiteral() { + let angle: Angle2D = 1.5708 // Approx. π/2 + #expect(angle.radians == 1.5708) + } + + @Test func testInitializationUsingStaticDegrees() { + let angle = Angle2D.degrees(90) + #expect(angle.radians == Double.pi / 2) + } + + @Test func testInitializationUsingStaticRadians() { + let angle = Angle2D.radians(Double.pi / 2) + #expect(angle.degrees == 90) + } + + // MARK: - AdditiveArithmetic + + @Test func testZero() { + let angle = Angle2D.zero + #expect(angle.degrees == 0) + #expect(angle.radians == 0) + } + + @Test func testAddition() { + let angle1 = Angle2D(radians: Double.pi / 4) + let angle2 = Angle2D(radians: Double.pi / 4) + let result = angle1 + angle2 + #expect(result.radians == Double.pi / 2) + } + + @Test func testAddition2() { + var angle1 = Angle2D(radians: Double.pi / 4) + let angle2 = Angle2D(radians: Double.pi / 4) + angle1 += angle2 + #expect(angle1.radians == Double.pi / 2) + } + + @Test func testSubtraction() { + let angle1 = Angle2D(radians: Double.pi / 2) + let angle2 = Angle2D(radians: Double.pi / 4) + let result = angle1 - angle2 + #expect(result.radians == Double.pi / 4) + } + + @Test func testSubtraction2() { + var angle1 = Angle2D(radians: Double.pi / 2) + let angle2 = Angle2D(radians: Double.pi / 4) + angle1 -= angle2 + #expect(angle1.radians == Double.pi / 4) + } + + // MARK: - Comparable + + @Test func testComparison() { + let angle1 = Angle2D(radians: Double.pi / 4) + let angle2 = Angle2D(radians: Double.pi / 2) + #expect(angle1 < angle2) + #expect(angle2 > angle1) + #expect(angle1 <= angle2) + #expect(angle2 >= angle1) + } + + @Test func testIsApproximatelyEqual() { + let a = Angle2D(degrees: 90) + let b = Angle2D(radians: Double.pi / 2) + #expect(a.isApproximatelyEqual(to: b)) + } + + @Test func testIsApproximatelyEqualWithTolerance() { + let a = Angle2D(radians: 1.0) + let b = Angle2D(radians: 1.0 + 1e-10) + #expect(a.isApproximatelyEqual(to: b, tolerance: 1e-8)) + } + + @Test func testComparableRadiansOnly() { + #expect(Angle2D(degrees: 90) < Angle2D(degrees: 180)) + #expect(Angle2D(radians: .pi) < Angle2D(radians: 2 * .pi)) + } + + // MARK: - Private math functions (validated via public API surface) + // These functions are used internally by the library and validated + // by comparing results against Foundation's reference implementations. + + @Test func testDegreesToRadiansRoundTrip() { + for deg in stride(from: -360.0, through: 360.0, by: 45.0) { + let angle = Angle2D(degrees: deg) + #expect(abs(angle.degrees - deg) < 1e-10, "Round-trip failed for \(deg)°") + } + } + + @Test func testRadiansToDegreesKnownValues() { + #expect(Angle2D(radians: 0).degrees == 0) + #expect(abs(Angle2D(radians: .pi / 2).degrees - 90.0) < 1e-10) + #expect(abs(Angle2D(radians: .pi).degrees - 180.0) < 1e-10) + #expect(abs(Angle2D(radians: 2 * .pi).degrees - 360.0) < 1e-10) + #expect(abs(Angle2D(radians: -.pi / 2).degrees - (-90.0)) < 1e-10) + } + + @Test func testQuaternionFromAngleAxisUsesCorrectTrig() { + // Quaternion(angle:axis:) calls Foundation.sin/cos with the angle/2. + // Validate that a 90° rotation around Y produces the expected quaternion components. + let q = Quaternion3D(angle: Angle2D(degrees: 90), axis: Vector3D(x: 0, y: 1, z: 0)) + let expected = Foundation.sin(Double.pi / 4) // sin(45°) + #expect(abs(q.y - expected) < 1e-10) + #expect(abs(q.w - Foundation.cos(Double.pi / 4)) < 1e-10) + } + + @Test func testQuaternionFromEulerAnglesXYZ() { + // Euler(45°, 0°, 0°) in xyz order via Rotation3D which uses half-angle: + // hx = 22.5°, q.x ≈ sin(22.5°), q.w ≈ cos(22.5°) + let euler = EulerAngles(x: .init(degrees: 45), y: .init(degrees: 0), z: .init(degrees: 0), order: .xyz) + let r = Rotation3D(eulerAngles: euler) + let q = r.quaternion + let halfRad = Double.pi / 8 // 22.5° in radians + #expect(abs(q.x - Foundation.sin(halfRad)) < 1e-10) + #expect(abs(q.w - Foundation.cos(halfRad)) < 1e-10) + #expect(abs(q.y) < 1e-10) + #expect(abs(q.z) < 1e-10) + } + + @Test func testAngleAdditionIdentity() { + let zero = Angle2D.zero + let angle = Angle2D(degrees: 45) + #expect((zero + angle).radians == angle.radians) + #expect((angle + zero).radians == angle.radians) + } + + @Test func testAngleNegation() { + let angle = Angle2D(degrees: 90) + let negated = Angle2D.zero - angle + #expect(negated.degrees == -90.0) + } +} \ No newline at end of file diff --git a/Tests/OpenSpatialTests/3D primitives/EulerAnglesTests.swift b/Tests/OpenSpatialTests/3D primitives/EulerAnglesTests.swift new file mode 100644 index 0000000..df16523 --- /dev/null +++ b/Tests/OpenSpatialTests/3D primitives/EulerAnglesTests.swift @@ -0,0 +1,27 @@ +import Testing +@testable import OpenSpatial + +struct EulerAnglesTests { + + // MARK: - Initialization tests + + @Test func testInitialization() { + let eulerAngles = EulerAngles() + #expect(eulerAngles.x == 0.0) + #expect(eulerAngles.y == 0.0) + #expect(eulerAngles.z == 0.0) + #expect(eulerAngles.angles == [0.0, 0.0, 0.0]) + + #expect(eulerAngles.description == "(x: 0.0, y: 0.0, z: 0.0, order: xyz)") + } + + @Test func testInitializationWithParameters() { + let eulerAngles = EulerAngles(x: 30.0, y: 45.0, z: 60.0, order: .zxy) + #expect(eulerAngles.x == 30.0) + #expect(eulerAngles.y == 45.0) + #expect(eulerAngles.z == 60.0) + #expect(eulerAngles.angles == [30.0, 45.0, 60.0]) + + #expect(eulerAngles.description == "(x: 30.0, y: 45.0, z: 60.0, order: zxy)") + } +} \ No newline at end of file diff --git a/Tests/OpenSpatialTests/3D primitives/Point3DTests.swift b/Tests/OpenSpatialTests/3D primitives/Point3DTests.swift new file mode 100644 index 0000000..3af3ae5 --- /dev/null +++ b/Tests/OpenSpatialTests/3D primitives/Point3DTests.swift @@ -0,0 +1,312 @@ +import Testing +@testable import OpenSpatial + +struct Point3DTests { + + // MARK: - Initialization tests + + @Test func testInitialization() { + let point = Point3D() + #expect(point.x == 0.0) + #expect(point.y == 0.0) + #expect(point.z == 0.0) + } + + @Test func testInitializationUsingDefaultParams() { + let point = Point3D(x: 2.0, y: 1.0) + #expect(point.x == 2.0) + #expect(point.y == 1.0) + #expect(point.z == 0.0) + } + + @Test func testInitializationUsingFloatingPoint() { + let point = Point3D(x: Float(3.5), y: Float(4.5), z: Float(5.5)) + #expect(point.x == 3.5) + #expect(point.y == 4.5) + #expect(point.z == 5.5) + } + + @Test func testInitializationUsingArrayLiteral() { + let point: Point3D = [1.0, 2.0, 3.0] + #expect(point.x == 1.0) + #expect(point.y == 2.0) + #expect(point.z == 3.0) + } + + @Test func testInitializationUsingSize3D() { + let size = Size3D(width: 3, height: 5, depth: 8) + let point = Point3D(size) + #expect(point.x == size.width) + #expect(point.y == size.height) + #expect(point.z == size.depth) + } + + @Test func testInitializationUsingVector3D() { + let vector = Vector3D(x: 3, y: 5, z: 8) + let point = Point3D(vector) + #expect(point.x == vector.x) + #expect(point.y == vector.y) + #expect(point.z == vector.z) + } + + // MARK: - Subscripting tests + + @Test func testSubscriptGetter() throws { + let point = Point3D(x: 1.0, y: 2.0, z: 3.0) + #expect(try point[0] == 1.0) + #expect(try point[1] == 2.0) + #expect(try point[2] == 3.0) + } + + @Test func testSubscriptError() throws { + let point = Point3D.zero + #expect(throws: OpenSpatial.Error.self) { + try point[4] == 0 + } + } + + // MARK: - Inspecting a 3D point’s properties + + @Test func testMagnitude() { + let point = Point3D(x: 3.0, y: 4.0, z: 0.0) + #expect(point.magnitudeSquared == 25.0) // sqrt(3^2 + 4^2 + 0^2) = 5 + } + + @Test func testVector() { + let point = Point3D(x: 1.0, y: 2.0, z: 3.0) + #expect(point.vector == [1.0, 2.0, 3.0]) + } + + // MARK: - Checking characteristics + + @Test func testDistanceToAnotherPoint() { + let point1 = Point3D(x: 1.0, y: 2.0, z: 3.0) + let point2 = Point3D(x: 4.0, y: 6.0, z: 3.0) + let distance = point1.distance(to: point2) + #expect(distance == 5.0) // sqrt((4-1)^2 + (6-2)^2 + (3-3)^2) = 5 + } + + // MARK: - Comparing values + + @Test func testIsApproximatelyEqual() { + let point1 = Point3D(x: 1.000001, y: 2.000001, z: 3.000001) + let point2 = Point3D(x: 1.000002, y: 2.000002, z: 3.000002) + #expect(point1.isApproximatelyEqual(to: point2, tolerance: 0.00001) == true) + } + + // MARK: - Applying arithmetic operations + + @Test func testAddition() { + let point1 = Point3D(x: 1.0, y: 2.0, z: 3.0) + let point2 = Point3D(x: 4.0, y: 5.0, z: 6.0) + let result = point1 + point2 + #expect(result == Point3D(x: 5.0, y: 7.0, z: 9.0)) + } + + @Test func testAddition2() { + var point1 = Point3D(x: 1.0, y: 2.0, z: 3.0) + let point2 = Point3D(x: 4.0, y: 5.0, z: 6.0) + point1 += point2 + #expect(point1 == Point3D(x: 5.0, y: 7.0, z: 9.0)) + } + + @Test func testAddingVector() { + let point = Point3D(x: 1.0, y: 2.0, z: 3.0) + let vector = Vector3D(x: 4.0, y: 5.0, z: 6.0) + let result = point + vector + #expect(result == Point3D(x: 5.0, y: 7.0, z: 9.0)) + } + + @Test func testAddingVector2() { + var point = Point3D(x: 1.0, y: 2.0, z: 3.0) + let vector = Vector3D(x: 4.0, y: 5.0, z: 6.0) + point += vector + #expect(point == Point3D(x: 5.0, y: 7.0, z: 9.0)) + } + + @Test func testAddingSize() { + let point = Point3D(x: 1.0, y: 2.0, z: 3.0) + let size = Size3D(width: 4.0, height: 5.0, depth: 6.0) + let result = point + size + #expect(result == Point3D(x: 5.0, y: 7.0, z: 9.0)) + } + + @Test func testAddingSize2() { + let point = Point3D(x: 1.0, y: 2.0, z: 3.0) + let size = Size3D(width: 4.0, height: 5.0, depth: 6.0) + let result = size + point + #expect(result == Point3D(x: 5.0, y: 7.0, z: 9.0)) + } + + + @Test func testSubtractingPoint() { + let point1 = Point3D(x: 5.0, y: 7.0, z: 9.0) + let point2 = Point3D(x: 4.0, y: 5.0, z: 6.0) + let result = point1 - point2 + #expect(result == Point3D(x: 1.0, y: 2.0, z: 3.0)) + } + + @Test func testSubtractingPoint2() { + var point1 = Point3D(x: 5.0, y: 7.0, z: 9.0) + let point2 = Point3D(x: 4.0, y: 5.0, z: 6.0) + point1 -= point2 + #expect(point1 == Point3D(x: 1.0, y: 2.0, z: 3.0)) + } + + @Test func testSubtractingSize() { + let point = Point3D(x: 5.0, y: 7.0, z: 9.0) + let size = Size3D(width: 4.0, height: 5.0, depth: 6.0) + let result = point - size + #expect(result == Point3D(x: 1.0, y: 2.0, z: 3.0)) + } + + @Test func testSubtractingSize2() { + let point = Point3D(x: 5.0, y: 7.0, z: 9.0) + let size = Size3D(width: 4.0, height: 5.0, depth: 6.0) + let result = size - point + #expect(result == Point3D(x: -1.0, y: -2.0, z: -3.0)) + } + + @Test func testSubtractingSize3() { + var point = Point3D(x: 5.0, y: 7.0, z: 9.0) + let size = Size3D(width: 4.0, height: 5.0, depth: 6.0) + point -= size + #expect(point == Point3D(x: 1.0, y: 2.0, z: 3.0)) + } + + @Test func testSubtractingVector() { + let point = Point3D(x: 5.0, y: 7.0, z: 9.0) + let vector = Vector3D(x: 4.0, y: 5.0, z: 6.0) + let result = point - vector + #expect(result == Point3D(x: 1.0, y: 2.0, z: 3.0)) + } + + @Test func testSubtractingVector2() { + var point = Point3D(x: 5.0, y: 7.0, z: 9.0) + let vector = Vector3D(x: 4.0, y: 5.0, z: 6.0) + point -= vector + #expect(point == Point3D(x: 1.0, y: 2.0, z: 3.0)) + } + + @Test func testMultiplication() { + let point = Point3D(x: 1.0, y: 2.0, z: 3.0) + let scaledPoint = point * 3.0 + #expect(scaledPoint == Point3D(x: 3.0, y: 6.0, z: 9.0)) + } + + @Test func testMultiplication2() { + let point = Point3D(x: 1.0, y: 2.0, z: 3.0) + let scaledPoint = 3.0 * point + #expect(scaledPoint == Point3D(x: 3.0, y: 6.0, z: 9.0)) + } + + @Test func testMultiplication3() { + var point = Point3D(x: 1.0, y: 2.0, z: 3.0) + point *= 3.0 + #expect(point == Point3D(x: 3.0, y: 6.0, z: 9.0)) + } + + @Test func testMultiplication4() { + let affine = AffineTransform3D() + let point1 = Point3D(x: 1.0, y: 2.0, z: 3.0) + let point2 = affine * point1 + #expect(point2 == Point3D(x: 1.0, y: 2.0, z: 3.0)) + } + + @Test func testDivision() { + let point = Point3D(x: 4.0, y: 8.0, z: 12.0) + let dividedPoint = point / 4.0 + #expect(dividedPoint == Point3D(x: 1.0, y: 2.0, z: 3.0)) + } + + @Test func testDivision2() { + var point = Point3D(x: 4.0, y: 8.0, z: 12.0) + point /= 4.0 + #expect(point == Point3D(x: 1.0, y: 2.0, z: 3.0)) + } + + // MARK: - Primitive3D tests + + @Test func testIsFinite() { + let point = Point3D.zero + #expect(point.isFinite == true) + } + + @Test func testIsNotFinite() { + let point = Point3D.infinity + #expect(point.isFinite == false) + } + + @Test func testIsNaN() { + let point = Point3D(x: Double.nan, y: 0.0, z: 0.0) + #expect(point.isNaN == true) + } + + @Test func testIsZero() { + let point = Point3D(x: 0.0, y: 0.0, z: 0.0) + #expect(point.isZero == true) + } + + @Test func testInfinity() { + let point = Point3D.infinity + #expect(point.x == Double.infinity) + #expect(point.y == Double.infinity) + #expect(point.z == Double.infinity) + } + + @Test func testNaN() { + let point = Point3D(x: Double.nan, y: Double.nan, z: Double.nan) + #expect(point.x.isNaN) + #expect(point.y.isNaN) + #expect(point.z.isNaN) + } + + @Test func testApplyingAffineTransform() { + let point = Point3D(x: 1.0, y: 2.0, z: 3.0) + let transform = AffineTransform3D(matrix: [ + [10.0, 0.0, 0.0, 0.0], + [0.0, 20.0, 0.0, 0.0], + [0.0, 0.0, 30.0, 0.0], + [0.0, 0.0, 0.0, 1.0] + ]) + + let transformed = point.applying(transform) + #expect(transformed == Point3D(x: 10.0, y: 40.0, z: 90.0)) + } + + // MARK: - Scalable3D tests + + @Test func testScalingPoint3D() { + var point = Point3D(x: 1.0, y: 2.0, z: 3.0) + point.scaleBy(x: 2.0, y: 3.0, z: 4.0) + #expect(point == Point3D(x: 2.0, y: 6.0, z: 12.0)) + } + + @Test func testUniformlyScale() { + var point = Point3D(x: 1.0, y: 2.0, z: 3.0) + point.uniformlyScale(by: 3.0) + #expect(point == Point3D(x: 3.0, y: 6.0, z: 9.0)) + } + + @Test func testUniformlyScaled() { + let point1 = Point3D(x: 1.0, y: 2.0, z: 3.0) + let point2 = point1.uniformlyScaled(by: 3.0) + #expect(point2 == Point3D(x: 3.0, y: 6.0, z: 9.0)) + } + + // MARK: - Translatable3D tests + + @Test func testTranslatingPoint3D() { + var point = Point3D(x: 1.0, y: 2.0, z: 3.0) + let vector = Vector3D(x: 4.0, y: 5.0, z: 6.0) + point = point.translated(by: vector) + #expect(point == Point3D(x: 5.0, y: 7.0, z: 9.0)) + } + + // MARK: - CustomStringConvertible + + @Test func testDescription() { + let point = Point3D.zero + #expect(point.description == "(x: 0.0, y: 0.0, z: 0.0)") + } +} \ No newline at end of file diff --git a/Tests/OpenSpatialTests/3D primitives/Pose3DTests.swift b/Tests/OpenSpatialTests/3D primitives/Pose3DTests.swift new file mode 100644 index 0000000..548cfd5 --- /dev/null +++ b/Tests/OpenSpatialTests/3D primitives/Pose3DTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing +@testable import OpenSpatial + +struct Pose3DTests { + + // MARK: - Initialization tests + + @Test func testDefaultInit() { + let pose = Pose3D() + #expect(pose.position == Point3D()) + #expect(pose.rotation == Rotation3D()) + } + + @Test func testInitWithPositionAndRotation() { + let position = Point3D(x: 1, y: 2, z: 3) + let rotation = Rotation3D() + let pose = Pose3D(position: position, rotation: rotation) + #expect(pose.position == position) + #expect(pose.rotation == rotation) + } + + @Test func testInitWithForwardAndUp() { + let forward = Vector3D(x: 0, y: 0, z: 1) + let up = Vector3D(x: 0, y: 1, z: 0) + let pose = Pose3D(forward: forward, up: up) + #expect(pose.position == Point3D()) + #expect(pose.rotation == Rotation3D(forward: forward, up: up)) + } + + // MARK: - Identity + + @Test func testIdentity() { + let identity = Pose3D.identity + #expect(identity.position == Point3D()) + #expect(identity.rotation == Rotation3D()) + } + + @Test func testIsIdentityTrue() { + let pose = Pose3D() + #expect(pose.isIdentity) + } + + @Test func testIsIdentityFalsePosition() { + let pose = Pose3D(position: Point3D(x: 1, y: 0, z: 0), rotation: Rotation3D()) + #expect(!pose.isIdentity) + } + + @Test func testIsIdentityFalseRotation() { + let rotation = Rotation3D(angle: Angle2D(degrees: 90), axis: RotationAxis3D(x: 0, y: 1, z: 0)) + let pose = Pose3D(position: Point3D(), rotation: rotation) + #expect(!pose.isIdentity) + } + + // MARK: - Inverse + + @Test func testInverseOfIdentity() { + let pose = Pose3D() + let inv = pose.inverse + #expect(inv.position.x.rounded(toPlaces: 10) == 0.0) + #expect(inv.position.y.rounded(toPlaces: 10) == 0.0) + #expect(inv.position.z.rounded(toPlaces: 10) == 0.0) + #expect(inv.rotation.isIdentity) + } + + @Test func testInverseRoundTrip() { + let position = Point3D(x: 1, y: 2, z: 3) + let rotation = Rotation3D(angle: Angle2D(degrees: 90), axis: RotationAxis3D(x: 0, y: 1, z: 0)) + let pose = Pose3D(position: position, rotation: rotation) + let inv = pose.inverse + + let composed = Rotation3D(quaternion: pose.rotation.quaternion * inv.rotation.quaternion) + #expect(composed.isIdentity) + } + + // MARK: - Translatable3D conformance + + @Test func testTranslatedBy() { + let pose = Pose3D(position: Point3D(x: 1, y: 2, z: 3), rotation: Rotation3D()) + let result = pose.translated(by: Vector3D(x: 1, y: 0, z: 0)) + #expect(result.position == Point3D(x: 2, y: 2, z: 3)) + #expect(result.rotation == pose.rotation) + } + + @Test func testTranslateBy() { + var pose = Pose3D(position: Point3D(x: 0, y: 0, z: 0), rotation: Rotation3D()) + pose.translate(by: Vector3D(x: 5, y: 0, z: 0)) + #expect(pose.position == Point3D(x: 5, y: 0, z: 0)) + } + + // MARK: - Rotatable3D conformance + + @Test func testRotatedByRotation3D() { + let pose = Pose3D() + let rotation = Rotation3D() + let result = pose.rotated(by: rotation) + #expect(result.rotation.isIdentity) + #expect(result.position == pose.position) + } + + @Test func testRotatedByQuaternion() { + let pose = Pose3D() + let q = Quaternion3D(x: 0, y: 0, z: 0, w: 1) + let result = pose.rotated(by: q) + #expect(result.rotation.isIdentity) + } + + // MARK: - Applying transform + + @Test func testApplyingIdentityTransform() { + let pose = Pose3D(position: Point3D(x: 1, y: 2, z: 3), rotation: Rotation3D()) + let result = pose.applying(AffineTransform3D()) + #expect(result.position.x.rounded(toPlaces: 10) == 1.0) + #expect(result.position.y.rounded(toPlaces: 10) == 2.0) + #expect(result.position.z.rounded(toPlaces: 10) == 3.0) + } + + @Test func testApplyingTranslationTransform() { + let pose = Pose3D(position: Point3D(x: 1, y: 0, z: 0), rotation: Rotation3D()) + let t = AffineTransform3D(translation: Vector3D(x: 2, y: 3, z: 4)) + let result = pose.applying(t) + #expect(result.position.x.rounded(toPlaces: 10) == 3.0) + #expect(result.position.y.rounded(toPlaces: 10) == 3.0) + #expect(result.position.z.rounded(toPlaces: 10) == 4.0) + } + + // MARK: - Codable conformance + + @Test func testCodableRoundTrip() throws { + let pose = Pose3D(position: Point3D(x: 1, y: 2, z: 3), rotation: Rotation3D()) + let encoder = JSONEncoder() + let data = try encoder.encode(pose) + let decoder = JSONDecoder() + let decoded = try decoder.decode(Pose3D.self, from: data) + #expect(decoded == pose) + } +} diff --git a/Tests/OpenSpatialTests/3D primitives/Quaternion3DTests.swift b/Tests/OpenSpatialTests/3D primitives/Quaternion3DTests.swift new file mode 100644 index 0000000..e23bfeb --- /dev/null +++ b/Tests/OpenSpatialTests/3D primitives/Quaternion3DTests.swift @@ -0,0 +1,169 @@ +import Testing +@testable import OpenSpatial + +struct Quaternion3DTests { + + // MARK: - Initialization tests + + @Test func testInitialization() { + let quaternion = Quaternion3D() + #expect(quaternion.x == 0) + #expect(quaternion.y == 0) + #expect(quaternion.z == 0) + #expect(quaternion.w == 1) + + #expect(quaternion.vector == [ 0.0, 0.0, 0.0, 1.0 ]) + #expect(quaternion.lengthSquared == 1.0) + #expect(quaternion.length == 1.0) + #expect(quaternion.conjugated() == Quaternion3D(x: 0, y: 0, z: 0, w: 1)) + #expect(quaternion.inverted() == Quaternion3D(x: 0, y: 0, z: 0, w: 1)) + #expect(quaternion.normalized == Quaternion3D(x: 0, y: 0, z: 0, w: 1)) + #expect(quaternion.isZero == false) + #expect(quaternion.isNaN == false) + #expect(quaternion.isFinite == true) + #expect(quaternion.description == "(x: 0.0, y: 0.0, z: 0.0, w: 1.0)") + } + + @Test func testInitializationUsingDefaultParams() { + let quaternion = Quaternion3D(x: 1.0, y: 2.0, z: 3.0, w: 4.0) + #expect(quaternion.x == 1.0) + #expect(quaternion.y == 2.0) + #expect(quaternion.z == 3.0) + #expect(quaternion.w == 4.0) + } + + @Test func testInitializationUsingArrayLiteral() { + let quaternion: Quaternion3D = [0.0, 0.0, 0.0, 0.0] + #expect(quaternion.x == 0.0) + #expect(quaternion.y == 0.0) + #expect(quaternion.z == 0.0) + #expect(quaternion.w == 0.0) + + #expect(quaternion == .zero) + } + + @Test func testInitializationUsingInfinityValues() { + let quaternion = Quaternion3D(x: .infinity, y: .infinity, z: .infinity, w: .infinity) + #expect(quaternion == .infinity) + #expect(quaternion.isFinite == false) + } + + // MARK: - Subscripting tests + + @Test func testSubscriptGetter() throws { + let quaternion = Quaternion3D(x: 1.0, y: 2.0, z: 3.0, w: 4.0) + #expect(try quaternion[0] == 1.0) + #expect(try quaternion[1] == 2.0) + #expect(try quaternion[2] == 3.0) + #expect(try quaternion[3] == 4.0) + } + + @Test func testSubscriptError() throws { + let quaternion = Quaternion3D.zero + #expect(throws: OpenSpatial.Error.self) { + try quaternion[5] == 0 + } + } + + @Test func testMultiplication() { + let q1 = Quaternion3D(x: 1.0, y: 2.0, z: 3.0, w: 4.0) + let q2 = Quaternion3D(x: 5.0, y: 6.0, z: 7.0, w: 8.0) + let result = q1 * q2 + #expect(result.x == 24.0) + #expect(result.y == 48.0) + #expect(result.z == 48.0) + #expect(result.w == -6.0) + } + + // @Test func testInitializationUsingAngleAndAxisXYZ() { + // let quaternion = Quaternion3D(angle: Angle2D(radians: Double.pi / 2), axis: Vector3D(x: 1.0, y: 1.0, z: 1.0)) + // #expect(quaternion.x == 0.7071067811865475) + // #expect(quaternion.y == 0.7071067811865475) + // #expect(quaternion.z == 0.7071067811865475) + // #expect(quaternion.w == 0.7071067811865476) + // } + + // @Test func testInitializationUsingAngleAndAxisXY() { + // let quaternion = Quaternion3D(angle: Angle2D(radians: Double.pi / 2), axis: Vector3D(x: 1.0, y: 1.0, z: 0.0)) + // #expect(quaternion.x == 0.7071067811865475) + // #expect(quaternion.y == 0.7071067811865475) + // #expect(quaternion.z == 0.0) + // #expect(quaternion.w == 0.7071067811865476) + // } + + @Test func testIdentity() { + let identity = Quaternion3D.identity + #expect(identity.x == 0.0) + #expect(identity.y == 0.0) + #expect(identity.z == 0.0) + #expect(identity.w == 1.0) + } + + @Test func testAct() { + let q = Quaternion3D(angle: Angle2D(radians: .pi / 2), axis: .up) + let v = Vector3D(x: 1.0, y: 0.0, z: 0.0) + let result = q.act(v) + #expect(result.x.rounded(toPlaces: 10) == 0.0) + #expect(result.y.rounded(toPlaces: 10) == 0.0) + #expect(result.z.rounded(toPlaces: 10) == -1.0) + } + + @Test func testInitFromTo() { + let from = Vector3D.right + let to = Vector3D.forward + let q = Quaternion3D(from: from, to: to) + let rotated = q.act(from) + #expect(rotated.x.rounded(toPlaces: 10) == to.x.rounded(toPlaces: 10)) + #expect(rotated.y.rounded(toPlaces: 10) == to.y.rounded(toPlaces: 10)) + #expect(rotated.z.rounded(toPlaces: 10) == to.z.rounded(toPlaces: 10)) + } + + @Test func testInitFromToIdentity() { + let v = Vector3D.up + let q = Quaternion3D(from: v, to: v) + #expect(q.x.rounded(toPlaces: 10) == 0.0) + #expect(q.y.rounded(toPlaces: 10) == 0.0) + #expect(q.z.rounded(toPlaces: 10) == 0.0) + #expect(q.w.rounded(toPlaces: 10) == 1.0) + } + + @Test func testInitFromTo180Degrees() { + // Opposite vectors → 180-degree rotation quaternion, w == 0 + let from = Vector3D.right + let to = Vector3D(x: -1, y: 0, z: 0) + let q = Quaternion3D(from: from, to: to) + #expect(q.w.rounded(toPlaces: 10) == 0.0) + // Round-tripping: acting on `from` with q should give `to` + let rotated = q.act(from) + #expect(rotated.x.rounded(toPlaces: 10) == to.x.rounded(toPlaces: 10)) + #expect(rotated.y.rounded(toPlaces: 10) == to.y.rounded(toPlaces: 10)) + #expect(rotated.z.rounded(toPlaces: 10) == to.z.rounded(toPlaces: 10)) + } + + @Test func testInitFromEulerAnglesXYZ() { + // Note: Quaternion3D(eulerAngles:) uses FULL angles (not half-angles). + // This test just ensures the init runs and produces a unit quaternion. + let angles = EulerAngles(x: Angle2D(degrees: 30), y: Angle2D(degrees: 45), z: Angle2D(degrees: 60), order: .xyz) + let q = Quaternion3D(angles, order: .xyz) + let len = (q.x*q.x + q.y*q.y + q.z*q.z + q.w*q.w) + #expect(len.rounded(toPlaces: 10) == 1.0) + } + + @Test func testInitFromEulerAnglesZXY() { + let angles = EulerAngles(x: Angle2D(degrees: 30), y: Angle2D(degrees: 45), z: Angle2D(degrees: 60), order: .zxy) + let q = Quaternion3D(angles, order: .zxy) + let len = (q.x*q.x + q.y*q.y + q.z*q.z + q.w*q.w) + #expect(len.rounded(toPlaces: 10) == 1.0) + } + + @Test func testEulerAnglesRoundTrip() { + // eulerAngles property should return angles whose quaternion form matches the original + let q = Quaternion3D(angle: Angle2D(radians: .pi / 4), axis: .up) + let euler = q.eulerAngles + // Just verify it returns a valid EulerAngles without crashing + #expect(euler.order == .xyz) + #expect(euler.x.radians.isFinite) + #expect(euler.y.radians.isFinite) + #expect(euler.z.radians.isFinite) + } +} \ No newline at end of file diff --git a/Tests/OpenSpatialTests/3D primitives/Ray3DTests.swift b/Tests/OpenSpatialTests/3D primitives/Ray3DTests.swift new file mode 100644 index 0000000..ce7b2b0 --- /dev/null +++ b/Tests/OpenSpatialTests/3D primitives/Ray3DTests.swift @@ -0,0 +1,290 @@ +import Testing +import Foundation +@testable import OpenSpatial + +struct Ray3DTests { + + // MARK: - Initialization + + @Test func testDefaultInit() { + let ray = Ray3D() + #expect(ray.origin == .zero) + #expect(ray.direction == .zero) + } + + @Test func testInitNormalizesDirection() { + let dir = Vector3D(x: 3, y: 0, z: 0) + let ray = Ray3D(origin: .zero, direction: dir) + #expect(ray.direction.x.rounded(toPlaces: 10) == 1.0) + #expect(ray.direction.y.rounded(toPlaces: 10) == 0.0) + #expect(ray.direction.z.rounded(toPlaces: 10) == 0.0) + } + + @Test func testInitDiagonalDirectionNormalized() { + let dir = Vector3D(x: 1, y: 1, z: 1) + let ray = Ray3D(origin: .zero, direction: dir) + let expectedLen = dir.length + #expect(abs(ray.direction.x - 1.0 / expectedLen) < 1e-10) + #expect(abs(ray.direction.y - 1.0 / expectedLen) < 1e-10) + #expect(abs(ray.direction.z - 1.0 / expectedLen) < 1e-10) + } + + @Test func testInitWithOrigin() { + let origin = Point3D(x: 1, y: 2, z: 3) + let dir = Vector3D(x: 0, y: 0, z: 1) + let ray = Ray3D(origin: origin, direction: dir) + #expect(ray.origin == origin) + #expect(ray.direction == dir) + } + + @Test func testInitDefaultOriginIsZero() { + let ray = Ray3D(direction: Vector3D(x: 0, y: 1, z: 0)) + #expect(ray.origin == .zero) + } + + // MARK: - Static properties + + @Test func testZeroRay() { + let ray = Ray3D.zero + #expect(ray.origin == .zero) + #expect(ray.direction == .zero) + } + + @Test func testInfinityRay() { + let ray = Ray3D.infinity + #expect(ray.origin.x.isInfinite) + #expect(ray.origin.y.isInfinite) + #expect(ray.origin.z.isInfinite) + } + + // MARK: - Primitive3D properties + + @Test func testIsNaNFalseForNormal() { + let ray = Ray3D(origin: .zero, direction: Vector3D(x: 0, y: 0, z: 1)) + #expect(!ray.isNaN) + } + + @Test func testIsNaNTrueWhenOriginNaN() { + let ray = Ray3D(origin: Point3D(x: .nan, y: 0, z: 0), direction: Vector3D(x: 0, y: 0, z: 1)) + #expect(ray.isNaN) + } + + @Test func testIsNaNTrueWhenDirectionNaN() { + var ray = Ray3D() + ray.direction = Vector3D(x: .nan, y: 0, z: 0) + #expect(ray.isNaN) + } + + @Test func testIsFiniteTrueForNormal() { + let ray = Ray3D(origin: .zero, direction: Vector3D(x: 0, y: 0, z: 1)) + #expect(ray.isFinite) + } + + @Test func testIsFiniteFalseForInfinityRay() { + #expect(!Ray3D.infinity.isFinite) + } + + @Test func testIsZeroTrueForDefaultInit() { + #expect(Ray3D().isZero) + } + + @Test func testIsZeroFalseForNonZero() { + let ray = Ray3D(origin: .zero, direction: Vector3D(x: 0, y: 0, z: 1)) + #expect(!ray.isZero) + } + + // MARK: - description + + @Test func testDescription() { + let ray = Ray3D(origin: Point3D(x: 1, y: 2, z: 3), direction: Vector3D(x: 0, y: 0, z: 1)) + let desc = ray.description + #expect(desc.contains("origin:")) + #expect(desc.contains("direction:")) + } + + // MARK: - Translatable3D + + @Test func testTranslatedByMovesOrigin() { + let ray = Ray3D(origin: Point3D(x: 1, y: 2, z: 3), direction: Vector3D(x: 0, y: 0, z: 1)) + let translated = ray.translated(by: Vector3D(x: 1, y: 0, z: 0)) + #expect(translated.origin == Point3D(x: 2, y: 2, z: 3)) + #expect(translated.direction == ray.direction) + } + + @Test func testTranslateByMutates() { + var ray = Ray3D(origin: .zero, direction: Vector3D(x: 0, y: 1, z: 0)) + ray.translate(by: Vector3D(x: 5, y: 0, z: 0)) + #expect(ray.origin == Point3D(x: 5, y: 0, z: 0)) + } + + // MARK: - Rotatable3D + + @Test func testRotatedByRotation3D() { + let ray = Ray3D(origin: .zero, direction: Vector3D(x: 1, y: 0, z: 0)) + let rotation = Rotation3D(angle: Angle2D(radians: .pi / 2), axis: RotationAxis3D(x: 0, y: 0, z: 1)) + let rotated = ray.rotated(by: rotation) + #expect(abs(rotated.direction.x) < 1e-10) + #expect(abs(rotated.direction.y - 1.0) < 1e-10) + } + + @Test func testRotatedByQuaternion() { + let ray = Ray3D(origin: .zero, direction: Vector3D(x: 1, y: 0, z: 0)) + let q = Quaternion3D(x: 0, y: 0, z: 0, w: 1) + let rotated = ray.rotated(by: q) + #expect(abs(rotated.direction.x - 1.0) < 1e-10) + } + + @Test func testRotationDoesNotMoveOrigin() { + let origin = Point3D(x: 5, y: 5, z: 5) + let ray = Ray3D(origin: origin, direction: Vector3D(x: 1, y: 0, z: 0)) + let rotation = Rotation3D(angle: Angle2D(radians: .pi / 2), axis: RotationAxis3D(x: 0, y: 1, z: 0)) + let rotated = ray.rotated(by: rotation) + #expect(rotated.origin == origin) + } + + @Test func testRotatedAroundPivotRotation3D() { + let pivot = Point3D(x: 1, y: 0, z: 0) + let ray = Ray3D(origin: .zero, direction: Vector3D(x: 0, y: 0, z: 1)) + let rotation = Rotation3D(angle: Angle2D(radians: .pi / 2), axis: RotationAxis3D(x: 0, y: 1, z: 0)) + let rotated = ray.rotated(by: rotation, around: pivot) + #expect(abs(rotated.origin.y) < 1e-10) + } + + @Test func testRotatedAroundPivotQuaternion() { + let pivot = Point3D(x: 1, y: 0, z: 0) + let ray = Ray3D(origin: .zero, direction: Vector3D(x: 0, y: 0, z: 1)) + let q = Quaternion3D(angle: Angle2D(radians: .pi / 2), axis: Vector3D(x: 0, y: 1, z: 0)) + let rotated = ray.rotated(by: q, around: pivot) + #expect(abs(rotated.origin.y) < 1e-10) + } + + // MARK: - Intersects Rect3D + + @Test func testIntersectsRectHit() { + let ray = Ray3D(origin: Point3D(x: 0, y: 0, z: -5), direction: Vector3D(x: 0, y: 0, z: 1)) + let rect = Rect3D(origin: Point3D(x: -1, y: -1, z: 0), size: Size3D(width: 2, height: 2, depth: 2)) + #expect(ray.intersects(rect)) + } + + @Test func testIntersectsRectMiss() { + let ray = Ray3D(origin: Point3D(x: 5, y: 0, z: -5), direction: Vector3D(x: 0, y: 0, z: 1)) + let rect = Rect3D(origin: Point3D(x: -1, y: -1, z: 0), size: Size3D(width: 2, height: 2, depth: 2)) + #expect(!ray.intersects(rect)) + } + + @Test func testIntersectsRectOriginInsideBox() { + let ray = Ray3D(origin: Point3D(x: 0, y: 0, z: 1), direction: Vector3D(x: 0, y: 0, z: 1)) + let rect = Rect3D(origin: Point3D(x: -1, y: -1, z: 0), size: Size3D(width: 2, height: 2, depth: 2)) + #expect(ray.intersects(rect)) + } + + @Test func testIntersectsRectBehindRay() { + let ray = Ray3D(origin: Point3D(x: 0, y: 0, z: 10), direction: Vector3D(x: 0, y: 0, z: 1)) + let rect = Rect3D(origin: Point3D(x: -1, y: -1, z: 0), size: Size3D(width: 2, height: 2, depth: 2)) + #expect(!ray.intersects(rect)) + } + + // MARK: - Pose3D transforms + + @Test func testApplyingPose3D() { + let ray = Ray3D(origin: .zero, direction: Vector3D(x: 0, y: 0, z: 1)) + let pose = Pose3D(position: Point3D(x: 1, y: 0, z: 0), rotation: Rotation3D()) + let result = ray.applying(pose) + #expect(result.origin.x.rounded(toPlaces: 10) == 1.0) + #expect(abs(result.direction.z - 1.0) < 1e-10) + } + + @Test func testApplyPose3DMutating() { + var ray = Ray3D(origin: .zero, direction: Vector3D(x: 0, y: 0, z: 1)) + let pose = Pose3D(position: Point3D(x: 2, y: 0, z: 0), rotation: Rotation3D()) + ray.apply(pose) + #expect(ray.origin.x.rounded(toPlaces: 10) == 2.0) + } + + @Test func testUnapplyingPose3D() { + let ray = Ray3D(origin: .zero, direction: Vector3D(x: 0, y: 0, z: 1)) + let pose = Pose3D(position: Point3D(x: 1, y: 0, z: 0), rotation: Rotation3D()) + let applied = ray.applying(pose) + let unapplied = applied.unapplying(pose) + #expect(abs(unapplied.origin.x) < 1e-10) + #expect(abs(unapplied.origin.y) < 1e-10) + #expect(abs(unapplied.origin.z) < 1e-10) + } + + // MARK: - ScaledPose3D transforms + + @Test func testApplyingScaledPose3D() { + let ray = Ray3D(origin: .zero, direction: Vector3D(x: 0, y: 0, z: 1)) + let scaledPose = ScaledPose3D(position: Point3D(x: 1, y: 0, z: 0), rotation: Rotation3D(), scale: 1.0) + let result = ray.applying(scaledPose) + #expect(abs(result.origin.x - 1.0) < 1e-10) + } + + @Test func testUnapplyingScaledPose3D() { + let ray = Ray3D(origin: Point3D(x: 1, y: 0, z: 0), direction: Vector3D(x: 0, y: 0, z: 1)) + let scaledPose = ScaledPose3D(position: Point3D(x: 1, y: 0, z: 0), rotation: Rotation3D(), scale: 1.0) + let result = ray.unapplying(scaledPose) + #expect(abs(result.origin.x) < 1e-10) + } + + // MARK: - AffineTransform3D transforms + + @Test func testApplyingAffineTransform() { + let ray = Ray3D(origin: .zero, direction: Vector3D(x: 0, y: 0, z: 1)) + let transform = AffineTransform3D(translation: Vector3D(x: 3, y: 0, z: 0)) + let result = ray.applying(transform) + #expect(abs(result.origin.x - 3.0) < 1e-10) + #expect(abs(result.direction.z - 1.0) < 1e-10) + } + + @Test func testUnapplyingAffineTransform() { + let ray = Ray3D(origin: Point3D(x: 3, y: 0, z: 0), direction: Vector3D(x: 0, y: 0, z: 1)) + let transform = AffineTransform3D(translation: Vector3D(x: 3, y: 0, z: 0)) + let result = ray.unapplying(transform) + #expect(abs(result.origin.x) < 1e-10) + } + + // MARK: - ProjectiveTransform3D transforms + + @Test func testApplyingProjectiveTransform() { + let ray = Ray3D(origin: .zero, direction: Vector3D(x: 0, y: 0, z: 1)) + let transform = ProjectiveTransform3D(AffineTransform3D(translation: Vector3D(x: 1, y: 0, z: 0))) + let result = ray.applying(transform) + #expect(abs(result.origin.x - 1.0) < 1e-10) + } + + @Test func testUnapplyingProjectiveTransform() { + let ray = Ray3D(origin: Point3D(x: 1, y: 0, z: 0), direction: Vector3D(x: 0, y: 0, z: 1)) + let transform = ProjectiveTransform3D(AffineTransform3D(translation: Vector3D(x: 1, y: 0, z: 0))) + let result = ray.unapplying(transform) + #expect(abs(result.origin.x) < 1e-10) + } + + // MARK: - Equatable & Hashable & Codable + + @Test func testEquatable() { + let ray1 = Ray3D(origin: .zero, direction: Vector3D(x: 0, y: 0, z: 1)) + let ray2 = Ray3D(origin: .zero, direction: Vector3D(x: 0, y: 0, z: 1)) + #expect(ray1 == ray2) + } + + @Test func testNotEqual() { + let ray1 = Ray3D(origin: .zero, direction: Vector3D(x: 0, y: 0, z: 1)) + let ray2 = Ray3D(origin: Point3D(x: 1, y: 0, z: 0), direction: Vector3D(x: 0, y: 0, z: 1)) + #expect(ray1 != ray2) + } + + @Test func testHashable() { + let ray = Ray3D(origin: .zero, direction: Vector3D(x: 0, y: 0, z: 1)) + var set = Set() + set.insert(ray) + #expect(set.contains(ray)) + } + + @Test func testCodableRoundTrip() throws { + let ray = Ray3D(origin: Point3D(x: 1, y: 2, z: 3), direction: Vector3D(x: 0, y: 0, z: 1)) + let data = try JSONEncoder().encode(ray) + let decoded = try JSONDecoder().decode(Ray3D.self, from: data) + #expect(decoded == ray) + } +} diff --git a/Tests/OpenSpatialTests/3D primitives/Rect3DTests.swift b/Tests/OpenSpatialTests/3D primitives/Rect3DTests.swift new file mode 100644 index 0000000..44b6dbe --- /dev/null +++ b/Tests/OpenSpatialTests/3D primitives/Rect3DTests.swift @@ -0,0 +1,170 @@ +import Testing +@testable import OpenSpatial + +struct Rect3DTests { + + // MARK: - Initialization tests + + @Test func testInitialization() { + let rect = Rect3D() + #expect(rect.origin == Point3D.zero) + #expect(rect.min == Point3D.zero) + #expect(rect.max == Point3D.zero) + #expect(rect.center == Point3D.zero) + #expect(rect == .zero) + #expect(rect.isFinite == true) + #expect(rect.isNaN == false) + #expect(rect.isZero == true) + #expect(rect.description == "(origin: (x: 0.0, y: 0.0, z: 0.0), size: (width: 0.0, height: 0.0, depth: 0.0))") + #expect(rect.isEmpty == true) + } + + @Test func testInitializationWithOriginAndSize() { + let origin = Point3D(x: -1.0, y: -1.0, z: -1.0) + let size = Size3D(width: 2.0, height: 2.0, depth: 2.0) + let rect = Rect3D(origin: origin, size: size) + #expect(rect.origin == origin) + #expect(rect.min == origin) + #expect(rect.max == Point3D(x: 1.0, y: 1.0, z: 1.0)) + #expect(rect.center == Point3D(x: 0.0, y: 0.0, z: 0.0)) + } + + @Test func testInitializationWithInfinityOriginAndSize() { + let origin = Point3D(x: .infinity, y: .infinity, z: .infinity) + let size = Size3D(width: .infinity, height: .infinity, depth: .infinity) + let rect = Rect3D(origin: origin, size: size) + #expect(rect.isFinite == false) + #expect(rect == .infinity) + } + + @Test func testInitializationWithOriginAndSizeVectors() { + let origin = Vector3D.zero + let size = Vector3D(x: 5.0, y: 10.0, z: 15.0) + let rect = Rect3D(origin: origin, size: size) + #expect(rect.origin == Point3D.zero) + #expect(rect.size == Size3D(width: 5.0, height: 10.0, depth: 15.0)) + } + + @Test func testInitializationWithCenterAndSizeVectors() { + let origin = Vector3D.zero + let size = Vector3D(x: 5.0, y: 10.0, z: 15.0) + let rect = Rect3D(center: origin, size: size) + #expect(rect.origin == Point3D(x: -2.5, y: -5.0, z: -7.5)) + #expect(rect.size == Size3D(width: 5.0, height: 10.0, depth: 15.0)) + } + + @Test func testInitializationWithCenterAndSize() { + let center = Point3D(x: 3, y: -2, z: 9) + let size = Size3D(width: 2.5, height: 3.71, depth: 1.44) + let rect = Rect3D(center: center, size: size) + + #expect(rect.origin == Point3D(x: 1.75, y: -3.855, z: 8.28)) + #expect(rect.min == Point3D(x: 1.75, y: -3.855, z: 8.28)) + #expect(rect.center == Point3D(x: 3.0, y: -2.0, z: 9.0)) + #expect(rect.max == Point3D(x: 4.25, y: -0.14500000000000002, z: 9.719999999999999)) + #expect(rect.cornerPoints == [ + .init(x: 1.75, y: -3.855, z: 8.28), + .init(x: 1.75, y: -0.14500000000000002, z: 8.28), + .init(x: 4.25, y: -0.14500000000000002, z: 8.28), + .init(x: 4.25, y: -3.855, z: 8.28), + .init(x: 1.75, y: -3.855, z: 9.719999999999999), + .init(x: 1.75, y: -0.14500000000000002, z: 9.719999999999999), + .init(x: 4.25, y: -0.14500000000000002, z: 9.719999999999999), + .init(x: 4.25, y: -3.855, z: 9.719999999999999) + ] + ) + // #expect(rect.integral == Rect3D(origin: Point3D(x: 3.0, y: -2.0, z: 9.0), size: Size3D(width: 3.0, height: 4.0, depth: 2.0))) + // #expect(rect.standardized == Rect3D(origin: Point3D(x: 3.0, y: -2.0, z: 9.0), size: Size3D(width: 2.5, height: 3.71, depth: 1.44))) + } + + // MARK: - Primitive3D tests + + @Test func testApplyingAffineTransform() { + let rect = Rect3D(origin: Point3D(x: 1.0, y: 2.0, z: 3.0), size: Size3D(width: 4.0, height: 5.0, depth: 6.0)) + let transform = AffineTransform3D(matrix: [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [2.0, 4.0, 6.0, 1.0] + ]) + let transformed = rect.applying(transform) + #expect(transformed == Rect3D(origin: Point3D(x: 3.0, y: 6.0, z: 9.0), size: Size3D(width: 4.0, height: 5.0, depth: 6.0))) + } + + // MARK: - Scalable3D tests + + @Test func testScalingRect3D() { + let rect = Rect3D(origin: Point3D(x: 1.0, y: 2.0, z: 3.0), size: Size3D(width: 4.0, height: 5.0, depth: 6.0)) + let scaled = rect.scaled(by: Size3D(width: 2.0, height: 3.0, depth: 4.0)) + #expect(scaled == Rect3D(origin: Point3D(x: 2.0, y: 6.0, z: 12.0), size: Size3D(width: 8.0, height: 15.0, depth: 24.0))) + } + + @Test func testUniformlyScalingRect3D() { + let rect = Rect3D(origin: Point3D(x: 1.0, y: 2.0, z: 3.0), size: Size3D(width: 4.0, height: 5.0, depth: 6.0)) + let uniformlyScaled = rect.uniformlyScaled(by: 2.0) + #expect(uniformlyScaled == Rect3D(origin: Point3D(x: 2.0, y: 4.0, z: 6.0), size: Size3D(width: 8.0, height: 10.0, depth: 12.0))) + } + + // MARK: - Translatable3D tests + + @Test func testTranslatingRect3D() { + let rect = Rect3D(origin: Point3D(x: 1.0, y: 2.0, z: 3.0), size: Size3D(width: 4.0, height: 5.0, depth: 6.0)) + let translated = rect.translated(by: Vector3D(x: 1.0, y: 2.0, z: 3.0)) + #expect(translated == Rect3D(origin: Point3D(x: 2.0, y: 4.0, z: 6.0), size: Size3D(width: 4.0, height: 5.0, depth: 6.0))) + } + + // MARK: - Volumetric tests + + @Test func testContains() { + let rect = Rect3D(origin: Point3D(x: 1.0, y: 2.0, z: 3.0), size: Size3D(width: 4.0, height: 5.0, depth: 6.0)) + let point = Point3D(x: 2.0, y: 3.0, z: 4.0) + #expect(rect.contains(point: point) == true) + } + + @Test func testContainsRect3D() { + let rect = Rect3D(origin: Point3D(x: 1.0, y: 2.0, z: 3.0), size: Size3D(width: 4.0, height: 5.0, depth: 6.0)) + let otherRect = Rect3D(origin: Point3D(x: 2.0, y: 3.0, z: 4.0), size: Size3D(width: 3.0, height: 4.0, depth: 5.0)) + #expect(rect.contains(otherRect) == true) + } + + @Test func testContainsAnyOf() { + let rect = Rect3D(origin: Point3D(x: 1.0, y: 2.0, z: 3.0), size: Size3D(width: 4.0, height: 5.0, depth: 6.0)) + let points = [Point3D(x: 2.0, y: 3.0, z: 4.0), Point3D(x: 5.0, y: 6.0, z: 7.0)] + #expect(rect.contains(anyOf: points) == true) + } + + @Test func testNotContainsAnyOf() { + let rect = Rect3D(origin: Point3D(x: 1.0, y: 2.0, z: 3.0), size: Size3D(width: 4.0, height: 5.0, depth: 6.0)) + let points = [Point3D(x: 6.0, y: 7.0, z: 8.0), Point3D(x: 9.0, y: 10.0, z: 11.0)] + #expect(rect.contains(anyOf: points) == false) + } + + @Test func testFormIntersection() { + var rect = Rect3D(origin: Point3D(x: 1.0, y: 2.0, z: 3.0), size: Size3D(width: 4.0, height: 5.0, depth: 6.0)) + let otherRect = Rect3D(origin: Point3D(x: 2.0, y: 3.0, z: 4.0), size: Size3D(width: 3.0, height: 4.0, depth: 5.0)) + rect.formIntersection(otherRect) + #expect(rect == Rect3D(origin: Point3D(x: 2.0, y: 3.0, z: 4.0), size: Size3D(width: 3.0, height: 4.0, depth: 5.0))) + } + + @Test func testFormUnion() { + var rect = Rect3D(origin: Point3D(x: 1.0, y: 2.0, z: 3.0), size: Size3D(width: 4.0, height: 5.0, depth: 6.0)) + let otherRect = Rect3D(origin: Point3D(x: 2.0, y: 3.0, z: 4.0), size: Size3D(width: 3.0, height: 4.0, depth: 5.0)) + rect.formUnion(otherRect) + #expect(rect == Rect3D(origin: Point3D(x: 1.0, y: 2.0, z: 3.0), size: Size3D(width: 4.0, height: 5.0, depth: 6.0))) + } + + @Test func testIntersection() { + let rect = Rect3D(origin: Point3D(x: 1.0, y: 2.0, z: 3.0), size: Size3D(width: 4.0, height: 5.0, depth: 6.0)) + let otherRect = Rect3D(origin: Point3D(x: 2.0, y: 3.0, z: 4.0), size: Size3D(width: 3.0, height: 4.0, depth: 5.0)) + let intersection = rect.intersection(otherRect) + #expect(intersection == Rect3D(origin: Point3D(x: 2.0, y: 3.0, z: 4.0), size: Size3D(width: 3.0, height: 4.0, depth: 5.0))) + #expect(rect.intersects(otherRect) == true) + } + + @Test func testUnion() { + let rect = Rect3D(origin: Point3D(x: 1.0, y: 2.0, z: 3.0), size: Size3D(width: 4.0, height: 5.0, depth: 6.0)) + let otherRect = Rect3D(origin: Point3D(x: 2.0, y: 3.0, z: 4.0), size: Size3D(width: 3.0, height: 4.0, depth: 5.0)) + let union = rect.union(otherRect) + #expect(union == Rect3D(origin: Point3D(x: 1.0, y: 2.0, z: 3.0), size: Size3D(width: 4.0, height: 5.0, depth: 6.0))) + } +} diff --git a/Tests/OpenSpatialTests/3D primitives/Rotation3DTests.swift b/Tests/OpenSpatialTests/3D primitives/Rotation3DTests.swift new file mode 100644 index 0000000..eedc752 --- /dev/null +++ b/Tests/OpenSpatialTests/3D primitives/Rotation3DTests.swift @@ -0,0 +1,148 @@ +import Testing +@testable import OpenSpatial + +struct Rotation3DTests { + + // MARK: - Initialization + + @Test func testIdentityInit() { + let r = Rotation3D() + #expect(r.quaternion.x == 0.0) + #expect(r.quaternion.y == 0.0) + #expect(r.quaternion.z == 0.0) + #expect(r.quaternion.w == 1.0) + #expect(r.isIdentity) + } + + @Test func testInitWithQuaternion() { + let q = Quaternion3D(x: 0.0, y: 1.0, z: 0.0, w: 0.0) + let r = Rotation3D(quaternion: q) + #expect(r.quaternion.x.rounded(toPlaces: 10) == 0.0) + #expect(r.quaternion.y.rounded(toPlaces: 10) == 1.0) + #expect(r.quaternion.z.rounded(toPlaces: 10) == 0.0) + #expect(r.quaternion.w.rounded(toPlaces: 10) == 0.0) + } + + @Test func testInitWithEulerAngles60Degrees() { + let deg60 = Angle2D(degrees: 60) + let eulerAngles = EulerAngles(x: deg60, y: deg60, z: deg60, order: .xyz) + let r = Rotation3D(eulerAngles: eulerAngles) + + #expect(r.angle.radians.rounded(toPlaces: 4) == 1.3697) + #expect(r.axis.x.rounded(toPlaces: 4) == 0.2506) + #expect(r.axis.y.rounded(toPlaces: 4) == 0.9351) + #expect(r.axis.z.rounded(toPlaces: 4) == 0.2506) + #expect(r.quaternion.x.rounded(toPlaces: 4) == 0.1585) + #expect(r.quaternion.y.rounded(toPlaces: 4) == 0.5915) + #expect(r.quaternion.z.rounded(toPlaces: 4) == 0.1585) + #expect(r.quaternion.w.rounded(toPlaces: 4) == 0.7745) + } + + @Test func testInitWithAngleAxis() { + let r = Rotation3D(angle: Angle2D(radians: .pi / 2), axis: RotationAxis3D(x: 0, y: 1, z: 0)) + #expect(r.quaternion.x.rounded(toPlaces: 10) == 0.0) + #expect(r.quaternion.y.rounded(toPlaces: 4) == 0.7071) + #expect(r.quaternion.z.rounded(toPlaces: 10) == 0.0) + #expect(r.quaternion.w.rounded(toPlaces: 4) == 0.7071) + } + + // MARK: - Properties + + @Test func testVector() { + let r = Rotation3D() + #expect(r.vector == [0.0, 0.0, 0.0, 1.0]) + } + + @Test func testIsIdentityTrue() { + #expect(Rotation3D().isIdentity) + } + + @Test func testIsIdentityFalse() { + let r = Rotation3D(angle: Angle2D(radians: 0.1), axis: RotationAxis3D(x: 0, y: 1, z: 0)) + #expect(!r.isIdentity) + } + + // MARK: - Inverse + + @Test func testInverse() { + let r = Rotation3D(angle: Angle2D(radians: .pi / 2), axis: RotationAxis3D(x: 0, y: 1, z: 0)) + let inv = r.inverse + let composed = r.quaternion * inv.quaternion + #expect(composed.x.rounded(toPlaces: 10) == 0.0) + #expect(composed.y.rounded(toPlaces: 10) == 0.0) + #expect(composed.z.rounded(toPlaces: 10) == 0.0) + #expect(composed.w.rounded(toPlaces: 10) == 1.0) + } + + // MARK: - Look-at and forward initialisers + + @Test func testInitForwardIsIdentity() { + let r = Rotation3D(forward: Vector3D.forward) + #expect(r.quaternion.x.rounded(toPlaces: 10) == 0.0) + #expect(r.quaternion.y.rounded(toPlaces: 10) == 0.0) + #expect(r.quaternion.z.rounded(toPlaces: 10) == 0.0) + #expect(r.quaternion.w.rounded(toPlaces: 10) == 1.0) + } + + @Test func testInitForwardNormalisedQuaternion() { + let r = Rotation3D(forward: Vector3D(x: 1, y: 0, z: 1)) + let len = r.quaternion.length + #expect(len.rounded(toPlaces: 10) == 1.0) + } + + @Test func testInitForwardUpNormalisedQuaternion() { + let r = Rotation3D(forward: Vector3D(x: 0, y: 0, z: -1), up: Vector3D(x: 0, y: 1, z: 0)) + let len = r.quaternion.length + #expect(len.rounded(toPlaces: 10) == 1.0) + } + + @Test func testInitPositionTargetUpNormalisedQuaternion() { + let r = Rotation3D( + position: Point3D(x: 0, y: 0, z: 0), + target: Point3D(x: 1, y: 0, z: 0), + up: Vector3D(x: 0, y: 1, z: 0) + ) + let len = r.quaternion.length + #expect(len.rounded(toPlaces: 10) == 1.0) + } + + // MARK: - Act + + @Test func testAct() { + let r = Rotation3D(angle: Angle2D(radians: .pi / 2), axis: RotationAxis3D(x: 0, y: 1, z: 0)) + let v = Vector3D(x: 1.0, y: 0.0, z: 0.0) + let result = r.act(v) + #expect(result.x.rounded(toPlaces: 10) == 0.0) + #expect(result.y.rounded(toPlaces: 10) == 0.0) + #expect(result.z.rounded(toPlaces: 10) == -1.0) + } + + // MARK: - Rotatable3D conformance with Rotation3D + + @Test func testVector3DRotatedByRotation3D() { + let r = Rotation3D(angle: Angle2D(radians: .pi / 2), axis: RotationAxis3D(x: 0, y: 1, z: 0)) + let v = Vector3D(x: 1.0, y: 0.0, z: 0.0) + let result = v.rotated(by: r) + #expect(result.x.rounded(toPlaces: 10) == 0.0) + #expect(result.y.rounded(toPlaces: 10) == 0.0) + #expect(result.z.rounded(toPlaces: 10) == -1.0) + } + + @Test func testVector3DRotateByRotation3D() { + let r = Rotation3D(angle: Angle2D(radians: .pi / 2), axis: RotationAxis3D(x: 0, y: 1, z: 0)) + var v = Vector3D(x: 1.0, y: 0.0, z: 0.0) + v.rotate(by: r) + #expect(v.x.rounded(toPlaces: 10) == 0.0) + #expect(v.y.rounded(toPlaces: 10) == 0.0) + #expect(v.z.rounded(toPlaces: 10) == -1.0) + } + + @Test func testPoint3DRotatedByRotation3D() { + let r = Rotation3D(angle: Angle2D(radians: .pi / 2), axis: RotationAxis3D(x: 0, y: 1, z: 0)) + let p = Point3D(x: 1.0, y: 0.0, z: 0.0) + let result = p.rotated(by: r) + #expect(result.x.rounded(toPlaces: 10) == 0.0) + #expect(result.y.rounded(toPlaces: 10) == 0.0) + #expect(result.z.rounded(toPlaces: 10) == -1.0) + } +} diff --git a/Tests/OpenSpatialTests/3D primitives/RotationAxis3DTestes.swift b/Tests/OpenSpatialTests/3D primitives/RotationAxis3DTestes.swift new file mode 100644 index 0000000..ab2c21d --- /dev/null +++ b/Tests/OpenSpatialTests/3D primitives/RotationAxis3DTestes.swift @@ -0,0 +1,83 @@ +import Testing +@testable import OpenSpatial + +struct RotationAxis3DTests { + + // MARK: - Initialization tests + + @Test func testInitialization() { + let axis = RotationAxis3D() + #expect(axis.x == 0.0) + #expect(axis.y == 0.0) + #expect(axis.z == 0.0) + #expect(axis.vector == [0.0, 0.0, 0.0]) + #expect(axis.isZero == true) + #expect(axis.description == "(x: 0.0, y: 0.0, z: 0.0)") + } + + @Test func testInitializationWithParams() { + let axis = RotationAxis3D(x: 1.0, y: 2.0, z: 3.0) + #expect(axis.x == 1.0) + #expect(axis.y == 2.0) + #expect(axis.z == 3.0) + #expect(axis.vector == [1.0, 2.0, 3.0]) + #expect(axis.isZero == false) + #expect(axis.description == "(x: 1.0, y: 2.0, z: 3.0)") + } + + @Test func testInitializationWithFloatParams() { + let axis = RotationAxis3D(x: Float(1.0), y: Float(2.0), z: Float(3.0)) + #expect(axis.x == 1.0) + #expect(axis.y == 2.0) + #expect(axis.z == 3.0) + #expect(axis.vector == [1.0, 2.0, 3.0]) + #expect(axis.isZero == false) + #expect(axis.description == "(x: 1.0, y: 2.0, z: 3.0)") + } + + @Test func testInitializationWithArrayLiteral() { + let axis: RotationAxis3D = [4.0, 5.0, 6.0] + #expect(axis.x == 4.0) + #expect(axis.y == 5.0) + #expect(axis.z == 6.0) + #expect(axis.vector == [4.0, 5.0, 6.0]) + #expect(axis.isZero == false) + #expect(axis.description == "(x: 4.0, y: 5.0, z: 6.0)") + } + + @Test func testInitializationWithVector() { + let axis = RotationAxis3D(Vector3D(x: 7.0, y: 8.0, z: 9.0)) + #expect(axis.x == 7.0) + #expect(axis.y == 8.0) + #expect(axis.z == 9.0) + #expect(axis.vector == [7.0, 8.0, 9.0]) + #expect(axis.isZero == false) + #expect(axis.description == "(x: 7.0, y: 8.0, z: 9.0)") + } + + @Test func testPredefinedAxes() { + #expect(RotationAxis3D.zero == RotationAxis3D()) + #expect(RotationAxis3D.x == RotationAxis3D(x: 1, y: 0, z: 0)) + #expect(RotationAxis3D.y == RotationAxis3D(x: 0, y: 1, z: 0)) + #expect(RotationAxis3D.z == RotationAxis3D(x: 0, y: 0, z: 1)) + } + + @Test func testIsApproximatelyEqual() { + let a = RotationAxis3D(x: 0.0, y: 1.0, z: 0.0) + let b = RotationAxis3D(x: 0.0, y: 1.0, z: 0.0) + #expect(a.isApproximatelyEqual(to: b)) + } + + @Test func testIsApproximatelyEqualWithTolerance() { + let a = RotationAxis3D(x: 1.0, y: 0.0, z: 0.0) + let b = RotationAxis3D(x: 1.0 + 1e-10, y: 0.0, z: 0.0) + #expect(a.isApproximatelyEqual(to: b, tolerance: 1e-8)) + #expect(!a.isApproximatelyEqual(to: b, tolerance: 1e-12)) + } + + @Test func testIsNotApproximatelyEqual() { + let a = RotationAxis3D(x: 1.0, y: 0.0, z: 0.0) + let b = RotationAxis3D(x: 0.0, y: 1.0, z: 0.0) + #expect(!a.isApproximatelyEqual(to: b)) + } +} diff --git a/Tests/OpenSpatialTests/3D primitives/ScaledPose3DTests.swift b/Tests/OpenSpatialTests/3D primitives/ScaledPose3DTests.swift new file mode 100644 index 0000000..291ae9f --- /dev/null +++ b/Tests/OpenSpatialTests/3D primitives/ScaledPose3DTests.swift @@ -0,0 +1,397 @@ +import Testing +import Foundation +@testable import OpenSpatial + +struct ScaledPose3DTests { + + // MARK: - Initialization + + @Test func testDefaultInit() { + let pose = ScaledPose3D() + #expect(pose.position == .zero) + #expect(pose.rotation.isIdentity) + #expect(pose.scale == 1.0) + } + + @Test func testInitPositionRotationScale() { + let position = Point3D(x: 1, y: 2, z: 3) + let rotation = Rotation3D() + let pose = ScaledPose3D(position: position, rotation: rotation, scale: 2.0) + #expect(pose.position == position) + #expect(pose.rotation == rotation) + #expect(pose.scale == 2.0) + } + + @Test func testInitWithDefaultsRotation3D() { + let rotation = Rotation3D(angle: Angle2D(degrees: 45), axis: RotationAxis3D(x: 0, y: 1, z: 0)) + let pose = ScaledPose3D(position: .zero, rotation: rotation, scale: 1.0) + #expect(pose.position == .zero) + #expect(pose.scale == 1.0) + #expect(pose.rotation == rotation) + } + + @Test func testInitWithQuaternion() { + let q = Quaternion3D(x: 0, y: 0, z: 0, w: 1) + let pose = ScaledPose3D(rotation: q) + #expect(pose.rotation.isIdentity) + #expect(pose.scale == 1.0) + } + + @Test func testInitLookAt() { + let position = Point3D(x: 0, y: 0, z: 0) + let target = Point3D(x: 0, y: 0, z: 1) + let pose = ScaledPose3D(position: position, target: target, scale: 1.0) + #expect(!pose.rotation.isIdentity || pose.rotation.isIdentity) + #expect(pose.position == position) + #expect(pose.scale == 1.0) + } + + @Test func testInitForward() { + let forward = Vector3D(x: 0, y: 0, z: 1) + let pose = ScaledPose3D(forward: forward) + #expect(pose.position == .zero) + #expect(pose.scale == 1.0) + } + + // MARK: - Identity + + @Test func testIdentity() { + let identity = ScaledPose3D.identity + #expect(identity.isIdentity) + #expect(identity.position == .zero) + #expect(identity.rotation.isIdentity) + #expect(identity.scale == 1.0) + } + + @Test func testIsIdentityFalseNonUnitScale() { + let pose = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 2.0) + #expect(!pose.isIdentity) + } + + @Test func testIsIdentityFalseNonZeroPosition() { + let pose = ScaledPose3D(position: Point3D(x: 1, y: 0, z: 0), rotation: Rotation3D(), scale: 1.0) + #expect(!pose.isIdentity) + } + + // MARK: - Matrix + + @Test func testMatrixIsCorrectSize() { + let pose = ScaledPose3D() + let m = pose.matrix + #expect(m.count == 4) + #expect(m.allSatisfy { $0.count == 4 }) + } + + @Test func testIdentityMatrix() { + let pose = ScaledPose3D.identity + let m = pose.matrix + #expect(abs(m[0][0] - 1.0) < 1e-10) + #expect(abs(m[1][1] - 1.0) < 1e-10) + #expect(abs(m[2][2] - 1.0) < 1e-10) + #expect(abs(m[3][3] - 1.0) < 1e-10) + #expect(abs(m[3][0]) < 1e-10) + #expect(abs(m[3][1]) < 1e-10) + #expect(abs(m[3][2]) < 1e-10) + } + + @Test func testMatrixEncodesTranslation() { + let pose = ScaledPose3D(position: Point3D(x: 1, y: 2, z: 3), rotation: Rotation3D(), scale: 1.0) + let m = pose.matrix + #expect(abs(m[3][0] - 1.0) < 1e-10) + #expect(abs(m[3][1] - 2.0) < 1e-10) + #expect(abs(m[3][2] - 3.0) < 1e-10) + } + + @Test func testMatrixRoundTrip() { + let pose = ScaledPose3D(position: Point3D(x: 1, y: 2, z: 3), rotation: Rotation3D(), scale: 2.0) + let recovered = ScaledPose3D(pose.matrix) + #expect(recovered != nil) + #expect(abs(recovered!.scale - 2.0) < 1e-6) + #expect(recovered!.position.isApproximatelyEqual(to: pose.position, tolerance: 1e-6)) + } + + @Test func testMatrixInitFromDoubleMatrix() { + let identity: [[Double]] = [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1] + ] + let pose = ScaledPose3D(identity) + #expect(pose != nil) + #expect(pose!.isIdentity) + } + + @Test func testMatrixInitNilForNonUniformScale() { + let nonUniform: [[Double]] = [ + [2, 0, 0, 0], + [0, 3, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1] + ] + let pose = ScaledPose3D(nonUniform) + #expect(pose == nil) + } + + @Test func testMatrixInitFromFloatMatrix() { + let identity: [[Float]] = [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1] + ] + let pose = ScaledPose3D(identity) + #expect(pose != nil) + #expect(pose!.isIdentity) + } + + @Test func testInitFromAffineTransform() { + let transform = AffineTransform3D(translation: Vector3D(x: 1, y: 2, z: 3)) + let pose = ScaledPose3D(transform: transform) + #expect(pose != nil) + #expect(pose!.position.isApproximatelyEqual(to: Point3D(x: 1, y: 2, z: 3), tolerance: 1e-6)) + } + + @Test func testInitFromProjectiveTransform() { + let affine = AffineTransform3D(translation: Vector3D(x: 1, y: 0, z: 0)) + let projective = ProjectiveTransform3D(affine) + let pose = ScaledPose3D(transform: projective) + #expect(pose != nil) + } + + // MARK: - Inverse + + @Test func testInverseOfIdentity() { + let identity = ScaledPose3D.identity + let inv = identity.inverse + #expect(inv.isIdentity) + } + + @Test func testInverseRoundTrip() { + let pose = ScaledPose3D(position: Point3D(x: 1, y: 2, z: 3), rotation: Rotation3D(), scale: 2.0) + let composed = pose * pose.inverse + #expect(composed.isApproximatelyEqual(to: .identity, tolerance: 1e-10)) + } + + // MARK: - Operators and Concatenation + + @Test func testMultiplyScaledPoseByScaledPose() { + let a = ScaledPose3D(position: Point3D(x: 1, y: 0, z: 0), rotation: Rotation3D(), scale: 2.0) + let b = ScaledPose3D(position: Point3D(x: 1, y: 0, z: 0), rotation: Rotation3D(), scale: 1.0) + let result = a * b + #expect(result.scale == 2.0) + } + + @Test func testMultiplyScaledPoseByPose3D() { + let a = ScaledPose3D(position: Point3D(x: 1, y: 0, z: 0), rotation: Rotation3D(), scale: 2.0) + let b = Pose3D(position: Point3D(x: 1, y: 0, z: 0), rotation: Rotation3D()) + let result = a * b + #expect(result.scale == 2.0) + } + + @Test func testMultiplyPose3DByScaledPose() { + let a = Pose3D(position: Point3D(x: 1, y: 0, z: 0), rotation: Rotation3D()) + let b = ScaledPose3D(position: Point3D(x: 0, y: 0, z: 0), rotation: Rotation3D(), scale: 3.0) + let result = a * b + #expect(result.scale == 3.0) + } + + @Test func testMultiplyAssign() { + var pose = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 2.0) + let rhs = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 3.0) + pose *= rhs + #expect(pose.scale == 6.0) + } + + @Test func testConcatenatingScaledPose() { + let a = ScaledPose3D(position: Point3D(x: 1, y: 0, z: 0), rotation: Rotation3D(), scale: 2.0) + let b = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 1.0) + let result = a.concatenating(b) + #expect(result.scale == 2.0) + } + + @Test func testConcatenatingPose3D() { + let a = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 1.0) + let b = Pose3D(position: Point3D(x: 2, y: 0, z: 0), rotation: Rotation3D()) + let result = a.concatenating(b) + #expect(abs(result.position.x - 2.0) < 1e-10) + } + + // MARK: - Flip + + @Test func testFlippedAlongX() { + let pose = ScaledPose3D(position: Point3D(x: 1, y: 2, z: 3), rotation: Rotation3D(), scale: 1.0) + let flipped = pose.flipped(along: .x) + #expect(flipped.position.x == -1.0) + #expect(flipped.position.y == 2.0) + #expect(flipped.position.z == 3.0) + } + + @Test func testFlippedAlongY() { + let pose = ScaledPose3D(position: Point3D(x: 1, y: 2, z: 3), rotation: Rotation3D(), scale: 1.0) + let flipped = pose.flipped(along: .y) + #expect(flipped.position.x == 1.0) + #expect(flipped.position.y == -2.0) + #expect(flipped.position.z == 3.0) + } + + @Test func testFlippedAlongZ() { + let pose = ScaledPose3D(position: Point3D(x: 1, y: 2, z: 3), rotation: Rotation3D(), scale: 1.0) + let flipped = pose.flipped(along: .z) + #expect(flipped.position.x == 1.0) + #expect(flipped.position.y == 2.0) + #expect(flipped.position.z == -3.0) + } + + @Test func testFlipMutating() { + var pose = ScaledPose3D(position: Point3D(x: 1, y: 2, z: 3), rotation: Rotation3D(), scale: 1.0) + pose.flip(along: .x) + #expect(pose.position.x == -1.0) + } + + @Test func testFlippedAlongXYAxes() { + let pose = ScaledPose3D(position: Point3D(x: 1, y: 2, z: 3), rotation: Rotation3D(), scale: 1.0) + let flipped = pose.flipped(along: [.x, .y]) + #expect(flipped.position.x == -1.0) + #expect(flipped.position.y == -2.0) + #expect(flipped.position.z == 3.0) + } + + @Test func testFlippedAlongAllAxes() { + let pose = ScaledPose3D(position: Point3D(x: 1, y: 2, z: 3), rotation: Rotation3D(), scale: 1.0) + let flipped = pose.flipped(along: [.x, .y, .z]) + #expect(flipped.position.x == -1.0) + #expect(flipped.position.y == -2.0) + #expect(flipped.position.z == -3.0) + } + + @Test func testFlippedPreservesRotationAndScale() { + let rotation = Rotation3D(angle: Angle2D(degrees: 45), axis: RotationAxis3D(x: 0, y: 1, z: 0)) + let pose = ScaledPose3D(position: Point3D(x: 5, y: 0, z: 0), rotation: rotation, scale: 3.0) + let flipped = pose.flipped(along: .x) + #expect(flipped.rotation == rotation) + #expect(flipped.scale == 3.0) + #expect(flipped.position.x == -5.0) + } + + @Test func testFlippedZeroPositionIsNoOp() { + let pose = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 1.0) + let flipped = pose.flipped(along: [.x, .y, .z]) + #expect(flipped.position == .zero) + } + + @Test func testFlipMutatingAllAxes() { + var pose = ScaledPose3D(position: Point3D(x: -4, y: 7, z: 2), rotation: Rotation3D(), scale: 1.0) + pose.flip(along: [.x, .y, .z]) + #expect(pose.position.x == 4.0) + #expect(pose.position.y == -7.0) + #expect(pose.position.z == -2.0) + } + + // MARK: - Translatable3D + + @Test func testTranslatedByVector() { + let pose = ScaledPose3D(position: Point3D(x: 1, y: 0, z: 0), rotation: Rotation3D(), scale: 1.0) + let result = pose.translated(by: Vector3D(x: 2, y: 0, z: 0)) + #expect(result.position == Point3D(x: 3, y: 0, z: 0)) + #expect(result.rotation == pose.rotation) + #expect(result.scale == pose.scale) + } + + @Test func testTranslatedBySize() { + let pose = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 1.0) + let size = Size3D(width: 1, height: 2, depth: 3) + let result = pose.translated(by: Vector3D(size)) + #expect(result.position == Point3D(x: 1, y: 2, z: 3)) + } + + @Test func testTranslate() { + var pose = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 1.0) + pose.translate(by: Vector3D(x: 5, y: 0, z: 0)) + #expect(pose.position.x == 5.0) + } + + // MARK: - Rotatable3D + + @Test func testRotatedByRotation3D() { + let pose = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 1.0) + let rotation = Rotation3D(angle: Angle2D(degrees: 90), axis: RotationAxis3D(x: 0, y: 1, z: 0)) + let result = pose.rotated(by: rotation) + #expect(!result.rotation.isIdentity) + } + + @Test func testRotatedByQuaternion() { + let pose = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 1.0) + let q = Quaternion3D(angle: Angle2D(degrees: 90), axis: Vector3D(x: 0, y: 1, z: 0)) + let result = pose.rotated(by: q) + #expect(!result.rotation.isIdentity) + } + + // MARK: - Equatable + + @Test func testEquatable() { + let pose1 = ScaledPose3D(position: Point3D(x: 1, y: 2, z: 3), rotation: Rotation3D(), scale: 2.0) + let pose2 = ScaledPose3D(position: Point3D(x: 1, y: 2, z: 3), rotation: Rotation3D(), scale: 2.0) + #expect(pose1 == pose2) + } + + @Test func testNotEqual() { + let pose1 = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 1.0) + let pose2 = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 2.0) + #expect(pose1 != pose2) + } + + // MARK: - Hashable + + @Test func testHashable() { + let pose = ScaledPose3D(position: Point3D(x: 1, y: 2, z: 3), rotation: Rotation3D(), scale: 1.0) + var set = Set() + set.insert(pose) + #expect(set.contains(pose)) + } + + @Test func testHashConsistency() { + let pose1 = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 1.0) + let pose2 = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 1.0) + #expect(pose1.hashValue == pose2.hashValue) + } + + // MARK: - Codable + + @Test func testCodableRoundTrip() throws { + let pose = ScaledPose3D(position: Point3D(x: 1, y: 2, z: 3), rotation: Rotation3D(), scale: 2.5) + let data = try JSONEncoder().encode(pose) + let decoded = try JSONDecoder().decode(ScaledPose3D.self, from: data) + #expect(decoded == pose) + } + + // MARK: - isApproximatelyEqual + + @Test func testIsApproximatelyEqualTrue() { + let pose1 = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 1.0) + let pose2 = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 1.0) + #expect(pose1.isApproximatelyEqual(to: pose2)) + } + + @Test func testIsApproximatelyEqualFalse() { + let pose1 = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 1.0) + let pose2 = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 2.0) + #expect(!pose1.isApproximatelyEqual(to: pose2)) + } + + @Test func testIsApproximatelyEqualWithTolerance() { + let pose1 = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 1.0) + let pose2 = ScaledPose3D(position: .zero, rotation: Rotation3D(), scale: 1.0 + 1e-15) + #expect(pose1.isApproximatelyEqual(to: pose2)) + } + + // MARK: - description + + @Test func testDescription() { + let pose = ScaledPose3D(position: Point3D(x: 1, y: 2, z: 3), rotation: Rotation3D(), scale: 1.0) + let desc = pose.description + #expect(desc.contains("position:")) + #expect(desc.contains("rotation:")) + #expect(desc.contains("scale:")) + } +} diff --git a/Tests/OpenSpatialTests/3D primitives/Size3DTests.swift b/Tests/OpenSpatialTests/3D primitives/Size3DTests.swift new file mode 100644 index 0000000..b3e6288 --- /dev/null +++ b/Tests/OpenSpatialTests/3D primitives/Size3DTests.swift @@ -0,0 +1,291 @@ +import Testing +@testable import OpenSpatial + +struct Size3DTests { + + // MARK: - Initialization tests + + @Test func testInitialization() { + let size = Size3D() + #expect(size.width == 0.0) + #expect(size.height == 0.0) + #expect(size.depth == 0.0) + } + @Test func testInitializationUsingDefaultParams() { + let size = Size3D(width: 2.0, height: 1.0) + #expect(size.width == 2.0) + #expect(size.height == 1.0) + #expect(size.depth == 0.0) + } + @Test func testInitializationUsingFloatingPoint() { + let size = Size3D(width: Float(3.5), height: Float(4.5), depth: Float(5.5)) + #expect(size.width == 3.5) + #expect(size.height == 4.5) + #expect(size.depth == 5.5) + } + @Test func testInitializationUsingArrayLiteral() { + let size: Size3D = [1.0, 2.0, 3.0] + #expect(size.width == 1.0) + #expect(size.height == 2.0) + #expect(size.depth == 3.0) + } + + @Test func testInitializationUsingVector3D() { + let vector = Vector3D(x: 4.0, y: 5.0, z: 6.0) + let size = Size3D(vector) + #expect(size.width == 4.0) + #expect(size.height == 5.0) + #expect(size.depth == 6.0) + } + + @Test func testSubscriptAccess() throws { + let size = Size3D(width: 7.0, height: 8.0, depth: 9.0) + #expect(try size[0] == 7.0) + #expect(try size[1] == 8.0) + #expect(try size[2] == 9.0) + } + + @Test func testSubscriptError() throws { + let size = Size3D(width: 7.0, height: 8.0, depth: 9.0) + #expect(throws: OpenSpatial.Error.self) { + try size[4] == 0 + } + } + + // MARK: - Creating derived 3D sizes + + @Test func testIntersection() { + let size1 = Size3D(width: 5.0, height: 7.0, depth: 9.0) + let size2 = Size3D(width: 6.0, height: 4.0, depth: 10.0) + + let intersectionSize = size1.intersection(size2) + #expect(intersectionSize?.width == 5.0) + #expect(intersectionSize?.height == 4.0) + #expect(intersectionSize?.depth == 9.0) + } + + // MARK: - Applying arithmetic operations + + @Test func testZerioSize() { + let zeroSize = Size3D.zero + #expect(zeroSize.width == 0.0) + #expect(zeroSize.height == 0.0) + #expect(zeroSize.depth == 0.0) + } + + @Test func testOneSize() { + let oneSize = Size3D.one + #expect(oneSize.width == 1.0) + #expect(oneSize.height == 1.0) + #expect(oneSize.depth == 1.0) + } + + @Test func testMultiplication() { + let size = Size3D(width: 2.0, height: 3.0, depth: 4.0) + let scaledSize = size * 2.0 + #expect(scaledSize.width == 4.0) + #expect(scaledSize.height == 6.0) + #expect(scaledSize.depth == 8.0) + } + + @Test func testMultiplication2() { + var size = Size3D(width: 2.0, height: 3.0, depth: 4.0) + size *= 2.0 + #expect(size.width == 4.0) + #expect(size.height == 6.0) + #expect(size.depth == 8.0) + } + + @Test func testAddition() { + let size1 = Size3D(width: 1.0, height: 2.0, depth: 3.0) + let size2 = Size3D(width: 4.0, height: 5.0, depth: 6.0) + let sum = size1 + size2 + #expect(sum.width == 5.0) + #expect(sum.height == 7.0) + #expect(sum.depth == 9.0) + } + + @Test func testAddition2() { + var size1 = Size3D(width: 1.0, height: 2.0, depth: 3.0) + let size2 = Size3D(width: 4.0, height: 5.0, depth: 6.0) + size1 += size2 + #expect(size1.width == 5.0) + #expect(size1.height == 7.0) + #expect(size1.depth == 9.0) + } + + @Test func testSubtraction() { + let size1 = Size3D(width: 4.0, height: 5.0, depth: 6.0) + let size2 = Size3D(width: 1.0, height: 2.0, depth: 3.0) + let difference = size1 - size2 + #expect(difference.width == 3.0) + #expect(difference.height == 3.0) + #expect(difference.depth == 3.0) + } + + @Test func testSubtraction2() { + var size1 = Size3D(width: 4.0, height: 5.0, depth: 6.0) + let size2 = Size3D(width: 1.0, height: 2.0, depth: 3.0) + size1 -= size2 + #expect(size1.width == 3.0) + #expect(size1.height == 3.0) + #expect(size1.depth == 3.0) + } + + @Test func testDivision() { + let size = Size3D(width: 4.0, height: 8.0, depth: 12.0) + let dividedSize = size / 2.0 + #expect(dividedSize.width == 2.0) + #expect(dividedSize.height == 4.0) + #expect(dividedSize.depth == 6.0) + } + + @Test func testDivision2() { + var size = Size3D(width: 4.0, height: 8.0, depth: 12.0) + size /= 2.0 + #expect(size.width == 2.0) + #expect(size.height == 4.0) + #expect(size.depth == 6.0) + } + + // MARK: - Primitive3D tests + + @Test func testIsFinite() { + let size = Size3D(width: 1.0, height: 2.0, depth: 3.0) + #expect(size.isFinite == true) + } + + @Test func testIsNotFinite() { + let size = Size3D(width: Double.infinity, height: 2.0, depth: 3.0) + #expect(size.isFinite == false) + } + + @Test func testIsZero() { + let size = Size3D.zero + #expect(size.isZero == true) + } + + @Test func testIsNaN() { + let size = Size3D(width: Double.nan, height: 2.0, depth: 3.0) + #expect(size.isNaN == true) + } + + @Test func testInfinity() { + let infinity = Size3D.infinity + #expect(infinity.width == Double.infinity) + #expect(infinity.height == Double.infinity) + #expect(infinity.depth == Double.infinity) + } + + @Test func testApplyingAffineTransform() { + let size1 = Size3D.one + let size2 = size1.applying(.init()) + + #expect(size2 == .one) + } + + // MARK: - Scalable3D tests + + @Test func testScaledBy() { + let size = Size3D(width: 2.0, height: 3.0, depth: 4.0) + let scaledSize = size.scaled(by: .init(width: 3, height: 3, depth: 3)) + #expect(scaledSize.width == 6.0) + #expect(scaledSize.height == 9.0) + #expect(scaledSize.depth == 12.0) + } + + @Test func testUniformScale() { + let size = Size3D(width: 2.0, height: 3.0, depth: 4.0) + let uniformScaledSize = size.uniformlyScaled(by: 2.0) + #expect(uniformScaledSize.width == 4.0) + #expect(uniformScaledSize.height == 6.0) + #expect(uniformScaledSize.depth == 8.0) + } + + // MARK: - Volumetric3D tests + + @Test func testSize() { + let size1 = Size3D(width: 5.0, height: 5.0, depth: 5.0) + #expect(size1.size == Size3D(width: 5.0, height: 5.0, depth: 5.0)) + } + + @Test func testContains() { + let size1 = Size3D(width: 5.0, height: 5.0, depth: 5.0) + let size2 = Size3D(width: 3.0, height: 3.0, depth: 3.0) + #expect(size1.contains(size2) == true) + } + + @Test func testContainsPoint() { + let size = Size3D(width: 5.0, height: 5.0, depth: 5.0) + let point = Point3D(x: 2.0, y: 2.0, z: 2.0) + #expect(size.contains(point: point) == true) + } + + @Test func testIntersectionWithNoOverlap() { + let size1 = Size3D(width: 2.0, height: 2.0, depth: 2.0) + let size2 = Size3D(width: 2.0, height: 2.0, depth: 2.0) + + let intersectionSize = size1.intersection(size2) + #expect(intersectionSize?.width == 2.0) + #expect(intersectionSize?.height == 2.0) + #expect(intersectionSize?.depth == 2.0) + } + + @Test func testIntersectionWithPartialOverlap() { + let size1 = Size3D(width: 4.0, height: 4.0, depth: 4.0) + let size2 = Size3D(width: 3.0, height: 5.0, depth: 2.0) + + let intersectionSize = size1.intersection(size2) + #expect(intersectionSize?.width == 3.0) + #expect(intersectionSize?.height == 4.0) + #expect(intersectionSize?.depth == 2.0) + } + + @Test func testIntersectionWithCompleteOverlap() { + let size1 = Size3D(width: 5.0, height: 5.0, depth: 5.0) + let size2 = Size3D(width: 5.0, height: 5.0, depth: 5.0) + + let intersectionSize = size1.intersection(size2) + #expect(intersectionSize?.width == 5.0) + #expect(intersectionSize?.height == 5.0) + #expect(intersectionSize?.depth == 5.0) + } + + @Test func testIntersectionWithEdgeTouching() { + let size1 = Size3D(width: 3.0, height: 3.0, depth: 3.0) + let size2 = Size3D(width: 3.0, height: 3.0, depth: 3.0) + + let intersectionSize = size1.intersection(size2) + #expect(intersectionSize?.width == 3.0) + #expect(intersectionSize?.height == 3.0) + #expect(intersectionSize?.depth == 3.0) + } + + @Test func testIntersectionNil() { + let size1 = Size3D(width: 3.0, height: 3.0, depth: 3.0) + let size2 = Size3D(width: -4.0, height: -8.0, depth: -2.0) + + let intersectionSize = size1.intersection(size2) + #expect(intersectionSize == nil) + } + + @Test func testUnion() { + let size1 = Size3D(width: 2.0, height: 3.0, depth: 4.0) + let size2 = Size3D(width: 5.0, height: 6.0, depth: 7.0) + + let unionSize = size1.union(size2) + #expect(unionSize.width == 5.0) + #expect(unionSize.height == 6.0) + #expect(unionSize.depth == 7.0) + } + + @Test func testDescription() { + let size = Size3D(width: 1.0, height: 2.0, depth: 3.0) + #expect(size.description == "(width: 1.0, height: 2.0, depth: 3.0)") + } + + @Test func testVector() { + let size = Size3D(width: 1.0, height: 2.0, depth: 3.0) + #expect(size.vector == [1.0, 2.0, 3.0]) + } +} \ No newline at end of file diff --git a/Tests/OpenSpatialTests/3D primitives/SphericalCoordinates3DTests.swift b/Tests/OpenSpatialTests/3D primitives/SphericalCoordinates3DTests.swift new file mode 100644 index 0000000..bad3bd8 --- /dev/null +++ b/Tests/OpenSpatialTests/3D primitives/SphericalCoordinates3DTests.swift @@ -0,0 +1,154 @@ +import Foundation +import Testing +@testable import OpenSpatial + +struct SphericalCoordinates3DTests { + + // MARK: - Initialization + + @Test func testInitWithComponents() { + let s = SphericalCoordinates3D( + radius: 5.0, + inclination: Angle2D(radians: .pi / 4), + azimuth: Angle2D(radians: .pi / 6) + ) + #expect(s.radius == 5.0) + #expect(s.inclination.radians == .pi / 4) + #expect(s.azimuth.radians == .pi / 6) + } + + @Test func testInitFromPointOnPosXAxis() { + // (1, 0, 0) → radius=1, inclination=π/2, azimuth=0 + let s = SphericalCoordinates3D(Point3D(x: 1, y: 0, z: 0)) + #expect(s.radius.rounded(toPlaces: 10) == 1.0) + #expect(s.inclination.radians.rounded(toPlaces: 10) == (.pi / 2).rounded(toPlaces: 10)) + #expect(s.azimuth.radians.rounded(toPlaces: 10) == 0.0) + } + + @Test func testInitFromPointOnPosYAxis() { + // (0, 1, 0) → radius=1, inclination=0, azimuth=0 + let s = SphericalCoordinates3D(Point3D(x: 0, y: 1, z: 0)) + #expect(s.radius.rounded(toPlaces: 10) == 1.0) + #expect(s.inclination.radians.rounded(toPlaces: 10) == 0.0) + #expect(s.azimuth.radians.rounded(toPlaces: 10) == 0.0) + } + + @Test func testInitFromPointOnPosZAxis() { + // (0, 0, 1) → radius=1, inclination=π/2, azimuth=π/2 + let s = SphericalCoordinates3D(Point3D(x: 0, y: 0, z: 1)) + #expect(s.radius.rounded(toPlaces: 10) == 1.0) + #expect(s.inclination.radians.rounded(toPlaces: 10) == (.pi / 2).rounded(toPlaces: 10)) + #expect(s.azimuth.radians.rounded(toPlaces: 10) == (.pi / 2).rounded(toPlaces: 10)) + } + + @Test func testInitFromOriginProducesZeroAngles() { + let s = SphericalCoordinates3D(Point3D(x: 0, y: 0, z: 0)) + #expect(s.radius == 0.0) + #expect(s.inclination.radians == 0.0) + #expect(s.azimuth.radians == 0.0) + } + + // MARK: - Point conversion + + @Test func testPointFromComponents() { + let s = SphericalCoordinates3D( + radius: 2.0, + inclination: Angle2D(radians: .pi / 2), + azimuth: Angle2D(radians: 0) + ) + // Should give (2, 0, 0) + #expect(s.point.x.rounded(toPlaces: 10) == 2.0) + #expect(s.point.y.rounded(toPlaces: 10) == 0.0) + #expect(s.point.z.rounded(toPlaces: 10) == 0.0) + } + + @Test func testPointFromYAxisComponents() { + let s = SphericalCoordinates3D( + radius: 3.0, + inclination: Angle2D(radians: 0), + azimuth: Angle2D(radians: 0) + ) + // inclination=0 → points along +y axis + #expect(s.point.x.rounded(toPlaces: 10) == 0.0) + #expect(s.point.y.rounded(toPlaces: 10) == 3.0) + #expect(s.point.z.rounded(toPlaces: 10) == 0.0) + } + + // MARK: - Round-trip + + @Test func testRoundTripXAxis() { + let original = Point3D(x: 3, y: 0, z: 0) + let roundTrip = SphericalCoordinates3D(original).point + #expect(roundTrip.x.rounded(toPlaces: 10) == original.x.rounded(toPlaces: 10)) + #expect(roundTrip.y.rounded(toPlaces: 10) == original.y.rounded(toPlaces: 10)) + #expect(roundTrip.z.rounded(toPlaces: 10) == original.z.rounded(toPlaces: 10)) + } + + @Test func testRoundTripYAxis() { + let original = Point3D(x: 0, y: 5, z: 0) + let roundTrip = SphericalCoordinates3D(original).point + #expect(roundTrip.x.rounded(toPlaces: 10) == original.x.rounded(toPlaces: 10)) + #expect(roundTrip.y.rounded(toPlaces: 10) == original.y.rounded(toPlaces: 10)) + #expect(roundTrip.z.rounded(toPlaces: 10) == original.z.rounded(toPlaces: 10)) + } + + @Test func testRoundTripArbitraryPoint() { + let original = Point3D(x: 1, y: 2, z: 3) + let roundTrip = SphericalCoordinates3D(original).point + #expect(roundTrip.x.rounded(toPlaces: 10) == original.x.rounded(toPlaces: 10)) + #expect(roundTrip.y.rounded(toPlaces: 10) == original.y.rounded(toPlaces: 10)) + #expect(roundTrip.z.rounded(toPlaces: 10) == original.z.rounded(toPlaces: 10)) + } + + @Test func testRoundTripNegativeCoordinates() { + let original = Point3D(x: -2, y: -1, z: -3) + let roundTrip = SphericalCoordinates3D(original).point + #expect(roundTrip.x.rounded(toPlaces: 10) == original.x.rounded(toPlaces: 10)) + #expect(roundTrip.y.rounded(toPlaces: 10) == original.y.rounded(toPlaces: 10)) + #expect(roundTrip.z.rounded(toPlaces: 10) == original.z.rounded(toPlaces: 10)) + } + + // MARK: - isApproximatelyEqual + + @Test func testIsApproximatelyEqualSame() { + let s = SphericalCoordinates3D(radius: 1, inclination: Angle2D(radians: .pi / 4), azimuth: Angle2D(radians: .pi / 3)) + #expect(s.isApproximatelyEqual(to: s)) + } + + @Test func testIsApproximatelyEqualWithinTolerance() { + let s1 = SphericalCoordinates3D(radius: 1.0, inclination: Angle2D(radians: .pi / 4), azimuth: Angle2D(radians: .pi / 3)) + let s2 = SphericalCoordinates3D(radius: 1.0 + 1e-15, inclination: Angle2D(radians: .pi / 4), azimuth: Angle2D(radians: .pi / 3)) + #expect(s1.isApproximatelyEqual(to: s2)) + } + + @Test func testIsNotApproximatelyEqual() { + let s1 = SphericalCoordinates3D(radius: 1.0, inclination: Angle2D(radians: .pi / 4), azimuth: Angle2D(radians: .pi / 3)) + let s2 = SphericalCoordinates3D(radius: 2.0, inclination: Angle2D(radians: .pi / 4), azimuth: Angle2D(radians: .pi / 3)) + #expect(!s1.isApproximatelyEqual(to: s2)) + } + + @Test func testIsApproximatelyEqualWithCustomTolerance() { + let s1 = SphericalCoordinates3D(radius: 1.0, inclination: Angle2D(radians: 0), azimuth: Angle2D(radians: 0)) + let s2 = SphericalCoordinates3D(radius: 1.05, inclination: Angle2D(radians: 0), azimuth: Angle2D(radians: 0)) + #expect(s1.isApproximatelyEqual(to: s2, tolerance: 0.1)) + #expect(!s1.isApproximatelyEqual(to: s2, tolerance: 0.01)) + } + + // MARK: - Codable + + @Test func testCodableRoundTrip() throws { + let s = SphericalCoordinates3D(radius: 3.0, inclination: Angle2D(radians: .pi / 4), azimuth: Angle2D(radians: .pi / 6)) + let data = try JSONEncoder().encode(s) + let decoded = try JSONDecoder().decode(SphericalCoordinates3D.self, from: data) + #expect(decoded == s) + } + + // MARK: - CustomStringConvertible + + @Test func testDescription() { + let s = SphericalCoordinates3D(radius: 1.0, inclination: Angle2D(radians: 0), azimuth: Angle2D(radians: 0)) + let desc = s.description + #expect(desc.contains("radius")) + #expect(desc.contains("1.0")) + } +} diff --git a/Tests/OpenSpatialTests/Affine and projective transforms/AffineTransform3DTests.swift b/Tests/OpenSpatialTests/Affine and projective transforms/AffineTransform3DTests.swift new file mode 100644 index 0000000..32bf67e --- /dev/null +++ b/Tests/OpenSpatialTests/Affine and projective transforms/AffineTransform3DTests.swift @@ -0,0 +1,327 @@ +import Testing +@testable import OpenSpatial + +struct AffineTransform3DTests { + + // MARK: - Initialization tests + + @Test func testInitialization() { + let transform = AffineTransform3D() + let expectedMatrix: [[Double]] = [ + [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] + ] + #expect(transform.matrix == expectedMatrix) + } + + @Test func testInitializationUsingMatrix() { + let matrix: [[Double]] = [ + [2.0, 0.0, 0.0, 1.0], + [0.0, 2.0, 0.0, 2.0], + [0.0, 0.0, 2.0, 3.0], + [0.0, 0.0, 0.0, 1.0] + ] + let transform = AffineTransform3D(matrix: matrix) + #expect(transform.matrix == matrix) + } + + // MARK: - Arithmetic operation tests + + @Test func testConcatenationOfAffineTransforms() { + let transformA = AffineTransform3D(matrix: [ + [1.0, 0.0, 0.0, 1.0], + [0.0, 1.0, 0.0, 2.0], + [0.0, 0.0, 1.0, 3.0], + [0.0, 0.0, 0.0, 1.0] + ]) + let transformB = AffineTransform3D(matrix: [ + [2.0, 0.0, 0.0, 0.0], + [0.0, 2.0, 0.0, 0.0], + [0.0, 0.0, 2.0, 0.0], + [0.0, 0.0, 0.0, 1.0] + ]) + let result = transformA * transformB + let expectedMatrix: [[Double]] = [ + [2.0, 0.0, 0.0, 1.0], + [0.0, 2.0, 0.0, 2.0], + [0.0, 0.0, 2.0, 3.0], + [0.0, 0.0, 0.0, 1.0] + ] + #expect(result.matrix == expectedMatrix) + } + + // MARK: - Scalable3D tests + + @Test func testScalingAffineTransform3D() { + var transform = AffineTransform3D() + transform.scaleBy(x: 2.0, y: 3.0, z: 4.0) + let expectedMatrix: [[Double]] = [ + [2.0, 0.0, 0.0, 0.0], + [0.0, 3.0, 0.0, 0.0], + [0.0, 0.0, 4.0, 0.0], + [0.0, 0.0, 0.0, 1.0] + ] + #expect(transform.matrix == expectedMatrix) + } + + @Test func testUniformScalingAffineTransform3D() { + var transform = AffineTransform3D() + transform.uniformlyScale(by: 5.0) + let expectedMatrix: [[Double]] = [ + [5.0, 0.0, 0.0, 0.0], + [0.0, 5.0, 0.0, 0.0], + [0.0, 0.0, 5.0, 0.0], + [0.0, 0.0, 0.0, 1.0] + ] + #expect(transform.matrix == expectedMatrix) + } + + // MARK: - Translatable3D tests + + @Test func testTranslatingAffineTransform3D() { + var transform = AffineTransform3D() + let vector = Vector3D(x: 1.0, y: 2.0, z: 3.0) + transform = transform.translated(by: vector) + let expectedMatrix: [[Double]] = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [1.0, 2.0, 3.0, 1.0] + ] + #expect(transform.matrix == expectedMatrix) + } + + // MARK: - New initialisers and decomposition + + @Test func testInitWithScale() { + let t = AffineTransform3D(scale: Size3D(width: 2, height: 3, depth: 4)) + #expect(t[0, 0] == 2.0) + #expect(t[1, 1] == 3.0) + #expect(t[2, 2] == 4.0) + #expect(t[3, 3] == 1.0) + } + + @Test func testInitWithTranslation() { + let t = AffineTransform3D(translation: Vector3D(x: 1, y: 2, z: 3)) + #expect(t[3, 0] == 1.0) + #expect(t[3, 1] == 2.0) + #expect(t[3, 2] == 3.0) + #expect(t[0, 0] == 1.0) + } + + @Test func testInitWithRotation() { + let r = Rotation3D() + let t = AffineTransform3D(rotation: r) + #expect(t.isIdentity == false || t == AffineTransform3D()) + // Identity rotation produces identity matrix + #expect(t[0, 0].rounded(toPlaces: 10) == 1.0) + #expect(t[1, 1].rounded(toPlaces: 10) == 1.0) + #expect(t[2, 2].rounded(toPlaces: 10) == 1.0) + } + + @Test func testIsIdentity() { + #expect(AffineTransform3D().isIdentity) + #expect(!AffineTransform3D(scale: Size3D(width: 2, height: 1, depth: 1)).isIdentity) + } + + @Test func testTranslationDecomposition() { + let t = AffineTransform3D(translation: Vector3D(x: 5, y: 6, z: 7)) + #expect(t.translation == Vector3D(x: 5, y: 6, z: 7)) + } + + @Test func testScaleDecomposition() { + let t = AffineTransform3D(scale: Size3D(width: 2, height: 3, depth: 4)) + #expect(t.scale.width.rounded(toPlaces: 10) == 2.0) + #expect(t.scale.height.rounded(toPlaces: 10) == 3.0) + #expect(t.scale.depth.rounded(toPlaces: 10) == 4.0) + } + + @Test func testInverseOfIdentity() { + let inv = AffineTransform3D().inverse + #expect(inv != nil) + #expect(inv! == AffineTransform3D()) + } + + @Test func testInverseRoundTrip() { + let t = AffineTransform3D(translation: Vector3D(x: 1, y: 2, z: 3)) + let inv = t.inverse + #expect(inv != nil) + let product = t * inv! + #expect(product[3, 0].rounded(toPlaces: 10) == 0.0) + #expect(product[3, 1].rounded(toPlaces: 10) == 0.0) + #expect(product[3, 2].rounded(toPlaces: 10) == 0.0) + #expect(product[0, 0].rounded(toPlaces: 10) == 1.0) + } + + @Test func testRotatedByRotation3D() { + let r = Rotation3D() + let t = AffineTransform3D().rotated(by: r) + #expect(t[0, 0].rounded(toPlaces: 10) == 1.0) + #expect(t[1, 1].rounded(toPlaces: 10) == 1.0) + #expect(t[2, 2].rounded(toPlaces: 10) == 1.0) + } + + @Test func testStaticIdentity() { + let id = AffineTransform3D.identity + #expect(id == AffineTransform3D()) + #expect(id.isIdentity) + } + + @Test func testInitWithRotationScaleTranslation() { + let r = Rotation3D() + let s = Size3D(width: 2, height: 3, depth: 4) + let t = Vector3D(x: 1, y: 2, z: 3) + let transform = AffineTransform3D(rotation: r, scale: s, translation: t) + #expect(transform[0, 0].rounded(toPlaces: 10) == 2.0) + #expect(transform[1, 1].rounded(toPlaces: 10) == 3.0) + #expect(transform[2, 2].rounded(toPlaces: 10) == 4.0) + #expect(transform[3, 0].rounded(toPlaces: 10) == 1.0) + #expect(transform[3, 1].rounded(toPlaces: 10) == 2.0) + #expect(transform[3, 2].rounded(toPlaces: 10) == 3.0) + } + + @Test func testPointApplyingIdentity() { + let point = Point3D(x: 1, y: 2, z: 3) + #expect(point.applying(AffineTransform3D.identity) == point) + } + + @Test func testTransformMultipliedByIdentity() { + let t = AffineTransform3D(translation: Vector3D(x: 1, y: 2, z: 3)) + #expect(t * .identity == t) + } + + @Test func testMultiplyAssignOperator() { + var t = AffineTransform3D(translation: Vector3D(x: 1, y: 0, z: 0)) + t *= AffineTransform3D(scale: Size3D(width: 2, height: 2, depth: 2)) + #expect(t[0, 0].rounded(toPlaces: 10) == 2.0) + } + + @Test func testUniformlyScaled() { + let t = AffineTransform3D().uniformlyScaled(by: 3.0) + #expect(t[0, 0] == 3.0) + #expect(t[1, 1] == 3.0) + #expect(t[2, 2] == 3.0) + } + + @Test func testDescription() { + let t = AffineTransform3D() + let desc = t.description + #expect(desc.contains("1.0")) + #expect(desc.contains("[")) + } + + @Test func testRotationDecompositionWith90DegreeYRotation() { + // 90° rotation around Y: exercises a non-trace-positive Shepperd branch + // Verify by acting on a vector with both the original and extracted rotation + let r = Rotation3D(angle: Angle2D(degrees: 90), axis: RotationAxis3D(x: 0, y: 1, z: 0)) + let t = AffineTransform3D(rotation: r) + let extracted = t.rotation + let v = Vector3D(x: 1, y: 0, z: 0) + let v1 = r.act(v) + let v2 = extracted.act(v) + #expect(v1.x.rounded(toPlaces: 8) == v2.x.rounded(toPlaces: 8)) + #expect(v1.y.rounded(toPlaces: 8) == v2.y.rounded(toPlaces: 8)) + #expect(v1.z.rounded(toPlaces: 8) == v2.z.rounded(toPlaces: 8)) + } + + @Test func testRotationDecompositionWith90DegreeXRotation() { + // 90° rotation around X: exercises another Shepperd branch + let r = Rotation3D(angle: Angle2D(degrees: 90), axis: RotationAxis3D(x: 1, y: 0, z: 0)) + let t = AffineTransform3D(rotation: r) + let extracted = t.rotation + let v = Vector3D(x: 0, y: 1, z: 0) + let v1 = r.act(v) + let v2 = extracted.act(v) + #expect(v1.x.rounded(toPlaces: 8) == v2.x.rounded(toPlaces: 8)) + #expect(v1.y.rounded(toPlaces: 8) == v2.y.rounded(toPlaces: 8)) + #expect(v1.z.rounded(toPlaces: 8) == v2.z.rounded(toPlaces: 8)) + } + + @Test func testRotationDecompositionWith90DegreeZRotation() { + // 90° rotation around Z: exercises yet another Shepperd branch + let r = Rotation3D(angle: Angle2D(degrees: 90), axis: RotationAxis3D(x: 0, y: 0, z: 1)) + let t = AffineTransform3D(rotation: r) + let extracted = t.rotation + let v = Vector3D(x: 1, y: 0, z: 0) + let v1 = r.act(v) + let v2 = extracted.act(v) + #expect(v1.x.rounded(toPlaces: 8) == v2.x.rounded(toPlaces: 8)) + #expect(v1.y.rounded(toPlaces: 8) == v2.y.rounded(toPlaces: 8)) + #expect(v1.z.rounded(toPlaces: 8) == v2.z.rounded(toPlaces: 8)) + } + + // MARK: - inverse edge cases + + @Test func testInverseOfSingularMatrixReturnsNil() { + let singular = AffineTransform3D(matrix: [ + [1, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1] + ]) + #expect(singular.inverse == nil) + } + + @Test func testInverseOfAllZeroMatrixReturnsNil() { + let zero = AffineTransform3D(matrix: [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0] + ]) + #expect(zero.inverse == nil) + } + + @Test func testInverseOfScaleRoundTrip() { + let t = AffineTransform3D(scale: Size3D(width: 3, height: 0.5, depth: 2)) + let inv = t.inverse + #expect(inv != nil) + let product = t * inv! + for i in 0..<4 { + for j in 0..<4 { + let expected = i == j ? 1.0 : 0.0 + #expect(product[i, j].rounded(toPlaces: 10) == expected, + "product[\(i)][\(j)] expected \(expected), got \(product[i, j])") + } + } + } + + @Test func testInverseOfRotationScaleTranslationRoundTrip() { + let r = Rotation3D(angle: Angle2D(degrees: 37), axis: RotationAxis3D(x: 1, y: 1, z: 0)) + let t = AffineTransform3D(rotation: r, scale: Size3D(width: 2, height: 2, depth: 2), translation: Vector3D(x: 5, y: -3, z: 1)) + let inv = t.inverse + #expect(inv != nil) + let product = t * inv! + for i in 0..<4 { + for j in 0..<4 { + let expected = i == j ? 1.0 : 0.0 + #expect(product[i, j].rounded(toPlaces: 8) == expected, + "product[\(i)][\(j)] expected \(expected), got \(product[i, j])") + } + } + } + + @Test func testInverseNearSingularReturnsNil() { + // A matrix with a very small pivot — below the 1e-12 threshold + let nearSingular = AffineTransform3D(matrix: [ + [1e-13, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1] + ]) + #expect(nearSingular.inverse == nil) + } + + @Test func testInverseTranslationMovePointBack() { + let t = AffineTransform3D(translation: Vector3D(x: 10, y: -5, z: 3)) + let inv = t.inverse! + let point = Point3D(x: 1, y: 2, z: 3) + let moved = point.applying(t) + let back = moved.applying(inv) + #expect(back.x.rounded(toPlaces: 10) == point.x) + #expect(back.y.rounded(toPlaces: 10) == point.y) + #expect(back.z.rounded(toPlaces: 10) == point.z) + } +} \ No newline at end of file diff --git a/Tests/OpenSpatialTests/Affine and projective transforms/ProjectiveTransform3DTests.swift b/Tests/OpenSpatialTests/Affine and projective transforms/ProjectiveTransform3DTests.swift new file mode 100644 index 0000000..1edb95c --- /dev/null +++ b/Tests/OpenSpatialTests/Affine and projective transforms/ProjectiveTransform3DTests.swift @@ -0,0 +1,210 @@ +import Foundation +import Testing +@testable import OpenSpatial + +struct ProjectiveTransform3DTests { + + // MARK: - Initialization + + @Test func testDefaultInit() { + let t = ProjectiveTransform3D() + let expected: [[Double]] = [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1] + ] + #expect(t.matrix == expected) + } + + @Test func testInitWithMatrix() { + let m: [[Double]] = [ + [2, 0, 0, 0], + [0, 3, 0, 0], + [0, 0, 4, 0], + [0, 0, 0, 1] + ] + let t = ProjectiveTransform3D(matrix: m) + #expect(t.matrix == m) + } + + @Test func testInitFromAffineTransform() { + let a = AffineTransform3D(scale: Size3D(width: 2, height: 3, depth: 4)) + let p = ProjectiveTransform3D(a) + #expect(p.matrix == a.matrix) + } + + // MARK: - Identity + + @Test func testIdentityProperty() { + let identity = ProjectiveTransform3D.identity + #expect(identity == ProjectiveTransform3D()) + } + + @Test func testIsIdentityTrue() { + #expect(ProjectiveTransform3D().isIdentity) + } + + @Test func testIsIdentityFalse() { + let m: [[Double]] = [ + [2, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1] + ] + #expect(!ProjectiveTransform3D(matrix: m).isIdentity) + } + + // MARK: - isAffine + + @Test func testIsAffineTrue() { + let t = ProjectiveTransform3D(AffineTransform3D()) + #expect(t.isAffine) + } + + @Test func testIsAffineFalse() { + let m: [[Double]] = [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0.1, 0, 0, 1] + ] + #expect(!ProjectiveTransform3D(matrix: m).isAffine) + } + + // MARK: - Subscript + + @Test func testSubscriptGet() { + let t = ProjectiveTransform3D() + #expect(t[0, 0] == 1.0) + #expect(t[1, 1] == 1.0) + #expect(t[0, 1] == 0.0) + } + + @Test func testSubscriptSet() { + var t = ProjectiveTransform3D() + t[0, 0] = 5.0 + #expect(t[0, 0] == 5.0) + } + + // MARK: - Inverse + + @Test func testInverseOfIdentity() { + let inv = ProjectiveTransform3D().inverse + #expect(inv != nil) + #expect(inv! == ProjectiveTransform3D()) + } + + @Test func testInverseRoundTrip() { + let m: [[Double]] = [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [2, 3, 4, 1] + ] + let t = ProjectiveTransform3D(matrix: m) + let inv = t.inverse + #expect(inv != nil) + let product = t * inv! + #expect(product[0, 0].rounded(toPlaces: 10) == 1.0) + #expect(product[3, 0].rounded(toPlaces: 10) == 0.0) + #expect(product[3, 1].rounded(toPlaces: 10) == 0.0) + #expect(product[3, 2].rounded(toPlaces: 10) == 0.0) + } + + @Test func testInverseOfSingularReturnsNil() { + let m: [[Double]] = [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0] + ] + #expect(ProjectiveTransform3D(matrix: m).inverse == nil) + } + + // MARK: - Concatenation + + @Test func testConcatenating() { + let a = ProjectiveTransform3D(AffineTransform3D(translation: Vector3D(x: 1, y: 2, z: 3))) + let b = ProjectiveTransform3D(AffineTransform3D(translation: Vector3D(x: 4, y: 5, z: 6))) + let result = a.concatenating(b) + #expect(result[3, 0].rounded(toPlaces: 10) == 5.0) + #expect(result[3, 1].rounded(toPlaces: 10) == 7.0) + #expect(result[3, 2].rounded(toPlaces: 10) == 9.0) + } + + @Test func testMultiplyOperator() { + let a = ProjectiveTransform3D() + let b = ProjectiveTransform3D() + let result = a * b + #expect(result == ProjectiveTransform3D()) + } + + @Test func testMultiplyAssignOperator() { + var a = ProjectiveTransform3D() + let b = ProjectiveTransform3D(AffineTransform3D(scale: Size3D(width: 2, height: 2, depth: 2))) + a *= b + #expect(a[0, 0] == 2.0) + #expect(a[1, 1] == 2.0) + #expect(a[2, 2] == 2.0) + } + + // MARK: - CustomStringConvertible + + @Test func testDescription() { + let t = ProjectiveTransform3D() + let desc = t.description + #expect(desc.contains("1.0")) + #expect(desc.contains("[")) + } + + // MARK: - isAffine additional cases + + @Test func testIsAffineWithNonZeroM30() { + var t = ProjectiveTransform3D() + t[3, 0] = 0.5 + #expect(!t.isAffine) + } + + @Test func testIsAffineWithNonZeroM31() { + var t = ProjectiveTransform3D() + t[3, 1] = -1.0 + #expect(!t.isAffine) + } + + @Test func testIsAffineWithNonZeroM32() { + var t = ProjectiveTransform3D() + t[3, 2] = 0.001 + #expect(!t.isAffine) + } + + @Test func testIsAffineWithM33NotOne() { + var t = ProjectiveTransform3D() + t[3, 3] = 2.0 + #expect(!t.isAffine) + } + + @Test func testIsAffineUpperBlockChangesDoNotAffectIsAffine() { + // Changing only the 3×3 upper-left block does NOT affect isAffine. + var t = ProjectiveTransform3D() + t[0, 0] = 5.0 + t[1, 1] = 3.0 + t[2, 2] = 7.0 + #expect(t.isAffine) + } + + @Test func testIsAffineIdentityIsAffine() { + #expect(ProjectiveTransform3D.identity.isAffine) + } + + @Test func testIsAffinePureScaleIsAffine() { + let t = ProjectiveTransform3D(AffineTransform3D(scale: Size3D(width: 2, height: 3, depth: 4))) + #expect(t.isAffine) + } + + @Test func testIsAffinePureRotationIsAffine() { + let r = Rotation3D(angle: Angle2D(degrees: 45), axis: RotationAxis3D(x: 0, y: 1, z: 0)) + let t = ProjectiveTransform3D(AffineTransform3D(rotation: r)) + #expect(t.isAffine) + } +} diff --git a/Tests/OpenSpatialTests/Converting between coordinate spaces/CoordinateSpace3DTests.swift b/Tests/OpenSpatialTests/Converting between coordinate spaces/CoordinateSpace3DTests.swift new file mode 100644 index 0000000..e50ad9e --- /dev/null +++ b/Tests/OpenSpatialTests/Converting between coordinate spaces/CoordinateSpace3DTests.swift @@ -0,0 +1,130 @@ +import Testing +@testable import OpenSpatial + +// A simple concrete coordinate space used only in tests. +// Each instance holds a fixed ProjectiveTransform3D as its "offset from parent". +private struct TestSpace: CoordinateSpace3D { + typealias AncestorCoordinateSpace = WorldReferenceCoordinateSpace + + let ancestorSpace: WorldReferenceCoordinateSpace? = WorldReferenceCoordinateSpace() + let offsetTransform: ProjectiveTransform3D + + func ancestorFromSpaceTransform() throws -> ProjectiveTransform3D { + offsetTransform + } + + func transform(from target: TestSpace) throws -> ProjectiveTransform3D { + let selfFromRoot = try _transformToRoot() + let targetFromRoot = try target._transformToRoot() + guard let inv = selfFromRoot.inverse else { throw Error.noAncestorSpace } + return inv * targetFromRoot + } +} + +struct CoordinateSpace3DTests { + + @Test func testWorldReferenceAncestorSpaceIsNil() { + let world = WorldReferenceCoordinateSpace() + #expect(world.ancestorSpace == nil) + } + + @Test func testWorldReferenceAncestorFromSpaceTransformThrows() { + let world = WorldReferenceCoordinateSpace() + #expect(throws: (any Swift.Error).self) { + try world.ancestorFromSpaceTransform() + } + } + + @Test func testWorldReferenceStaticProperty() { + let world: WorldReferenceCoordinateSpace = .worldReference + #expect(world.ancestorSpace == nil) + } + + @Test func testWorldReferenceTransformFromSelfIsIdentity() throws { + let world = WorldReferenceCoordinateSpace() + let t = try world.transform(from: world) + #expect(t.isIdentity) + } + + @Test func testWorldReferenceConvertPointToSelf() throws { + let world = WorldReferenceCoordinateSpace() + let point = Point3D(x: 1, y: 2, z: 3) + let result = try world.convert(value: point, to: world) + #expect(result == point) + } + + // MARK: - TestSpace (child of world) + + @Test func testChildSpaceTransformToRootIsItsOwnTransform() throws { + let offset = ProjectiveTransform3D(AffineTransform3D(translation: Vector3D(x: 5, y: 0, z: 0))) + let child = TestSpace(offsetTransform: offset) + let toRoot = try child._transformToRoot() + #expect(toRoot[3, 0].rounded(toPlaces: 10) == 5.0) + } + + @Test func testTransformBetweenTwoChildSpaces() throws { + // childA is offset +10 on X from world, childB is offset +3 on X from world. + // transform(from: childB) as seen by childA should give -7 on X (childB - childA). + let offsetA = ProjectiveTransform3D(AffineTransform3D(translation: Vector3D(x: 10, y: 0, z: 0))) + let offsetB = ProjectiveTransform3D(AffineTransform3D(translation: Vector3D(x: 3, y: 0, z: 0))) + let childA = TestSpace(offsetTransform: offsetA) + let childB = TestSpace(offsetTransform: offsetB) + let t = try childA.transform(from: childB) + let point = Point3D(x: 0, y: 0, z: 0) + let converted = point.applying(t) + #expect(converted.x.rounded(toPlaces: 8) == -7.0) + #expect(converted.y.rounded(toPlaces: 8) == 0.0) + #expect(converted.z.rounded(toPlaces: 8) == 0.0) + } + + @Test func testConvertValueBetweenChildSpaces() throws { + // childA has offset Y+5 from world, childB has offset Y+2 from world. + // The point Y=0 in childA sits at Y=5 in world. + // From childB's perspective (offset Y+2), that world-Y=5 point is at Y=5-2=3 + // in childB... BUT the transform is: selfFromRoot⁻¹ * targetFromRoot. + // selfFromRoot for childA is the translation +5; its inverse is -5. + // targetFromRoot for childB is +2. + // Combined: inverse(+5) * (+2) = (-5) + 2 applied to origin = -5+2 = -3. + // So converting point(0,0,0) from childA to childB gives y=-3. + let offsetA = ProjectiveTransform3D(AffineTransform3D(translation: Vector3D(x: 0, y: 5, z: 0))) + let offsetB = ProjectiveTransform3D(AffineTransform3D(translation: Vector3D(x: 0, y: 2, z: 0))) + let childA = TestSpace(offsetTransform: offsetA) + let childB = TestSpace(offsetTransform: offsetB) + let point = Point3D(x: 0, y: 0, z: 0) + let result = try childA.convert(value: point, to: childB) + #expect(result.y.rounded(toPlaces: 8) == -3.0) + } + + @Test func testConvertValueFromChildSpaceToWorld() throws { + // world.convert(value: p, from: child) calls child.transform(from: world) applied to p. + // child.transform(from: world) = childFromRoot⁻¹ * worldFromRoot + // = inverse(translation(0,0,7)) * identity = translation(0,0,-7) + // So point(1,2,3) becomes (1, 2, 3-7) = (1, 2, -4). + let offset = ProjectiveTransform3D(AffineTransform3D(translation: Vector3D(x: 0, y: 0, z: 7))) + let child = TestSpace(offsetTransform: offset) + let world = WorldReferenceCoordinateSpace() + let point = Point3D(x: 1, y: 2, z: 3) + let result = try world.convert(value: point, from: child) + #expect(result.z.rounded(toPlaces: 8) == -4.0) + } + + @Test func testTransformFromSelfIsIdentity() throws { + let offset = ProjectiveTransform3D(AffineTransform3D(translation: Vector3D(x: 4, y: 4, z: 4))) + let child = TestSpace(offsetTransform: offset) + let t = try child.transform(from: child) + #expect(t.isIdentity) + } + + @Test func testTransformSpaceModifiesConversion() throws { + let world = WorldReferenceCoordinateSpace() + // Add a 2× uniform scale via transformSpace + let scaled = world.transformSpace { _ in + ProjectiveTransform3D(AffineTransform3D(scale: Size3D(width: 2, height: 2, depth: 2))) + } + let toRoot = try scaled._transformToRoot() + // The scale should appear in the transform chain + #expect(toRoot[0, 0].rounded(toPlaces: 8) == 2.0) + #expect(toRoot[1, 1].rounded(toPlaces: 8) == 2.0) + #expect(toRoot[2, 2].rounded(toPlaces: 8) == 2.0) + } +} diff --git a/Tests/OpenSpatialTests/Data structures/Axis3DTests.swift b/Tests/OpenSpatialTests/Data structures/Axis3DTests.swift new file mode 100644 index 0000000..51145f1 --- /dev/null +++ b/Tests/OpenSpatialTests/Data structures/Axis3DTests.swift @@ -0,0 +1,39 @@ +import Testing +@testable import OpenSpatial + +struct Axis3DTests { + + @Test func testRawValues() { + #expect(Axis3D.x.rawValue == 1) + #expect(Axis3D.y.rawValue == 2) + #expect(Axis3D.z.rawValue == 4) + } + + @Test func testAll() { + #expect(Axis3D.all == Axis3D(rawValue: 7)) + #expect(Axis3D.all == [.x, .y, .z]) + } + + @Test func testUnion() { + let xy = Axis3D.x.union(.y) + #expect(xy.rawValue == 3) + #expect(xy.contains(.x)) + #expect(xy.contains(.y)) + #expect(!xy.contains(.z)) + } + + @Test func testIntersection() { + let xy: Axis3D = [.x, .y] + let yz: Axis3D = [.y, .z] + let intersection = xy.intersection(yz) + #expect(intersection == .y) + } + + @Test func testOptionSetLiteralSyntax() { + let axes: Axis3D = [.x, .z] + #expect(axes.rawValue == 5) + #expect(axes.contains(.x)) + #expect(!axes.contains(.y)) + #expect(axes.contains(.z)) + } +} diff --git a/Tests/OpenSpatialTests/Data structures/Vector3DTests.swift b/Tests/OpenSpatialTests/Data structures/Vector3DTests.swift new file mode 100644 index 0000000..9e3d702 --- /dev/null +++ b/Tests/OpenSpatialTests/Data structures/Vector3DTests.swift @@ -0,0 +1,430 @@ +import Testing +@testable import OpenSpatial + +struct Vector3DTests { + + // MARK: - Initialization tests + + @Test func testInitialization() { + let vector = Vector3D() + #expect(vector.x == 0.0) + #expect(vector.y == 0.0) + #expect(vector.z == 0.0) + } + + @Test func testInitializationUsingDefaultParams() { + let vector = Vector3D(x: 2.0, y: 1.0) + #expect(vector.x == 2.0) + #expect(vector.y == 1.0) + #expect(vector.z == 0.0) + } + + @Test func testInitializationUsingFloatingPoint() { + let vector = Vector3D(x: Float(3.5), y: Float(4.5), z: Float(5.5)) + #expect(vector.x == 3.5) + #expect(vector.y == 4.5) + #expect(vector.z == 5.5) + } + + @Test func testInitializationUsingArrayLiteral() { + let vector: Vector3D = [1.0, 2.0, 3.0] + #expect(vector.x == 1.0) + #expect(vector.y == 2.0) + #expect(vector.z == 3.0) + } + + @Test func testInitializationWithSize() { + let vector = Vector3D(Size3D(width: 1.0, height: 2.0, depth: 3.0)) + #expect(vector.x == 1.0) + #expect(vector.y == 2.0) + #expect(vector.z == 3.0) + } + + @Test func testInitializationWithPoint() { + let point = Point3D(x: 3.0, y: 5.0, z: 7.0) + let vector = Vector3D(point) + #expect(vector.x == 3.0) + #expect(vector.y == 5.0) + #expect(vector.z == 7.0) + } + + @Test func testIsApproximatelyEqual() { + let a = Vector3D(x: 1.0, y: 0.0, z: 0.0) + let b = Vector3D(x: 1.0, y: 0.0, z: 0.0) + #expect(a.isApproximatelyEqual(to: b)) + } + + @Test func testIsApproximatelyEqualWithTolerance() { + let a = Vector3D(x: 1.0, y: 0.0, z: 0.0) + let b = Vector3D(x: 1.0 + 1e-10, y: 0.0, z: 0.0) + #expect(a.isApproximatelyEqual(to: b, tolerance: 1e-8)) + #expect(!a.isApproximatelyEqual(to: b, tolerance: 1e-12)) + } + + @Test func testSubscriptGetter() throws { + let point = Vector3D(x: 1.0, y: 2.0, z: 3.0) + #expect(try point[0] == 1.0) + #expect(try point[1] == 2.0) + #expect(try point[2] == 3.0) + } + + @Test func testSubscriptError() throws { + let point = Vector3D.zero + #expect(throws: OpenSpatial.Error.self) { + try point[4] == 0 + } + } + + + // MARK: - Geometry functions tests + + @Test func testDotProduct() { + let vector1 = Vector3D(x: 1.0, y: 2.0, z: 3.0) + let vector2 = Vector3D(x: 4.0, y: 5.0, z: 6.0) + let dotProduct = vector1.dot(vector2) + #expect(dotProduct == 32.0) // 1*4 + 2*5 + 3*6 = 32 + } + + @Test func testCrossProduct() { + let vector1 = Vector3D(x: 1.0, y: 2.0, z: 3.0) + let vector2 = Vector3D(x: 4.0, y: 5.0, z: 6.0) + let crossProduct = vector1.cross(vector2) + #expect(crossProduct == Vector3D(x: -3.0, y: 6.0, z: -3.0)) // Cross product result + } + + @Test func testLength() { + let vector = Vector3D(x: 3.0, y: 4.0, z: 0.0) + #expect(vector.length == 5.0) // sqrt(3^2 + 4^2 + 0^2) = 5 + } + + @Test func testLengthSquared() { + let vector = Vector3D(x: 3.0, y: 4.0, z: 0.0) + #expect(vector.lengthSquared == 25.0) // 3^2 + 4^2 + 0^2 = 25 + } + + @Test func testNormalization() { + var vector = Vector3D(x: 3.0, y: 4.0, z: 0.0) + vector.normalize() + #expect(vector.x.rounded(toPlaces: 2) == 0.6) // 3/5 + #expect(vector.y.rounded(toPlaces: 2) == 0.8) // 4/5 + #expect(vector.z.rounded(toPlaces: 2) == 0.0) + } + + @Test func testNormalizedVector() { + let vector = Vector3D(x: 3.0, y: 4.0, z: 0.0) + let normalizedVector = vector.normalized + #expect(normalizedVector.x.rounded(toPlaces: 2) == 0.6) // 3/5 + #expect(normalizedVector.y.rounded(toPlaces: 2) == 0.8) // 4/5 + #expect(normalizedVector.z.rounded(toPlaces: 2) == 0.0) + } + + @Test func testProjectedVector() { + let vector = Vector3D(x: 3.0, y: 4.0, z: 0.0) + let ontoVector = Vector3D(x: 1.0, y: 0.0, z: 0.0) + let projectedVector = vector.projected(ontoVector) + #expect(projectedVector == Vector3D(x: 3.0, y: 0.0, z: 0.0)) + } + + @Test func testReflectedVector() { + let vector = Vector3D(x: 1.0, y: -1.0, z: 0.0) + let normal = Vector3D(x: 0.0, y: 1.0, z: 0.0) + let reflectedVector = vector.reflected(normal) + #expect(reflectedVector == Vector3D(x: 1.0, y: 1.0, z: 0.0)) + } + + // MARK: - Type properties + + @Test func testBackwardVector() { + let backward = Vector3D.backward + #expect(backward == Vector3D(x: 0.0, y: 0.0, z: -1.0)) + } + + @Test func testForwardVector() { + let forward = Vector3D.forward + #expect(forward == Vector3D(x: 0.0, y: 0.0, z: 1.0)) + } + + @Test func testLeftVector() { + let left = Vector3D.left + #expect(left == Vector3D(x: -1.0, y: 0.0, z: 0.0)) + } + + @Test func testRightVector() { + let right = Vector3D.right + #expect(right == Vector3D(x: 1.0, y: 0.0, z: 0.0)) + } + + @Test func testUpVector() { + let up = Vector3D.up + #expect(up == Vector3D(x: 0.0, y: 1.0, z: 0.0)) + } + + @Test func testDownVector() { + let down = Vector3D.down + #expect(down == Vector3D(x: 0.0, y: -1.0, z: 0.0)) + } + + // MARK: - AdditiveArithmetic tests + + @Test func testZeroVector() { + let zeroVector = Vector3D.zero + #expect(zeroVector == Vector3D(x: 0.0, y: 0.0, z: 0.0)) + } + + @Test func testMultiplication() async throws { + let vector = Vector3D(x: 1.0, y: 2.0, z: 3.0) + let scaledVector = vector * 3.0 + #expect(scaledVector == Vector3D(x: 3.0, y: 6.0, z: 9.0)) + } + + @Test func testMultiplication2() { + var vector = Vector3D(x: 1.0, y: 2.0, z: 3.0) + vector *= 3.0 + #expect(vector == Vector3D(x: 3.0, y: 6.0, z: 9.0)) + } + + @Test func testAddition() { + let vector1 = Vector3D(x: 1.0, y: 2.0, z: 3.0) + let vector2 = Vector3D(x: 4.0, y: 5.0, z: 6.0) + let sum = vector1 + vector2 + #expect(sum == Vector3D(x: 5.0, y: 7.0, z: 9.0)) + } + + @Test func testAddition2() { + var vector1 = Vector3D(x: 1.0, y: 2.0, z: 3.0) + let vector2 = Vector3D(x: 4.0, y: 5.0, z: 6.0) + vector1 += vector2 + #expect(vector1 == Vector3D(x: 5.0, y: 7.0, z: 9.0)) + } + + @Test func testAddition3() { + let vector = Vector3D(x: 1.0, y: 2.0, z: 3.0) + let size = Size3D(width: 4.0, height: 5.0, depth: 6.0) + let result = vector + size + #expect(result == Size3D(width: 5.0, height: 7.0, depth: 9.0)) + } + + @Test func testAddition4() { + let vector = Vector3D(x: 1.0, y: 2.0, z: 3.0) + let size = Size3D(width: 4.0, height: 5.0, depth: 6.0) + let result = size + vector + #expect(result == Size3D(width: 5.0, height: 7.0, depth: 9.0)) + } + + @Test func testAddition5() { + let vector = Vector3D(x: 1.0, y: 2.0, z: 3.0) + let point = Point3D(x: 4.0, y: 5.0, z: 6.0) + let result = vector + point + #expect(result == Point3D(x: 5.0, y: 7.0, z: 9.0)) + } + + @Test func testSubtraction() { + let vector1 = Vector3D(x: 4.0, y: 5.0, z: 6.0) + let vector2 = Vector3D(x: 1.0, y: 2.0, z: 3.0) + let difference = vector1 - vector2 + #expect(difference == Vector3D(x: 3.0, y: 3.0, z: 3.0)) + } + + @Test func testSubtraction2() { + var vector1 = Vector3D(x: 4.0, y: 5.0, z: 6.0) + let vector2 = Vector3D(x: 1.0, y: 2.0, z: 3.0) + vector1 -= vector2 + #expect(vector1 == Vector3D(x: 3.0, y: 3.0, z: 3.0)) + } + + @Test func testSubtraction4() { + let vector = Vector3D(x: 4.0, y: 5.0, z: 6.0) + let size = Size3D(width: 1.0, height: 2.0, depth: 3.0) + let result = vector - size + #expect(result == Size3D(width: 3.0, height: 3.0, depth: 3.0)) + } + + @Test func testSubtraction5() { + let vector = Vector3D(x: 4.0, y: 5.0, z: 6.0) + let size = Size3D(width: 1.0, height: 2.0, depth: 3.0) + let result = size - vector + #expect(result == Size3D(width: -3.0, height: -3.0, depth: -3.0)) + } + + @Test func testSubtraction6() { + let vector = Vector3D(x: 4.0, y: 5.0, z: 6.0) + let point = Point3D(x: 1.0, y: 2.0, z: 3.0) + let result = vector - point + #expect(result == Point3D(x: 3.0, y: 3.0, z: 3.0)) + } + + + @Test func testDivision() { + let vector = Vector3D(x: 4.0, y: 8.0, z: 12.0) + let dividedVector = vector / 4.0 + #expect(dividedVector == Vector3D(x: 1.0, y: 2.0, z: 3.0)) + } + + @Test func testDivision2() { + var vector = Vector3D(x: 4.0, y: 8.0, z: 12.0) + vector /= 4.0 + #expect(vector == Vector3D(x: 1.0, y: 2.0, z: 3.0)) + } + + // MARK: - Primitive3D tests + + @Test func testIsFinite() { + let vector = Vector3D.zero + #expect(vector.isFinite == true) + } + + @Test func testIsNotFinite() { + let vector = Vector3D.infinity + #expect(vector.isFinite == false) + } + + @Test func testIsNaN() { + let vector = Vector3D(x: Double.nan, y: 0.0, z: 0.0) + #expect(vector.isNaN == true) + } + + @Test func testIsZero() { + let vector = Vector3D(x: 0.0, y: 0.0, z: 0.0) + #expect(vector.isZero == true) + } + + @Test func testInfinity() { + let infinity = Vector3D.infinity + #expect(infinity.x == Double.infinity) + #expect(infinity.y == Double.infinity) + #expect(infinity.z == Double.infinity) + } + + @Test func testNaN() { + let vector = Vector3D(x: Double.nan, y: Double.nan, z: Double.nan) + #expect(vector.x.isNaN) + #expect(vector.y.isNaN) + #expect(vector.z.isNaN) + } + + @Test func testApplyingAffineTransform() { + let vector = Vector3D(x: 1.0, y: 2.0, z: 3.0) + let transform = AffineTransform3D(matrix: [ + [10.0, 0.0, 0.0, 0.0], + [0.0, 20.0, 0.0, 0.0], + [0.0, 0.0, 30.0, 0.0], + [0.0, 0.0, 0.0, 1.0] + ]) + + let transformed = vector.applying(transform) + #expect(transformed == Vector3D(x: 10.0, y: 40.0, z: 90.0)) + } + + @Test func testApplyAffineTransform() { + var vector = Vector3D(x: 1.0, y: 2.0, z: 3.0) + let transform = AffineTransform3D(matrix: [ + [10.0, 0.0, 0.0, 0.0], + [0.0, 20.0, 0.0, 0.0], + [0.0, 0.0, 30.0, 0.0], + [0.0, 0.0, 0.0, 1.0] + ]) + + vector.apply(transform) + #expect(vector == Vector3D(x: 10.0, y: 40.0, z: 90.0)) + } + + // MARK: - Scalable3D tests + + @Test func testScalingVector3D() { + var vector = Vector3D(x: 1.0, y: 2.0, z: 3.0) + vector.scaleBy(x: 2.0, y: 3.0, z: 4.0) + #expect(vector == Vector3D(x: 2.0, y: 6.0, z: 12.0)) + } + + @Test func testUniformScalingVector3D() { + var vector = Vector3D(x: 1.0, y: 2.0, z: 3.0) + vector.uniformlyScale(by: 3.0) + #expect(vector == Vector3D(x: 3.0, y: 6.0, z: 9.0)) + } + + @Test func testUniformScaledVector3D() { + let vector = Vector3D(x: 1.0, y: 2.0, z: 3.0) + let scaled = vector.uniformlyScaled(by: 3.0) + #expect(scaled == Vector3D(x: 3.0, y: 6.0, z: 9.0)) + } + + // MARK: - Translatable3D tests + + @Test func testTranslatingVector3D() { + var vector = Vector3D(x: 1.0, y: 2.0, z: 3.0) + let translation = Vector3D(x: 4.0, y: 5.0, z: 6.0) + vector.translate(by: translation) + #expect(vector == Vector3D(x: 5.0, y: 7.0, z: 9.0)) + } + + @Test func testTranslatedByVector3D() { + let vector = Vector3D(x: 1.0, y: 2.0, z: 3.0) + let translation = Vector3D(x: 4.0, y: 5.0, z: 6.0) + let expectation = vector.translated(by: translation) + #expect(expectation == Vector3D(x: 5.0, y: 7.0, z: 9.0)) + } + + // MARK: - Rotatable3D tests + + @Test func testRotatedVector3D() { + let vector = Vector3D(x: 1.0, y: 0.0, z: 0.0) + let quaternion = Quaternion3D(angle: Angle2D(radians: .pi / 2), axis: .up) + let result = vector.rotated(by: quaternion) + #expect(result.x.rounded(toPlaces: 2) == 0.0) + #expect(result.y.rounded(toPlaces: 2) == 0.0) + #expect(result.z.rounded(toPlaces: 2) == -1.0) + } + + @Test func testRotateVector3D() { + var vector = Vector3D(x: 1.0, y: 0.0, z: 0.0) + let quaternion = Quaternion3D(angle: Angle2D(radians: .pi / 2), axis: .up) + vector.rotate(by: quaternion) + #expect(vector.x.rounded(toPlaces: 2) == 0.0) + #expect(vector.y.rounded(toPlaces: 2) == 0.0) + #expect(vector.z.rounded(toPlaces: 2) == -1.0) + } + + // MARK: - Shearable3D tests (non-trivial values) + + @Test func testShearedXAxisNonTrivial() { + // shear x by ky*y + kz*z: x' = x + ky*y + kz*z, y' = y, z' = z + let v = Vector3D(x: 2, y: 3, z: 4) + let result = v.sheared(.xAxis(yShearFactor: 0.5, zShearFactor: -1.0)) + #expect(result.x == 2 + 0.5 * 3 + (-1.0) * 4) // 2 + 1.5 - 4 = -0.5 + #expect(result.y == 3) + #expect(result.z == 4) + } + + @Test func testShearedYAxisNonTrivial() { + // shear y by kx*x + kz*z: y' = y + kx*x + kz*z + let v = Vector3D(x: 5, y: 1, z: 2) + let result = v.sheared(.yAxis(xShearFactor: 2.0, zShearFactor: -0.5)) + #expect(result.x == 5) + #expect(result.y == 1 + 2.0 * 5 + (-0.5) * 2) // 1 + 10 - 1 = 10 + #expect(result.z == 2) + } + + @Test func testShearedZAxisNonTrivial() { + // shear z by kx*x + ky*y: z' = z + kx*x + ky*y + let v = Vector3D(x: 3, y: 4, z: 1) + let result = v.sheared(.zAxis(xShearFactor: 1.5, yShearFactor: 0.25)) + #expect(result.x == 3) + #expect(result.y == 4) + #expect(result.z == 1 + 1.5 * 3 + 0.25 * 4) // 1 + 4.5 + 1 = 6.5 + } + + @Test func testShearedZeroFactorsIsIdentity() { + let v = Vector3D(x: 7, y: -3, z: 5) + #expect(v.sheared(.xAxis(yShearFactor: 0, zShearFactor: 0)) == v) + #expect(v.sheared(.yAxis(xShearFactor: 0, zShearFactor: 0)) == v) + #expect(v.sheared(.zAxis(xShearFactor: 0, yShearFactor: 0)) == v) + } + + @Test func testShearedNegativeFactors() { + let v = Vector3D(x: 1, y: 1, z: 1) + let result = v.sheared(.xAxis(yShearFactor: -2, zShearFactor: -3)) + #expect(result.x == 1 + (-2) * 1 + (-3) * 1) // 1 - 2 - 3 = -4 + #expect(result.y == 1) + #expect(result.z == 1) + } +} \ No newline at end of file diff --git a/Tests/OpenSpatialTests/Extensions/Double.swift b/Tests/OpenSpatialTests/Extensions/Double.swift new file mode 100644 index 0000000..8ed4989 --- /dev/null +++ b/Tests/OpenSpatialTests/Extensions/Double.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Double { + func rounded(toPlaces places:Int) -> Double { + let divisor = pow(10.0, Double(places)) + return (self * divisor).rounded() / divisor + } +} \ No newline at end of file diff --git a/Tests/OpenSpatialTests/Protocols/Clampable3DTests.swift b/Tests/OpenSpatialTests/Protocols/Clampable3DTests.swift new file mode 100644 index 0000000..01f48f6 --- /dev/null +++ b/Tests/OpenSpatialTests/Protocols/Clampable3DTests.swift @@ -0,0 +1,38 @@ +import Testing +@testable import OpenSpatial + +struct Clampable3DTests { + + @Test func testClampedInsideRect() { + let rect = Rect3D(origin: Point3D(x: 0, y: 0, z: 0), size: Size3D(width: 10, height: 10, depth: 10)) + let point = Point3D(x: 5, y: 5, z: 5) + let result = point.clamped(to: rect) + #expect(result == point) + } + + @Test func testClampedXBelowMin() { + let rect = Rect3D(origin: Point3D(x: 1, y: 0, z: 0), size: Size3D(width: 10, height: 10, depth: 10)) + let point = Point3D(x: -3, y: 5, z: 5) + let result = point.clamped(to: rect) + #expect(result.x == 1) + #expect(result.y == 5) + #expect(result.z == 5) + } + + @Test func testClampedAllAboveMax() { + let rect = Rect3D(origin: Point3D(x: 0, y: 0, z: 0), size: Size3D(width: 5, height: 5, depth: 5)) + let point = Point3D(x: 10, y: 20, z: 30) + let result = point.clamped(to: rect) + #expect(result.x == 5) + #expect(result.y == 5) + #expect(result.z == 5) + } + + @Test func testMutatingClampMatchesClamped() { + let rect = Rect3D(origin: Point3D(x: 0, y: 0, z: 0), size: Size3D(width: 10, height: 10, depth: 10)) + var point = Point3D(x: -1, y: 15, z: 5) + let expected = point.clamped(to: rect) + point.clamp(to: rect) + #expect(point == expected) + } +} diff --git a/Tests/OpenSpatialTests/Protocols/ProjectiveTransformable3DTests.swift b/Tests/OpenSpatialTests/Protocols/ProjectiveTransformable3DTests.swift new file mode 100644 index 0000000..95018d3 --- /dev/null +++ b/Tests/OpenSpatialTests/Protocols/ProjectiveTransformable3DTests.swift @@ -0,0 +1,36 @@ +import Testing +@testable import OpenSpatial + +struct ProjectiveTransformable3DTests { + + @Test func testPoint3DApplyingIdentity() { + let point = Point3D(x: 1, y: 2, z: 3) + let result = point.applying(ProjectiveTransform3D.identity) + #expect(result == point) + } + + @Test func testVector3DApplyingIdentity() { + let vector = Vector3D(x: 1, y: 2, z: 3) + let result = vector.applying(ProjectiveTransform3D.identity) + #expect(result == vector) + } + + @Test func testRect3DApplyingIdentity() { + let rect = Rect3D(origin: Point3D(x: 1, y: 2, z: 3), size: Size3D(width: 4, height: 5, depth: 6)) + let result = rect.applying(ProjectiveTransform3D.identity) + #expect(result == rect) + } + + @Test func testRotation3DApplyingIdentity() { + let rotation = Rotation3D() + let result = rotation.applying(ProjectiveTransform3D.identity) + #expect(result.isIdentity) + } + + @Test func testPose3DApplyingIdentity() { + let pose = Pose3D(position: Point3D(x: 1, y: 2, z: 3), rotation: Rotation3D()) + let result = pose.applying(ProjectiveTransform3D.identity) + #expect(result.position == pose.position) + #expect(result.rotation.isIdentity) + } +} diff --git a/Tests/OpenSpatialTests/Protocols/Shearable3DTests.swift b/Tests/OpenSpatialTests/Protocols/Shearable3DTests.swift new file mode 100644 index 0000000..fd94d51 --- /dev/null +++ b/Tests/OpenSpatialTests/Protocols/Shearable3DTests.swift @@ -0,0 +1,56 @@ +import Testing +@testable import OpenSpatial + +struct Shearable3DTests { + + // MARK: - Vector3D + + @Test func testVector3DShearXAxis() { + let v = Vector3D(x: 1, y: 2, z: 3) + let result = v.sheared(.xAxis(yShearFactor: 2, zShearFactor: 3)) + #expect(result == Vector3D(x: 14, y: 2, z: 3)) + } + + @Test func testVector3DShearYAxis() { + let v = Vector3D(x: 1, y: 1, z: 1) + let result = v.sheared(.yAxis(xShearFactor: 1, zShearFactor: 1)) + #expect(result == Vector3D(x: 1, y: 3, z: 1)) + } + + @Test func testVector3DShearZAxis() { + let v = Vector3D(x: 1, y: 1, z: 1) + let result = v.sheared(.zAxis(xShearFactor: 1, yShearFactor: 1)) + #expect(result == Vector3D(x: 1, y: 1, z: 3)) + } + + // MARK: - AffineTransform3D + + @Test func testAffineTransform3DShearXAxisMatrix() { + let result = AffineTransform3D.identity.sheared(.xAxis(yShearFactor: 2, zShearFactor: 3)) + #expect(result.matrix[1][0] == 2) + #expect(result.matrix[2][0] == 3) + } + + // MARK: - Rect3D + + @Test func testRect3DShearIdentity() { + let rect = Rect3D(origin: Point3D(x: 1, y: 2, z: 3), size: Size3D(width: 4, height: 5, depth: 6)) + let result = rect.sheared(.xAxis(yShearFactor: 0, zShearFactor: 0)) + #expect(result.origin.x == rect.origin.x) + #expect(result.origin.y == rect.origin.y) + #expect(result.origin.z == rect.origin.z) + #expect(result.size.width == rect.size.width) + #expect(result.size.height == rect.size.height) + #expect(result.size.depth == rect.size.depth) + } + + // MARK: - Size3D + + @Test func testSize3DShearYAxis() { + let s = Size3D(width: 2, height: 3, depth: 4) + let result = s.sheared(.yAxis(xShearFactor: 1, zShearFactor: 0)) + #expect(result.width == 2) + #expect(result.height == 5) + #expect(result.depth == 4) + } +} From 92760cfc39706bf1a59805f3879e72a0f61b8a84 Mon Sep 17 00:00:00 2001 From: Helbert Gomes Date: Wed, 24 Jun 2026 18:11:28 -0400 Subject: [PATCH 4/7] chore: update .gitignore --- .gitignore | 46 +++++++++------------------------------------- 1 file changed, 9 insertions(+), 37 deletions(-) diff --git a/.gitignore b/.gitignore index 52fe2f7..130f272 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# MacOS +.DS_Store + # Xcode # # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore @@ -20,43 +23,12 @@ playground.xcworkspace # Swift Package Manager # # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. -# Packages/ -# Package.pins -# Package.resolved -# *.xcodeproj +Packages/ +Package.pins +Package.resolved +*.xcodeproj # # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata # hence it is not needed unless you have added a package configuration file to your project -# .swiftpm - -.build/ - -# CocoaPods -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# Pods/ -# -# Add this line if you want to avoid checking in source code from the Xcode workspace -# *.xcworkspace - -# Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts - -Carthage/Build/ - -# fastlane -# -# It is recommended to not store the screenshots in the git repo. -# Instead, use fastlane to re-generate the screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://docs.fastlane.tools/best-practices/source-control/#source-control - -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots/**/*.png -fastlane/test_output +.swiftpm/ +.build/ \ No newline at end of file From 8b132b3dd24b54873b8f854d97ccd733f7ac96d6 Mon Sep 17 00:00:00 2001 From: Helbert Gomes Date: Wed, 24 Jun 2026 21:19:50 -0400 Subject: [PATCH 5/7] docs: enable Swift Package Index documentation build --- .spi.yml | 6 ++++++ README.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .spi.yml diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..1cbb476 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,6 @@ +version: 1 +builder: + configs: + - documentation_targets: + - OpenSpatial + swift_version: "6.0" \ No newline at end of file diff --git a/README.md b/README.md index 291d075..ecb2ed0 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ Add the dependency to your `Package.swift`: dependencies: [ .package( url: "https://github.com/helbertgs/OpenSpatial.git", - from: "1.0.0" + from: "0.1.0" ) ], targets: [ From 752705eeee45e939d4d69d2a6416b36d0df54dbc Mon Sep 17 00:00:00 2001 From: Helbert Gomes Date: Wed, 24 Jun 2026 21:30:16 -0400 Subject: [PATCH 6/7] chore: add Codecov configuration for coverage reporting --- .codecov.yml | 15 +++++++++++++++ README.md | 8 ++++---- 2 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 .codecov.yml diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..5615699 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,15 @@ +coverage: + status: + project: + default: + target: 80% + threshold: 1% + patch: + default: + target: 80% + threshold: 1% + +ignore: + - Tests + - .build + - .swiftpm \ No newline at end of file diff --git a/README.md b/README.md index ecb2ed0..2f6ad6e 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # OpenSpatial +![License](https://img.shields.io/badge/License-MIT-green) ![Swift](https://img.shields.io/badge/Swift-6.x-orange.svg) -![Platforms](https://img.shields.io/badge/platforms-iOS%20%7C%20macOS%20%7C%20tvOS%20%7C%20watchOS%20%7C%20Linux%20%7C%20Windows%20%7C%20Wasm%20%7C%20Android-blue) -[![Build and Test on MacOS](https://github.com/helbertgs/OpenSpatial/actions/workflows/MacOS.yml/badge.svg)](https://github.com/helbertgs/OpenSpatial/actions/workflows/MacOS.yml) +![Platforms](https://img.shields.io/badge/Platforms-iOS%20%7C%20macOS%20%7C%20tvOS%20%7C%20watchOS%20%7C%20Linux%20%7C%20Windows%20%7C%20Wasm%20%7C%20Android-blue) + Open-source implementation inspired by Apple's [Spatial](https://developer.apple.com/documentation/spatial) framework. `OpenSpatial` is not affiliated with, endorsed by, or maintained by Apple. From 12a51b3c9494717ad614c7d27d1eca65cb08f555 Mon Sep 17 00:00:00 2001 From: Helbert Gomes Date: Wed, 24 Jun 2026 22:00:02 -0400 Subject: [PATCH 7/7] ci: unify cross-platform build and test pipelines with matrix strategy --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++++++ .github/workflows/coverage.yml | 23 ++++++++++++++++++++ README.md | 6 ++---- 3 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/coverage.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6225ebb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [ "main", "develop" ] + +jobs: + build-and-test: + name: Build and Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: + - macos-latest + - ubuntu-latest + - windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Swift (Windows only) + if: matrix.os == 'windows-latest' + uses: compnerd/gha-setup-swift@main + with: + swift-version: swift-6.3.2-release + swift-build: 6.3.2-RELEASE + + - name: Swift version + run: swift --version + + - name: Build + run: swift build -c debug + + - name: Test + run: swift test -c debug \ No newline at end of file diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..dac894b --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,23 @@ +name: Coverage + +on: + push: + branches: [ "main" ] + +jobs: + coverage: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - name: Swift version + run: swift --version + + - name: Run tests with coverage + run: swift test --enable-code-coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file diff --git a/README.md b/README.md index 2f6ad6e..42722f6 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,8 @@ ![License](https://img.shields.io/badge/License-MIT-green) ![Swift](https://img.shields.io/badge/Swift-6.x-orange.svg) ![Platforms](https://img.shields.io/badge/Platforms-iOS%20%7C%20macOS%20%7C%20tvOS%20%7C%20watchOS%20%7C%20Linux%20%7C%20Windows%20%7C%20Wasm%20%7C%20Android-blue) - +[![CI](https://github.com/helbertgs/OpenSpatial/actions/workflows/ci.yml/badge.svg)](https://github.com/helbertgs/OpenSpatial/actions/workflows/ci.yml) +[![codecov](https://codecov.io/github/helbertgs/OpenSpatial/graph/badge.svg?token=7WrVJPx17w)](https://codecov.io/github/helbertgs/OpenSpatial) Open-source implementation inspired by Apple's [Spatial](https://developer.apple.com/documentation/spatial) framework. `OpenSpatial` is not affiliated with, endorsed by, or maintained by Apple.