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.} + * @private + */ + this._objects = {} + /** + * The gimbals in the universe. + * @type {Array.} + * @private + */ + this._gimbals = [] + /** + * The sensors in the universe. + * @type {Array.} + * @private + */ + this._sensors = [] + /** + * The trackable objects in the universe. + * @type {Array.} + * @private + */ + this._trackables = [] + /** + * The non-trackable objects in the universe. + * @type {Array.} + * @private + */ + this._nontrackables = [] + /** + * The observatories in the universe. + * @type {Array.<{site: SimObject, gimbal: AzElGimbal, sensor: ElectroOpicalSensor}>} + * @private + */ + this._observatories = [] + } + + /** + * Checks if an object with the given name exists in the universe. + * @param {string} name - The name of the object to check. + * @returns {boolean} - True if an object with the given name exists in the universe, false otherwise. + */ + hasObject(name) { + return name in this._objects; + } + + /** + * Gets the object with the given name from the universe. + * @param {string} name - The name of the object to get. + * @returns {SimObject} - The object with the given name. + */ + getObject(name) { + return this._objects[name]; + } + + /** + * Adds an object to the universe. + * @param {SimObject} object - The object to add. + * @param {boolean} [trackable=true] - Whether the object is trackable or not. + * @returns {SimObject} - The added object. + */ + addObject(object, trackable=true) { + if (object.name in this._objects) { + console.warn(`Object with name ${object.name} already exists in universe`); + } + this._objects[object.name] = object; + if (trackable) { + this._trackables.push(object); + } else { + this._nontrackables.push(object); + } + return object; + } + + /** + * Adds a ground station to the universe. + * @param {string} name - The name of the ground station. + * @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 - The altitude of the ground station in meters. + * @param {boolean} [trackable=false] - Whether the ground station is trackable or not. + * @returns {EarthGroundStation} - The added ground station. + */ + addGroundSite(name, latitude, longitude, altitude, trackable=false) { + const site = new EarthGroundStation(latitude, longitude, altitude, name) + site.attach(this.earth) + this.addObject(site, trackable) + return site + } + + /** + * Adds an SGP4 satellite to the universe. + * @param {string} name - The name of the satellite. + * @param {string} line1 - The first line of the TLE for the satellite. + * @param {string} line2 - The second line of the TLE for the satellite. + * @param {string} orientation - The orientation of the satellite. + * @param {boolean} [lagrangeInterpolated=false] - Whether the satellite is lagrange interpolated or not. + * @param {boolean} [trackable=true] - Whether the satellite is trackable or not. + * @returns {SGP4Satellite|LagrangeInterpolatedObject} - The added satellite. + */ + addSGP4Satellite(name, line1, line2, orientation, lagrangeInterpolated=false, trackable=true) { + let satellite = new SGP4Satellite(line1, line2, orientation, name); + if (lagrangeInterpolated) + satellite = this.addObject(new LagrangeInterpolatedObject(satellite), trackable); + else + satellite = this.addObject(satellite, trackable); + return satellite; + } + + /** + * Adds a two-body satellite to the universe. + * @param {string} name - The name of the satellite. + * @param {Vector3} r0 - The initial position vector of the satellite in meters. + * @param {Vector3} v0 - The initial velocity vector of the satellite in meters per second. + * @param {JulianDate} t0 - The initial time of the satellite. + * @param {string} orientation - The orientation of the satellite. + * @param {boolean} [lagrangeInterpolated=false] - Whether the satellite is lagrange interpolated or not. + * @param {boolean} [trackable=true] - Whether the satellite is trackable or not. + * @returns {TwoBodySatellite|LagrangeInterpolatedObject} - The added satellite. + */ + addTwoBodySatellite(name, r0, v0, t0, orientation, lagrangeInterpolated=false, trackable=true) { + let satellite = new TwoBodySatellite(r0, v0, t0, orientation, name); + if (lagrangeInterpolated) + satellite = this.addObject(new LagrangeInterpolatedObject(satellite), trackable); + else + satellite = this.addObject(satellite, trackable); + return satellite; + } + + /** + * Adds a ground electro-optical observatory to the universe. + * @param {string} name - The name of the observatory. + * @param {number} latitude - The latitude of the observatory in degrees. + * @param {number} longitude - The longitude of the observatory in degrees. + * @param {number} altitude - The altitude of the observatory in meters. + * @param {string} gimbalType - The type of gimbal used by the observatory. + * @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 {number} field_of_regard - The field of regard of the sensor. + * @returns {{site: EarthGroundStation, gimbal: AzElGimbal, sensor: ElectroOpicalSensor}} - The added observatory. + */ + addGroundElectroOpticalObservatory(name, latitude, longitude, altitude, gimbalType, height, width, y_fov, x_fov, field_of_regard) { + const site = new EarthGroundStation(latitude, longitude, altitude, name) + site.attach(this.earth) + + const gimbal = new AzElGimbal(name + ' Gimbal') + gimbal.attach(site) + + const sensor = new ElectroOpicalSensor(height, width, y_fov, x_fov, field_of_regard, name + ' Sensor') + sensor.attach(gimbal) + + this._objects[name] = site + this._gimbals.push(gimbal) + this._sensors.push(sensor) + + const observatory = { site, gimbal, sensor } + this._observatories.push(observatory) + + return observatory + } + + /** + * Gets the Earth object in the universe. + * @type {Earth} + */ + get earth() { + return this._earth; + } + + /** + * Gets the gimbals in the universe. + * @type {Array.} + */ + get gimbals() { + return this._gimbals; + } + + /** + * Gets the sensors in the universe. + * @type {Array.} + */ + get sensors() { + return this._sensors; + } + + /** + * Gets the objects in the universe. + * @type {Object.} + */ + get objects() { + return this._objects + } + + /** + * Gets the trackable objects in the universe. + * @type {Array.} + */ + get trackables() { + return this._trackables + } + + /** + * Updates the universe to the given time. + * @param {JulianDate} time - The time to update the universe to. + */ + update(time) { + // TODO replace this with graph traversal + this._earth.update(time, this) + this._nontrackables.forEach((o) => { + o.update(time, this) + }) + this._trackables.forEach((o) => { + o.update(time, this) + }) + this._observatories.forEach((o) => { + o.site.update(time, this) + o.gimbal.update(time, this) + o.sensor.update(time, this) + }) + } +} + +export default Universe; diff --git a/src/engine/cesium/CallbackPositionProperty.js b/src/engine/cesium/CallbackPositionProperty.js new file mode 100644 index 0000000..753b731 --- /dev/null +++ b/src/engine/cesium/CallbackPositionProperty.js @@ -0,0 +1,155 @@ +import { defined, DeveloperError, Event, PositionProperty, ReferenceFrame, defaultValue } from "cesium"; + +/** + * A {@link Property} whose value is lazily evaluated by a callback function. + * + * @alias CallbackPositionProperty + * @constructor + * + * @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. + */ +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 = + '
'; + + /** + * Gets the SVG path of the camera icon, which can change to be "crossed out" or not. + * @type {string} + */ + this.cameraIconPath = undefined; + knockout.defineProperty(this, "cameraIconPath", { + get: function () { + return !this.enableCamera || this.isCameraTracking + ? cameraDisabledPath + : cameraEnabledPath; + }, + }); + + knockout.defineProperty(this, "_bodyless", { + get: function () { + return !defined(this.description) || this.description.length === 0; + }, + }); +} + +/** + * Gets the maximum height of sections within the info box, minus an offset, in CSS-ready form. + * @param {number} offset The offset in pixels. + * @returns {string} + */ +InfoBoxViewModel.prototype.maxHeightOffset = function (offset) { + return `${this.maxHeight - offset}px`; +}; + +Object.defineProperties(InfoBoxViewModel.prototype, { + /** + * Gets an {@link Event} that is fired when the user clicks the camera icon. + * @memberof InfoBoxViewModel.prototype + * @type {Event} + */ + cameraClicked: { + get: function () { + return this._cameraClicked; + }, + }, + /** + * Gets an {@link Event} that is fired when the user closes the info box. + * @memberof InfoBoxViewModel.prototype + * @type {Event} + */ + closeClicked: { + get: function () { + return this._closeClicked; + }, + }, +}); +export default InfoBoxViewModel; \ No newline at end of file diff --git a/src/widgets/Toolbar.js b/src/widgets/Toolbar.js new file mode 100644 index 0000000..06dab13 --- /dev/null +++ b/src/widgets/Toolbar.js @@ -0,0 +1,122 @@ +import { defined, getElement } from "cesium" + +/** + * A toolbar widget for adding buttons, menus, and separators. + * + * @constructor + * @param {Element|String} container The DOM element or ID that will contain the toolbar. + */ +function Toolbar(container) { + this._container = getElement(container) +} + +/** + * Adds a separator to the toolbar. + */ +Toolbar.prototype.addSeparator = function () { + const separator = document.createElement("br"); + this._container.appendChild(separator); +} + +/** + * Adds a toggle button to the toolbar. + * + * @param {String} text The text label for the button. + * @param {Boolean} checked Whether the button is initially checked. + * @param {Function} onchange The function to call when the button is toggled. + * @returns {HTMLInputElement} The input element for the toggle button. + */ +Toolbar.prototype.addToggleButton = function (text, checked, onchange) { + const input = document.createElement("input"); + input.checked = checked; + input.type = "checkbox"; + input.style.pointerEvents = "none"; + const label = document.createElement("label"); + label.appendChild(input); + label.appendChild(document.createTextNode(text)); + label.style.pointerEvents = "none"; + const button = document.createElement("button"); + button.type = "button"; + button.className = "cesium-button"; + button.appendChild(label); + button.onclick = function () { + input.checked = !input.checked; + onchange(input.checked); + }; + this._container.appendChild(button); + + input.enable = function(value) { + input.disabled = !value; + button.disabled = !value; + } + + return input; +} + +/** + * Adds a button to the toolbar. + * + * @param {String} text The text label for the button. + * @param {Function} onclick The function to call when the button is clicked. + * @returns {HTMLButtonElement} The button element. + */ +Toolbar.prototype.addToolbarButton = function (text, onclick) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "cesium-button"; + button.onclick = function () { + onclick(); + }; + button.textContent = text; + this._container.appendChild(button); + + button.enable = function(value) { + button.disabled = !value; + } + + return button; +} + +/** + * Adds a menu to the toolbar. + * + * @param {Object[]} options The menu options. + * @param {String} options[].text The text label for the menu option. + * @param {String} options[].value The value for the menu option. + * @param {Object} [menu] The existing menu element to add options to. + * @returns {HTMLSelectElement} The menu element. + */ +Toolbar.prototype.addToolbarMenu = function (options, menu) { + if (!defined(menu)) { + menu = document.createElement("select"); + menu.className = "cesium-button"; + menu.userOptions = []; + menu.enable = function(value) { + menu.disabled = !value; + } + menu.onchange = function () { + const item = menu.userOptions[menu.selectedIndex]; + if (item && typeof item.onselect === "function") { + item.onselect(); + } + }; + this._container.appendChild(menu); + } + menu.userOptions.push(...options) + + for (let i = 0, len = options.length; i < len; ++i) { + const option = document.createElement("option"); + option.textContent = options[i].text; + option.value = options[i].value; + menu.appendChild(option); + } + + return menu; +} + +/** + * Resets the toolbar. + */ +Toolbar.prototype.reset = function () { } + +export default Toolbar; \ No newline at end of file diff --git a/src/widgets/Viewer.js b/src/widgets/Viewer.js new file mode 100644 index 0000000..4b7de8f --- /dev/null +++ b/src/widgets/Viewer.js @@ -0,0 +1,755 @@ +import { JulianDate, PointPrimitiveCollection, BillboardCollection, Viewer, ImageryLayer, UrlTemplateImageryProvider, IonImageryProvider, defined, Math as CMath, Cartesian2, Cartesian3, Matrix4, Color, SceneMode, ReferenceFrame, defaultValue, PostProcessStage, EntityView, Entity, Clock } from "cesium" +import InfoBox from "./InfoBox.js" +import Toolbar from "./Toolbar.js" +import SensorFieldOfRegardVisualizer from "../engine/cesium/SensorFieldOfRegardVisualizer.js" +import SensorFieldOfViewVisualizer from "../engine/cesium/SensorFieldOfVIewVisualizer.js" +import GeoBeltVisualizer from "../engine/cesium/GeoBeltVisualizer.js" +import { createObjectPositionProperty, createObjectOrientationProperty, getObjectPositionInCesiumFrame } from "../engine/cesium/utils.js" +import ElectroOpicalSensor from "../engine/objects/ElectroOpticalSensor.js" +import CoverageGridVisualizer from "../engine/cesium/CoverageGridVisualizer.js" +import SimObject from "../engine/objects/SimObject.js" +import { CompoundElementVisualizer } from "../index.js" + + +/** + * Create a new Cesium Viewer and mixin new capabilities. + * + * @param {string | Element} container - The HTML container. + * @param {Universe} universe - The SatSim Universe. + * @param {*} options - Options + * @returns {Viewer} - The new viewer instance. + */ +function createViewer(container, universe, options) { + + options = defaultValue(options, {}) + if (!defined(options.infoBox)) + options.infoBox = false + + // Create Baseline Cesium Viewer + const viewer = new Viewer(container, options) + + // Mixin new capabilities + return mixinViewer(viewer, universe, options) +} + +/** + * Mixin new capabilities into the viewer. + * + * @param {Viewer} viewer - The viewer to upgrade. + * @param {Universe} universe - The universe. + * @param {*} options - Options + * @returns {Viewer} - The upgraded viewer instance. + */ +function mixinViewer(viewer, universe, options) { + + // Default options + options = defaultValue(options, {}) + options.showNightLayer = defaultValue(options.showNightLayer, true) + options.showWeatherLayer = defaultValue(options.showWeatherLayer, true) + options.weatherApiKey = defaultValue(options.weatherApiKey, 'YOUR_API_KEY') + options.infoBox2 = defaultValue(options.infoBox2, true) + options.infoBox2Container = defaultValue(viewer._element, undefined) + options.toolbar2 = defaultValue(options.toolbar2, true) + options.toolbar2Container = defaultValue(viewer._element, undefined) + + // Viewer variables + const scene = viewer.scene + const layers = scene.imageryLayers + const controller = scene.screenSpaceCameraController + const camera = viewer.camera + const selectionIndicator = viewer._selectionIndicator + + // Mixin variables + viewer.referenceFrameView = ReferenceFrame.FIXED + viewer.trackedSensor = null + viewer.cameraMode = "world" + viewer.sensorFovVisualizers = [] + viewer.sensorForVisualizers = [] + viewer.geoBeltVisualizer = new GeoBeltVisualizer(viewer) + viewer.sensorGrids = [] + viewer.lastPicked = undefined + viewer.billboards = scene.primitives.add(new BillboardCollection()); + viewer.points = scene.primitives.add(new PointPrimitiveCollection()); + viewer.coverageVisualizer = new CoverageGridVisualizer(viewer, universe); + + viewer.BILLBOARD_SATELLITE = viewer.billboards.add({ + show: false, + image: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADJSURBVDhPnZHRDcMgEEMZjVEYpaNklIzSEfLfD4qNnXAJSFWfhO7w2Zc0Tf9QG2rXrEzSUeZLOGm47WoH95x3Hl3jEgilvDgsOQUTqsNl68ezEwn1vae6lceSEEYvvWNT/Rxc4CXQNGadho1NXoJ+9iaqc2xi2xbt23PJCDIB6TQjOC6Bho/sDy3fBQT8PrVhibU7yBFcEPaRxOoeTwbwByCOYf9VGp1BYI1BA+EeHhmfzKbBoJEQwn1yzUZtyspIQUha85MpkNIXB7GizqDEECsAAAAASUVORK5CYII=" + }).image + viewer.BILLBOARD_GROUNDSTATION = viewer.billboards.add({ + show: false, + image: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAACvSURBVDhPrZDRDcMgDAU9GqN0lIzijw6SUbJJygUeNQgSqepJTyHG91LVVpwDdfxM3T9TSl1EXZvDwii471fivK73cBFFQNTT/d2KoGpfGOpSIkhUpgUMxq9DFEsWv4IXhlyCnhBFnZcFEEuYqbiUlNwWgMTdrZ3JbQFoEVG53rd8ztG9aPJMnBUQf/VFraBJeWnLS0RfjbKyLJA8FkT5seDYS1Qwyv8t0B/5C2ZmH2/eTGNNBgMmAAAAAElFTkSuQmCC" + }).image + + // Improved globe settings + scene.globe.enableLighting = true + scene.globe.lightingFadeInDistance = 0.0 + scene.globe.lightingFadeOutDistance = 0.0 + scene.globe.nightFadeInDistance = 10e10 + scene.globe.nightFadeOutDistance = 0 + scene.globe.brightness = 0.0 + scene.globe.dayAlpha = 0.0 + scene.globe.nightAlpha = 0.0 + scene.globe.dynamicAtmosphereLightingFromSun = true + scene.globe.atmosphereLightIntensity = 3.0 + + // Night layer + if (options.showNightLayer) { + const blackMarble = ImageryLayer.fromProviderAsync( + IonImageryProvider.fromAssetId(3812) + ) + blackMarble.dayAlpha = 0.0 + blackMarble.nightAlpha = 0.9 + blackMarble.brightness = 1.5 + layers.add(blackMarble) + } + + // Openweathermap layer + if (options.showWeatherLayer) { + const weatherLayer = new ImageryLayer(new UrlTemplateImageryProvider({ + url: `https://tile.openweathermap.org/map/clouds_new/{z}/{x}/{y}.png?appid=${options.weatherApiKey}`, + maximumLevel: 3, + })) + weatherLayer.dayAlpha = 0.8 + weatherLayer.nightAlpha = 0.5 + layers.add(weatherLayer) + } + + + /////////////////// + // Viewer Overrides + /////////////////// + + /** + * Add an updatePost listener to fix point primitive tracking. + * @param {Clock} clock + */ + const updatePost = function (clock) { + if (defined(viewer._entityView && defined(viewer._trackedEntity)) && defined(viewer._trackedEntity.point2) ) { // fix for point primitive not tracking + viewer._entityView.update(clock.currentTime, viewer.boundingSphereScratch) + } + } + viewer._eventHelper.add(viewer.clock.onTick, updatePost, viewer); + + /** + * Override the scene pick function. + * @param {Cartesian2} windowPosition - The window position. + * @param {number} [width] - Seems to always be undefined. + * @param {number} [height] - Seems to always be undefined. + * @returns {Entity} - The picked entity. + */ + scene.pick = function (windowPosition, width, height) { + + if(viewer.cameraMode === "up") { + //note: don't override width and height, makes drillPick super slow + const bwidth = scene.context.drawingBufferWidth; + const bheight = scene.context.drawingBufferHeight; + let uv = new Cartesian2(windowPosition.x / bwidth * 2.0 - 1.0, windowPosition.y / bheight * 2.0 - 1.0); + + if(Cartesian2.magnitude(uv) >= 1.0) { //don't pick outside the sphere + return undefined + } + + let z = Math.sqrt(1.0 - uv.x * uv.x - uv.y * uv.y); // sphere eq: r^2 = x^2 + y^2 + z^2 + let k = 1.0 / (z * Math.tan(camera.frustum.fov * 0.5)); + uv.x = (uv.x * k) + 0.5; + uv.y = (uv.y * k) + 0.5; + windowPosition = new Cartesian2(bwidth * uv.x, bheight * uv.y) + } + + let e = this._picking.drillPick(this, windowPosition, 100, width, height) + + for (let i = 0; i < e.length; i++) { + if (defined(e[i]) && defined(e[i].id)) { + if (e[i].id.allowPicking !== false) { + viewer.objectPickListener(e[i].id.simObjectRef, viewer.lastPicked) + viewer.lastPicked = e[i].id.simObjectRef + return e[i] + } else if (e[i].collection === viewer.points) { + viewer.objectPickListener(e[i].primitive.id.simObjectRef, viewer.lastPicked) + viewer.lastPicked = e[i].primitive.id.simObjectRef + return e[i].primitive + } + } + } + + viewer.objectPickListener(undefined, viewer.lastPicked) + viewer.lastPicked = undefined + + return undefined + } + + // Setup new morph to 2D, mainly to disable visualizers that don't work in 2D + const origMorphTo2D = scene.morphTo2D + scene.morphTo2D = function (duration) { + ecrButton.enable(false) + sensorFieldOfRegardButton.enable(false) + sensorFieldOfViewButton.enable(false) + geoButton.enable(false) + cameraViewMenu.enable(false) + viewer.showVisual(viewer.geoBeltVisualizer, false) + viewer.showVisual(viewer.sensorForVisualizers, false) + viewer.showVisual(viewer.sensorFovVisualizers, false) + viewer.setCameraMode("world") + cameraViewMenu.selectedIndex = 0 + origMorphTo2D.call(scene, duration) + } + + const origMorphToColumbusView = scene.morphToColumbusView + scene.morphToColumbusView = function (duration) { + ecrButton.enable(false) + sensorFieldOfRegardButton.enable(false) + sensorFieldOfViewButton.enable(false) + geoButton.enable(false) + cameraViewMenu.enable(false) + viewer.showVisual(viewer.geoBeltVisualizer, false) + viewer.showVisual(viewer.sensorForVisualizers, false) + viewer.showVisual(viewer.sensorFovVisualizers, false) + viewer.setCameraMode("world") + cameraViewMenu.selectedIndex = 0 + origMorphToColumbusView.call(scene, duration) + } + + /////////////////// + // New Listeners + /////////////////// + + /** + * Post update listener which adds new 3D perspectives and ECI mode. + */ + scene.postUpdate.addEventListener((scene, time) => { + const camera = viewer.camera + ecrButton.checked = viewer.referenceFrameView === ReferenceFrame.FIXED + universe.earth.update(time, universe) + viewer._selectionIndicator = selectionIndicator + + // senor perspective + if (viewer.cameraMode === "sensor") { + viewer.trackedSensor.update(time, universe) + camera.direction = new Cartesian3(0, 0, -1); + camera.right = new Cartesian3(1, 0, 0); + camera.up = new Cartesian3(0, 1, 0); + camera.position = new Cartesian3(CMath.EPSILON19, 0, 0) // if 0,0,0, cesium will crash + + universe.earth.update(time, universe) + const transform = new Matrix4() + Matrix4.multiply(universe.earth.worldToLocalTransform, viewer.trackedSensor.localToWorldTransform, transform) + Matrix4.clone(transform, camera.transform); + camera.frustum.fov = CMath.toRadians(viewer.trackedSensor.x_fov + viewer.trackedSensor.x_fov * 0.2) + // sensor what's up + } else if (viewer.cameraMode === "up") { + viewer._selectionIndicator = undefined //doesn't work in this mode + viewer.trackedSensor.update(time, universe) + camera.direction = new Cartesian3(0, 0, 1); + camera.right = new Cartesian3(0, 1, 0); + camera.up = new Cartesian3(-1, 0, 0); + camera.position = new Cartesian3(CMath.EPSILON19, 0, 0) // if 0,0,0, cesium will crash + + universe.earth.update(time, universe) + const transform = new Matrix4() + Matrix4.multiply(universe.earth.worldToLocalTransform, viewer.trackedSensor.parent.parent.localToWorldTransform, transform) + Matrix4.clone(transform, camera.transform); + camera.frustum.fov = CMath.toRadians(160) + // world view + } else { + // 2D modes + if (scene.mode !== SceneMode.SCENE3D) { + return + // 3D mode + } else { + // ECI mode + if (viewer.referenceFrameView === ReferenceFrame.INERTIAL) { + viewer.trackedEntity = undefined // disable tracking, doesn't work in ECI + const offset = Cartesian3.clone(camera.position) + camera.lookAtTransform(universe.earth.worldToLocalTransform, offset) + } + } + } + }) + + /** + * Morph complete listener which enables/disables UI elements based on the mode. + */ + scene.morphComplete.addEventListener(function () { + // 2D mode + if (scene.mode !== SceneMode.SCENE3D) { + ecrButton.enable(false) + sensorFieldOfRegardButton.enable(false) + sensorFieldOfViewButton.enable(false) + geoButton.enable(false) + cameraViewMenu.enable(false) + viewer.showVisual(viewer.geoBeltVisualizer, false) + viewer.showVisual(viewer.sensorForVisualizers, false) + viewer.showVisual(viewer.sensorFovVisualizers, false) + // 3D mode + } else { + ecrButton.enable(true) + sensorFieldOfRegardButton.enable(true) + sensorFieldOfViewButton.enable(true) + geoButton.enable(true) + cameraViewMenu.enable(true) + viewer.showVisual(viewer.geoBeltVisualizer, geoButton.checked) + viewer.showVisual(viewer.sensorForVisualizers, sensorFieldOfRegardButton.checked) + viewer.showVisual(viewer.sensorFovVisualizers, sensorFieldOfViewButton.checked) + } + + }) + + const lastUniverseUpdate = new JulianDate(); + let lastPickedUpdate = undefined + + /** + * Check if the universe needs to be updated. + */ + function isDirty(time, picked) { + + let dirty = false; + if(Math.abs(JulianDate.secondsDifference(lastUniverseUpdate, time)) != 0) { + JulianDate.clone(time, lastUniverseUpdate); + dirty = true; + } + + if(picked !== lastPickedUpdate) { + lastPickedUpdate = picked; + dirty = true; + } + + return dirty; + } + + /** + * Pre update listener which updates the universe. + */ + viewer.scene.preUpdate.addEventListener((scene, time) => { + + if(!isDirty(time, viewer.pickedObject)) { + return; + } + + universe.update(time) + }); + + + /////////////////// + // Viewer Mixins + /////////////////// + + /** + * Object pick listener. This should not be called directly. + * + * @param {Element} picked - The picked element. + * @param {Element} lastPicked - The last picked element. + */ + viewer.objectPickListener = function (picked, lastPicked) { + if (defined(lastPicked) && defined(lastPicked.visualizer) && defined(lastPicked.visualizer._path)) + lastPicked.visualizer._path.show = false + + if (defined(picked) && defined(picked.visualizer) && defined(picked.visualizer._path)) { + picked.visualizer._path.show = true + } + + } + + /** + * Add a sensor visualizer to the viewer. + * + * @param {Site} site - The site. + * @param {Gimbal} gimbal - The gimbal. + * @param {ElectroOpicalSensor} sensor - The sensor. + */ + viewer.addSensorVisualizer = function (site, gimbal, sensor) { + const forViz = new SensorFieldOfRegardVisualizer(viewer, site, sensor, universe) + forViz.show = false + viewer.sensorForVisualizers.push(forViz) + const fovViz = new SensorFieldOfViewVisualizer(viewer, site, gimbal, sensor, universe) + viewer.sensorFovVisualizers.push(fovViz) + + toolbar.addToolbarMenu([ + { + text: "Sensor View @ " + sensor.name, + onselect: function () { + viewer.setCameraMode("sensor", sensor) + }, + }, + { + text: "What's Up View @ " + sensor.name, + onselect: function () { + viewer.setCameraMode("up", sensor) + }, + }, + ], cameraViewMenu) + + // Grid for what's up view + viewer.sensorGrids.push(viewer.entities.add({ + name: sensor.name + ' grid ', + position: createObjectPositionProperty(site, universe, viewer), + orientation: createObjectOrientationProperty(site, universe), + ellipsoid: { + radii: new Cartesian3(1000000, 1000000, 1000000), + material: Color.WHITE.withAlpha(0.0), + outlineColor: Color.WHEAT.withAlpha(0.25), + outline: true, + slicePartitions: 36, + stackPartitions: 18 + }, + show: false, + allowPicking: false + })) + }; + + /** + * Add a site visualizer to the viewer. + * @param {SimObject} site + * @param {string} description + * @param {Entity} options + */ + viewer.addSiteVisualizer = function (site, description, options) { + const base = { + name: site.name, + description: description, + position: createObjectPositionProperty(site, universe, viewer), + orientation: createObjectOrientationProperty(site, universe), + simObjectRef: site + } + + const entity = viewer.entities.add(Object.assign(base, options)) + site.visualizer = entity + } + + /** + * Add an observatory visualizer to the viewer. + * @param {Observatory} observatory + * @param {string} description + */ + viewer.addObservatoryVisualizer = function (observatory, description) { + viewer.addSiteVisualizer(observatory.site, description, { + billboard: { + image: viewer.BILLBOARD_GROUNDSTATION, + disableDepthTestDistance: 2000000, + show: true + }, + simObjectRef: observatory + }) + viewer.addSensorVisualizer(observatory.site, observatory.gimbal, observatory.sensor) + } + + /** + * Add a satellite visualizer to the viewer. + * @param {SimObject} object + * @param {string} description + * @param {Entity} options + * @param {boolean} isStatic + */ + viewer.addObjectVisualizer = function (object, description, options, isStatic = false) { + + // clear point object to be added to point collection for 2-3x better performance + const point = options.point + options.point = undefined + + // create base entity which uses the traditional object position callback + const base = { + name: object.name, + description: description, + position: isStatic ? object.position : createObjectPositionProperty(object, universe, viewer), + simObjectRef: object, + allowPicking: true + } + const entity = viewer.entities.add(Object.assign(base, options)) + + // create new point primitive + if(defined(point)) { + entity.point2 = viewer.points.add(point) + entity.point2.id = entity // required for picking + entity.update = function(time, universe) { + entity.point2.position = getObjectPositionInCesiumFrame(viewer, universe, object, time) + } + object.visualizer = entity + object.updateListeners.push(entity); + } + } + + /** + * Set the camera mode. + * + * @param {string} mode - The camera mode. "world", "sensor", "up". + * @param {ElectroOpicalSensor} sensor - The sensor. + */ + viewer.setCameraMode = function (mode, sensor) { + + // save camera position + if (viewer.cameraMode === "world") { + originalCameraState = getCameraState(); + } + + if (mode === "sensor" || mode === "up") { + viewer.selectedEntity = undefined; + viewer.trackedEntity = undefined; + + controller.update = function () { }; + controller.enableTranslate = false; + controller.enableZoom = false; + controller.enableRotate = false; + controller.enableTilt = false; + controller.enableLook = false; + viewer.trackedSensor = sensor; + + } else if (mode === "world") { + setCameraState(originalCameraState); + camera.flyHome(); + } else { + console.log('unknown camera mode: ' + mode); + return; + } + + viewer.sensorGrids.forEach(function (v) { + v.show = mode === "up"; + }); + + viewer.sensorForVisualizers.forEach(function (v) { + v.outline = mode === "world"; + }); + + viewer.sensorFovVisualizers.forEach(function (v) { + v.outline = mode === "world"; + }); + + viewer.cameraMode = mode; + }; + + /** + * Shows or hides the visualizer(s). + * + * @param {CompoundElementVisualizer} visualizer - The visualizer or array of visualizers. + * @param {boolean} show - True to show, false to hide. + */ + viewer.showVisual = function (visualizer, show) { + + if (defined(visualizer.length)) { + visualizer.forEach(v => { + v.show = show; + }); + } else { + visualizer.show = show; + } + }; + + + + + /////////////////// + // UI Widgets + /////////////////// + + // Improved Infobox + if (options.infoBox2) { + const infoBoxContainer = document.createElement("div"); + infoBoxContainer.className = "cesium-viewer-infoBoxContainer"; + options.infoBox2Container.appendChild(infoBoxContainer); + const infoBox = new InfoBox(infoBoxContainer); + + function trackCallback(infoBoxViewModel) { + viewer.referenceFrameView = ReferenceFrame.FIXED; + if ( + infoBoxViewModel.isCameraTracking && + viewer.trackedEntity === viewer.selectedEntity + ) { + viewer.trackedEntity = undefined; + } else { + const selectedEntity = viewer.selectedEntity; + const position = selectedEntity.position; + if (defined(position)) { + viewer.trackedEntity = viewer.selectedEntity; + } else { + viewer.zoomTo(viewer.selectedEntity); + } + } + } + + function closeInfoBox(infoBoxViewModel) { + viewer.selectedEntity = undefined; + }; + + const infoBoxViewModel = infoBox.viewModel; + viewer._eventHelper.add( + infoBoxViewModel.cameraClicked, + trackCallback, + this + ); + viewer._eventHelper.add( + infoBoxViewModel.closeClicked, + closeInfoBox, + this + ); + viewer._infoBox = infoBox; + } + + // Toolbar + const toolbarContainer = document.createElement('div'); + toolbarContainer.classname = "cesium-viewer-actionbar"; + toolbarContainer.style.position = 'absolute'; + toolbarContainer.style.top = '10px'; + toolbarContainer.style.left = '10px'; + if (options.toolbar2) { + options.toolbar2Container.appendChild(toolbarContainer); + } + const toolbar = new Toolbar(toolbarContainer); + viewer.toolbar = toolbar; + const ecrButton = toolbar.addToggleButton('ECR', true, (checked) => { + if (!checked) { + viewer.referenceFrameView = ReferenceFrame.INERTIAL; + } else { + viewer.referenceFrameView = ReferenceFrame.FIXED; + } + }); + const sensorFieldOfRegardButton = toolbar.addToggleButton('FoR', false, (checked) => { + viewer.showVisual(viewer.sensorForVisualizers, checked); + }); + const sensorFieldOfViewButton = toolbar.addToggleButton('FoV', true, (checked) => { + viewer.showVisual(viewer.sensorFovVisualizers, checked); + }); + const geoButton = toolbar.addToggleButton('GEO', true, (checked) => { + viewer.showVisual(viewer.geoBeltVisualizer, checked); + }); + const satButton = toolbar.addToggleButton('Satellites', true, (checked) => { + viewer.points.show = checked; + }); + toolbar.addSeparator(); + const cameraViewMenu = toolbar.addToolbarMenu([ + { + text: "World View", + onselect: function () { + viewer.setCameraMode("world"); + } + } + ]); + const coverageMenu = toolbar.addToolbarMenu([ + { + text: "No Coverage Map", + onselect: function () { + viewer.coverageVisualizer.show = false; + }, + }, + { + text: "LEO Coverage Map", + onselect: function () { + viewer.coverageVisualizer.orbit = 'LEO'; + viewer.coverageVisualizer.show = true; + viewer.coverageVisualizer.update(viewer.clock.currentTime) + }, + }, + { + text: "MEO Coverage Map", + onselect: function () { + viewer.coverageVisualizer.orbit = 'MEO'; + viewer.coverageVisualizer.show = true; + viewer.coverageVisualizer.update(viewer.clock.currentTime) + }, + }, + { + text: "GEO Coverage Map", + onselect: function () { + viewer.coverageVisualizer.orbit = 'GEO'; + viewer.coverageVisualizer.show = true; + viewer.coverageVisualizer.update(viewer.clock.currentTime) + }, + }, + { + text: "Lunar Coverage Map", + onselect: function () { + viewer.coverageVisualizer.orbit = 'LUNAR'; + viewer.coverageVisualizer.show = true; + viewer.coverageVisualizer.update(viewer.clock.currentTime) + }, + }, + ]); + + + /////////////////// + // Misc + /////////////////// + let originalCameraState = getCameraState(); + function getCameraState() { + return { + update: scene.screenSpaceCameraController.update, + enableTranslate: controller.enableTranslate, + enableZoom: controller.enableZoom, + enableRotate: controller.enableRotate, + enableTilt: controller.enableTilt, + enableLook: controller.enableLook, + fov: camera.frustum.fov, + direction: Cartesian3.clone(camera.direction), + right: Cartesian3.clone(camera.right), + up: Cartesian3.clone(camera.up), + position: Cartesian3.clone(camera.position), + transform: Matrix4.clone(camera.transform) + }; + } + + function setCameraState(state) { + camera.frustum.fov = state.fov; + camera.frustum = camera.frustum; + Cartesian3.clone(state.direction, camera.direction); + Cartesian3.clone(state.right, camera.right); + Cartesian3.clone(state.up, camera.up); + Cartesian3.clone(state.position, camera.position); + Matrix4.clone(state.transform, camera.transform); + controller.update = state.update; + controller.enableTranslate = state.enableTranslate; + controller.enableZoom = state.enableZoom; + controller.enableRotate = state.enableRotate; + controller.enableTilt = state.enableTilt; + controller.enableLook = state.enableLook; + } + + // Add post process stage to fix wide fov distortion + const fs = ` +uniform sampler2D colorTexture; +uniform float fov; +uniform int mode; +in vec2 v_textureCoordinates; // input coord is 0 to +1 +//const float fovTheta = 160.0 * 3.1415926535 / 180.0; // FOV's theta +const float PI = 3.1415926535; + +void main (void) +{ + if (mode == 1) { + out_FragColor = texture(colorTexture, v_textureCoordinates); + return; + } + + vec2 uv = 2.0 * v_textureCoordinates - 1.0; // between -1 and +1 + float d = length(uv); + + if (d < 0.95) { + float z = sqrt(1.0 - uv.x * uv.x - uv.y * uv.y); // sphere eq: r^2 = x^2 + y^2 + z^2 + float k = 1.0 / (z * tan(fov * 0.5)); + vec4 c = texture(colorTexture, (uv * k) + 0.5); // between 0 and +1 + out_FragColor = c; + } else { + uv = v_textureCoordinates; + vec4 c = texture(colorTexture, uv); + out_FragColor = vec4(c.rgb * 0.0, 1.0); + } + } +`; + + scene.postProcessStages.add(new PostProcessStage({ + fragmentShader : fs, + uniforms : { + fov : function() { + return defined(camera.frustum.fov) ? camera.frustum.fov : 0.0; + }, + mode : function() { + return viewer.cameraMode === "up" ? 0 : 1; + } + } + })); + + return viewer; +}; + + +export { + createViewer, + mixinViewer +} diff --git a/webpack.config.js b/webpack.config.js new file mode 100755 index 0000000..0bde6f3 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,68 @@ +// The path to the CesiumJS source code +const cesiumSource = 'node_modules/cesium/Source'; +const cesiumWorkers = '../Build/Cesium/Workers'; +const CopyWebpackPlugin = require('copy-webpack-plugin'); +const path = require('path'); +const webpack = require('webpack'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); + +module.exports = { + context: __dirname, + entry: { + app: './app/index.js' + }, + output: { + filename: 'app.js', + path: path.resolve(__dirname, 'dist'), + sourcePrefix: '' + }, + resolve: { + fallback: { "https": false, "zlib": false, "http": false, "url": false }, + mainFiles: ['index', 'Cesium'], + // add satsim as an alias to the root directory + alias: { + "satsim": path.resolve(__dirname, ".") + } + }, + module: { + rules: [{ + test: /\.css$/, + use: [ 'style-loader', 'css-loader' ] + }, { + test: /\.(png|gif|jpg|jpeg|svg|xml|json)$/, + use: [ 'url-loader' ] + }] + }, + plugins: [ + new HtmlWebpackPlugin({ + template: './app/index.html' + }), + // Copy Cesium Assets, Widgets, and Workers to a static directory + new CopyWebpackPlugin({ + patterns: [ + { from: path.join(cesiumSource, cesiumWorkers), to: 'Workers' }, + { from: path.join(cesiumSource, 'Assets'), to: 'Assets' }, + { from: path.join(cesiumSource, 'Widgets'), to: 'Widgets' }, + { from: path.join(cesiumSource, 'ThirdParty'), to: 'ThirdParty' } + ] + }), + new webpack.DefinePlugin({ + // Define relative base path in cesium for loading assets + CESIUM_BASE_URL: JSON.stringify('') + }) + ], + mode: 'development', + devtool: 'source-map', + devServer: { + watchFiles: { + paths: ['./src/**/*.js', './src/**/*.css', './src/**/*.html', './app/**/*'], + options: { + usePolling: false, + }, + }, + static: { + directory: path.resolve(__dirname, './app/assets'), + publicPath: '/assets' + } + } +};