Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Documentation/HarpoonAnchoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,5 @@ Winch payout/retraction is commanded mechanical work. Motor energy/force limits,
capstan friction, subsurface stress distribution, temperature-dependent strength,
foot screws and hold-down thrusters are outside this initial model. Event timing
is resolved to the chosen mechanical substep, not continuously certified.

Harpoon and tether/contact APIs belong to `Mechanics`; import it explicitly.
4 changes: 3 additions & 1 deletion Documentation/LandingMechanics.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Compliant landing mechanics

`CelMek/LandingMechanics.swift` provides independent rigid bodies, cable edges,
`Mechanics/LandingMechanics.swift` provides independent rigid bodies, cable edges,
spherical contact lobes/feet, local terrain planes and adiabatic gas cushions.
This is a compliant engineering model, not a rigid constraint solver or deformable
fabric model. All quantities use SI units.
Expand Down Expand Up @@ -54,3 +54,5 @@ contact/cable natural frequency and check convergence. Open Orbit uses 0.002 s
for its declared low-mass landing demonstrations. No continuous collision search,
inter-body collision, rigid joints, line/cable terrain collision or long-duration
tether orbital integrator is provided.

Import `Mechanics` explicitly. Frames, epochs and inertia remain public CelMek contracts.
50 changes: 50 additions & 0 deletions Documentation/WheelDynamics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Driven suspended wheels

`WheelDynamics.swift` supplies `SuspendedWheel`, `WheelState`, `WheelCommand` and
`SuspendedWheelSet` for the existing independent rigid-body mechanical system.
Body +X is forward, +Y left and +Z up. Positive wheel spin is about the steered
+Y axle and rolls forward. Wheel mass and locked-wheel inertia must already be
included in the chassis mass/tensor. Do not add them a second time.

The initial suspension is a massless strut along body -Z. The caller supplies a
local terrain plane, normal and instantaneous surface velocity for each wheel.
The plane determines hub extension; spring compression, normal velocity damping
and a stiffer bottom-out term produce a nonnegative normal load. Unsupported,
overextended or overturned wheels produce no ground force. Wheel travel is
bounded, but bottom-out is compliant rather than a hard geometric constraint.
No normal attraction, grounded-state snap or render-LOD collision is used.

Longitudinal slip compares contact surface speed to wheel spin times effective
rolling radius. Lateral slip comes from the contact point's relative velocity.
Linear creep stiffness produces longitudinal/lateral demands, limited together
by a Coulomb friction circle (mu times normal load). This permits wheelspin,
sliding and loss of traction. Wheel spin responds to drive torque, ground-contact
moment, bounded brakes and a load-dependent rolling-loss torque. Brake torque
opposes rotation or holds the rotor when within capacity. Regularized creep is
not an exact static-friction constraint; slight drift can remain on slopes.

The complete ground force acts at the contact point. The chassis also receives
the negative derivative/transport of relative rotor angular momentum, including
steering-axis transport and the gyroscopic term. Thus motor reaction is present
in flight; spinning an airborne wheel cannot propel the chassis. Rotor speed and
steering angle are real integrated states, not distance-driven visual animation.
Steering is an ideal bounded-rate command (0.7 rad/s, 0.15 s response), not a rack
and motor model. The body tensor stays fixed as suspension/steering geometry
changes; small wheel translations and inertia reorientation are approximated.

`SuspendedWheelSet.step` evaluates body and wheel rates at the same beginning and
midpoint stages. It commits both only after a successful step; a terrain/load
error leaves the previous state intact. Ground and external-load callbacks must
be pure and share the mechanical system's frame/epoch. External gravity and any
other vehicle/cable loads remain separate. Do not apply wheel wrenches again.

Use steps appropriate to suspension and tyre stiffness/rotor inertia. The 0.01 s
API cap is not a stability guarantee. Open Orbit's declared original rover uses
2 ms steps; arbitrary wheels/terrain need their own convergence checks. This is
not a Pacejka tyre, deformable-soil, terramechanics or full articulated rocker-
bogie solver. Sinkage, tread/rock collision, hub masses, wheel damage, motor
power/energy/thermal limits and drivetrain compliance remain future extensions.

