Initial Commit
This commit is contained in:
53
packages/geospatial/geo/geo-planet/README.md
Normal file
53
packages/geospatial/geo/geo-planet/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# @suaveplan/geo-planet - Configurable planet surface geometry from PlanetConfig — ellipsoid, atmosphere, satellites, and orbital elements.
|
||||
|
||||
## Summary
|
||||
|
||||
Configurable planet surface geometry from `PlanetConfig` — ellipsoid, atmosphere, satellites, and orbital elements.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [atmospheric](./src/atmospheric/atmospheric.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [atmospheric](./src/atmospheric/atmospheric.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schemas](./src/types/schemas.md) — The `schemas` module exposes Zod runtime schemas mirroring every public type declared in `types.ts`. Validation is the…
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [atmospheric](./src/atmospheric/atmospheric.md) — <!-- begin:auto-docs --> _(no submodules)_- [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_- [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/types/schemas.md) — The `schemas` module exposes Zod runtime schemas mirroring every public type declared in `types.ts`. Validation is the…
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [atmospheric](./src/atmospheric/atmospheric.md) — <!-- begin:auto-docs --> _(no submodules)_- [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_- [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/types/schemas.md) — The `schemas` module exposes Zod runtime schemas mirroring every public type declared in `types.ts`. Validation is the…
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [atmospheric](./src/atmospheric/atmospheric.md) — <!-- begin:auto-docs --> _(no submodules)_- [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_- [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/types/schemas.md) — The `schemas` module exposes Zod runtime schemas mirroring every public type declared in `types.ts`. Validation is the…
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [atmospheric](./src/atmospheric/atmospheric.md) — <!-- begin:auto-docs --> _(no submodules)_- [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_ - [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schemas](./src/types/schemas.md) — The `schemas` module exposes Zod runtime schemas mirroring every public type declared in `types.ts`. Validation is the…
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/geo-planet
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { PlanetSurface, PlanetConfigSchema } from "@suaveplan/geo-planet";
|
||||
|
||||
const config = PlanetConfigSchema.parse({
|
||||
name: "Mars",
|
||||
mass: 6.39e23,
|
||||
radius: 3_396_200,
|
||||
polarRadius: 3_376_200,
|
||||
surfaceGravity: 3.72,
|
||||
rotationPeriod: 88_620,
|
||||
axialTilt: 25.2,
|
||||
atmosphere: null,
|
||||
satellites: [],
|
||||
});
|
||||
|
||||
const surface = new PlanetSurface(config);
|
||||
console.log(surface.distance(a, b, "kilometre"));
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/planet-surface/planet-surface.md`](src/planet-surface/planet-surface.md)
|
||||
- [`src/atmospheric/atmospheric.md`](src/atmospheric/atmospheric.md)
|
||||
- [`src/types/types.md`](src/types/types.md)
|
||||
65
packages/geospatial/geo/geo-planet/package.json
Normal file
65
packages/geospatial/geo/geo-planet/package.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@suaveplan/geo-planet",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Configurable planet surface geometry from PlanetConfig — ellipsoid, atmosphere, satellites, and orbital elements.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
|
||||
},
|
||||
"./atmospheric/atmospheric": {
|
||||
"import": { "types": "./dist/atmospheric/atmospheric.d.ts", "default": "./dist/atmospheric/atmospheric.js" },
|
||||
"require": { "types": "./dist/atmospheric/atmospheric.d.ts", "default": "./dist/atmospheric/atmospheric.js" }
|
||||
},
|
||||
"./planet-surface/planet-surface": {
|
||||
"import": { "types": "./dist/planet-surface/planet-surface.d.ts", "default": "./dist/planet-surface/planet-surface.js" },
|
||||
"require": { "types": "./dist/planet-surface/planet-surface.d.ts", "default": "./dist/planet-surface/planet-surface.js" }
|
||||
},
|
||||
"./types/types": {
|
||||
"import": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" },
|
||||
"require": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" }
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/geo": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `atmospheric` module provides the two physics primitives every planetary renderer, sky-colour solver, and observation-quality estimator needs: the local density of an atmospheric column at altitude, and the relative path length light travels through that column for a given elevation angle. Both functions accept a `PlanetConfig` so the same code path serves Earth, Mars, Titan, or any other modelled body.
|
||||
|
||||
These primitives are intentionally pure and free of vendor coupling so they can be reused by `@suaveplan/universe`, `@suaveplan/gaming-environment-lighting`, server-side astronomical computations, and offline analysis without dragging a graphics runtime into the call site.
|
||||
|
||||
## Features
|
||||
|
||||
- `atmosphericDensity(planet, altitudeMetres)` — exponential isothermal scale-height model that returns `0` for airless bodies
|
||||
- `airMass(altitudeDeg)` — Kasten–Young 1989 air-mass approximation valid from zenith down to the horizon
|
||||
- Numerical-stable horizon handling: negative altitudes are clamped to `0` so the formula remains finite at the bound
|
||||
- Pure functions: no I/O, no side effects, no global state — safe for `Worker` threads, SSR, and deterministic tests
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```ts
|
||||
import { atmosphericDensity } from "@suaveplan/geo-planet";
|
||||
import type { PlanetConfig } from "@suaveplan/geo-planet";
|
||||
|
||||
const earth: PlanetConfig = {
|
||||
name: "Earth",
|
||||
mass: 5.972e24,
|
||||
radius: 6_378_137,
|
||||
polarRadius: 6_356_752,
|
||||
surfaceGravity: 9.81,
|
||||
rotationPeriod: 86_164,
|
||||
axialTilt: 23.44,
|
||||
atmosphere: {
|
||||
surfacePressure: 101_325,
|
||||
scaleHeight: 8_500,
|
||||
rayleighCoefficient: 5.8e-6,
|
||||
mieCoefficient: 21e-6,
|
||||
extinctionPerAirMass: 0.13,
|
||||
},
|
||||
satellites: [],
|
||||
};
|
||||
|
||||
const surface = atmosphericDensity(earth, 0); // 101325 Pa
|
||||
const cruise = atmosphericDensity(earth, 11_000); // ~27 kPa
|
||||
```
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```ts
|
||||
import { airMass, atmosphericDensity } from "@suaveplan/geo-planet";
|
||||
import type { PlanetConfig } from "@suaveplan/geo-planet";
|
||||
|
||||
function transmittance(
|
||||
planet: PlanetConfig,
|
||||
altitudeMetres: number,
|
||||
sunElevationDeg: number,
|
||||
): number {
|
||||
if (!planet.atmosphere) return 1;
|
||||
const density = atmosphericDensity(planet, altitudeMetres);
|
||||
const am = airMass(sunElevationDeg);
|
||||
const tau = planet.atmosphere.extinctionPerAirMass * am;
|
||||
return Math.exp(-tau) * (density / planet.atmosphere.surfacePressure);
|
||||
}
|
||||
|
||||
// Use at every camera step in a sky shader, or to score
|
||||
// observability for ground-station pass scheduling.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `atmosphericDensity(planet: PlanetConfig, altitudeMetres: number): number`
|
||||
|
||||
Returns the local pressure (`Pa`) using `p₀ · exp(-h / H)` where `p₀` is `planet.atmosphere.surfacePressure` and `H` is `planet.atmosphere.scaleHeight`. Returns `0` when `planet.atmosphere === null`. No clamping is applied to altitude; negative altitudes (subterranean, sub-sea-level) yield pressures above `p₀`, which is the physically correct extrapolation for a barometric law.
|
||||
|
||||
### `airMass(altitudeDeg: number): number`
|
||||
|
||||
Returns the Kasten–Young air mass:
|
||||
|
||||
`1 / (sin(alt) + 0.50572 · (alt + 6.07995)^-1.6364)`
|
||||
|
||||
with `alt` in degrees. Negative altitudes are clamped to `0` so the result remains finite at the horizon (returning ≈ `38`). At zenith the return value approaches `1`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The density function uses the isothermal barometric equation rather than a layered ISA model because every consumer in this project treats density as a smooth analytic function for shader sampling. The Kasten–Young formulation was chosen over the simpler `1 / sin(alt)` secant model because it remains stable down to the horizon and only diverges by ≈ `0.4%` from full atmospheric ray-tracing above 5° elevation. Both functions are allocation-free hot-path code; consumers can call them once per pixel without GC pressure.
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { airMass, atmosphericDensity } from "./atmospheric.js";
|
||||
|
||||
describe("atmosphericDensity", () => {
|
||||
const planet = {
|
||||
name: "Test",
|
||||
mass: 1,
|
||||
radius: 1,
|
||||
polarRadius: 1,
|
||||
surfaceGravity: 1,
|
||||
rotationPeriod: 1,
|
||||
axialTilt: 0,
|
||||
atmosphere: {
|
||||
surfacePressure: 101325,
|
||||
scaleHeight: 8500,
|
||||
rayleighCoefficient: 1,
|
||||
mieCoefficient: 1,
|
||||
extinctionPerAirMass: 1,
|
||||
},
|
||||
satellites: [],
|
||||
};
|
||||
|
||||
it("returns surface pressure at altitude 0", { timeout: 5000 }, () => {
|
||||
expect(atmosphericDensity(planet, 0)).toBe(101325);
|
||||
});
|
||||
|
||||
it("returns ~37% at scale height", { timeout: 5000 }, () => {
|
||||
expect(atmosphericDensity(planet, 8500)).toBeCloseTo(
|
||||
101325 / Math.E,
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 0 for no atmosphere", { timeout: 5000 }, () => {
|
||||
const noAtmo = { ...planet, atmosphere: null };
|
||||
expect(atmosphericDensity(noAtmo, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("airMass", () => {
|
||||
it("returns approximately 1 at zenith", { timeout: 5000 }, () => {
|
||||
expect(airMass(90)).toBeCloseTo(1, 1);
|
||||
});
|
||||
|
||||
it("returns a large value at horizon", { timeout: 5000 }, () => {
|
||||
expect(airMass(0)).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("returns 1 for negative altitude", { timeout: 5000 }, () => {
|
||||
expect(airMass(-10)).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { PlanetConfig } from "../types/types.js";
|
||||
|
||||
export function atmosphericDensity(
|
||||
planet: PlanetConfig,
|
||||
altitudeMetres: number
|
||||
): number {
|
||||
if (!planet.atmosphere) return 0;
|
||||
const p0 = planet.atmosphere.surfacePressure;
|
||||
const H = planet.atmosphere.scaleHeight;
|
||||
return p0 * Math.exp(-altitudeMetres / H);
|
||||
}
|
||||
|
||||
export function airMass(altitudeDeg: number): number {
|
||||
const alt = Math.max(0, altitudeDeg);
|
||||
return (
|
||||
1 /
|
||||
(Math.sin((alt * Math.PI) / 180) + 0.50572 * (alt + 6.07995) ** -1.6364)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
airMass,
|
||||
atmosphericDensity,
|
||||
} from "./atmospheric.js";
|
||||
16
packages/geospatial/geo/geo-planet/src/index.ts
Normal file
16
packages/geospatial/geo/geo-planet/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export { airMass, atmosphericDensity } from "./atmospheric/index.js";
|
||||
export { PlanetSurface } from "./planet-surface/index.js";
|
||||
export type {
|
||||
AtmosphereConfig,
|
||||
OrbitalConfig,
|
||||
PlanetConfig,
|
||||
PlanetTextureManifest,
|
||||
SatelliteConfig,
|
||||
} from "./types/index.js";
|
||||
export {
|
||||
AtmosphereConfigSchema,
|
||||
OrbitalConfigSchema,
|
||||
PlanetConfigSchema,
|
||||
PlanetTextureManifestSchema,
|
||||
SatelliteConfigSchema,
|
||||
} from "./types/index.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { PlanetSurface } from "./planet-surface.js";
|
||||
@@ -0,0 +1,111 @@
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `planet-surface` module provides the `PlanetSurface` class, an oblate-ellipsoid wrapper around a `PlanetConfig` that exposes the geodetic operations every multi-planet system needs: great-circle distance, initial bearing, terminal-point projection from a heading and distance, and point-in-polygon tests. It is the single bridge between body-specific configuration (Earth, Mars, Titan, …) and the body-agnostic geometry primitives in `@suaveplan/geo`.
|
||||
|
||||
By deriving the ellipsoid once per `PlanetSurface` instance from the `radius` and `polarRadius` fields of `PlanetConfig`, callers never have to thread an ellipsoid through every distance call, and they cannot accidentally mix Earth-flattening into a Mars computation.
|
||||
|
||||
## Features
|
||||
|
||||
- `PlanetSurface` class — owns the planet config and the cached `Ellipsoid`
|
||||
- `distance(a, b, unit?)` — geodesic great-circle distance with selectable distance unit
|
||||
- `bearing(from, to)` — initial forward azimuth in degrees from north, clockwise
|
||||
- `destination(start, bearingDeg, distanceM)` — terminal-point projection by direct-geodesic solution
|
||||
- `pointInPolygon(point, polygon)` — spherical-coordinate ray-cast / winding test for arbitrary closed rings
|
||||
- Body-agnostic — works for any planet whose oblateness is well-modelled by an ellipsoid of revolution
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```ts
|
||||
import { PlanetSurface } from "@suaveplan/geo-planet";
|
||||
import type { PlanetConfig } from "@suaveplan/geo-planet";
|
||||
|
||||
const earth: PlanetConfig = {
|
||||
name: "Earth",
|
||||
mass: 5.972e24,
|
||||
radius: 6_378_137,
|
||||
polarRadius: 6_356_752,
|
||||
surfaceGravity: 9.81,
|
||||
rotationPeriod: 86_164,
|
||||
axialTilt: 23.44,
|
||||
atmosphere: null,
|
||||
satellites: [],
|
||||
};
|
||||
|
||||
const surface = new PlanetSurface(earth);
|
||||
const km = surface.distance(
|
||||
{ latitude: 51.5, longitude: -0.13 }, // London
|
||||
{ latitude: 40.71, longitude: -74.0 }, // New York
|
||||
"kilometre",
|
||||
);
|
||||
```
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```ts
|
||||
import { PlanetSurface } from "@suaveplan/geo-planet";
|
||||
import type { PlanetConfig } from "@suaveplan/geo-planet";
|
||||
|
||||
const mars: PlanetConfig = {
|
||||
name: "Mars",
|
||||
mass: 6.39e23,
|
||||
radius: 3_396_200,
|
||||
polarRadius: 3_376_200,
|
||||
surfaceGravity: 3.72,
|
||||
rotationPeriod: 88_620,
|
||||
axialTilt: 25.2,
|
||||
atmosphere: null,
|
||||
satellites: [],
|
||||
};
|
||||
|
||||
const surface = new PlanetSurface(mars);
|
||||
|
||||
// Project a 100 km traverse from Jezero crater on a 45° heading.
|
||||
const start = { latitude: 18.44, longitude: 77.45 };
|
||||
const end = surface.destination(start, 45, 100_000);
|
||||
|
||||
// Heading toward Olympus Mons.
|
||||
const heading = surface.bearing(start, { latitude: 18.65, longitude: -133.8 });
|
||||
|
||||
// Region containment for science-target zones.
|
||||
const aoi = [
|
||||
{ latitude: 18.0, longitude: 77.0 },
|
||||
{ latitude: 19.0, longitude: 77.0 },
|
||||
{ latitude: 19.0, longitude: 78.0 },
|
||||
{ latitude: 18.0, longitude: 78.0 },
|
||||
];
|
||||
const inside = surface.pointInPolygon(end, aoi);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class PlanetSurface`
|
||||
|
||||
#### `constructor(planet: PlanetConfig)`
|
||||
|
||||
Stores the planet on `surface.planet` and constructs an `Ellipsoid` via `defineEllipsoid(planet.radius, planet.polarRadius)` once per instance.
|
||||
|
||||
#### `distance(a, b, unit?: DistanceUnit): number`
|
||||
|
||||
Returns the great-circle distance between two `SphericalCoord` points along the cached ellipsoid. `unit` defaults to `"metre"`; pass `"kilometre"`, `"mile"`, etc. as supported by `@suaveplan/geo`.
|
||||
|
||||
#### `bearing(from, to): number`
|
||||
|
||||
Returns the initial forward azimuth in degrees clockwise from north. Note: the spherical bearing in `@suaveplan/geo` does not depend on the ellipsoid.
|
||||
|
||||
#### `destination(start, bearingDeg, distanceM): SphericalCoord`
|
||||
|
||||
Solves the direct geodesic problem on the cached ellipsoid: given a start point, an initial bearing, and a distance in metres, returns the terminal point.
|
||||
|
||||
#### `pointInPolygon(point, polygon: SphericalCoord[]): boolean`
|
||||
|
||||
Tests containment of `point` inside the closed polygon ring; uses spherical-coordinate point-in-polygon math from `@suaveplan/geo`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The ellipsoid is built once at construction and then reused on every method call, which keeps every distance / destination call allocation-free in steady state. Bearing intentionally does not pass the ellipsoid because the underlying `bearing` from `@suaveplan/geo` is a pure spherical primitive — for the precision needed in mission-planning UIs, the difference between spherical and geodetic bearing is below display granularity, and the geometric simplicity is worth more than the marginal accuracy gain. Storing `planet` as a public readonly property lets consumers retain the original config (for atmosphere, satellites, textures, etc.) without round-tripping through a separate registry.
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PlanetSurface } from "./planet-surface.js";
|
||||
|
||||
const TEST_PLANET = {
|
||||
name: "TestPlanet",
|
||||
mass: 5.972e24,
|
||||
radius: 6_371_000,
|
||||
polarRadius: 6_356_000,
|
||||
surfaceGravity: 9.81,
|
||||
rotationPeriod: 86_400,
|
||||
axialTilt: 23.5,
|
||||
atmosphere: null,
|
||||
satellites: [],
|
||||
};
|
||||
|
||||
describe("PlanetSurface", () => {
|
||||
it("computes distance with default unit", { timeout: 5000 }, () => {
|
||||
const surface = new PlanetSurface(TEST_PLANET);
|
||||
const a = { latitude: 0, longitude: 0 };
|
||||
const b = { latitude: 0, longitude: 1 };
|
||||
const d = surface.distance(a, b);
|
||||
expect(d).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("computes distance with explicit unit", { timeout: 5000 }, () => {
|
||||
const surface = new PlanetSurface(TEST_PLANET);
|
||||
const a = { latitude: 0, longitude: 0 };
|
||||
const b = { latitude: 0, longitude: 1 };
|
||||
const d = surface.distance(a, b, "kilometre");
|
||||
expect(d).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("computes bearing", { timeout: 5000 }, () => {
|
||||
const surface = new PlanetSurface(TEST_PLANET);
|
||||
const a = { latitude: 0, longitude: 0 };
|
||||
const b = { latitude: 10, longitude: 0 };
|
||||
expect(surface.bearing(a, b)).toBeCloseTo(0, 0);
|
||||
});
|
||||
|
||||
it("computes destination", { timeout: 5000 }, () => {
|
||||
const surface = new PlanetSurface(TEST_PLANET);
|
||||
const start = { latitude: 0, longitude: 0 };
|
||||
const end = surface.destination(start, 90, 100_000);
|
||||
expect(end.latitude).toBeDefined();
|
||||
expect(end.longitude).toBeDefined();
|
||||
});
|
||||
|
||||
it("checks point in polygon", { timeout: 5000 }, () => {
|
||||
const surface = new PlanetSurface(TEST_PLANET);
|
||||
const square = [
|
||||
{ latitude: 0, longitude: 0 },
|
||||
{ latitude: 0, longitude: 10 },
|
||||
{ latitude: 10, longitude: 10 },
|
||||
{ latitude: 10, longitude: 0 },
|
||||
];
|
||||
expect(
|
||||
surface.pointInPolygon({ latitude: 5, longitude: 5 }, square)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
bearing,
|
||||
type DistanceUnit,
|
||||
defineEllipsoid,
|
||||
destination,
|
||||
type Ellipsoid,
|
||||
greatCircleDistance,
|
||||
pointInPolygon,
|
||||
type SphericalCoord,
|
||||
} from "@suaveplan/geo";
|
||||
import type { PlanetConfig } from "../types/types.js";
|
||||
|
||||
export class PlanetSurface {
|
||||
private readonly ellipsoid: Ellipsoid;
|
||||
|
||||
constructor(public readonly planet: PlanetConfig) {
|
||||
this.ellipsoid = defineEllipsoid(planet.radius, planet.polarRadius);
|
||||
}
|
||||
|
||||
distance(
|
||||
a: SphericalCoord,
|
||||
b: SphericalCoord,
|
||||
unit: DistanceUnit = "metre"
|
||||
): number {
|
||||
return greatCircleDistance(a, b, this.ellipsoid, unit);
|
||||
}
|
||||
|
||||
bearing(from: SphericalCoord, to: SphericalCoord): number {
|
||||
return bearing(from, to);
|
||||
}
|
||||
|
||||
destination(
|
||||
start: SphericalCoord,
|
||||
bearingDeg: number,
|
||||
distanceM: number
|
||||
): SphericalCoord {
|
||||
return destination(start, bearingDeg, distanceM, this.ellipsoid);
|
||||
}
|
||||
|
||||
pointInPolygon(point: SphericalCoord, polygon: SphericalCoord[]): boolean {
|
||||
return pointInPolygon(point, polygon);
|
||||
}
|
||||
}
|
||||
14
packages/geospatial/geo/geo-planet/src/types/index.ts
Normal file
14
packages/geospatial/geo/geo-planet/src/types/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
AtmosphereConfigSchema,
|
||||
OrbitalConfigSchema,
|
||||
PlanetConfigSchema,
|
||||
PlanetTextureManifestSchema,
|
||||
SatelliteConfigSchema,
|
||||
} from "./schemas.js";
|
||||
export type {
|
||||
AtmosphereConfig,
|
||||
OrbitalConfig,
|
||||
PlanetConfig,
|
||||
PlanetTextureManifest,
|
||||
SatelliteConfig,
|
||||
} from "./types.js";
|
||||
84
packages/geospatial/geo/geo-planet/src/types/schemas.md
Normal file
84
packages/geospatial/geo/geo-planet/src/types/schemas.md
Normal file
@@ -0,0 +1,84 @@
|
||||
## Purpose
|
||||
|
||||
The `schemas` module exposes Zod runtime schemas mirroring every public type declared in `types.ts`. Validation is the boundary contract for any consumer accepting raw JSON, configuration files, IPC payloads, or untrusted user input that will later be handed to `PlanetSurface`, atmospheric solvers, or astronomical providers.
|
||||
|
||||
By keeping schemas co-located with the type module they validate, `geo-planet` guarantees that the runtime parser and the static `interface` shapes never drift. Every numeric invariant required by physical models (positive masses, normalised eccentricities, non-negative pressures) is enforced at parse time so downstream code can assume well-formed inputs without defensive null checks.
|
||||
|
||||
## Features
|
||||
|
||||
- `AtmosphereConfigSchema` — non-negative pressure / coefficients, positive scale height
|
||||
- `SatelliteConfigSchema` — positive mass/radius/orbit, eccentricity ∈ `[0, 1]`, albedo ∈ `[0, 1]`
|
||||
- `OrbitalConfigSchema` — Keplerian elements (semi-major axis AU, inclination, perihelion, ascending node, mean anomaly, epoch JD)
|
||||
- `PlanetTextureManifestSchema` — optional diffuse/normal/specular/clouds/night texture URLs
|
||||
- `PlanetConfigSchema` — composite root validating physical, orbital, and rendering metadata
|
||||
- Inferred types via `z.infer<typeof Schema>` interoperate with the hand-authored `interface`s in `types.ts`
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```ts
|
||||
import { PlanetConfigSchema } from "@suaveplan/geo-planet";
|
||||
|
||||
const earth = PlanetConfigSchema.parse({
|
||||
name: "Earth",
|
||||
mass: 5.972e24,
|
||||
radius: 6_378_137,
|
||||
polarRadius: 6_356_752,
|
||||
surfaceGravity: 9.81,
|
||||
rotationPeriod: 86_164,
|
||||
axialTilt: 23.44,
|
||||
atmosphere: null,
|
||||
satellites: [],
|
||||
});
|
||||
```
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AtmosphereConfigSchema,
|
||||
OrbitalConfigSchema,
|
||||
PlanetConfigSchema,
|
||||
SatelliteConfigSchema,
|
||||
} from "@suaveplan/geo-planet";
|
||||
|
||||
const result = PlanetConfigSchema.safeParse(rawJson);
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
console.error(issue.path.join("."), issue.message);
|
||||
}
|
||||
throw new Error("invalid planet config");
|
||||
}
|
||||
|
||||
// Per-field schemas compose for partial validation pipelines.
|
||||
const atmo = AtmosphereConfigSchema.parse(rawJson.atmosphere);
|
||||
const orbit = OrbitalConfigSchema.parse(rawJson.orbital);
|
||||
const moon = SatelliteConfigSchema.parse(rawJson.satellites[0]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AtmosphereConfigSchema`
|
||||
|
||||
Validates `surfacePressure ≥ 0`, `scaleHeight > 0`, and non-negative Rayleigh, Mie, and air-mass extinction coefficients. Throws on negative numbers or missing fields.
|
||||
|
||||
### `SatelliteConfigSchema`
|
||||
|
||||
Validates non-empty `name`, positive `mass` / `radius` / `orbitalRadius` / `orbitalPeriod`, free `orbitalInclination`, and clamped `eccentricity` and `albedo` in `[0, 1]`.
|
||||
|
||||
### `OrbitalConfigSchema`
|
||||
|
||||
Validates positive `semiMajorAxisAU`, eccentricity ∈ `[0, 1]`, free angular elements, and any finite epoch Julian Date.
|
||||
|
||||
### `PlanetTextureManifestSchema`
|
||||
|
||||
All texture URL fields are optional strings; missing fields default to `undefined`.
|
||||
|
||||
### `PlanetConfigSchema`
|
||||
|
||||
Composite schema enforcing positive `mass` / `radius` / `polarRadius` / `surfaceGravity` / `rotationPeriod`, free `axialTilt`, `atmosphere: AtmosphereConfig | null`, an array of satellites, and optional `orbital` and `textures`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every schema is built directly with `zod` and exports the parser; `z.infer` keeps inferred shapes structurally identical to the hand-authored interfaces. The `null`-permissive atmosphere field models airless bodies (Mercury, Moon) without forcing consumers into an option-type at the validation layer. Eccentricity is clamped to a closed interval rather than a half-open one because a parabolic trajectory (`e === 1`) is a useful boundary case for astronomical fixtures even though it is not bound. Schemas avoid `.strict()` so additive forward-compatible fields can be carried by raw payloads without forcing a parser bump.
|
||||
35
packages/geospatial/geo/geo-planet/src/types/schemas.test.ts
Normal file
35
packages/geospatial/geo/geo-planet/src/types/schemas.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PlanetConfigSchema } from "./schemas.js";
|
||||
|
||||
describe("PlanetConfigSchema", () => {
|
||||
it("parses a valid planet config", { timeout: 5000 }, () => {
|
||||
const parsed = PlanetConfigSchema.parse({
|
||||
name: "Mars",
|
||||
mass: 6.39e23,
|
||||
radius: 3_396_200,
|
||||
polarRadius: 3_376_200,
|
||||
surfaceGravity: 3.72,
|
||||
rotationPeriod: 88_620,
|
||||
axialTilt: 25.2,
|
||||
atmosphere: null,
|
||||
satellites: [],
|
||||
});
|
||||
expect(parsed.name).toBe("Mars");
|
||||
});
|
||||
|
||||
it("rejects negative mass", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
PlanetConfigSchema.parse({
|
||||
name: "X",
|
||||
mass: -1,
|
||||
radius: 1,
|
||||
polarRadius: 1,
|
||||
surfaceGravity: 1,
|
||||
rotationPeriod: 1,
|
||||
axialTilt: 0,
|
||||
atmosphere: null,
|
||||
satellites: [],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
52
packages/geospatial/geo/geo-planet/src/types/schemas.ts
Normal file
52
packages/geospatial/geo/geo-planet/src/types/schemas.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import z from "zod";
|
||||
|
||||
export const AtmosphereConfigSchema = z.object({
|
||||
surfacePressure: z.number().nonnegative(),
|
||||
scaleHeight: z.number().positive(),
|
||||
rayleighCoefficient: z.number().nonnegative(),
|
||||
mieCoefficient: z.number().nonnegative(),
|
||||
extinctionPerAirMass: z.number().nonnegative(),
|
||||
});
|
||||
|
||||
export const SatelliteConfigSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
mass: z.number().positive(),
|
||||
radius: z.number().positive(),
|
||||
orbitalRadius: z.number().positive(),
|
||||
orbitalPeriod: z.number().positive(),
|
||||
orbitalInclination: z.number(),
|
||||
eccentricity: z.number().min(0).max(1),
|
||||
albedo: z.number().min(0).max(1),
|
||||
});
|
||||
|
||||
export const OrbitalConfigSchema = z.object({
|
||||
semiMajorAxisAU: z.number().positive(),
|
||||
eccentricity: z.number().min(0).max(1),
|
||||
inclinationDeg: z.number(),
|
||||
argumentOfPerihelionDeg: z.number(),
|
||||
ascendingNodeDeg: z.number(),
|
||||
meanAnomalyAtEpochDeg: z.number(),
|
||||
epochJd: z.number(),
|
||||
});
|
||||
|
||||
export const PlanetTextureManifestSchema = z.object({
|
||||
diffuse: z.string().optional(),
|
||||
normal: z.string().optional(),
|
||||
specular: z.string().optional(),
|
||||
clouds: z.string().optional(),
|
||||
night: z.string().optional(),
|
||||
});
|
||||
|
||||
export const PlanetConfigSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
mass: z.number().positive(),
|
||||
radius: z.number().positive(),
|
||||
polarRadius: z.number().positive(),
|
||||
surfaceGravity: z.number().positive(),
|
||||
rotationPeriod: z.number().positive(),
|
||||
axialTilt: z.number(),
|
||||
atmosphere: AtmosphereConfigSchema.nullable(),
|
||||
satellites: z.array(SatelliteConfigSchema),
|
||||
orbital: OrbitalConfigSchema.optional(),
|
||||
textures: PlanetTextureManifestSchema.optional(),
|
||||
});
|
||||
117
packages/geospatial/geo/geo-planet/src/types/types.md
Normal file
117
packages/geospatial/geo/geo-planet/src/types/types.md
Normal file
@@ -0,0 +1,117 @@
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `types` module declares the static TypeScript interfaces describing every value `geo-planet` accepts and emits. These are the canonical shapes consumed by `PlanetSurface`, atmospheric models, and any downstream renderer or astronomical provider that needs a single source of truth for what defines a planetary body.
|
||||
|
||||
Keeping the interfaces in a hand-authored file (rather than relying solely on `z.infer`) lets the compiler emit precise, documented declarations into `.d.ts` outputs without dragging the Zod runtime into type-only consumers. The runtime mirror lives in `schemas.ts` and is verified by tests to stay structurally aligned with these interfaces.
|
||||
|
||||
## Features
|
||||
|
||||
- `AtmosphereConfig` — exponential atmosphere parameters (surface pressure, scale height, Rayleigh / Mie coefficients, extinction)
|
||||
- `SatelliteConfig` — physical and orbital parameters for a natural satellite
|
||||
- `OrbitalConfig` — heliocentric Keplerian elements with epoch Julian Date
|
||||
- `PlanetTextureManifest` — optional URLs for diffuse / normal / specular / cloud / night textures
|
||||
- `PlanetConfig` — composite top-level shape: physical body + optional atmosphere + satellites + optional orbital and texture metadata
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```ts
|
||||
import type { PlanetConfig } from "@suaveplan/geo-planet";
|
||||
|
||||
const mars: PlanetConfig = {
|
||||
name: "Mars",
|
||||
mass: 6.39e23,
|
||||
radius: 3_396_200,
|
||||
polarRadius: 3_376_200,
|
||||
surfaceGravity: 3.72,
|
||||
rotationPeriod: 88_620,
|
||||
axialTilt: 25.2,
|
||||
atmosphere: null,
|
||||
satellites: [],
|
||||
};
|
||||
```
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AtmosphereConfig,
|
||||
OrbitalConfig,
|
||||
PlanetConfig,
|
||||
SatelliteConfig,
|
||||
} from "@suaveplan/geo-planet";
|
||||
|
||||
const earthAtmosphere: AtmosphereConfig = {
|
||||
surfacePressure: 101_325,
|
||||
scaleHeight: 8_500,
|
||||
rayleighCoefficient: 5.8e-6,
|
||||
mieCoefficient: 21e-6,
|
||||
extinctionPerAirMass: 0.13,
|
||||
};
|
||||
|
||||
const earthOrbit: OrbitalConfig = {
|
||||
semiMajorAxisAU: 1.0,
|
||||
eccentricity: 0.0167,
|
||||
inclinationDeg: 0.0,
|
||||
argumentOfPerihelionDeg: 102.94,
|
||||
ascendingNodeDeg: -11.26,
|
||||
meanAnomalyAtEpochDeg: 357.51,
|
||||
epochJd: 2_451_545,
|
||||
};
|
||||
|
||||
const luna: SatelliteConfig = {
|
||||
name: "Luna",
|
||||
mass: 7.342e22,
|
||||
radius: 1_737_400,
|
||||
orbitalRadius: 384_400_000,
|
||||
orbitalPeriod: 2_360_592,
|
||||
orbitalInclination: 5.145,
|
||||
eccentricity: 0.0549,
|
||||
albedo: 0.12,
|
||||
};
|
||||
|
||||
const earth: PlanetConfig = {
|
||||
name: "Earth",
|
||||
mass: 5.972e24,
|
||||
radius: 6_378_137,
|
||||
polarRadius: 6_356_752,
|
||||
surfaceGravity: 9.81,
|
||||
rotationPeriod: 86_164,
|
||||
axialTilt: 23.44,
|
||||
atmosphere: earthAtmosphere,
|
||||
satellites: [luna],
|
||||
orbital: earthOrbit,
|
||||
textures: { diffuse: "/textures/earth-diffuse.ktx2" },
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AtmosphereConfig`
|
||||
|
||||
`surfacePressure: number`, `scaleHeight: number`, `rayleighCoefficient: number`, `mieCoefficient: number`, `extinctionPerAirMass: number`.
|
||||
|
||||
### `SatelliteConfig`
|
||||
|
||||
`name: string`, plus the same physical and orbital scalars as a planet, restricted to `eccentricity ∈ [0, 1]` and `albedo ∈ [0, 1]` at the runtime layer.
|
||||
|
||||
### `OrbitalConfig`
|
||||
|
||||
`semiMajorAxisAU`, `eccentricity`, `inclinationDeg`, `argumentOfPerihelionDeg`, `ascendingNodeDeg`, `meanAnomalyAtEpochDeg`, `epochJd`.
|
||||
|
||||
### `PlanetTextureManifest`
|
||||
|
||||
Optional `diffuse`, `normal`, `specular`, `clouds`, `night` texture URL strings.
|
||||
|
||||
### `PlanetConfig`
|
||||
|
||||
`name`, `mass`, `radius`, `polarRadius`, `surfaceGravity`, `rotationPeriod`, `axialTilt`, `atmosphere: AtmosphereConfig | null`, `satellites: SatelliteConfig[]`, optional `orbital`, optional `textures`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The atmosphere field is `AtmosphereConfig | null` rather than `AtmosphereConfig | undefined` so airless bodies (Mercury, Moon) are explicitly modelled rather than accidentally omitted; renderers and atmospheric solvers branch on `=== null` directly. Both equatorial `radius` and `polarRadius` are required because `PlanetSurface` constructs an oblate ellipsoid for accurate great-circle math; spherical bodies pass equal values. Orbital and texture metadata are optional because tests, fixtures, and offline renderers often only need physical scalars.
|
||||
50
packages/geospatial/geo/geo-planet/src/types/types.ts
Normal file
50
packages/geospatial/geo/geo-planet/src/types/types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export interface AtmosphereConfig {
|
||||
surfacePressure: number;
|
||||
scaleHeight: number;
|
||||
rayleighCoefficient: number;
|
||||
mieCoefficient: number;
|
||||
extinctionPerAirMass: number;
|
||||
}
|
||||
|
||||
export interface SatelliteConfig {
|
||||
name: string;
|
||||
mass: number;
|
||||
radius: number;
|
||||
orbitalRadius: number;
|
||||
orbitalPeriod: number;
|
||||
orbitalInclination: number;
|
||||
eccentricity: number;
|
||||
albedo: number;
|
||||
}
|
||||
|
||||
export interface OrbitalConfig {
|
||||
semiMajorAxisAU: number;
|
||||
eccentricity: number;
|
||||
inclinationDeg: number;
|
||||
argumentOfPerihelionDeg: number;
|
||||
ascendingNodeDeg: number;
|
||||
meanAnomalyAtEpochDeg: number;
|
||||
epochJd: number;
|
||||
}
|
||||
|
||||
export interface PlanetTextureManifest {
|
||||
diffuse?: string;
|
||||
normal?: string;
|
||||
specular?: string;
|
||||
clouds?: string;
|
||||
night?: string;
|
||||
}
|
||||
|
||||
export interface PlanetConfig {
|
||||
name: string;
|
||||
mass: number;
|
||||
radius: number;
|
||||
polarRadius: number;
|
||||
surfaceGravity: number;
|
||||
rotationPeriod: number;
|
||||
axialTilt: number;
|
||||
atmosphere: AtmosphereConfig | null;
|
||||
satellites: SatelliteConfig[];
|
||||
orbital?: OrbitalConfig;
|
||||
textures?: PlanetTextureManifest;
|
||||
}
|
||||
12
packages/geospatial/geo/geo-planet/tsconfig.lib.json
Normal file
12
packages/geospatial/geo/geo-planet/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
37
packages/geospatial/geo/geo-planet/vite.config.ts
Normal file
37
packages/geospatial/geo/geo-planet/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../node_modules/.vite/packages/geo-planet",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/geo",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
2
packages/geospatial/geo/geo-planet/vitest.config.ts
Normal file
2
packages/geospatial/geo/geo-planet/vitest.config.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
export default createTestConfig();
|
||||
Reference in New Issue
Block a user