From 0342905f48bcf718676c85ca08c1770f02ae9867 Mon Sep 17 00:00:00 2001 From: Mattias Holm Date: Wed, 21 Oct 2026 21:24:41 +0200 Subject: [PATCH 1/2] Wheel dynamics --- Documentation/WheelDynamics.md | 46 ++++++++ Sources/CelMek/WheelDynamics.swift | 130 +++++++++++++++++++++ Tests/CelMekTests/WheelDynamicsTests.swift | 39 +++++++ 3 files changed, 215 insertions(+) create mode 100644 Documentation/WheelDynamics.md create mode 100644 Sources/CelMek/WheelDynamics.swift create mode 100644 Tests/CelMekTests/WheelDynamicsTests.swift diff --git a/Documentation/WheelDynamics.md b/Documentation/WheelDynamics.md new file mode 100644 index 0000000..d0d278b --- /dev/null +++ b/Documentation/WheelDynamics.md @@ -0,0 +1,46 @@ +# 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. diff --git a/Sources/CelMek/WheelDynamics.swift b/Sources/CelMek/WheelDynamics.swift new file mode 100644 index 0000000..ec0a243 --- /dev/null +++ b/Sources/CelMek/WheelDynamics.swift @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mattias Holm +import Foundation +import Math + +public struct WheelCommand { + public let motorTorqueNewtonMetres,brakeFraction,steeringRadians:Double + public init(motorTorqueNewtonMetres:Double=0,brakeFraction:Double=0,steeringRadians:Double=0) throws { + guard motorTorqueNewtonMetres.isFinite,brakeFraction.isFinite,(0...1).contains(brakeFraction), + steeringRadians.isFinite,abs(steeringRadians)<=0.7 else {throw LandingMechanicsError.invalidParameter} + self.motorTorqueNewtonMetres=motorTorqueNewtonMetres;self.brakeFraction=brakeFraction;self.steeringRadians=steeringRadians + } +} +public struct WheelState { + public let spinRadiansPerSecond,angleRadians,steeringRadians:Double + public init(spinRadiansPerSecond:Double=0,angleRadians:Double=0,steeringRadians:Double=0) throws { + guard [spinRadiansPerSecond,angleRadians,steeringRadians].allSatisfy(\.isFinite),abs(steeringRadians)<=0.7 else {throw LandingMechanicsError.invalidParameter} + self.spinRadiansPerSecond=spinRadiansPerSecond;self.angleRadians=angleRadians;self.steeringRadians=steeringRadians + } + func advanced(_ sample:WheelSample,seconds:Double) throws -> WheelState { + try WheelState(spinRadiansPerSecond:spinRadiansPerSecond+sample.spinAcceleration*seconds, + angleRadians:(angleRadians+sample.spinRate*seconds).truncatingRemainder(dividingBy:2 * .pi), + steeringRadians:steeringRadians+sample.steeringRate*seconds) + } +} +/// Body axes: +X forward, +Y left, +Z suspension up. Positive spin is forward +/// rolling about +Y. Geometry/mass/inertia of wheels are already in the chassis; +/// only relative rotor momentum is added, never another translational mass. +public struct SuspendedWheel { + public let id:String,mountBodyMetres:SIMD3 + public let radiusMetres,rotorInertia,restLengthMetres,travelMetres,spring,damping,friction,slipStiffness,maximumBrakeTorque,rollingResistance:Double + public init(id:String,mountBodyMetres:SIMD3,radiusMetres:Double=0.28,rotorInertia:Double=0.25, + restLengthMetres:Double=0.24,travelMetres:Double=0.16,springNewtonsPerMetre:Double=3500,dampingNewtonSecondsPerMetre:Double=400, + friction:Double=0.8,slipStiffnessNewtonSecondsPerMetre:Double=250,maximumBrakeTorque:Double=15,rollingResistance:Double=0.02) throws { + guard !id.isEmpty,attitudeFinite(mountBodyMetres),[radiusMetres,rotorInertia,restLengthMetres,travelMetres,springNewtonsPerMetre,slipStiffnessNewtonSecondsPerMetre].allSatisfy({$0.isFinite && $0>0}), + [dampingNewtonSecondsPerMetre,friction,maximumBrakeTorque,rollingResistance].allSatisfy({$0.isFinite && $0>=0}),travelMetres WheelSample { + guard stepSeconds.isFinite,stepSeconds>0,stepSeconds<=0.01 else {throw LandingMechanicsError.invalidParameter} + let q=body.bodyToReference,up=q.matrix*SIMD3(0,0,1),alignment=(up*ground.normal).sum() + let steeringRate=max(-0.7,min(0.7,(command.steeringRadians-state.steeringRadians)/0.15)) + let forwardBody=SIMD3(cos(state.steeringRadians),sin(state.steeringRadians),0.0),axleBody=SIMD3(-forwardBody.y,forwardBody.x,0) + let axleWorld=q.matrix*axleBody + let height=((body.point(mountBodyMetres)-ground.pointMetres)*ground.normal).sum() + let requiredLength=alignment>0.2 ? (height-radiusMetres)/alignment : restLengthMetres+travelMetres+1 + let length=max(restLengthMetres-travelMetres,min(restLengthMetres+travelMetres,requiredLength)) + let centre=mountBodyMetres-SIMD3(0,0,length),point=centre-q.invMatrix*(ground.normal*radiusMetres) + let contact=alignment>0.2 && requiredLength<=restLengthMetres+travelMetres + var force=SIMD3.zero,normalLoad=0.0,slip=0.0,lateralSpeed=0.0,groundTorque=0.0 + if contact { + let velocity=body.pointVelocity(point)-ground.velocityMetresPerSecond,vn=(velocity*ground.normal).sum() + normalLoad=max(0,spring*max(0,restLengthMetres-requiredLength)-damping*vn) + + spring*8*max(0,restLengthMetres-travelMetres-requiredLength) + var forward=q.matrix*forwardBody;forward -= ground.normal*(forward*ground.normal).sum() + forward /= sqrt((forward*forward).sum()) + let side=attitudeCross(ground.normal,forward) + let effectiveRadius=radiusMetres*(axleWorld*side).sum() + slip=effectiveRadius*state.spinRadiansPerSecond-(velocity*forward).sum();lateralSpeed=(velocity*side).sum() + var longitudinal=slipStiffness*slip,lateral = -slipStiffness*lateralSpeed + let demand=hypot(longitudinal,lateral),limit=friction*normalLoad + if demand>limit && demand>0 {longitudinal *= limit/demand;lateral *= limit/demand} + force=ground.normal*normalLoad+forward*longitudinal+side*lateral + groundTorque=(attitudeCross(-ground.normal*radiusMetres,force)*axleWorld).sum() + } + let rollingTorque=rollingResistance*normalLoad*radiusMetres*tanh(state.spinRadiansPerSecond/0.1) + let freeTorque=command.motorTorqueNewtonMetres+groundTorque-rollingTorque + let brakeLimit=maximumBrakeTorque*command.brakeFraction + let brake=max(-brakeLimit,min(brakeLimit,freeTorque+rotorInertia*state.spinRadiansPerSecond/stepSeconds)) + let acceleration=(freeTorque-brake)/rotorInertia + var wrench=MechanicalWrench();wrench.apply(force:force,at:point,to:body) + let momentum=axleBody*(rotorInertia*state.spinRadiansPerSecond) + let momentumRate=axleBody*(rotorInertia*acceleration)-forwardBody*(steeringRate*rotorInertia*state.spinRadiansPerSecond) + wrench.torqueBodyNewtonMetres -= momentumRate+attitudeCross(body.angularVelocityBody,momentum) + guard attitudeFinite(wrench.forceReferenceNewtons),attitudeFinite(wrench.torqueBodyNewtonMetres),[acceleration,normalLoad,slip,lateralSpeed].allSatisfy(\.isFinite) else {throw LandingMechanicsError.numericalRange} + return WheelSample(wrench:wrench,spinAcceleration:acceleration,spinRate:state.spinRadiansPerSecond,steeringRate:steeringRate, + centreBodyMetres:centre,normalLoad:normalLoad,compression:restLengthMetres-length,longitudinalSlip:slip,lateralSpeed:lateralSpeed) + } +} +public struct WheelSample { + public let wrench:MechanicalWrench + public let spinAcceleration,spinRate,steeringRate:Double + public let centreBodyMetres:SIMD3 + public let normalLoad,compression,longitudinalSlip,lateralSpeed:Double +} +/// Couples relative wheel spin and the existing multi-body midpoint integrator at +/// the same two stages. External loads/terrain must be pure stage evaluations. +/// Failed steps leave both chassis and wheel state unchanged. +public struct SuspendedWheelSet { + public let bodyID:String,wheels:[SuspendedWheel] + public private(set) var states:[WheelState] + public init(bodyID:String,wheels:[SuspendedWheel],states:[WheelState]?=nil) throws { + guard !bodyID.isEmpty,!wheels.isEmpty,Set(wheels.map(\.id)).count==wheels.count,states == nil || states!.count==wheels.count else {throw LandingMechanicsError.invalidTopology} + self.bodyID=bodyID;self.wheels=wheels;self.states=try states ?? wheels.map{_ in try WheelState()} + } + public func samples(body:MechanicalBody,commands:[WheelCommand],stepSeconds:Double, + ground:(MechanicalBody,SuspendedWheel) throws -> MechanicalGround) throws -> [WheelSample] { + guard body.id==bodyID,commands.count==wheels.count else {throw LandingMechanicsError.invalidTopology} + return try wheels.indices.map{try wheels[$0].evaluate(body:body,state:states[$0],ground:ground(body,wheels[$0]),command:commands[$0],stepSeconds:stepSeconds)} + } + @discardableResult public mutating func step(system:inout MechanicalSystem,seconds:Double,commands:[WheelCommand],payout:[String:Double]=[:], + ground:(Epoch,MechanicalBody,SuspendedWheel) throws -> MechanicalGround, + externalLoads:(Epoch,[MechanicalBody]) throws -> [MechanicalWrench]) throws -> [String] { + guard commands.count==wheels.count,let bodyIndex=system.bodies.firstIndex(where:{$0.id==bodyID}) else {throw LandingMechanicsError.invalidTopology} + let initial=states,config=wheels,startEpoch=system.epoch + var half=states,midpoint:[WheelSample]=[],next=system + let broken=try next.step(seconds:seconds,payout:payout){epoch,bodies in + let second=epoch>startEpoch,samples=try config.indices.map { i in + try config[i].evaluate(body:bodies[bodyIndex],state:second ? half[i] : initial[i],ground:ground(epoch,bodies[bodyIndex],config[i]),command:commands[i],stepSeconds:seconds) + } + if second {midpoint=samples} + else {half=try initial.indices.map{try initial[$0].advanced(samples[$0],seconds:seconds/2)}} + var loads=try externalLoads(epoch,bodies) + guard loads.count==bodies.count else {throw LandingMechanicsError.invalidTopology} + for sample in samples { + loads[bodyIndex].forceReferenceNewtons += sample.wrench.forceReferenceNewtons + loads[bodyIndex].torqueBodyNewtonMetres += sample.wrench.torqueBodyNewtonMetres + } + return loads + } + guard midpoint.count==initial.count else {throw LandingMechanicsError.numericalRange} + let final=try initial.indices.map{try initial[$0].advanced(midpoint[$0],seconds:seconds)} + system=next;states=final;return broken + } +} diff --git a/Tests/CelMekTests/WheelDynamicsTests.swift b/Tests/CelMekTests/WheelDynamicsTests.swift new file mode 100644 index 0000000..68c3188 --- /dev/null +++ b/Tests/CelMekTests/WheelDynamicsTests.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing +import CelMek +import Math + +private func wheelBody(_ z:Double,_ v:SIMD3 = .zero) throws -> MechanicalBody { + try MechanicalBody(id:"rover",massKilograms:100,inertia:RigidBodyInertia(xx:100,yy:100,zz:100),positionMetres:SIMD3(0,0,z),velocityMetresPerSecond:v) +} +@Test func wheelGroundTractionCircleAirborneAndFrictionlessSpin() throws { + let ground=try MechanicalGround(pointMetres:.zero,normal:SIMD3(0,0,1)),body=try wheelBody(0.48) + let wheel=try SuspendedWheel(id:"w",mountBodyMetres:.zero) + let command=try WheelCommand(motorTorqueNewtonMetres:3),spin=try WheelState(spinRadiansPerSecond:10) + let hit=try wheel.evaluate(body:body,state:spin,ground:ground,command:command,stepSeconds:0.001) + #expect(hit.normalLoad>0);#expect(hit.wrench.forceReferenceNewtons.x>0) + #expect(hypot(hit.wrench.forceReferenceNewtons.x,hit.wrench.forceReferenceNewtons.y)<=0.8*hit.normalLoad+1e-10) + let air=try wheel.evaluate(body:wheelBody(3),state:spin,ground:ground,command:command,stepSeconds:0.001) + #expect(air.normalLoad==0);#expect(air.wrench.forceReferenceNewtons == .zero) + #expect(air.spinAcceleration>0);#expect(air.wrench.torqueBodyNewtonMetres.y<0) + let ice=try SuspendedWheel(id:"ice",mountBodyMetres:.zero,friction:0) + let iceSample=try ice.evaluate(body:body,state:spin,ground:ground,command:command,stepSeconds:0.001) + #expect(iceSample.wrench.forceReferenceNewtons.x==0) + #expect(throws:LandingMechanicsError.self) {try WheelCommand(brakeFraction:1.1)} +} +@Test func wheelAirborneMotorConservesTotalAngularMomentumAndStepRollback() throws { + let wheel=try SuspendedWheel(id:"w",mountBodyMetres:.zero) + var set=try SuspendedWheelSet(bodyID:"rover",wheels:[wheel]),system=try MechanicalSystem(frame:CelestialFrames.icrf,epoch:.j2000,bodies:[wheelBody(3)]) + let command=try WheelCommand(motorTorqueNewtonMetres:3),ground=try MechanicalGround(pointMetres:.zero,normal:SIMD3(0,0,1)) + for _ in 0..<1000 { + try set.step(system:&system,seconds:0.001,commands:[command],ground:{_,_,_ in ground},externalLoads:{_,_ in [MechanicalWrench()]}) + } + let omega=system.bodies[0].angularVelocityBody.y,spin=set.states[0].spinRadiansPerSecond + #expect(abs(100*omega+0.25*spin)<1e-9) + #expect(spin>10);#expect(omega<0) + let epoch=system.epoch,previousSpin=spin + #expect(throws:LandingMechanicsError.self) { + try set.step(system:&system,seconds:0.001,commands:[command],ground:{_,_,_ in throw LandingMechanicsError.invalidParameter},externalLoads:{_,_ in []}) + } + #expect(system.epoch==epoch);#expect(set.states[0].spinRadiansPerSecond==previousSpin) +} From c7204195875af5b8d90481cec512135d06e80941 Mon Sep 17 00:00:00 2001 From: Mattias Holm Date: Wed, 21 Oct 2026 21:44:20 +0200 Subject: [PATCH 2/2] Reorganize source, add Mechanics module and propeller model --- Documentation/HarpoonAnchoring.md | 2 ++ Documentation/LandingMechanics.md | 4 ++- Documentation/WheelDynamics.md | 4 +++ MODULES.md | 12 +++++-- Package.swift | 5 ++- README.md | 4 +-- .../FluidDynamics.docc/PropellersAndCFD.md | 10 ++++++ Sources/FluidDynamics/PropellerRotor.swift | 32 +++++++++++++++++++ .../HarpoonAnchoring.swift | 7 ++-- .../LandingMechanics.swift | 17 +++++----- Sources/Mechanics/VectorHelpers.swift | 5 +++ .../{CelMek => Mechanics}/WheelDynamics.swift | 11 ++++--- .../PropellerRotorTests.swift | 14 ++++++++ .../HarpoonTests.swift | 1 + .../LandingConservationTests.swift | 1 + .../LandingMechanicsTests.swift | 1 + .../WheelDynamicsTests.swift | 1 + 17 files changed, 109 insertions(+), 22 deletions(-) create mode 100644 Sources/FluidDynamics/PropellerRotor.swift rename Sources/{CelMek => Mechanics}/HarpoonAnchoring.swift (96%) rename Sources/{CelMek => Mechanics}/LandingMechanics.swift (95%) create mode 100644 Sources/Mechanics/VectorHelpers.swift rename Sources/{CelMek => Mechanics}/WheelDynamics.swift (93%) create mode 100644 Tests/FluidDynamicsTests/PropellerRotorTests.swift rename Tests/{CelMekTests => MechanicsTests}/HarpoonTests.swift (99%) rename Tests/{CelMekTests => MechanicsTests}/LandingConservationTests.swift (99%) rename Tests/{CelMekTests => MechanicsTests}/LandingMechanicsTests.swift (99%) rename Tests/{CelMekTests => MechanicsTests}/WheelDynamicsTests.swift (99%) diff --git a/Documentation/HarpoonAnchoring.md b/Documentation/HarpoonAnchoring.md index 92d96bd..47b01a9 100644 --- a/Documentation/HarpoonAnchoring.md +++ b/Documentation/HarpoonAnchoring.md @@ -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. diff --git a/Documentation/LandingMechanics.md b/Documentation/LandingMechanics.md index b01c998..ddf73dd 100644 --- a/Documentation/LandingMechanics.md +++ b/Documentation/LandingMechanics.md @@ -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. @@ -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. diff --git a/Documentation/WheelDynamics.md b/Documentation/WheelDynamics.md index d0d278b..23746cd 100644 --- a/Documentation/WheelDynamics.md +++ b/Documentation/WheelDynamics.md @@ -44,3 +44,7 @@ API cap is not a stability guarantee. Open Orbit's declared original rover uses 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. diff --git a/MODULES.md b/MODULES.md index b4c66b6..f36053e 100644 --- a/MODULES.md +++ b/MODULES.md @@ -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. @@ -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 @@ -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`. diff --git a/Package.swift b/Package.swift index 463cad9..aad2e23 100644 --- a/Package.swift +++ b/Package.swift @@ -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"]), @@ -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"), @@ -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"]), diff --git a/README.md b/README.md index 8d6cf21..637e6bf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/FluidDynamics/FluidDynamics.docc/PropellersAndCFD.md b/Sources/FluidDynamics/FluidDynamics.docc/PropellersAndCFD.md index 534f736..08bbd20 100644 --- a/Sources/FluidDynamics/FluidDynamics.docc/PropellersAndCFD.md +++ b/Sources/FluidDynamics/FluidDynamics.docc/PropellersAndCFD.md @@ -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 . + +## 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. diff --git a/Sources/FluidDynamics/PropellerRotor.swift b/Sources/FluidDynamics/PropellerRotor.swift new file mode 100644 index 0000000..79c5cb2 --- /dev/null +++ b/Sources/FluidDynamics/PropellerRotor.swift @@ -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 +} diff --git a/Sources/CelMek/HarpoonAnchoring.swift b/Sources/Mechanics/HarpoonAnchoring.swift similarity index 96% rename from Sources/CelMek/HarpoonAnchoring.swift rename to Sources/Mechanics/HarpoonAnchoring.swift index 0b7d25d..e45d278 100644 --- a/Sources/CelMek/HarpoonAnchoring.swift +++ b/Sources/Mechanics/HarpoonAnchoring.swift @@ -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 } @@ -39,7 +40,7 @@ public struct HarpoonAnchor { /// burial layer. Rejected impacts remain dynamic and can bounce away. @discardableResult public mutating func impact(pointMetres:SIMD3,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 } @@ -89,7 +90,7 @@ 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,impulseBodyNewtonSeconds j:SIMD3) 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,_ 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) } @@ -97,7 +98,7 @@ extension MechanicalBody { 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) diff --git a/Sources/CelMek/LandingMechanics.swift b/Sources/Mechanics/LandingMechanics.swift similarity index 95% rename from Sources/CelMek/LandingMechanics.swift rename to Sources/Mechanics/LandingMechanics.swift index 3c3a40b..480a5d8 100644 --- a/Sources/CelMek/LandingMechanics.swift +++ b/Sources/Mechanics/LandingMechanics.swift @@ -2,6 +2,7 @@ // Copyright 2026 Mattias Holm import Foundation import Math +import CelMek public enum LandingMechanicsError: Error { case invalidParameter, invalidTopology, numericalRange } @@ -19,7 +20,7 @@ public struct MechanicalBody { positionMetres: SIMD3, velocityMetresPerSecond: SIMD3 = .zero, bodyToReference: Quaternion = .identity, angularVelocityBody: SIMD3 = .zero) throws { guard !id.isEmpty, massKilograms.isFinite, massKilograms > 0, - attitudeFinite(positionMetres), attitudeFinite(velocityMetresPerSecond), attitudeFinite(angularVelocityBody) else { + mechanicsFinite(positionMetres), mechanicsFinite(velocityMetresPerSecond), mechanicsFinite(angularVelocityBody) else { throw LandingMechanicsError.invalidParameter } self.id=id; self.massKilograms=massKilograms; self.inertia=inertia @@ -28,7 +29,7 @@ public struct MechanicalBody { } public func point(_ local: SIMD3) -> SIMD3 { positionMetres + bodyToReference.matrix*local } public func pointVelocity(_ local: SIMD3) -> SIMD3 { - velocityMetresPerSecond + bodyToReference.matrix*attitudeCross(angularVelocityBody,local) + velocityMetresPerSecond + bodyToReference.matrix*mechanicsCross(angularVelocityBody,local) } } @@ -40,7 +41,7 @@ public struct MechanicalWrench { } public mutating func apply(force: SIMD3, at local: SIMD3, to body: MechanicalBody) { forceReferenceNewtons += force - torqueBodyNewtonMetres += attitudeCross(local,body.bodyToReference.invMatrix*force) + torqueBodyNewtonMetres += mechanicsCross(local,body.bodyToReference.invMatrix*force) } } @@ -62,7 +63,7 @@ public struct MechanicalTether { lengthMetres: Double, stiffnessNewtonsPerMetre: Double, dampingNewtonSecondsPerMetre: Double, breakingTensionNewtons: Double = .greatestFiniteMagnitude) throws { guard !id.isEmpty, !firstBody.isEmpty, !secondBody.isEmpty, firstBody != secondBody, - attitudeFinite(firstMountMetres), attitudeFinite(secondMountMetres), + mechanicsFinite(firstMountMetres), mechanicsFinite(secondMountMetres), [lengthMetres,stiffnessNewtonsPerMetre,breakingTensionNewtons].allSatisfy({$0.isFinite && $0 > 0}), dampingNewtonSecondsPerMetre.isFinite, dampingNewtonSecondsPerMetre >= 0 else { throw LandingMechanicsError.invalidParameter } self.id=id; self.firstBody=firstBody; self.secondBody=secondBody @@ -137,7 +138,7 @@ public struct MechanicalGround { public let normal: SIMD3 public let velocityMetresPerSecond: SIMD3 public init(pointMetres: SIMD3, normal: SIMD3, velocityMetresPerSecond: SIMD3 = .zero) throws { - guard attitudeFinite(pointMetres), attitudeFinite(velocityMetresPerSecond), attitudeFinite(normal), + guard mechanicsFinite(pointMetres), mechanicsFinite(velocityMetresPerSecond), mechanicsFinite(normal), (normal*normal).sum().isFinite, (normal*normal).sum() > 1e-24 else { throw LandingMechanicsError.invalidParameter } self.pointMetres=pointMetres; self.normal=normal/sqrt((normal*normal).sum()); self.velocityMetresPerSecond=velocityMetresPerSecond } @@ -159,7 +160,7 @@ public struct MechanicalContact { public init(body: String, mountMetres: SIMD3, radiusMetres: Double, stiffnessNewtonsPerMetre: Double, dampingNewtonSecondsPerMetre: Double, frictionCoefficient: Double = 0.6, frictionTransitionMetresPerSecond: Double = 0.05, rollingResistanceMetres: Double = 0, cushion: GasCushion? = nil) throws { - guard !body.isEmpty, attitudeFinite(mountMetres), + guard !body.isEmpty, mechanicsFinite(mountMetres), [radiusMetres,stiffnessNewtonsPerMetre,dampingNewtonSecondsPerMetre,frictionCoefficient,rollingResistanceMetres].allSatisfy({$0.isFinite && $0 >= 0}), frictionTransitionMetresPerSecond.isFinite, frictionTransitionMetresPerSecond > 0 else { throw LandingMechanicsError.invalidParameter } self.body=body; self.mountMetres=mountMetres; self.radiusMetres=radiusMetres @@ -177,7 +178,7 @@ public struct MechanicalContact { + (try cushion?.forceNewtons(compressionMetres:penetration,ambientPressurePascals:ambientPressurePascals) ?? 0)*min(1,penetration/max(0.001,radiusMetres*0.5)) let tangent=velocity-ground.normal*normalSpeed, speed=sqrt((tangent*tangent).sum()) let force=ground.normal*normalForce-tangent*(frictionCoefficient*normalForce/max(frictionTransitionMetresPerSecond,speed)) - guard attitudeFinite(force) else { throw LandingMechanicsError.numericalRange } + guard mechanicsFinite(force) else { throw LandingMechanicsError.numericalRange } var result=MechanicalWrench(); result.apply(force:force,at:local,to:state) let nBody=state.bodyToReference.invMatrix*ground.normal let roll=state.angularVelocityBody-nBody*(state.angularVelocityBody*nBody).sum() @@ -238,7 +239,7 @@ public struct MechanicalSystem { } func advanced(_ base:MechanicalBody, using sample:MechanicalBody, load:MechanicalWrench, dt:Double) throws -> MechanicalBody { let momentum=try sample.inertia.angularMomentumBody(for:sample.angularVelocityBody) - let angularAcceleration=try sample.inertia.solve(load.torqueBodyNewtonMetres-attitudeCross(sample.angularVelocityBody,momentum)) + let angularAcceleration=try sample.inertia.solve(load.torqueBodyNewtonMetres-mechanicsCross(sample.angularVelocityBody,momentum)) let qdot=0.5*(sample.bodyToReference*Quaternion(x:sample.angularVelocityBody.x,y:sample.angularVelocityBody.y,z:sample.angularVelocityBody.z,w:0)) return try MechanicalBody(id:base.id,massKilograms:base.massKilograms,inertia:base.inertia, positionMetres:base.positionMetres+sample.velocityMetresPerSecond*dt, diff --git a/Sources/Mechanics/VectorHelpers.swift b/Sources/Mechanics/VectorHelpers.swift new file mode 100644 index 0000000..05f7e09 --- /dev/null +++ b/Sources/Mechanics/VectorHelpers.swift @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mattias Holm +import Foundation +func mechanicsFinite(_ p:SIMD3)->Bool {[p.x,p.y,p.z].allSatisfy(\.isFinite)} +func mechanicsCross(_ a:SIMD3,_ b:SIMD3)->SIMD3 {SIMD3(a.y*b.z-a.z*b.y,a.z*b.x-a.x*b.z,a.x*b.y-a.y*b.x)} diff --git a/Sources/CelMek/WheelDynamics.swift b/Sources/Mechanics/WheelDynamics.swift similarity index 93% rename from Sources/CelMek/WheelDynamics.swift rename to Sources/Mechanics/WheelDynamics.swift index ec0a243..c71248f 100644 --- a/Sources/CelMek/WheelDynamics.swift +++ b/Sources/Mechanics/WheelDynamics.swift @@ -2,6 +2,7 @@ // Copyright 2026 Mattias Holm import Foundation import Math +import CelMek public struct WheelCommand { public let motorTorqueNewtonMetres,brakeFraction,steeringRadians:Double @@ -32,7 +33,7 @@ public struct SuspendedWheel { public init(id:String,mountBodyMetres:SIMD3,radiusMetres:Double=0.28,rotorInertia:Double=0.25, restLengthMetres:Double=0.24,travelMetres:Double=0.16,springNewtonsPerMetre:Double=3500,dampingNewtonSecondsPerMetre:Double=400, friction:Double=0.8,slipStiffnessNewtonSecondsPerMetre:Double=250,maximumBrakeTorque:Double=15,rollingResistance:Double=0.02) throws { - guard !id.isEmpty,attitudeFinite(mountBodyMetres),[radiusMetres,rotorInertia,restLengthMetres,travelMetres,springNewtonsPerMetre,slipStiffnessNewtonSecondsPerMetre].allSatisfy({$0.isFinite && $0>0}), + guard !id.isEmpty,mechanicsFinite(mountBodyMetres),[radiusMetres,rotorInertia,restLengthMetres,travelMetres,springNewtonsPerMetre,slipStiffnessNewtonSecondsPerMetre].allSatisfy({$0.isFinite && $0>0}), [dampingNewtonSecondsPerMetre,friction,maximumBrakeTorque,rollingResistance].allSatisfy({$0.isFinite && $0>=0}),travelMetreslimit && demand>0 {longitudinal *= limit/demand;lateral *= limit/demand} force=ground.normal*normalLoad+forward*longitudinal+side*lateral - groundTorque=(attitudeCross(-ground.normal*radiusMetres,force)*axleWorld).sum() + groundTorque=(mechanicsCross(-ground.normal*radiusMetres,force)*axleWorld).sum() } let rollingTorque=rollingResistance*normalLoad*radiusMetres*tanh(state.spinRadiansPerSecond/0.1) let freeTorque=command.motorTorqueNewtonMetres+groundTorque-rollingTorque @@ -76,8 +77,8 @@ public struct SuspendedWheel { var wrench=MechanicalWrench();wrench.apply(force:force,at:point,to:body) let momentum=axleBody*(rotorInertia*state.spinRadiansPerSecond) let momentumRate=axleBody*(rotorInertia*acceleration)-forwardBody*(steeringRate*rotorInertia*state.spinRadiansPerSecond) - wrench.torqueBodyNewtonMetres -= momentumRate+attitudeCross(body.angularVelocityBody,momentum) - guard attitudeFinite(wrench.forceReferenceNewtons),attitudeFinite(wrench.torqueBodyNewtonMetres),[acceleration,normalLoad,slip,lateralSpeed].allSatisfy(\.isFinite) else {throw LandingMechanicsError.numericalRange} + wrench.torqueBodyNewtonMetres -= momentumRate+mechanicsCross(body.angularVelocityBody,momentum) + guard mechanicsFinite(wrench.forceReferenceNewtons),mechanicsFinite(wrench.torqueBodyNewtonMetres),[acceleration,normalLoad,slip,lateralSpeed].allSatisfy(\.isFinite) else {throw LandingMechanicsError.numericalRange} return WheelSample(wrench:wrench,spinAcceleration:acceleration,spinRate:state.spinRadiansPerSecond,steeringRate:steeringRate, centreBodyMetres:centre,normalLoad:normalLoad,compression:restLengthMetres-length,longitudinalSlip:slip,lateralSpeed:lateralSpeed) } diff --git a/Tests/FluidDynamicsTests/PropellerRotorTests.swift b/Tests/FluidDynamicsTests/PropellerRotorTests.swift new file mode 100644 index 0000000..a59f0bf --- /dev/null +++ b/Tests/FluidDynamicsTests/PropellerRotorTests.swift @@ -0,0 +1,14 @@ +import Testing +@testable import FluidDynamics +@Test func coefficientRotorScalesWithDensityRpmAndConservesShaftPower() throws { + let rotor=try PropellerRotor(radiusMetres:0.6,maximumThrustCoefficient:0.015,profilePowerCoefficient:0.0006,inertiaKilogramMetresSquared:0.002) + let a=try rotor.sample(angularSpeed:250,collective:0.5,density:0.018) + let b=try rotor.sample(angularSpeed:500,collective:0.5,density:0.018) + let c=try rotor.sample(angularSpeed:250,collective:0.5,density:0.036) + #expect(abs(b.thrustNewtons-4*a.thrustNewtons)<1e-12) + #expect(abs(c.thrustNewtons-2*a.thrustNewtons)<1e-12) + #expect(abs(a.shaftPowerWatts-a.loadTorqueNewtonMetres*250)<1e-12) + let stopped=try rotor.sample(angularSpeed:0,collective:1,density:0.018) + #expect(stopped.thrustNewtons==0 && stopped.shaftPowerWatts==0) + #expect(throws:FluidError.self){try rotor.sample(angularSpeed:250,collective:2,density:0.018)} +} diff --git a/Tests/CelMekTests/HarpoonTests.swift b/Tests/MechanicsTests/HarpoonTests.swift similarity index 99% rename from Tests/CelMekTests/HarpoonTests.swift rename to Tests/MechanicsTests/HarpoonTests.swift index 7daf09e..78e20b7 100644 --- a/Tests/CelMekTests/HarpoonTests.swift +++ b/Tests/MechanicsTests/HarpoonTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import CelMek +import Mechanics import Math private func cometNorm(_ v:SIMD3)->Double {sqrt((v*v).sum())} diff --git a/Tests/CelMekTests/LandingConservationTests.swift b/Tests/MechanicsTests/LandingConservationTests.swift similarity index 99% rename from Tests/CelMekTests/LandingConservationTests.swift rename to Tests/MechanicsTests/LandingConservationTests.swift index 0819e3b..dde8bef 100644 --- a/Tests/CelMekTests/LandingConservationTests.swift +++ b/Tests/MechanicsTests/LandingConservationTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import CelMek +import Mechanics import Math private func lcCross(_ a:SIMD3,_ b:SIMD3) -> SIMD3 { SIMD3(a.y*b.z-a.z*b.y,a.z*b.x-a.x*b.z,a.x*b.y-a.y*b.x) } diff --git a/Tests/CelMekTests/LandingMechanicsTests.swift b/Tests/MechanicsTests/LandingMechanicsTests.swift similarity index 99% rename from Tests/CelMekTests/LandingMechanicsTests.swift rename to Tests/MechanicsTests/LandingMechanicsTests.swift index 21fdd2c..bae85f5 100644 --- a/Tests/CelMekTests/LandingMechanicsTests.swift +++ b/Tests/MechanicsTests/LandingMechanicsTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import CelMek +import Mechanics import Math private func mechanicalBody(_ id:String,_ p:SIMD3,_ v:SIMD3 = .zero) throws -> MechanicalBody { diff --git a/Tests/CelMekTests/WheelDynamicsTests.swift b/Tests/MechanicsTests/WheelDynamicsTests.swift similarity index 99% rename from Tests/CelMekTests/WheelDynamicsTests.swift rename to Tests/MechanicsTests/WheelDynamicsTests.swift index 68c3188..170298d 100644 --- a/Tests/CelMekTests/WheelDynamicsTests.swift +++ b/Tests/MechanicsTests/WheelDynamicsTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import CelMek +import Mechanics import Math private func wheelBody(_ z:Double,_ v:SIMD3 = .zero) throws -> MechanicalBody {