The wheel and suspension APIs are in the `Mechanics` product/module, alongside
compliant ground contact and tethers. Consumers must `import Mechanics`; these
types are no longer exported from CelMek.
12 changes: 10 additions & 2 deletions MODULES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Module boundaries

The package is `swift-physics`. Seven public modules share physical contracts
The package is `swift-physics`. Eight public modules share physical contracts
without importing vehicle systems or rendering. There are no compatibility
re-exports or old aerodynamic names for the shared fluid APIs.

Expand All @@ -11,11 +11,12 @@ re-exports or old aerodynamic names for the shared fluid APIs.
| `FluidDynamics` | None |
| `Hydrodynamics` | `FluidDynamics` |
| `Aerodynamics` | `FluidDynamics`, `Atmospheres`, `Yams` |
| `Mechanics` | `CelMek`, `Math` |
| `CelMek` | `Math`, `ERFASwift` (internal), `Atmospheres`, `MagneticFields`, `FluidDynamics` |
| `Math` | None |
| `Atmospheres` | None |
| `MagneticFields` | None |
| `Physics` | Bundles all seven module targets |
| `Physics` | Bundles all eight module targets |

`Physics` is a product name, not a module. Import each module whose APIs are used;
importing Aerodynamics does not re-export FluidDynamics or Atmospheres. A consumer
Expand All @@ -42,6 +43,13 @@ epochs, units of physical state, ephemerides, and orientation models belong abov
Existing astronomical coordinate structs and the legacy `Transformation` remain
here for compatibility; that is not a requirement to place new astronomy models here.

**Mechanics** owns independent mechanical bodies, compliant ground contact,
suspended driven wheels, tension-only tethers, gas cushions and harpoon anchors.
These are not celestial/orbital models. It currently consumes CelMek's public
epoch, inertial-frame and inertia primitives and Math's rotations; CelMek does not
depend on Mechanics. Import Mechanics explicitly; no CelMek compatibility re-export
is provided. Vehicle-specific controllers and rendering stay in the application.

