diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d09913e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[*.glsl] +indent_size = 4 \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..9a559ec --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,26 @@ +module.exports = { + env: { + browser: true, + es2021: true, + }, + extends: 'node', + overrides: [ + { + env: { + node: true, + }, + files: [ + '.eslintrc.{js,cjs}', + ], + parserOptions: { + sourceType: 'script', + }, + }, + ], + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + }, +}; diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..80fe4c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/dist/ +/node_modules/ +package-lock.json +.DS_Store +/module/ \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..39b1334 --- /dev/null +++ b/.npmignore @@ -0,0 +1,18 @@ +.git +.gitignore +.eslintrc +.babelrc +.npmignore +.travis.yml +package-lock.json +yarn.lock +webpack.config.js +webpack.module.js +.vscode/ +dist/ +build/ +node_modules/ +app/ +tests/ +doc/ +modules/ \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100755 index 0000000..e835430 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: node_js +node_js: + - '14' + - '16' + - '18' +script: + - npm run build \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..d9f08fa --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100755 index 0000000..dc7e6c9 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +SatSim JS +========= + +SatSim source code was developed under contract with ARFL/RDSM, and is approved for public release under Public Affairs release approval #AFRL-2022-1116. + +![screenshot](screenshot.jpg "screenshot") + +## Installation + +```sh +npm install satsim +``` + +## Usage + +index.js + +```javascript +import { Universe, createViewer } from "satsim"; +import "cesium/Build/Cesium/Widgets/widgets.css"; + +const universe = new Universe(); +const viewer = createViewer("cesiumContainer", universe); +``` + +index.html + +```html + + +
+ + + + + + +``` + +## Example Webpack Application + +````sh +git clone https://github.com/mixxen/satsimjs-example.git +cd satsimjs-example +npm install +npm start +```` \ No newline at end of file diff --git a/app/index.css b/app/index.css new file mode 100644 index 0000000..9f98daa --- /dev/null +++ b/app/index.css @@ -0,0 +1,17 @@ +html, body, #cesiumContainer { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} +.cesium-widget-credits { + display:none !important; +} + +.cesium-viewer-cesiumInspectorContainer { + display: block; + position: absolute; + top: 500px; + right: 10px; +} \ No newline at end of file diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..99ad7ab --- /dev/null +++ b/app/index.html @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/index.js b/app/index.js new file mode 100644 index 0000000..c63424b --- /dev/null +++ b/app/index.js @@ -0,0 +1,20 @@ +import { Universe, createViewer } from "../src/index.js"; +import { Math as CMath, JulianDate, ClockStep } from "cesium"; +import "cesium/Build/Cesium/Widgets/widgets.css"; +import "./index.css"; + +CMath.setRandomNumberSeed(42); + +const universe = new Universe(); +const viewer = createViewer("cesiumContainer", universe, { + showWeatherLayer: false, + showNightLayer: false, +}); +const start = JulianDate.now(); +viewer.clock.startTime = start.clone(); +viewer.clock.clockStep = ClockStep.SYSTEM_CLOCK; + + +viewer.scene.preUpdate.addEventListener((scene, time) => { + // do something +}); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100755 index 0000000..1e591b1 --- /dev/null +++ b/package.json @@ -0,0 +1,52 @@ +{ + "name": "satsim", + "version": "0.2.0", + "description": "SatSim for JavaScript.", + "homepage": "https://github.com/ssc-ai/satsimjs/", + "license": "MIT", + "author": { + "name": "SatSim JS.", + "url": "https://github.com/ssc-ai/satsimjs/" + }, + "keywords": [ + "cesium", + "satsim" + ], + "repository": { + "type": "git", + "url": "https://github.com/ssc-ai/satsimjs/" + }, + "main": "src/index.js", + "scripts": { + "build": "webpack --config webpack.config.js", + "watch": "webpack --config webpack.config.js --watch", + "start": "webpack serve --config webpack.config.js --open", + "test": "jest --verbose --runInBand", + "coverage": "jest --verbose --runInBand --collectCoverage" + }, + "jest": { + "testEnvironment": "node", + "collectCoverageFrom": [ + "src/**/*.js" + ] + }, + "devDependencies": { + "@babel/core": "^7.23.2", + "babel-loader": "^9.1.3", + "copy-webpack-plugin": "^9.0.1", + "css-loader": "^6.2.0", + "eslint": "^8.50.0", + "html-webpack-plugin": "^5.3.2", + "jest": "^29.7.0", + "style-loader": "^3.2.1", + "url-loader": "^4.1.1", + "webpack": "^5.51.1", + "webpack-cli": "^4.9.1", + "webpack-dev-server": "^4.3.1" + }, + "dependencies": { + "cesium": "^1.111.0", + "mathjs": "^11.11.1", + "satellite.js": "^5.0.0" + } +} diff --git a/screenshot.jpg b/screenshot.jpg new file mode 100644 index 0000000..8b1aead Binary files /dev/null and b/screenshot.jpg differ diff --git a/src/engine/Universe.js b/src/engine/Universe.js new file mode 100644 index 0000000..7b397d5 --- /dev/null +++ b/src/engine/Universe.js @@ -0,0 +1,248 @@ +import Earth from "./objects/Earth"; +import SGP4Satellite from "./objects/SGP4Satellite"; +import EarthGroundStation from "./objects/EarthGroundStation"; +import AzElGimbal from "./objects/AzElGimbal"; +import ElectroOpicalSensor from "./objects/ElectroOpticalSensor"; +import LagrangeInterpolatedObject from "./objects/LagrangeInterpolatedObject"; +import TwoBodySatellite from "./objects/TwoBodySatellite"; +import SimObject from "./objects/SimObject"; +import { JulianDate } from "cesium"; +import Gimbal from "./objects/Gimbal"; + +/** + * Represents a universe containing ECI objects, ground stations, sensors, and gimbals. + */ +class Universe { + constructor() { + /** + * The Earth object in the universe. + * @type {Earth} + * @private + */ + this._earth = new Earth(); + /** + * The objects in the universe. + * @type {Object.true
when the callback function returns the same value every time, false
if the value will change.
+ */
+function CallbackPositionProperty(callback, isConstant, referenceFrame) {
+
+ this._callback = undefined;
+ this._isConstant = undefined;
+ this._definitionChanged = new Event();
+ this._referenceFrame = defaultValue(referenceFrame, () => ReferenceFrame.FIXED);
+ this.setCallback(callback, isConstant);
+}
+
+Object.defineProperties(CallbackPositionProperty.prototype, {
+ /**
+ * Gets a value indicating if this property is constant.
+ * @memberof CallbackPositionProperty.prototype
+ *
+ * @type {boolean}
+ * @readonly
+ */
+ isConstant: {
+ get: function () {
+ return this._isConstant;
+ },
+ },
+ /**
+ * Gets the event that is raised whenever the definition of this property changes.
+ * The definition is changed whenever setCallback is called.
+ * @memberof CallbackPositionProperty.prototype
+ *
+ * @type {Event}
+ * @readonly
+ */
+ definitionChanged: {
+ get: function () {
+ return this._definitionChanged;
+ },
+ },
+
+ /**
+ * Gets the reference frame that the position is defined in.
+ * @memberof PositionProperty.prototype
+ * @type {ReferenceFrame}
+ */
+ referenceFrame: {
+ get: function () {
+ return this._referenceFrame();
+ },
+ },
+});
+
+/**
+ * Gets the value of the property.
+ *
+ * @param {JulianDate} time The time for which to retrieve the value.
+ * @param {object} [result] The object to store the value into, if omitted, a new instance is created and returned.
+ * @returns {object} The modified result parameter or a new instance if the result parameter was not supplied or is unsupported.
+ */
+CallbackPositionProperty.prototype.getValue = function (time, result) {
+// return this._callback(time, result);
+ return this.getValueInReferenceFrame(time, ReferenceFrame.FIXED, result)
+};
+
+/**
+ * Sets the callback to be used.
+ *
+ * @param {CallbackPositionProperty.Callback} callback The function to be called when the property is evaluated.
+ * @param {boolean} isConstant true
when the callback function returns the same value every time, false
if the value will change.
+ */
+CallbackPositionProperty.prototype.setCallback = function (callback, isConstant) {
+ //>>includeStart('debug', pragmas.debug);
+ if (!defined(callback)) {
+ throw new DeveloperError("callback is required.");
+ }
+ if (!defined(isConstant)) {
+ throw new DeveloperError("isConstant is required.");
+ }
+ //>>includeEnd('debug');
+
+ const changed =
+ this._callback !== callback || this._isConstant !== isConstant;
+
+ this._callback = callback;
+ this._isConstant = isConstant;
+
+ if (changed) {
+ this._definitionChanged.raiseEvent(this);
+ }
+};
+
+/**
+ * Compares this property to the provided property and returns
+ * true
if they are equal, false
otherwise.
+ *
+ * @param {Property} [other] The other property.
+ * @returns {boolean} true
if left and right are equal, false
otherwise.
+ */
+CallbackPositionProperty.prototype.equals = function (other) {
+ return (
+ this === other ||
+ (other instanceof CallbackPositionProperty &&
+ this._callback === other._callback &&
+ this._isConstant === other._isConstant)
+ );
+};
+
+/**
+ * Gets the value of the property at the provided time and in the provided reference frame.
+ *
+ * @param {JulianDate} time The time for which to retrieve the value.
+ * @param {ReferenceFrame} referenceFrame The desired referenceFrame of the result.
+ * @param {Cartesian3} [result] The object to store the value into, if omitted, a new instance is created and returned.
+ * @returns {Cartesian3} The modified result parameter or a new instance if the result parameter was not supplied.
+ */
+CallbackPositionProperty.prototype.getValueInReferenceFrame = function (
+ time,
+ referenceFrame,
+ result
+) {
+ //>>includeStart('debug', pragmas.debug);
+ if (!defined(time)) {
+ throw new DeveloperError("time is required.");
+ }
+ if (!defined(referenceFrame)) {
+ return this._callback(time, result);
+ }
+ //>>includeEnd('debug');
+
+ let p = this._callback(time, result);
+ return PositionProperty.convertToReferenceFrame(
+ time,
+ p,
+ this._referenceFrame(),
+ referenceFrame,
+ result
+ );
+};
+
+/**
+ * A function that returns the value of the property.
+ * @callback CallbackPositionProperty.Callback
+ *
+ * @param {JulianDate} time The time for which to retrieve the value.
+ * @param {object} [result] The object to store the value into. If omitted, the function must create and return a new instance.
+ * @returns {object} The modified result parameter, or a new instance if the result parameter was not supplied or is unsupported.
+ */
+export default CallbackPositionProperty;
\ No newline at end of file
diff --git a/src/engine/cesium/CompoundElementVisualizer.js b/src/engine/cesium/CompoundElementVisualizer.js
new file mode 100644
index 0000000..c9e2c70
--- /dev/null
+++ b/src/engine/cesium/CompoundElementVisualizer.js
@@ -0,0 +1,48 @@
+import { defaultValue, Color } from "cesium"
+
+class CompountElementVisualizer {
+ constructor(color, materialAlpha, outlineAlpha) {
+ this._entities = []
+ this._show = true
+ this._outline = true
+ this._color = defaultValue(color, Color.WHITE)
+ this._materialAlpha = defaultValue(materialAlpha, 0.25)
+ this._outlineAlpha = defaultValue(outlineAlpha, 0.5)
+ }
+
+ get show() {
+ return this._show
+ }
+
+ set show(value) {
+ this._show = value
+ this._entities.forEach(entity => {
+ entity.show = value
+ })
+ }
+
+ get outline() {
+ return this._outline
+ }
+
+ set outline(value) {
+ this._entities.forEach(entity => {
+ entity.outline = value
+ })
+ }
+
+ get color() {
+ return this._color
+ }
+
+ set color(value) {
+ this._color = value
+ this._entities.forEach(entity => {
+ entity.material = this._color.withAlpha(this._materialAlpha)
+ entity.outlineColor = this._color.withAlpha(this._outlineAlpha)
+ })
+ }
+
+}
+
+export default CompountElementVisualizer
diff --git a/src/engine/cesium/CoverageGridVisualizer.js b/src/engine/cesium/CoverageGridVisualizer.js
new file mode 100644
index 0000000..c4da36d
--- /dev/null
+++ b/src/engine/cesium/CoverageGridVisualizer.js
@@ -0,0 +1,100 @@
+import { PointPrimitiveCollection, Color } from 'cesium';
+import CompoundElementVisualizer from './CompoundElementVisualizer';
+import { colorVisibleSatellites } from "./utils.js";
+
+class CoverageGridVisualizer extends CompoundElementVisualizer {
+ constructor(viewer, universe, orbit='GEO', alpha=0.3) {
+ super()
+ this._objects = []
+ this._show = false
+ this.viewer = viewer
+ this.universe = universe
+ this._altitude = 384400.0e3
+ this.orbit = orbit
+ this._alpha = alpha
+
+ this._points = viewer.scene.primitives.add(new PointPrimitiveCollection());
+ }
+
+ initOrShowGridOfObjects() {
+
+ if(this._objects.length === 0) {
+ for(let lat = -90; lat < 90; lat += 1) {
+ for(let lon = -179.5; lon <= 179.5; lon += 1) { // don't start at -180, weird aliasing in 2D
+ const g = this.universe.addGroundSite('grid ' + lat + ':' + lon, lat, lon, this._altitude);
+ const description = undefined;
+ const color = Color.RED.withAlpha(this._alpha);
+ this._objects.push(g);
+ const e = this._points.add({
+ position: g.position,
+ pixelSize: 5,
+ color: color,
+ outlineColor: color,
+ show: true
+ })
+ this._entities.push(e)
+ // // console.log(e)
+ g.visualizer = {
+ point: e
+ }
+ }
+ }
+ }
+ this._points.show = true
+ }
+
+ set alpha(value) {
+ if(this._alpha === value)
+ return;
+
+ this._alpha = value;
+ this._entities.forEach(e => {
+ e.color.alpha = this._alpha;
+ e.outlineColor.alpha = this._alpha;
+ });
+ }
+
+ set orbit(value) {
+ if(this._orbit === value)
+ return;
+
+ this._orbit = value;
+ if (this._orbit === 'LEO')
+ this._altitude = 600e3;
+ else if(this._orbit === 'MEO')
+ this._altitude = 42164.0e3 / 2 - 6378.1e3;
+ else if(this._orbit === 'GEO')
+ this._altitude = 42164.0e3 - 6378.1e3;
+ else if(this._orbit === 'LUNAR')
+ this._altitude = 384400.0e3;
+
+ this._objects.forEach(obj => {
+ obj.altitude = this._altitude;
+ obj.visualizer.point.position = obj.position;
+ });
+ }
+
+ set show(value) {
+ if(this._show === value)
+ return;
+
+ this._show = value;
+
+ const ec = this.viewer.entities
+ ec.suspendEvents()
+ if(this._show) {
+ this.initOrShowGridOfObjects()
+ } else {
+ this._points.show = false
+ }
+ ec.resumeEvents()
+ }
+
+ update(time) {
+ colorVisibleSatellites(this.universe, this.universe._observatories, time, this._objects, this.alpha, false);
+ }
+
+
+}
+
+export default CoverageGridVisualizer
\ No newline at end of file
diff --git a/src/engine/cesium/GeoBeltVisualizer.js b/src/engine/cesium/GeoBeltVisualizer.js
new file mode 100644
index 0000000..6bc653a
--- /dev/null
+++ b/src/engine/cesium/GeoBeltVisualizer.js
@@ -0,0 +1,30 @@
+import { defaultValue, Color, Cartesian3, Math as CMath } from 'cesium';
+import CompoundElementVisualizer from './CompoundElementVisualizer';
+
+class GeoBeltVisualizer extends CompoundElementVisualizer {
+ constructor(viewer, color) {
+ super(defaultValue(color, Color.WHITE), 0.1, 0.2)
+
+ const e = viewer.entities.add({
+ name: 'GEO Belt',
+ position: Cartesian3.ZERO.clone(),
+ ellipsoid: {
+ radii: new Cartesian3(42164000.0, 42164000.0, 42164000.0),
+ minimumClock: CMath.toRadians(0),
+ maximumClock: CMath.toRadians(360),
+ minimumCone: CMath.toRadians(90.0-20.0),
+ maximumCone: CMath.toRadians(90.0+20.0),
+ material: Color.WHITE.withAlpha(0.1),
+ outlineColor: Color.WHITE.withAlpha(0.2),
+ outline: true,
+ slicePartitions: 36,
+ stackPartitions: 20
+ },
+ allowPicking: false
+ })
+
+ this._entities.push(e)
+ }
+}
+
+export default GeoBeltVisualizer
\ No newline at end of file
diff --git a/src/engine/cesium/SensorFieldOfRegardVisualizer.js b/src/engine/cesium/SensorFieldOfRegardVisualizer.js
new file mode 100644
index 0000000..d6da705
--- /dev/null
+++ b/src/engine/cesium/SensorFieldOfRegardVisualizer.js
@@ -0,0 +1,48 @@
+import { defined, Math as CMath, Color, Cartesian3 } from 'cesium'
+import { createObjectPositionProperty, createObjectOrientationProperty } from './utils.js'
+import CompountElementVisualizer from './CompoundElementVisualizer.js'
+
+class SensorFieldOfRegardVisualizer extends CompountElementVisualizer {
+ constructor(viewer, site, sensor, universe) {
+ super(Color.PURPLE, 0.1, 0.5)
+
+ if (defined(sensor.field_of_regard)) {
+ for (let i = 0; i < sensor.field_of_regard.length; i++) {
+ let fofr = sensor.field_of_regard[i]
+ let ent = createFieldofRegardSection(fofr.clock[0], fofr.clock[1], fofr.elevation[0], fofr.elevation[1], fofr.range)
+ this._entities.push(ent)
+ }
+ }
+
+ function createFieldofRegardSection(minClock, maxClock, minEl, maxEl, range) {
+ if(range === undefined)
+ range = 45000000.0
+
+ // TODO ellipsoid is buggy in Cesium (i.e., outline doesn't match, 2D view crashes), replace with a polygon
+ let ent = viewer.entities.add({
+ name: sensor.name + ' Field of Regard',
+ position: createObjectPositionProperty(site, universe, viewer),
+ orientation: createObjectOrientationProperty(site, universe),
+ ellipsoid: {
+ radii: new Cartesian3(range, range, range),
+ innerRadii: new Cartesian3(CMath.EPSILON1, CMath.EPSILON1, CMath.EPSILON1),
+ minimumClock: CMath.toRadians(minClock - 180.0),
+ maximumClock: CMath.toRadians(maxClock - 180.0),
+ minimumCone: CMath.toRadians(90.0-minEl),
+ maximumCone: CMath.toRadians(90.0-maxEl),
+ material: Color.PURPLE.withAlpha(0.1),
+ outlineColor: Color.PURPLE.withAlpha(0.5),
+ outline: true,
+ slicePartitions: 36,
+ stackPartitions: 20
+ },
+ simObjectRef: sensor,
+ allowPicking: false
+ });
+
+ return ent.ellipsoid
+ }
+ }
+}
+
+export default SensorFieldOfRegardVisualizer
diff --git a/src/engine/cesium/SensorFieldOfVIewVisualizer.js b/src/engine/cesium/SensorFieldOfVIewVisualizer.js
new file mode 100644
index 0000000..6b8343d
--- /dev/null
+++ b/src/engine/cesium/SensorFieldOfVIewVisualizer.js
@@ -0,0 +1,37 @@
+import { defaultValue, Color, CallbackProperty, Cartesian3, Math as CMath } from 'cesium'
+import { createObjectPositionProperty, createObjectOrientationProperty } from './utils.js'
+import CompountElementVisualizer from './CompoundElementVisualizer.js'
+
+class SensorFieldOfViewVisualizer extends CompountElementVisualizer {
+ constructor(viewer, site, gimbal, sensor, universe, color) {
+ super(defaultValue(color, Color.GREEN), 0.25, 0.5)
+ const e = viewer.entities.add({
+ name: sensor.name + ' Field of View',
+ position: createObjectPositionProperty(sensor, universe, viewer),
+ orientation: createObjectOrientationProperty(sensor, universe),
+ ellipsoid: {
+ radii: new CallbackProperty(function(time, result) {
+ gimbal.update(time, universe)
+ let range = gimbal.range <= 0 ? 1.0 : gimbal.range // Cesium will crash if range is less than 0
+ return Cartesian3.clone(new Cartesian3(range, range, range), result)
+ }, false),
+ innerRadii: new Cartesian3(CMath.EPSILON1, CMath.EPSILON1, CMath.EPSILON1), // Cesium will crash if innerRadii is small and radii is large
+ minimumClock: CMath.toRadians(-sensor.x_fov / 2),
+ maximumClock: CMath.toRadians(sensor.x_fov / 2),
+ minimumCone: CMath.toRadians(90 - sensor.y_fov / 2),
+ maximumCone: CMath.toRadians(90 + sensor.y_fov / 2),
+ material: this._color.withAlpha(0.25),
+ outlineColor: this._color.withAlpha(0.5),
+ outline: true,
+ slicePartitions: 3,
+ stackPartitions: 3
+ },
+ simObjectRef: sensor,
+ allowPicking: false
+ })
+
+ this._entities.push(e.ellipsoid)
+ }
+}
+
+export default SensorFieldOfViewVisualizer
diff --git a/src/engine/cesium/utils.js b/src/engine/cesium/utils.js
new file mode 100644
index 0000000..e130a15
--- /dev/null
+++ b/src/engine/cesium/utils.js
@@ -0,0 +1,173 @@
+import { Color, defaultValue, SampledPositionProperty, JulianDate, Cartesian3, LagrangePolynomialApproximation, defined, ReferenceFrame, Matrix3, Quaternion, CallbackProperty } from 'cesium'
+import { CallbackPositionProperty, ElectroOpicalSensor } from '../../index.js'
+import { southEastZenithToAzEl } from '../dynamics/gimbal.js'
+
+function toSampledPositionProperty(object, context, start, stop, step) {
+ start = JulianDate.addSeconds(start, -step, new JulianDate())
+ stop = JulianDate.addSeconds(stop, step, new JulianDate())
+ const prop = new SampledPositionProperty()
+ let current = JulianDate.clone(start)
+ let i = 0
+ while(JulianDate.lessThan(current, stop)) {
+ object.update(current, context)
+ prop.addSample(current.clone(), Cartesian3.clone(object.position))
+ JulianDate.addSeconds(current, step, current)
+ i = i + 1
+ }
+ prop.setInterpolationOptions({
+ interpolationDegree : 3,
+ interpolationAlgorithm : LagrangePolynomialApproximation
+ });
+ return prop
+}
+
+
+function createObjectPositionProperty(object, universe, viewer) {
+ return new CallbackPositionProperty(function(time, result) {
+ if(!defined(this.lastReferenceFrameView)) {
+ this.lastReferenceFrameView = viewer.referenceFrameView
+ } else if(this.lastReferenceFrameView !== viewer.referenceFrameView) {
+ this.lastReferenceFrameView = viewer.referenceFrameView
+ }
+
+ object.update(time, universe)
+ result = Cartesian3.clone(object.position, result)
+ if (viewer.referenceFrameView === ReferenceFrame.FIXED && object.referenceFrame === ReferenceFrame.INERTIAL) {
+ universe.earth.update(time, universe)
+ universe.earth.transformPointFromWorld(result, result)
+ } else if (viewer.referenceFrameView === ReferenceFrame.INERTIAL && object.referenceFrame === ReferenceFrame.FIXED) {
+ universe.earth.update(time, universe)
+ universe.earth.transformPointToWorld(result, result)
+ }
+ return result
+ }, false, () => viewer.referenceFrameView)
+}
+
+
+function getObjectPositionInCesiumFrame(viewer, universe, object, time, result) {
+ result = Cartesian3.clone(object.position, result)
+ if (object.referenceFrame === ReferenceFrame.INERTIAL) {
+ universe.earth.update(time, universe)
+ universe.earth.transformPointFromWorld(result, result)
+ }
+ // TODO - handle other reference frames
+ return result
+}
+
+
+function createObjectOrientationProperty(object, universe) {
+ return new CallbackProperty(function(time, result) {
+ object.update(time, universe)
+ let m;
+ if (object instanceof ElectroOpicalSensor) {
+ universe.earth.update(time, universe)
+ let x = object.transformVectorTo(universe.earth, new Cartesian3(0, 0, -1))
+ let y = object.transformVectorTo(universe.earth, Cartesian3.UNIT_Y)
+ let z = object.transformVectorTo(universe.earth, Cartesian3.UNIT_X)
+ m = new Matrix3(x.x, y.x, z.x, x.y, y.y, z.y, x.z, y.z, z.z)
+ } else if (object.referenceFrame === ReferenceFrame.INERTIAL) {
+ universe.earth.update(time, universe)
+ let x = universe.earth.transformVectorFromWorld(Cartesian3.UNIT_Y)
+ let y = universe.earth.transformVectorFromWorld(Cartesian3.UNIT_X)
+ let z = universe.earth.transformVectorFromWorld(new Cartesian3(0, 0, -1))
+ m = new Matrix3(x.x, y.x, z.x, x.y, y.y, z.y, x.z, y.z, z.z)
+ } else {
+ universe.earth.update(time, universe)
+ let x = object.transformVectorTo(universe.earth, Cartesian3.UNIT_X)
+ let y = object.transformVectorTo(universe.earth, Cartesian3.UNIT_Y)
+ let z = object.transformVectorTo(universe.earth, Cartesian3.UNIT_Z)
+ m = new Matrix3(x.x, y.x, z.x, x.y, y.y, z.y, x.z, y.z, z.z)
+ }
+ Quaternion.fromRotationMatrix(m, result)
+ return result
+ }, false)
+}
+
+
+function colorVisibleSatellites(universe, observatories, time, objects=undefined, alpha=0.5, showNonVisible=false) {
+
+ function getPoint(o) {
+ if(defined(o.visualizer.point)) {
+ return o.visualizer.point
+ } else if(defined(o.visualizer.point2)) {
+ return o.visualizer.point2
+ } else {
+ return undefined
+ }
+ }
+
+ const [R, O, Y, G] = [Color.RED.withAlpha(alpha), Color.ORANGE.withAlpha(alpha), Color.YELLOW.withAlpha(alpha), Color.GREEN.withAlpha(alpha)]
+ const trackables = defaultValue(objects, universe._trackables)
+ trackables.forEach((o) => {
+ const point = getPoint(o)
+ if(showNonVisible) {
+ if(!Color.equals(point.color._value, R)) {
+ point.color = R
+ point.outlineColor = R
+ }
+ } else {
+ if(defined(point.show)) {
+ if(point.show._value !== false) {
+ point.show = false;
+ }
+ } else {
+ point.show = false;
+ }
+ }
+ });
+
+ const counts = {}
+ observatories.forEach((o) => {
+ applyToVisible(universe, o, time, objects, (sat) => {
+ const point = getPoint(sat)
+ if(point.show._value !== true)
+ point.show = true;
+
+ if(counts[sat.name] === undefined) {
+ counts[sat.name] = 0
+ }
+ counts[sat.name] += 1
+ const count = counts[sat.name]
+ if(count == 1) {
+ point.color = showNonVisible ? O : R
+ point.outlineColor = showNonVisible ? O : R
+ } else if(count == 2) {
+ point.color = Y
+ point.outlineColor = Y
+ } else if(count > 2) {
+ point.color = G
+ point.outlineColor = G
+ }
+ });
+ });
+}
+
+
+
+function applyToVisible(universe, observatory, time, objects, callback) {
+ const field_of_regard = observatory.sensor.field_of_regard;
+ observatory.site.update(time, universe)
+ const localPos = new Cartesian3();
+ objects.forEach((sat) => {
+ sat.update(time, universe)
+ observatory.site.transformPointFromWorld(sat.worldPosition, localPos);
+ let [az, el, r] = southEastZenithToAzEl(localPos)
+ if(defined(field_of_regard)) {
+ for(let i = 0; i < field_of_regard.length; i ++) {
+ const f = field_of_regard[i];
+ if(az > f.clock[0] && az < f.clock[1] && el > f.elevation[0] && el < f.elevation[1]) {
+ callback(sat);
+ break;
+ }
+ }
+ }
+ });
+}
+
+export {
+ toSampledPositionProperty,
+ createObjectPositionProperty,
+ createObjectOrientationProperty,
+ colorVisibleSatellites,
+ getObjectPositionInCesiumFrame
+}
\ No newline at end of file
diff --git a/src/engine/dynamics/gimbal.js b/src/engine/dynamics/gimbal.js
new file mode 100644
index 0000000..2f7f227
--- /dev/null
+++ b/src/engine/dynamics/gimbal.js
@@ -0,0 +1,49 @@
+import { Cartesian3, Math as CMath } from 'cesium';
+
+function southEastZenithToAzEl(cartesian3) {
+ let az, el;
+ if (cartesian3.x === 0.0 && cartesian3.y === 0.0) {
+ az = 0.0;
+ } else {
+ az = Math.atan2(cartesian3.y, -cartesian3.x);
+ if (az < 0.0) {
+ az += CMath.TWO_PI;
+ }
+ }
+
+ const mag = Cartesian3.magnitude(cartesian3);
+ if (mag < 1e-9) {
+ el = 0.0;
+ } else {
+ el = Math.asin(cartesian3.z / mag);
+ }
+
+ return [az * CMath.DEGREES_PER_RADIAN, el * CMath.DEGREES_PER_RADIAN, mag];
+}
+
+function spaceBasedToAzEl(cartesian3) {
+ let az, el;
+ if (cartesian3.x === 0 && cartesian3.y === 0) {
+ az = 0.0;
+ } else {
+ az = Math.atan2(cartesian3.y, -cartesian3.x);
+ if (az < 0.0) {
+ az += CMath.TWO_PI;
+ }
+ }
+
+ const mag = Cartesian3.magnitude(cartesian3);
+ const r = Math.sqrt(cartesian3.x * cartesian3.x + cartesian3.y * cartesian3.y);
+ if (Math.abs(r) < 1e-9 && Math.abs(cartesian3.z) < 1e-9) {
+ el = 0.0;
+ } else {
+ el = Math.atan2(r, -cartesian3.z);
+ }
+
+ return [az * CMath.DEGREES_PER_RADIAN, el * CMath.DEGREES_PER_RADIAN, mag];
+}
+
+export {
+ southEastZenithToAzEl,
+ spaceBasedToAzEl
+}
\ No newline at end of file
diff --git a/src/engine/dynamics/lagrange.js b/src/engine/dynamics/lagrange.js
new file mode 100644
index 0000000..7226a3c
--- /dev/null
+++ b/src/engine/dynamics/lagrange.js
@@ -0,0 +1,50 @@
+import { JulianDate, LagrangePolynomialApproximation, Cartesian3 } from 'cesium'
+
+const _r = []
+function lagrange_fast(times, positions, t, result) {
+
+ _r.length = 0
+ LagrangePolynomialApproximation.interpolateOrderZero(t, times, positions, 3, _r)
+ return Cartesian3.fromArray(_r, 0, result)
+}
+
+
+function lagrange(times, positions, epoch, time, update, result, interval = 180) {
+
+ let delta = JulianDate.secondsDifference(time, epoch)
+ let numPoints = 7
+ if (times.length < numPoints || delta < times[0] || delta > times[times.length - 1]) {
+ initialize(times, positions, epoch, time, update, interval, numPoints)
+ delta = JulianDate.secondsDifference(time, epoch)
+ }
+
+ return lagrange_fast(times, positions, delta, result)
+}
+
+
+function initialize(times, positions, epoch, time, update, interval, numPoints) {
+
+ times.length = 0
+ positions.length = 0
+
+ let t1 = JulianDate.clone(time)
+ JulianDate.addSeconds(t1, -(numPoints - 1) / 2 * interval, t1)
+ for (let i = 0; i < numPoints; i++) {
+ JulianDate.addSeconds(t1, interval, t1)
+
+ if (i === 0)
+ JulianDate.clone(t1, epoch)
+
+ const p = update(t1)
+ positions.push(p.x)
+ positions.push(p.y)
+ positions.push(p.z)
+ times.push(interval * i)
+ }
+}
+
+export {
+ lagrange_fast,
+ lagrange,
+ initialize
+}
\ No newline at end of file
diff --git a/src/engine/dynamics/math.js b/src/engine/dynamics/math.js
new file mode 100644
index 0000000..2bc28b6
--- /dev/null
+++ b/src/engine/dynamics/math.js
@@ -0,0 +1,70 @@
+import { gamma } from "mathjs"
+
+
+function hyp2f1b(x) {
+ if (x >= 1.0) {
+ return Math.inf
+ } else {
+ let res = 1.0
+ let term = 1.0
+ let ii = 0
+ while (true) {
+ term = term * (3 + ii) * (1 + ii) / (5 / 2 + ii) * x / (ii + 1)
+ let res_old = res
+ res += term
+ if (res_old == res) {
+ return res
+ }
+ ii += 1
+ }
+ }
+}
+
+
+function stumpff_c2(psi) {
+ let eps = 1.0
+ let res
+ if (psi > eps) {
+ res = (1 - Math.cos(Math.sqrt(psi))) / psi
+ } else if (psi < -eps) {
+ res = (Math.cosh(Math.sqrt(-psi)) - 1) / (-psi)
+ } else {
+ res = 1.0 / 2.0
+ let delta = (-psi) / gamma(2 + 2 + 1)
+ let k = 1
+ while (res + delta != res) {
+ res = res + delta
+ k += 1
+ delta = (-psi) ** k / gamma(2 * k + 2 + 1)
+ }
+ }
+ return res
+}
+
+
+function stumpff_c3(psi) {
+ let eps = 1.0
+ let res
+ if (psi > eps) {
+ res = (Math.sqrt(psi) - Math.sin(Math.sqrt(psi))) / (psi * Math.sqrt(psi))
+ } else if (psi < -eps) {
+ res = (Math.sinh(Math.sqrt(-psi)) - Math.sqrt(-psi)) / (-psi * Math.sqrt(-psi))
+ } else {
+ res = 1.0 / 6.0
+ let delta = (-psi) / gamma(2 + 3 + 1)
+ let k = 1
+ while (res + delta != res) {
+ res = res + delta
+ k += 1
+ delta = (-psi) ** k / gamma(2 * k + 3 + 1)
+ }
+ }
+
+ return res
+}
+
+export {
+ hyp2f1b,
+ stumpff_c2,
+ stumpff_c3
+}
\ No newline at end of file
diff --git a/src/engine/dynamics/twobody.js b/src/engine/dynamics/twobody.js
new file mode 100644
index 0000000..69f0e09
--- /dev/null
+++ b/src/engine/dynamics/twobody.js
@@ -0,0 +1,130 @@
+import {
+ stumpff_c2 as c2,
+ stumpff_c3 as c3
+} from './math.js'
+import { Cartesian3 } from 'cesium'
+
+const _scratch = new Cartesian3()
+function cross(a, b) {
+ return Cartesian3.cross(a, b, _scratch)
+}
+
+function dot(a, b) {
+ return Cartesian3.dot(a, b)
+}
+
+function mult(a, b) {
+ return new Cartesian3(a.x * b, a.y * b, a.z * b)
+}
+
+function sub(a, b) {
+ return new Cartesian3(a.x - b.x, a.y - b.y, a.z - b.z)
+}
+
+function mag(a) {
+ return Cartesian3.magnitude(a)
+}
+
+function vallado_fast(k, r0, v0, tof, numiter) {
+ const dot_r0v0 = dot(r0, v0)
+ const norm_r0 = mag(r0)
+ const sqrt_mu = Math.pow(k, 0.5)
+ const alpha = -dot(v0, v0) / k + 2 / norm_r0
+
+ let xi_new
+ // First guess
+ if (alpha > 0) {
+ // Elliptic orbit
+ xi_new = sqrt_mu * tof * alpha
+ } else if (alpha < 0) {
+ // Hyperbolic orbit
+ xi_new = (
+ Math.sign(tof)
+ * Math.pow((-1 / alpha), 0.5)
+ * Math.log(
+ (-2 * k * alpha * tof)
+ / (
+ dot_r0v0
+ + Math.sign(tof)
+ * Math.sqrt(-k / alpha)
+ * (1 - norm_r0 * alpha)
+ )
+ )
+ )
+ } else {
+ // Parabolic orbit
+ // (Conservative initial guess)
+ xi_new = sqrt_mu * tof / norm_r0
+ }
+
+ // Newton-Raphson iteration on the Kepler equation
+ let xi, psi, c2_psi, c3_psi, norm_r
+ let count = 0
+ while (count < numiter) {
+ xi = xi_new
+ psi = xi * xi * alpha
+ c2_psi = c2(psi)
+ c3_psi = c3(psi)
+ norm_r = (
+ xi * xi * c2_psi
+ + dot_r0v0 / sqrt_mu * xi * (1 - psi * c3_psi)
+ + norm_r0 * (1 - psi * c2_psi)
+ )
+ xi_new = (
+ xi
+ + (
+ sqrt_mu * tof
+ - xi * xi * xi * c3_psi
+ - dot_r0v0 / sqrt_mu * xi * xi * c2_psi
+ - norm_r0 * xi * (1 - psi * c3_psi)
+ )
+ / norm_r
+ )
+ if (Math.abs(xi_new - xi) < 1e-7) {
+ break
+ } else {
+ count += 1
+ }
+ }
+
+ // Compute Lagrange coefficients
+ const f = 1 - Math.pow(xi, 2) / norm_r0 * c2_psi
+ const g = tof - Math.pow(xi, 3) / sqrt_mu * c3_psi
+
+ const gdot = 1 - Math.pow(xi, 2) / norm_r * c2_psi
+ const fdot = sqrt_mu / (norm_r * norm_r0) * xi * (psi * c3_psi - 1)
+
+ return [f, g, fdot, gdot]
+}
+
+
+function vallado(k, r0, v0, tof, numiter) {
+ // Compute Lagrange coefficients
+ let f, g, fdot, gdot
+ [f, g, fdot, gdot] = vallado_fast(k, r0, v0, tof, numiter)
+
+ // Return position and velocity vectors
+ return {
+ "position": { "x": f * r0.x + g * v0.x, "y": f * r0.y + g * v0.y, "z": f * r0.z + g * v0.z},
+ "velocity": { "x": fdot * r0.x + gdot * v0.x, "y": fdot * r0.y + gdot * v0.y, "z": fdot * r0.z + gdot * v0.z}
+ }
+}
+
+function rv2ecc(k, r, v) {
+ const e = mult(sub(mult(r, dot(v, v) - k / mag(r)), mult(v, dot(r, v))), 1/k)
+ const ecc = mag(e)
+ return ecc
+}
+
+
+function rv2p(k, r, v) {
+ const h = cross(r, v)
+ const p = dot(h, h) / k
+ const ecc = rv2ecc(k, r, v)
+ const a = p / (1 - ecc * ecc)
+ const mm = Math.sqrt(k / Math.abs(a*a*a))
+
+ return 2 * Math.PI / mm
+}
+
+export { vallado, rv2p, rv2ecc }
\ No newline at end of file
diff --git a/src/engine/graph/Group.js b/src/engine/graph/Group.js
new file mode 100644
index 0000000..1b2bcfb
--- /dev/null
+++ b/src/engine/graph/Group.js
@@ -0,0 +1,68 @@
+import Node from './Node';
+
+/**
+ * A group of nodes that can have children added and removed.
+ * @extends Node
+ */
+class Group extends Node {
+
+ /**
+ * Creates a new group.
+ */
+ constructor() {
+ super();
+ /**
+ * The children of this group.
+ * @type {Node[]}
+ */
+ this.children = [];
+ }
+
+ /**
+ * Adds a child node to this group.
+ * @param {Node} child - The child node to add.
+ */
+ addChild(child) {
+ child.parent = this;
+ this.children.push(child);
+ }
+
+ /**
+ * Removes a child node from this group.
+ * @param {Node} child - The child node to remove.
+ */
+ removeChild(child) {
+ const index = this.children.indexOf(child);
+ if (index !== -1) {
+ child.parent = null;
+ this.children.splice(index, 1);
+ }
+ }
+
+ /**
+ * Removes all children from this group.
+ */
+ removeAll() {
+ this.children.forEach(child => {
+ this.removeChild(child);
+ });
+ }
+
+ /**
+ * Checks if this group has any children.
+ * @returns {boolean} - True if this group has children, false otherwise.
+ */
+ hasChildren() {
+ return this.children.length > 0;
+ }
+
+ /**
+ * The number of children in this group.
+ * @type {number}
+ */
+ get length() {
+ return this.children.length;
+ }
+}
+
+export default Group;
diff --git a/src/engine/graph/Node.js b/src/engine/graph/Node.js
new file mode 100644
index 0000000..1ee5d17
--- /dev/null
+++ b/src/engine/graph/Node.js
@@ -0,0 +1,175 @@
+import { Matrix4, Cartesian3, defined } from 'cesium';
+
+/**
+ * A node in a scene graph.
+ */
+class Node {
+
+ /**
+ * Creates a new node.
+ */
+ constructor() {
+ /**
+ * The parent node.
+ * @type {Node|null}
+ */
+ this.parent = null;
+ }
+
+ /**
+ * Attaches this node to a parent node.
+ * @param {Node} parent - The parent node to attach to.
+ */
+ attach(parent) {
+ if (this.parent) {
+ this.parent.removeChild(this);
+ }
+ parent.addChild(this);
+ }
+
+ /**
+ * Detaches this node from its parent node.
+ */
+ detach() {
+ if (this.parent) {
+ this.parent.removeChild(this);
+ }
+ }
+
+ /**
+ * Transforms a local point to world coordinates.
+ * @param {Cartesian3} localPoint - The local point to transform.
+ * @param {Cartesian3} [result] - The object to store the result in.
+ * @returns {Cartesian3} The transformed point in world coordinates.
+ */
+ transformPointToWorld(localPoint, result) {
+ const localToWorldTransform = this.localToWorldTransform;
+ if (!defined(result)) {
+ result = new Cartesian3();
+ }
+ Matrix4.multiplyByPoint(localToWorldTransform, localPoint, result);
+ return result;
+ }
+
+ /**
+ * Transforms a world point to local coordinates.
+ * @param {Cartesian3} worldPoint - The world point to transform.
+ * @param {Cartesian3} [result] - The object to store the result in.
+ * @returns {Cartesian3} The transformed point in local coordinates.
+ */
+ transformPointFromWorld(worldPoint, result) {
+ // const worldToLocalTransform = new Matrix4();
+ // Matrix4.inverseTransformation(this.localToWorldTransform, worldToLocalTransform);
+ const worldToLocalTransform = this.worldToLocalTransform;
+ if (!defined(result)) {
+ result = new Cartesian3();
+ }
+ Matrix4.multiplyByPoint(worldToLocalTransform, worldPoint, result);
+ return result;
+ }
+
+ /**
+ * Transforms a local point from the this node's coordinate system to the destination node's coordinate system.
+ * @param {Node} destinationNode - The node that the local point should be transformed to.
+ * @param {Cartesian3} localPoint - The local point to transform.
+ * @param {Cartesian3} [result] - The object to store the result in.
+ * @returns {Cartesian3} The transformed point in the destination node's coordinate system.
+ */
+ transformPointTo(destinationNode, localPoint, result) {
+ const worldPoint = this.transformPointToWorld(localPoint);
+ const localResult = destinationNode.transformPointFromWorld(worldPoint, result);
+ return localResult;
+ }
+
+ /**
+ * Transforms a local vector to world coordinates.
+ * @param {Cartesian3} localVector - The local vector to transform.
+ * @param {Cartesian3} [result] - The object to store the result in.
+ * @returns {Cartesian3} The transformed vector in world coordinates.
+ */
+ transformVectorToWorld(localVector, result) {
+ if (!defined(result)) {
+ result = new Cartesian3();
+ }
+ Matrix4.multiplyByPointAsVector(this.localToWorldTransform, localVector, result);
+ return result;
+ }
+
+ /**
+ * Transforms a world vector to local coordinates.
+ * @param {Cartesian3} worldVector - The world vector to transform.
+ * @param {Cartesian3} [result] - The object to store the result in.
+ * @returns {Cartesian3} The transformed vector in local coordinates.
+ */
+ transformVectorFromWorld(worldVector, result) {
+ if (!defined(result)) {
+ result = new Cartesian3();
+ }
+ Matrix4.multiplyByPointAsVector(this.worldToLocalTransform, worldVector, result);
+ return result;
+ }
+
+ /**
+ * Transforms a local vector from the this node's coordinate system to the destination node's coordinate system.
+ * @param {Node} destinationNode - The node that the local vector should be transformed to.
+ * @param {Cartesian3} localVector - The local vector to transform.
+ * @param {Cartesian3} [result] - The object to store the result in.
+ * @returns {Cartesian3} The transformed vector in the destination node's coordinate system.
+ */
+ transformVectorTo(destinationNode, localVector, result) {
+ const worldVector = this.transformVectorToWorld(localVector);
+ const localResult = destinationNode.transformVectorFromWorld(worldVector, result);
+ return localResult;
+ }
+
+ /**
+ * Gets the local-to-world transformation matrix for the node.
+ * @returns {Matrix4} The local-to-world transformation matrix.
+ */
+ get localToWorldTransform() {
+ let transform = new Matrix4();
+ if (defined(this.parent)) {
+ Matrix4.multiply(this.parent.localToWorldTransform, this.transform, transform);
+ } else {
+ Matrix4.clone(this.transform, transform);
+ }
+ return transform;
+ }
+
+ /**
+ * Gets the world-to-local transformation matrix for the node.
+ * @returns {Matrix4} The world-to-local transformation matrix.
+ */
+ get worldToLocalTransform() {
+ const worldToLocalTransform = new Matrix4();
+ Matrix4.inverseTransformation(this.localToWorldTransform, worldToLocalTransform);
+ return worldToLocalTransform;
+ }
+
+ /**
+ * Returns the transform of the node.
+ * @returns {Matrix4} The identity matrix cloned.
+ */
+ get transform() {
+ return Matrix4.IDENTITY.clone()
+ }
+
+
+ /**
+ * Returns the world position of the node.
+ * @returns {Cartesian3} The world position of the node.
+ */
+ get worldPosition() {
+ let localToWorldTransform = this.localToWorldTransform
+ return Cartesian3.fromElements(localToWorldTransform[12], localToWorldTransform[13], localToWorldTransform[14]);
+ }
+
+ /**
+ * Returns zero.
+ */
+ get length() {
+ return 0;
+ }
+}
+
+export default Node;
diff --git a/src/engine/graph/TransformGroup.js b/src/engine/graph/TransformGroup.js
new file mode 100644
index 0000000..898529c
--- /dev/null
+++ b/src/engine/graph/TransformGroup.js
@@ -0,0 +1,130 @@
+import { Matrix3, Matrix4, Cartesian3 } from 'cesium';
+import Group from './Group';
+
+const _scratchMatrix3 = new Matrix3();
+
+/**
+ * A group note that contains a transform. This transform is applied to all children
+ * of this group. The effects of transformations in the scene graph are cumulative.
+ * @extends Group
+ */
+class TransformGroup extends Group {
+
+ /**
+ * Creates a new transform group.
+ */
+ constructor() {
+ super();
+ /**
+ * The transformation matrix of this group.
+ * @type {Matrix4}
+ * @private
+ */
+ this._transform = Matrix4.IDENTITY.clone();
+ }
+
+ /**
+ * Rotates this group around the X axis.
+ * @param {Number} angle - The angle to rotate, in radians.
+ */
+ rotateX(angle) {
+ Matrix3.fromRotationX(angle, _scratchMatrix3);
+ Matrix4.multiplyByMatrix3(this._transform, _scratchMatrix3, this._transform);
+ }
+
+ /**
+ * Rotates this group around the Y axis.
+ * @param {Number} angle - The angle to rotate, in radians.
+ */
+ rotateY(angle) {
+ Matrix3.fromRotationY(angle, _scratchMatrix3);
+ Matrix4.multiplyByMatrix3(this._transform, _scratchMatrix3, this._transform);
+ }
+
+ /**
+ * Rotates this group around the Z axis.
+ * @param {Number} angle - The angle to rotate, in radians.
+ */
+ rotateZ(angle) {
+ Matrix3.fromRotationZ(angle, _scratchMatrix3);
+ Matrix4.multiplyByMatrix3(this._transform, _scratchMatrix3, this._transform);
+ }
+
+ /**
+ * Translates this group.
+ * @param {Cartesian3} cartesian3 - The translation vector.
+ */
+ translate(cartesian3) {
+ Matrix4.multiplyByTranslation(this._transform, cartesian3, this._transform);
+ }
+
+ /**
+ * Sets the translation of this group.
+ * @param {Cartesian3} cartesian3 - The translation vector.
+ */
+ setTranslation(cartesian3) {
+ this._transform[12] = cartesian3.x;
+ this._transform[13] = cartesian3.y;
+ this._transform[14] = cartesian3.z;
+ }
+
+ /**
+ * Sets the rotation of this group.
+ * @param {Matrix3} matrix3 - The rotation matrix.
+ */
+ setRotation(matrix3) {
+ this._transform[0] = matrix3[0];
+ this._transform[1] = matrix3[1];
+ this._transform[2] = matrix3[2];
+ this._transform[4] = matrix3[3];
+ this._transform[5] = matrix3[4];
+ this._transform[6] = matrix3[5];
+ this._transform[8] = matrix3[6];
+ this._transform[9] = matrix3[7];
+ this._transform[10] = matrix3[8];
+ }
+
+ /**
+ * Sets the columns of this group's transformation matrix.
+ * @param {Cartesian3} x - The X column.
+ * @param {Cartesian3} y - The Y column.
+ * @param {Cartesian3} z - The Z column.
+ */
+ setColumns(x, y, z) {
+ this._transform[0] = x.x;
+ this._transform[1] = x.y;
+ this._transform[2] = x.z;
+ this._transform[4] = y.x;
+ this._transform[5] = y.y;
+ this._transform[6] = y.z;
+ this._transform[8] = z.x;
+ this._transform[9] = z.y;
+ this._transform[10] = z.z;
+ }
+
+ /**
+ * Resets this group's transformation matrix to the identity matrix.
+ */
+ reset() {
+ Matrix4.clone(Matrix4.IDENTITY, this._transform);
+ }
+
+ /**
+ * Returns this group's transformation matrix.
+ * @type {Matrix4}
+ */
+ get transform() {
+ return this._transform;
+ }
+
+ /**
+ * Sets this group's transformation matrix.
+ * @param {Matrix4} value - The new transformation matrix.
+ */
+ set transform(value) {
+ Matrix4.clone(value, this._transform);
+ }
+
+}
+
+export default TransformGroup;
diff --git a/src/engine/objects/AzElGimbal.js b/src/engine/objects/AzElGimbal.js
new file mode 100644
index 0000000..7bebfe3
--- /dev/null
+++ b/src/engine/objects/AzElGimbal.js
@@ -0,0 +1,42 @@
+import Gimbal from './Gimbal.js';
+import { southEastZenithToAzEl } from '../dynamics/gimbal.js';
+import { Math as CMath, JulianDate } from 'cesium';
+
+/**
+ * Represents an Azimuth-Elevation Gimbal object that extends the Gimbal class.
+ * @extends Gimbal
+ */
+class AzElGimbal extends Gimbal {
+ /**
+ * Creates an instance of AzElGimbal.
+ * @param {string} [name='AzElGimbal'] - The name of the AzElGimbal object.
+ */
+ constructor(name='AzElGimbal') {
+ super(name)
+ this.az = 0.0
+ this.el = 90.0
+ }
+
+ /**
+ * Updates the AzElGimbal object's position and orientation based on the current time and universe.
+ * @param {JulianDate} time - The current time.
+ * @param {Universe} universe - The universe object.
+ * @override
+ */
+ _update(time, universe) {
+ const localVector = this._trackToLocalVector(time, universe)
+ if (localVector !== null)
+ [this.az, this.el, this._range] = southEastZenithToAzEl(localVector)
+
+ // setup reference transform
+ this.reset()
+ this.rotateY(CMath.PI_OVER_TWO)
+ this.rotateZ(CMath.PI_OVER_TWO)
+
+ // move gimbals to position
+ this.rotateY(-this.az * CMath.RADIANS_PER_DEGREE)
+ this.rotateX(this.el * CMath.RADIANS_PER_DEGREE)
+ }
+}
+
+export default AzElGimbal;
diff --git a/src/engine/objects/Earth.js b/src/engine/objects/Earth.js
new file mode 100644
index 0000000..4e5169e
--- /dev/null
+++ b/src/engine/objects/Earth.js
@@ -0,0 +1,31 @@
+import { Transforms, Matrix3, defined, Matrix4, ReferenceFrame } from 'cesium';
+import SimObject from './SimObject.js';
+
+/**
+ * Represents the Earth object in the simulation.
+ * @extends SimObject
+ */
+class Earth extends SimObject {
+ constructor() {
+ super('Earth', ReferenceFrame.FIXED);
+ this._teme2ecef = new Matrix3();
+ this._ecef2teme = new Matrix3();
+ }
+
+ /**
+ * Updates the Earth object's position and orientation based on the current time and universe.
+ * @param {JulianDate} time - The current time in Julian Date format.
+ * @param {Universe} universe - The universe object containing information about the simulation.
+ * @override
+ */
+ _update(time, universe) {
+ const transform = Transforms.computeIcrfToFixedMatrix(time, this._teme2ecef);
+ if (!defined(transform)) {
+ Transforms.computeTemeToPseudoFixedMatrix(time, this._teme2ecef);
+ }
+ Matrix3.transpose(this._teme2ecef, this._ecef2teme);
+ Matrix4.fromRotation(this._ecef2teme, this.transform);
+ }
+}
+
+export default Earth;
diff --git a/src/engine/objects/EarthGroundStation.js b/src/engine/objects/EarthGroundStation.js
new file mode 100644
index 0000000..acc6659
--- /dev/null
+++ b/src/engine/objects/EarthGroundStation.js
@@ -0,0 +1,102 @@
+import { Math as CMath, Cartesian3, ReferenceFrame, defaultValue } from 'cesium'
+import SimObject from './SimObject.js'
+
+/**
+ * Represents a ground station on Earth.
+ * @extends SimObject
+ */
+class EarthGroundStation extends SimObject {
+ /**
+ * Creates a new EarthGroundStation object.
+ * @param {Number} latitude - The latitude of the ground station in degrees.
+ * @param {Number} longitude - The longitude of the ground station in degrees.
+ * @param {Number} [altitude=0.0] - The altitude of the ground station in meters.
+ * @param {String} [name='EarthGroundStation'] - The name of the ground station.
+ */
+ constructor(latitude, longitude, altitude, name='EarthGroundStation') {
+ super(name, ReferenceFrame.FIXED)
+ this._latitude = latitude
+ this._longitude = longitude
+ this._altitude = defaultValue(altitude, 0.0) // meters
+ this._period = 86400
+
+ this._initialize()
+ }
+
+ /**
+ * Initializes the ground station's position and orientation.
+ * @private
+ */
+ _initialize() {
+ this._position = Cartesian3.fromDegrees(this._longitude, this._latitude, this._altitude)
+ this._velocity = new Cartesian3()
+
+ this.reset()
+ this.rotateZ(this._longitude * CMath.RADIANS_PER_DEGREE)
+ this.rotateY(CMath.PI_OVER_TWO - this._latitude * CMath.RADIANS_PER_DEGREE)
+ this.setTranslation(this._position)
+ }
+
+ /**
+ * Gets the latitude of the ground station.
+ * @returns {Number} The latitude of the ground station in degrees.
+ */
+ get latitude() {
+ return this._latitude
+ }
+
+ /**
+ * Sets the latitude of the ground station.
+ * @param {Number} latitude - The latitude of the ground station in degrees.
+ */
+ set latitude(latitude) {
+ this._latitude = latitude
+ this._initialize()
+ }
+
+ /**
+ * Gets the longitude of the ground station.
+ * @returns {Number} The longitude of the ground station in degrees.
+ */
+ get longitude() {
+ return this._longitude
+ }
+
+ /**
+ * Sets the longitude of the ground station.
+ * @param {Number} longitude - The longitude of the ground station in degrees.
+ */
+ set longitude(longitude) {
+ this._longitude = longitude
+ this._initialize()
+ }
+
+ /**
+ * Gets the altitude of the ground station.
+ * @returns {Number} The altitude of the ground station in meters.
+ */
+ get altitude() {
+ return this._altitude
+ }
+
+ /**
+ * Sets the altitude of the ground station.
+ * @param {Number} altitude - The altitude of the ground station in meters.
+ */
+ set altitude(altitude) {
+ this._altitude = altitude
+ this._initialize()
+ }
+
+ /**
+ * Updates the ground station's state.
+ * @param {Number} time - The current simulation time in seconds.
+ * @param {Universe} universe - The universe in which the ground station exists.
+ * @override
+ */
+ _update(time, universe) {
+ // do nothing since this is a fixed object and _initialize() sets the position and orientation
+ }
+}
+
+export default EarthGroundStation
\ No newline at end of file
diff --git a/src/engine/objects/ElectroOpticalSensor.js b/src/engine/objects/ElectroOpticalSensor.js
new file mode 100644
index 0000000..7c80c33
--- /dev/null
+++ b/src/engine/objects/ElectroOpticalSensor.js
@@ -0,0 +1,41 @@
+import { JulianDate } from 'cesium'
+import SimObject from './SimObject.js'
+
+/**
+ * A class representing an electro-optical sensor.
+ * @extends SimObject
+ */
+class ElectroOpicalSensor extends SimObject {
+ /**
+ * Create an electro-optical sensor.
+ * @param {number} height - The height of the sensor in pixels.
+ * @param {number} width - The width of the sensor in pixels.
+ * @param {number} y_fov - The vertical field of view of the sensor in degrees.
+ * @param {number} x_fov - The horizontal field of view of the sensor in degrees.
+ * @param {Array} field_of_regard - An array of objects representing the field of regard of the sensor.
+ * @param {string} name - The name of the sensor.
+ */
+ constructor(height, width, y_fov, x_fov, field_of_regard=[], name='ElectroOpticalSensor') {
+ super(name)
+ this.height = height
+ this.width = width
+ this.y_fov = y_fov
+ this.x_fov = x_fov
+ this.y_ifov = this.y_fov / this.height
+ this.x_ifov = this.x_fov / this.width
+ this.field_of_regard = field_of_regard
+ }
+
+ /**
+ * Update the state of the sensor.
+ * @param {JulianDate} time - The current simulation time.
+ * @param {Universe} universe - The universe in which the sensor exists.
+ * @override
+ */
+ _update(time, universe) {
+ // TODO do nothing for now
+ }
+
+}
+
+export default ElectroOpicalSensor
diff --git a/src/engine/objects/EphemerisObject.js b/src/engine/objects/EphemerisObject.js
new file mode 100644
index 0000000..ac4bb2c
--- /dev/null
+++ b/src/engine/objects/EphemerisObject.js
@@ -0,0 +1,59 @@
+import { JulianDate, ReferenceFrame, Math as CMath} from 'cesium'
+import { lagrange_fast } from '../dynamics/lagrange'
+import SimObject from './SimObject'
+
+const K_KM = CMath.GRAVITATIONALPARAMETER / 1e9
+
+/**
+ * Represents an object with ephemeris data.
+ * @extends SimObject
+ */
+class EphemerisObject extends SimObject {
+ /**
+ * Creates an instance of EphemerisObject.
+ * @param {JulianDate[]} times - Array of JulianDate objects representing the times of the state vectors.
+ * @param {Cartesian3[]} positions - Array of Cartesian3 objects representing the positions of the state vectors.
+ * @param {Cartesian3[]} velocities - Array of Cartesian3 objects representing the velocities of the state vectors.
+ * @param {string} [name='EphemerisObject'] - Name of the object.
+ * @param {ReferenceFrame} [referenceFrame=ReferenceFrame.INERTIAL] - Reference frame of the object.
+ */
+ constructor(times, positions, velocities, name='EphemerisObject', referenceFrame=ReferenceFrame.INERTIAL) {
+ super(name, referenceFrame)
+
+ this._stateVectors = []
+ for(let i = 0; i < times.length; i++) {
+ this._stateVectors.push({
+ time: times[i],
+ position: positions[i],
+ velocity: velocities[i]
+ })
+ }
+ this._stateVectors.sort((a, b) => JulianDate.compare(a.time, b.time))
+
+ this._times = []
+ this._positions = []
+ this._epoch = new JulianDate(this._stateVectors[0].time)
+
+ for(let i = 0; i < this._stateVectors.length; i++) {
+ this._times.push(JulianDate.secondsDifference(this._stateVectors[0].time, this._stateVectors[i].time))
+ this._positions.push(this._stateVectors[i].position.x)
+ this._positions.push(this._stateVectors[i].position.y)
+ this._positions.push(this._stateVectors[i].position.z)
+ }
+
+ this._period = rv2p(K_KM, this._stateVectors[0].position, this._stateVectors[0].velocity)
+ }
+
+ /**
+ * Updates the position of the object at the given time.
+ * @param {JulianDate} time - The time to update the position to.
+ * @param {Universe} universe - The universe object containing gravitational constants and other data.
+ * @override
+ */
+ _update(time, universe) {
+ const delta = JulianDate.secondsDifference(time, this._epoch)
+ lagrange_fast(this._times, this._positions, delta, this._position)
+ }
+}
+
+export default EphemerisObject
diff --git a/src/engine/objects/Gimbal.js b/src/engine/objects/Gimbal.js
new file mode 100644
index 0000000..139c668
--- /dev/null
+++ b/src/engine/objects/Gimbal.js
@@ -0,0 +1,113 @@
+import { Cartesian3, JulianDate, defined } from "cesium";
+import SimObject from "./SimObject";
+import Universe from "../Universe";
+
+/**
+ * Represents a base gimbal object that can track another object.
+ * @extends SimObject
+ *
+ */
+class Gimbal extends SimObject {
+ /**
+ * Creates a new Gimbal object.
+ * @param {string} [name='Gimbal'] - The name of the Gimbal object.
+ */
+ constructor(name='Gimbal') {
+ super(name);
+ this._trackObject = null;
+ this._trackMode = 'fixed';
+ this._sidereal = null;
+ this._range = 0
+ }
+
+ /**
+ * Gets the range to the track object.
+ * @returns {number} - The range to the tracked object.
+ */
+ get range() {
+ if (this.trackMode === 'rate')
+ return this._range
+ else {
+ return 45000000.0
+ }
+ }
+
+ /**
+ * Gets the track mode of the Gimbal object.
+ * @returns {string} - The track mode of the Gimbal object.
+ */
+ get trackMode() {
+ return this._trackMode;
+ }
+
+ /**
+ * Sets the track mode of the Gimbal object.
+ * @param {string} value - The track mode to set.
+ */
+ set trackMode(value) {
+ this._trackMode = value;
+ }
+
+ /**
+ * Gets the object that the Gimbal object is tracking.
+ * @returns {SimObject} - The object that the Gimbal object is tracking.
+ */
+ get trackObject() {
+ return this._trackObject;
+ }
+
+ /**
+ * Sets the object that the Gimbal object is tracking.
+ * @param {SimObject} value - The object to track.
+ */
+ set trackObject(value) {
+ if(value === this || value.parent === this) {
+ console.warn('Gimbal.trackObject cannot be set to self or child.');
+ return;
+ }
+
+ this._trackObject = value;
+ }
+
+ /**
+ * Updates the Gimbal object.
+ * @param {JulianDate} time - The current time.
+ * @param {Universe} universe - The universe object.
+ * @override
+ */
+ update(time, universe) {
+ super.update(time, universe, true, true) // always force update for now since gimbal can moved when time stops
+ }
+
+ /**
+ * Gets the local vector of the Gimbal object based on tracking mode and track object.
+ * @param {JulianDate} time - The current time.
+ * @param {Universe} universe - The universe object.
+ * @returns {Cartesian3|null} - The local vector of the Gimbal object.
+ * @private
+ */
+ _trackToLocalVector(time, universe) {
+ let localVector = new Cartesian3();
+ if (defined(this._trackObject) && this._trackMode === 'rate') {
+ this._trackObject.update(time, universe);
+ localVector = this._trackObject.transformPointTo(this.parent, Cartesian3.ZERO, localVector);
+ } else if (this._trackMode === 'sidereal') {
+ console.log('sidereal not implemented');
+ } else {
+ return null; // fixed mode, do nothing
+ }
+ return localVector;
+ }
+
+ /**
+ * Override this function to update gimbal orientation.
+ * @param {JulianDate} time - The time to update the object to.
+ * @param {Universe} universe - The universe object.
+ * @abstract
+ */
+ _update(time, universe) {
+ throw new Error('Gimbal._update must be implemented in derived classes.');
+ }
+}
+
+export default Gimbal;
\ No newline at end of file
diff --git a/src/engine/objects/LagrangeInterpolatedObject.js b/src/engine/objects/LagrangeInterpolatedObject.js
new file mode 100644
index 0000000..a39273e
--- /dev/null
+++ b/src/engine/objects/LagrangeInterpolatedObject.js
@@ -0,0 +1,57 @@
+import { JulianDate, defaultValue} from 'cesium'
+import { lagrange } from '../dynamics/lagrange'
+import SimObject from './SimObject'
+import Universe from '../Universe'
+
+/**
+ * A class representing a Lagrange interpolated object.
+ * @extends SimObject
+ */
+class LagrangeInterpolatedObject extends SimObject {
+ /**
+ * Create a LagrangeInterpolatedObject.
+ * @param {Object} object - The object to interpolate.
+ */
+ constructor(object) {
+ super(object.name, object.referenceFrame)
+ this._object = object
+ this._times = []
+ this._positions = []
+ this._interval = defaultValue(this.period / 60.0, 100)
+ this._epoch = new JulianDate()
+ }
+
+ /**
+ * The period of the object.
+ * @type {Number}
+ */
+ get period() {
+ return this._object.period
+ }
+
+ /**
+ * The eccentricity of the object.
+ * @type {Number}
+ */
+ get eccentricity() {
+ return this._object.eccentricity
+ }
+
+ /**
+ * Update the object's position.
+ * @param {JulianDate} time - The time to update the position for.
+ * @param {Universe} universe - The universe object.
+ * @override
+ */
+ _update(time, universe) {
+ const that = this
+ const f = function(t) {
+ that._object.update(t, universe)
+ return that._object.position
+ }
+
+ lagrange(this._times, this._positions, this._epoch, time, f, this._position, this._interval)
+ }
+}
+
+export default LagrangeInterpolatedObject
diff --git a/src/engine/objects/SGP4Satellite.js b/src/engine/objects/SGP4Satellite.js
new file mode 100644
index 0000000..f9cd0fb
--- /dev/null
+++ b/src/engine/objects/SGP4Satellite.js
@@ -0,0 +1,47 @@
+import { sgp4, twoline2satrec } from 'satellite.js'
+import { Cartesian3, JulianDate, defined, ReferenceFrame, Math as CMath} from 'cesium'
+import SimObject from './SimObject.js'
+
+/**
+ * Represents a satellite object that uses the SGP4 model for propagation.
+ * @extends SimObject
+ */
+class SGP4Satellite extends SimObject {
+ /**
+ * Creates a new SGP4Satellite object.
+ * @param {string} tle1 - The first line of the TLE (Two-Line Element) set.
+ * @param {string} tle2 - The second line of the TLE (Two-Line Element) set.
+ * @param {string} orientation - The orientation of the satellite.
+ * @param {string} name - The name of the satellite.
+ */
+ constructor(tle1, tle2, orientation, name='SGP4Satellite') {
+ super(name, ReferenceFrame.INERTIAL)
+ this._satrec = twoline2satrec(tle1, tle2)
+ this._epoch = new JulianDate(this._satrec.jdsatepoch)
+ this._period = CMath.TWO_PI / this._satrec.no * 60
+ this._eccentricity = this._satrec.ecco
+ this.orientation = orientation //TODO
+ }
+
+ /**
+ * Updates the position and velocity of the satellite based on the current time and universe.
+ * @param {JulianDate} time - The current time.
+ * @param {Universe} universe - The universe object.
+ * @override
+ */
+ _update(time, universe) {
+ const deltaMin = JulianDate.secondsDifference(time, this._epoch) / 60.0
+ const positionAndVelocity = sgp4(this._satrec, deltaMin)
+
+ // check for bad sgp4 propagations
+ if(!defined(positionAndVelocity.position) || positionAndVelocity.position === false) {
+ positionAndVelocity.position = new Cartesian3()
+ positionAndVelocity.velocity = new Cartesian3()
+ } else {
+ Cartesian3.multiplyByScalar(positionAndVelocity.position, 1000.0, this._position)
+ Cartesian3.multiplyByScalar(positionAndVelocity.velocity, 1000.0, this._velocity)
+ }
+ }
+}
+
+export default SGP4Satellite
diff --git a/src/engine/objects/SimObject.js b/src/engine/objects/SimObject.js
new file mode 100644
index 0000000..7573d2a
--- /dev/null
+++ b/src/engine/objects/SimObject.js
@@ -0,0 +1,211 @@
+import { ReferenceFrame, Cartesian3, JulianDate, Matrix4, defined, defaultValue, Entity } from "cesium";
+import TransformGroup from "../graph/TransformGroup";
+import Universe from "../Universe";
+
+/**
+ * A base class for all simulation objects.
+ * @extends TransformGroup
+ */
+class SimObject extends TransformGroup {
+ /**
+ * Creates a new SimObject.
+ * @param {string} [name='undefined'] - The name of the object.
+ * @param {ReferenceFrame} [referenceFrame=undefined] - The reference frame of the object.
+ */
+ constructor(name='undefined', referenceFrame=undefined) {
+ super();
+ this._name = name;
+ this._referenceFrame = referenceFrame;
+
+ this._position = new Cartesian3();
+ this._velocity = new Cartesian3();
+
+ this._localToWorldTransform = new Matrix4();
+ this._worldToLocalTransform = new Matrix4();
+
+ this._lastUpdate = new JulianDate();
+ this._lastUniverse = undefined;
+ this._transformDirty = true;
+
+ this._visualizer = {};
+ this._updateListeners = [];
+ }
+
+ /**
+ * The eccentricity of the object's orbit.
+ * @type {number}
+ * @readonly
+ */
+ get eccentricity() {
+ return this._eccentricity;
+ }
+
+ /**
+ * The period of the object's orbit.
+ * @type {number}
+ * @readonly
+ */
+ get period() {
+ return this._period;
+ }
+
+ /**
+ * Sets the visualizer for the object.
+ * @type {Entity}
+ */
+ set visualizer(visualizer) {
+ this._visualizer = visualizer;
+ }
+
+ /**
+ * Gets the visualizer for the object.
+ * @type {Entity}
+ * @readonly
+ */
+ get visualizer() {
+ return this._visualizer;
+ }
+
+ /**
+ * Gets the update listeners for the object.
+ * @type {Array}
+ * @readonly
+ */
+ get updateListeners() {
+ return this._updateListeners;
+ }
+
+ /**
+ * Gets the reference frame of the object.
+ * @type {ReferenceFrame}
+ * @readonly
+ */
+ get referenceFrame() {
+ return defaultValue(this._referenceFrame, defined(this.parent) ? this.parent.referenceFrame : undefined);
+ }
+
+ /**
+ * Gets the position of the object in the Cesium world fixed reference frame.
+ * @type {Cartesian3}
+ * @readonly
+ */
+ get position() {
+ return defined(this._referenceFrame) ? this._position : defined(this.parent) ? this.parent.position : undefined;
+ }
+
+ /**
+ * Gets the velocity of the object in the Cesium world fixed reference frame.
+ * @type {Cartesian3}
+ * @readonly
+ */
+ get velocity() {
+ return defined(this._referenceFrame) ? this._velocity : defined(this._velocity) ? this.parent._velocity : undefined;
+ }
+
+ /**
+ * Gets the time of the last update for the object.
+ * @type {JulianDate}
+ * @readonly
+ */
+ get time() {
+ return this._lastUpdate;
+ }
+
+ /**
+ * Gets the name of the object.
+ * @type {string}
+ * @readonly
+ */
+ get name() {
+ return this._name;
+ }
+
+ /**
+ * Gets the world to local transform matrix for the object.
+ * @type {Matrix4}
+ * @readonly
+ */
+ get worldToLocalTransform() {
+ this._updateTransformsIfDirty()
+ return this._worldToLocalTransform;
+ }
+
+ /**
+ * Gets the local to world transform matrix for the object.
+ * @type {Matrix4}
+ * @readonly
+ */
+ get localToWorldTransform() {
+ this._updateTransformsIfDirty()
+ return this._localToWorldTransform;
+ }
+
+ /**
+ * Gets the world position (ECI) of the object.
+ * @type {Cartesian3}
+ * @readonly
+ */
+ get worldPosition() {
+ if(this._referenceFrame === ReferenceFrame.INERTIAL)
+ return this._position;
+ else
+ return super.worldPosition
+ }
+
+ /**
+ * Updates the object's position, velocity, and orientation.
+ * @param {JulianDate} time - The time to update the object to.
+ * @param {Universe} universe - The universe object.
+ * @param {boolean} [forceUpdate=false] - Whether to force an update.
+ * @param {boolean} [updateParent=true] - Whether to update the parent object.
+ */
+ update(time, universe, forceUpdate = false, updateParent = true) {
+ if (!forceUpdate && JulianDate.equals(time, this._lastUpdate))
+ return;
+
+ if(updateParent && defined(this.parent))
+ this.parent.update(time, universe, forceUpdate, updateParent);
+
+ // override this function to update the position, velocity, and orientation
+ this._update(time, universe);
+
+ // update position (for cesium)
+ this.setTranslation(this._position)
+
+ // mark transforms dirty
+ this._transformDirty = true;
+ JulianDate.clone(time, this._lastUpdate);
+ this._lastUniverse = universe;
+
+ // update any listeners
+ this._updateListeners.forEach(ul => ul.update(time, universe));
+ }
+
+ /**
+ * Updates the object's world to local and local to world transform matrices if they are dirty.
+ * @private
+ */
+ _updateTransformsIfDirty() {
+ if(this._transformDirty) {
+ if(defined(this.parent) && !this.parent._lastUpdate.equals(this._lastUpdate)) {
+ this.parent.update(this._lastUpdate, this._lastUniverse, true, false);
+ }
+ this._localToWorldTransform = super.localToWorldTransform;
+ Matrix4.inverseTransformation(this._localToWorldTransform, this._worldToLocalTransform);
+ this._transformDirty = false;
+ }
+ }
+
+ /**
+ * Override this function to update the position, velocity, and orientation of the object.
+ * @param {JulianDate} time - The time to update the object to.
+ * @param {Universe} universe - The universe object.
+ * @abstract
+ */
+ _update(time, universe) {
+ throw new Error('SimObject._update must be implemented in derived classes.');
+ }
+
+}
+
+export default SimObject;
diff --git a/src/engine/objects/TwoBodySatellite.js b/src/engine/objects/TwoBodySatellite.js
new file mode 100644
index 0000000..4f58d0c
--- /dev/null
+++ b/src/engine/objects/TwoBodySatellite.js
@@ -0,0 +1,46 @@
+import { vallado, rv2p, rv2ecc } from '../dynamics/twobody.js'
+import { Cartesian3, JulianDate, ReferenceFrame } from 'cesium'
+import { Math as CMath } from 'cesium'
+import SimObject from './SimObject.js'
+
+const K_KM = CMath.GRAVITATIONALPARAMETER / 1e9
+
+/**
+ * Represents a two-body satellite object.
+ * @extends SimObject
+ */
+class TwoBodySatellite extends SimObject {
+ /**
+ * Creates a new TwoBodySatellite object.
+ * @param {Cartesian3} position - The initial position of the satellite in meters.
+ * @param {Cartesian3} velocity - The initial velocity of the satellite in meters per second.
+ * @param {JulianDate} time - The initial time of the satellite.
+ * @param {string} orientation - The orientation of the satellite.
+ * @param {string} name - The name of the satellite.
+ */
+ constructor(position, velocity, time, orientation, name='TwoBodySatellite') {
+ super(name, ReferenceFrame.INERTIAL)
+ const positionKm = Cartesian3.multiplyByScalar(position, 1e-3, new Cartesian3())
+ const velocityKm = Cartesian3.multiplyByScalar(velocity, 1e-3, new Cartesian3())
+ this._epoch = { positionKm, velocityKm, time }
+ this._period = rv2p(K_KM, positionKm, velocityKm)
+ this._eccentricity = rv2ecc(K_KM, positionKm, velocityKm)
+ this.orientation = orientation //TODO
+ }
+
+ /**
+ * Updates the position and velocity of the satellite at the given time.
+ * @param {JulianDate} time - The time to update the satellite to.
+ * @param {Universe} universe - The universe object containing gravitational constants and other parameters.
+ * @override
+ */
+ _update(time, universe) {
+ let deltaSec = JulianDate.secondsDifference(time, this._epoch.time)
+ let positionAndVelocity = vallado(K_KM, this._epoch.positionKm, this._epoch.velocityKm, deltaSec, 350)
+
+ Cartesian3.multiplyByScalar(positionAndVelocity.position, 1000.0, this._position)
+ Cartesian3.multiplyByScalar(positionAndVelocity.velocity, 1000.0, this._velocity)
+ }
+}
+
+export default TwoBodySatellite
diff --git a/src/index.js b/src/index.js
new file mode 100755
index 0000000..8f28979
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,32 @@
+globalThis.SATSIM_VERSION = "0.2.0-alpha"
+
+export { default as Universe } from './engine/Universe.js'
+
+export { default as Node } from './engine/graph/Node.js'
+export { default as Group } from './engine/graph/Group.js'
+export { default as TransformGroup } from './engine/graph/TransformGroup.js'
+
+export { default as AzElGimbal } from './engine/objects/AzElGimbal.js'
+export { default as Earth } from './engine/objects/Earth.js'
+export { default as EarthGroundStation } from './engine/objects/EarthGroundStation.js'
+export { default as ElectroOpicalSensor } from './engine/objects/ElectroOpticalSensor.js'
+export { default as EphemerisObject } from './engine/objects/EphemerisObject.js'
+export { default as Gimbal } from './engine/objects/Gimbal.js'
+export { default as LagrangeInterpolatedObject } from './engine/objects/LagrangeInterpolatedObject.js'
+export { default as SGP4Satellite } from './engine/objects/SGP4Satellite.js'
+export { default as SimObject } from './engine/objects/SimObject.js'
+export { default as TwoBodySatellite } from './engine/objects/TwoBodySatellite.js'
+
+export { default as CallbackPositionProperty } from './engine/cesium/CallbackPositionProperty.js'
+export { default as CompoundElementVisualizer } from './engine/cesium/CompoundElementVisualizer.js'
+export { default as CoverageGridVisualizer } from './engine/cesium/CoverageGridVisualizer.js'
+export { default as GeoBeltVisualizer } from './engine/cesium/GeoBeltVisualizer.js'
+export { default as SensorFieldOfRegardVisualizer } from './engine/cesium/SensorFieldOfRegardVisualizer.js'
+export { default as SensorFieldOfVIewVisualizer } from './engine/cesium/SensorFieldOfVIewVisualizer.js'
+
+export { default as InfoBox } from './widgets/InfoBox.js'
+export { default as Toolbar } from './widgets/Toolbar.js'
+export { createViewer, mixinViewer } from './widgets/Viewer.js'
+
+export { fetchTle } from './io/tle.js'
+export { southEastZenithToAzEl, spaceBasedToAzEl } from './engine/dynamics/gimbal.js'
\ No newline at end of file
diff --git a/src/io/tle.js b/src/io/tle.js
new file mode 100644
index 0000000..6b94f31
--- /dev/null
+++ b/src/io/tle.js
@@ -0,0 +1,26 @@
+async function fetchTle(url, linesPerSatellite, callback) {
+ const response = await fetch(url);
+ const text = await response.text();
+
+ const lines = text.split('\n');
+ const count = lines.length - 1;
+
+ for(let i = 0; i < count; i+=linesPerSatellite) {
+ let line1, line2, line3 = '';
+ if(linesPerSatellite === 2) {
+ line1 = lines[i].slice(2, 8);
+ line2 = lines[i];
+ line3 = lines[i+1];
+ } else {
+ line1 = lines[i].trim();
+ line2 = lines[i+1];
+ line3 = lines[i+2];
+ }
+
+ callback(line1, line2, line3)
+ }
+}
+
+export {
+ fetchTle
+}
\ No newline at end of file
diff --git a/src/widgets/InfoBox.js b/src/widgets/InfoBox.js
new file mode 100644
index 0000000..608e2f1
--- /dev/null
+++ b/src/widgets/InfoBox.js
@@ -0,0 +1,198 @@
+import {
+ Check,
+ Color,
+ defined,
+ destroyObject,
+ getElement,
+ knockout,
+ subscribeAndEvaluate
+} from "cesium";
+import InfoBoxViewModel from "./InfoBoxViewModel.js";
+import "cesium/Build/Cesium/Widgets/InfoBox/InfoBoxDescription.css"
+
+/**
+ * A widget for displaying information or a description.
+ *
+ * @alias InfoBox
+ * @constructor
+ *
+ * @param {Element|string} container The DOM element or ID that will contain the widget.
+ *
+ * @exception {DeveloperError} Element with id "container" does not exist in the document.
+ */
+function InfoBox(container) {
+ //>>includeStart('debug', pragmas.debug);
+ Check.defined("container", container);
+ //>>includeEnd('debug')
+
+ container = getElement(container);
+
+ const infoElement = document.createElement("div");
+ infoElement.className = "cesium-infoBox";
+ infoElement.setAttribute(
+ "data-bind",
+ '\
+css: { "cesium-infoBox-visible" : showInfo, "cesium-infoBox-bodyless" : _bodyless }'
+ );
+ container.appendChild(infoElement);
+
+ const titleElement = document.createElement("div");
+ titleElement.className = "cesium-infoBox-title";
+ titleElement.setAttribute("data-bind", "text: titleText");
+ infoElement.appendChild(titleElement);
+
+ const cameraElement = document.createElement("button");
+ cameraElement.type = "button";
+ cameraElement.className = "cesium-button cesium-infoBox-camera";
+ cameraElement.setAttribute(
+ "data-bind",
+ '\
+attr: { title: "Focus camera on object" },\
+click: function () { cameraClicked.raiseEvent(this); },\
+enable: enableCamera,\
+cesiumSvgPath: { path: cameraIconPath, width: 32, height: 32 }'
+ );
+ infoElement.appendChild(cameraElement);
+
+ const closeElement = document.createElement("button");
+ closeElement.type = "button";
+ closeElement.className = "cesium-infoBox-close";
+ closeElement.setAttribute(
+ "data-bind",
+ "\
+click: function () { closeClicked.raiseEvent(this); }"
+ );
+ closeElement.innerHTML = "×";
+ infoElement.appendChild(closeElement);
+
+ const frame = document.createElement("div");
+ frame.className = "cesium-infoBox-iframe";
+ frame.setAttribute("sandbox", "allow-same-origin allow-popups allow-forms"); //allow-pointer-lock allow-scripts allow-top-navigation
+ frame.setAttribute(
+ "data-bind",
+ "style : { maxHeight : maxHeightOffset(40) }"
+ );
+ frame.setAttribute("allowfullscreen", true);
+ infoElement.appendChild(frame);
+
+ const viewModel = new InfoBoxViewModel();
+ knockout.applyBindings(viewModel, infoElement);
+
+ this._container = container;
+ this._element = infoElement;
+ this._frame = frame;
+ this._viewModel = viewModel;
+ this._descriptionSubscription = undefined;
+
+ const that = this;
+ //We can't actually add anything into the frame until the load event is fired
+
+ const frameDocument = document;
+
+ //div to use for description content.
+ const frameContent = frameDocument.createElement("div");
+ frame.appendChild(frameContent);
+ frameContent.className = "cesium-infoBox-description";
+
+ //We manually subscribe to the description event rather than through a binding for two reasons.
+ //1. It's an easy way to ensure order of operation so that we can adjust the height.
+ //2. Knockout does not bind to elements inside of an iFrame, so we would have to apply a second binding
+ // model anyway.
+ that._descriptionSubscription = subscribeAndEvaluate(
+ viewModel,
+ "description",
+ function (value) {
+ // Set the frame to small height, force vertical scroll bar to appear, and text to wrap accordingly.
+ frame.style.height = "5px";
+ frameContent.innerHTML = value;
+
+ //If the snippet is a single element, then use its background
+ //color for the body of the InfoBox. This makes the padding match
+ //the content and produces much nicer results.
+ let background = null;
+ const firstElementChild = frameContent.firstElementChild;
+ if (
+ firstElementChild !== null &&
+ frameContent.childNodes.length === 1
+ ) {
+ const style = window.getComputedStyle(firstElementChild);
+ if (style !== null) {
+ const backgroundColor = style["background-color"];
+ const color = Color.fromCssColorString(backgroundColor);
+ if (defined(color) && color.alpha !== 0) {
+ background = style["background-color"];
+ }
+ }
+ }
+ infoElement.style["background-color"] = background;
+
+ // Measure and set the new custom height, based on text wrapped above.
+ const height = frameContent.getBoundingClientRect().height;
+ frame.style.height = `${height}px`;
+ }
+ );
+
+}
+
+Object.defineProperties(InfoBox.prototype, {
+ /**
+ * Gets the parent container.
+ * @memberof InfoBox.prototype
+ *
+ * @type {Element}
+ */
+ container: {
+ get: function () {
+ return this._container;
+ },
+ },
+
+ /**
+ * Gets the view model.
+ * @memberof InfoBox.prototype
+ *
+ * @type {InfoBoxViewModel}
+ */
+ viewModel: {
+ get: function () {
+ return this._viewModel;
+ },
+ },
+
+ /**
+ * Gets the iframe used to display the description.
+ * @memberof InfoBox.prototype
+ *
+ * @type {HTMLIFrameElement}
+ */
+ frame: {
+ get: function () {
+ return this._frame;
+ },
+ },
+});
+
+/**
+ * @returns {boolean} true if the object has been destroyed, false otherwise.
+ */
+InfoBox.prototype.isDestroyed = function () {
+ return false;
+};
+
+/**
+ * Destroys the widget. Should be called if permanently
+ * removing the widget from layout.
+ */
+InfoBox.prototype.destroy = function () {
+ const container = this._container;
+ knockout.cleanNode(this._element);
+ container.removeChild(this._element);
+
+ if (defined(this._descriptionSubscription)) {
+ this._descriptionSubscription.dispose();
+ }
+
+ return destroyObject(this);
+};
+
+export default InfoBox;
\ No newline at end of file
diff --git a/src/widgets/InfoBoxViewModel.js b/src/widgets/InfoBoxViewModel.js
new file mode 100644
index 0000000..d84c70a
--- /dev/null
+++ b/src/widgets/InfoBoxViewModel.js
@@ -0,0 +1,116 @@
+import { defined, Event, knockout } from "cesium";
+
+const cameraEnabledPath =
+ "M 13.84375 7.03125 C 11.412798 7.03125 9.46875 8.975298 9.46875 11.40625 L 9.46875 11.59375 L 2.53125 7.21875 L 2.53125 24.0625 L 9.46875 19.6875 C 9.4853444 22.104033 11.423165 24.0625 13.84375 24.0625 L 25.875 24.0625 C 28.305952 24.0625 30.28125 22.087202 30.28125 19.65625 L 30.28125 11.40625 C 30.28125 8.975298 28.305952 7.03125 25.875 7.03125 L 13.84375 7.03125 z";
+const cameraDisabledPath =
+ "M 27.34375 1.65625 L 5.28125 27.9375 L 8.09375 30.3125 L 30.15625 4.03125 L 27.34375 1.65625 z M 13.84375 7.03125 C 11.412798 7.03125 9.46875 8.975298 9.46875 11.40625 L 9.46875 11.59375 L 2.53125 7.21875 L 2.53125 24.0625 L 9.46875 19.6875 C 9.4724893 20.232036 9.5676108 20.7379 9.75 21.21875 L 21.65625 7.03125 L 13.84375 7.03125 z M 28.21875 7.71875 L 14.53125 24.0625 L 25.875 24.0625 C 28.305952 24.0625 30.28125 22.087202 30.28125 19.65625 L 30.28125 11.40625 C 30.28125 9.8371439 29.456025 8.4902779 28.21875 7.71875 z";
+
+/**
+ * The view model for {@link InfoBox}.
+ * @alias InfoBoxViewModel
+ * @constructor
+ */
+function InfoBoxViewModel() {
+ this._cameraClicked = new Event();
+ this._closeClicked = new Event();
+
+ /**
+ * Gets or sets the maximum height of the info box in pixels. This property is observable.
+ * @type {number}
+ */
+ this.maxHeight = 500;
+
+ /**
+ * Gets or sets whether the camera tracking icon is enabled.
+ * @type {boolean}
+ */
+ this.enableCamera = false;
+
+ /**
+ * Gets or sets the status of current camera tracking of the selected object.
+ * @type {boolean}
+ */
+ this.isCameraTracking = false;
+
+ /**
+ * Gets or sets the visibility of the info box.
+ * @type {boolean}
+ */
+ this.showInfo = false;
+
+ /**
+ * Gets or sets the title text in the info box.
+ * @type {string}
+ */
+ this.titleText = "";
+
+ /**
+ * Gets or sets the description HTML for the info box.
+ * @type {string}
+ */
+ this.description = "";
+
+ knockout.track(this, [
+ "showInfo",
+ "titleText",
+ "description",
+ "maxHeight",
+ "enableCamera",
+ "isCameraTracking",
+ ]);
+
+ this._loadingIndicatorHtml =
+ '