**ERFASwift** is an internal target, with no standalone product or public API.
It isolates the ERFA-derived Earth-orientation and full Fairhead TDB routines and
tables under their BSD terms, retained in `Sources/ERFASwift/LICENSE.txt`.
Expand Down
5 changes: 4 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ let package = Package(
// Products define the executables and libraries produced by a package, and make them visible to other packages.
.library(
name: "Physics",
targets: ["CelMek", "Aerodynamics", "FluidDynamics", "Hydrodynamics", "Math", "Atmospheres", "MagneticFields"]),
targets: ["CelMek", "Mechanics", "Aerodynamics", "FluidDynamics", "Hydrodynamics", "Math", "Atmospheres", "MagneticFields"]),
.library(name: "CelMek", targets: ["CelMek"]),
.library(name: "Mechanics", targets: ["Mechanics"]),
.library(name: "Atmospheres", targets: ["Atmospheres"]),
.library(name: "MagneticFields", targets: ["MagneticFields"]),
.library(name: "Math", targets: ["Math"]),
Expand All @@ -34,6 +35,7 @@ let package = Package(
name: "CelMek",
dependencies: ["Math", "ERFASwift", "Atmospheres", "FluidDynamics",
"MagneticFields"]),
.target(name: "Mechanics", dependencies: ["CelMek", "Math"]),
.target(name: "MagneticFields", resources: [.process("Resources")]),
.target(name: "ERFASwift", dependencies: ["Math"], exclude: ["LICENSE.txt", "README.md"]),
.target(name: "Atmospheres"),
Expand Down Expand Up @@ -77,6 +79,7 @@ let package = Package(
.testTarget(
name: "AerodynamicsTests",
dependencies: ["Aerodynamics", "FluidDynamics", "Atmospheres"], resources: [.process("Resources")]),
.testTarget(name: "MechanicsTests", dependencies: ["Mechanics", "CelMek", "Math"]),
.testTarget(name: "FluidDynamicsTests", dependencies: ["FluidDynamics"]),
.testTarget(name: "HydrodynamicsTests", dependencies: ["Hydrodynamics", "FluidDynamics"]),
.testTarget(name: "AtmospheresTests", dependencies: ["Atmospheres"]),
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ Linux and Windows builds are covered by CI.

## Modules

Use the `CelMek`, `Math`, `FluidDynamics`, `Aerodynamics`, `Hydrodynamics`,
Use the `CelMek`, `Mechanics`, `Math`, `FluidDynamics`, `Aerodynamics`, `Hydrodynamics`,
`Atmospheres`, or `MagneticFields` product for the modules your application needs.
The `Physics` product bundles all seven.
The `Physics` product bundles all eight.
Import the corresponding module names. See [MODULES.md](MODULES.md) for responsibilities and dependency rules.

## Documentation
Expand Down
10 changes: 10 additions & 0 deletions Sources/FluidDynamics/FluidDynamics.docc/PropellersAndCFD.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,13 @@ The common solver does not require gas molecular properties for a liquid. Contin
applicability is a material/model responsibility. Hydrodynamics adds liquid-property
and absolute-pressure reference helpers; see [Hydrodynamics](/documentation/hydrodynamics).
Compressible flow uses a distinct conservation solver described in <doc:CompressibleFlow>.

## RPM-driven coefficient rotor

`PropellerRotor` converts caller-supplied CT, profile CP, radius, angular speed,
collective and density to thrust/shaft torque. It uses `ActuatorDiskSlipstream`
for ideal induced power and accepts added coaxial inflow. CT uses rho A (omega R)^2;
CP uses rho A (omega R)^3. Spin inertia is metadata for a caller's coupled motor
integration. No blade-element lift/stall, dynamic inflow, vortex-ring descent,
windmilling, ground effect, blade flexibility or measured propeller map is supplied.
Do not add its reaction thrust a second time through a separate actuator disk.
32 changes: 32 additions & 0 deletions Sources/FluidDynamics/PropellerRotor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Mattias Holm
import Foundation

/// Quasi-steady coefficient rotor coupled to the existing actuator disk. Coefficients
/// are caller-supplied engineering assumptions, not a blade-element solution.
/// CT uses rho*A*(omega*R)^2; CP0 uses rho*A*(omega*R)^3.
public struct PropellerRotor:Sendable {
public let radiusMetres,maximumThrustCoefficient,profilePowerCoefficient,inertiaKilogramMetresSquared:Double
public init(radiusMetres:Double,maximumThrustCoefficient:Double,profilePowerCoefficient:Double,inertiaKilogramMetresSquared:Double) throws {
guard [radiusMetres,maximumThrustCoefficient,profilePowerCoefficient,inertiaKilogramMetresSquared].allSatisfy({$0.isFinite && $0>0}) else {throw FluidError.invalidParameter}
self.radiusMetres=radiusMetres;self.maximumThrustCoefficient=maximumThrustCoefficient
self.profilePowerCoefficient=profilePowerCoefficient;self.inertiaKilogramMetresSquared=inertiaKilogramMetresSquared
}
/// Positive axial speed is climbing into still air. Descending/vortex-ring and
/// windmilling regimes are deliberately not resolved. Coaxial interference is
/// supplied as nonnegative added inflow, without adding its thrust twice.
public func sample(angularSpeed:Double,collective:Double,density:Double,axialSpeed:Double=0,addedInflow:Double=0) throws -> PropellerRotorSample {
guard [angularSpeed,collective,density,axialSpeed,addedInflow].allSatisfy(\.isFinite),angularSpeed>=0,(0...1).contains(collective),density>0,addedInflow>=0 else {throw FluidError.invalidParameter}
let area=Double.pi*radiusMetres*radiusMetres,tip=angularSpeed*radiusMetres
let thrust=density*area*tip*tip*maximumThrustCoefficient*collective
let axial=max(0,axialSpeed)+addedInflow
let disk=try ActuatorDiskSlipstream(centreMetres:.zero,downstreamAxis:SIMD3(0,0,-1),radiusMetres:radiusMetres,thrustNewtons:thrust,densityKilogramsPerCubicMetre:density,upstreamAxialSpeedMetresPerSecond:axial)
let power=disk.idealAxialPowerWatts+profilePowerCoefficient*density*area*tip*tip*tip
let torque=angularSpeed>0 ? power/angularSpeed : 0
guard thrust.isFinite,power.isFinite,torque.isFinite else {throw FluidError.nonFiniteResult}
return PropellerRotorSample(thrustNewtons:thrust,loadTorqueNewtonMetres:torque,shaftPowerWatts:power,inducedSpeed:disk.inducedSpeedAtDiskMetresPerSecond)
}
}
public struct PropellerRotorSample:Sendable {
public let thrustNewtons,loadTorqueNewtonMetres,shaftPowerWatts,inducedSpeed:Double
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Copyright 2026 Mattias Holm
import Foundation
import Math
import CelMek

public enum HarpoonPhase: String, Codable { case stowed, flying, anchored, rejected, pulledOut, misfire }
public enum AnchorCableState: String, Codable { case connected, cut, broken }
Expand Down Expand Up @@ -39,7 +40,7 @@ public struct HarpoonAnchor {
/// burial layer. Rejected impacts remain dynamic and can bounce away.
@discardableResult public mutating func impact(pointMetres:SIMD3<Double>,in frame:Frame,
massKilograms:Double,normalSpeedMetresPerSecond:Double,incidenceCosine:Double,substrate:HarpoonSubstrate) throws -> Bool {
guard phase == .flying,frame === surfaceFrame,attitudeFinite(pointMetres),massKilograms.isFinite,massKilograms > 0,
guard phase == .flying,frame === surfaceFrame,mechanicsFinite(pointMetres),massKilograms.isFinite,massKilograms > 0,
normalSpeedMetresPerSecond.isFinite,normalSpeedMetresPerSecond > 0,incidenceCosine.isFinite,(0...1).contains(incidenceCosine) else { throw LandingMechanicsError.invalidParameter }
let depth=0.5*massKilograms*normalSpeedMetresPerSecond*normalSpeedMetresPerSecond/substrate.penetrationResistanceNewtons
guard depth.isFinite else { throw LandingMechanicsError.numericalRange }
Expand Down Expand Up @@ -89,15 +90,15 @@ extension MechanicalBody {
/// removed and translated to the remaining body's new centre of mass.
public func launching(id projectileID:String,massKilograms m:Double,inertia projectileInertia:RigidBodyInertia,
centreBodyMetres r:SIMD3<Double>,impulseBodyNewtonSeconds j:SIMD3<Double>) throws -> MechanicalLaunch {
guard m.isFinite,m > 0,m < massKilograms,projectileID != id,attitudeFinite(r),attitudeFinite(j) else { throw LandingMechanicsError.invalidParameter }
guard m.isFinite,m > 0,m < massKilograms,projectileID != id,mechanicsFinite(r),mechanicsFinite(j) else { throw LandingMechanicsError.invalidParameter }
let remaining=massKilograms-m,s = -r*(m/remaining)
func parallel(_ v:SIMD3<Double>,_ mass:Double,_ i:Int,_ k:Int)->Double { mass*((i == k ? (v*v).sum() : 0)-v[i]*v[k]) }
func entry(_ i:Int,_ k:Int)->Double { inertia.matrix[i,k]-projectileInertia.matrix[i,k]-parallel(r,m,i,k)-parallel(s,remaining,i,k) }
let retainedInertia=try RigidBodyInertia(xx:entry(0,0),yy:entry(1,1),zz:entry(2,2),xy:entry(0,1),xz:entry(0,2),yz:entry(1,2))
let impulse=bodyToReference.matrix*j
let retained=try MechanicalBody(id:id,massKilograms:remaining,inertia:retainedInertia,positionMetres:point(s),
velocityMetresPerSecond:pointVelocity(s)-impulse/remaining,bodyToReference:bodyToReference,
angularVelocityBody:angularVelocityBody+retainedInertia.solve(attitudeCross(r-s,-j)))
angularVelocityBody:angularVelocityBody+retainedInertia.solve(mechanicsCross(r-s,-j)))
let projectile=try MechanicalBody(id:projectileID,massKilograms:m,inertia:projectileInertia,positionMetres:point(r),
velocityMetresPerSecond:pointVelocity(r)+impulse/m,bodyToReference:bodyToReference,angularVelocityBody:angularVelocityBody)
return MechanicalLaunch(retained:retained,projectile:projectile,retainedCentreBodyMetres:s)
Expand Down
Loading
Loading