Skip to content

Commit ecda31c

Browse files
committed
feat: refactor FarFieldViewport and introduce VehicleMesh component for improved rendering
1 parent ca837be commit ecda31c

6 files changed

Lines changed: 165 additions & 39 deletions

File tree

rpo-app/src/viewport3d/far-field/FarFieldViewport.tsx

Lines changed: 11 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,14 @@
1-
import { useMemo } from 'react';
21
import { OrbitControls } from '@react-three/drei';
32
import { Canvas } from '@react-three/fiber';
43
import { useShallow } from 'zustand/react/shallow';
54

6-
import { type Vehicle, VEHICLE_LABELS } from '@/domain/vehicle';
75
import { PRESETS, type SpacecraftConfig } from '@/schemas/spacecraft';
86
import type { StateVectorInput } from '@/schemas/stateVector';
97
import { useConfig, type VehicleStateSlot } from '@/stores/configuration';
10-
import { configToSpacecraftProps, Spacecraft, TINTS } from '@/viewport3d/spacecraft';
118

12-
import {
13-
FARFIELD_LABEL_FONT_SIZE,
14-
FARFIELD_LABEL_OFFSET_Y,
15-
FARFIELD_SPACECRAFT_SCALE,
16-
} from './constants';
17-
import { eciKmToScenePosition } from './coordinates';
189
import Earth from './Earth';
10+
import { computeCameraPos, computeEraRad, computeSunScenePos, FALLBACK_LIGHT_POS } from './scene';
11+
import VehicleMesh from './VehicleMesh';
1912

2013
const FALLBACK_CONFIG: SpacecraftConfig = {
2114
preset: 'Servicer 500kg',
@@ -26,33 +19,6 @@ function loadedVector(slot: VehicleStateSlot): StateVectorInput | null {
2619
return slot.status === 'loaded' ? slot.vector : null;
2720
}
2821

29-
function VehicleMesh({
30-
vehicle,
31-
config,
32-
vector,
33-
}: {
34-
vehicle: Vehicle;
35-
config: SpacecraftConfig;
36-
vector: StateVectorInput;
37-
}) {
38-
const props = useMemo(() => configToSpacecraftProps(config, vehicle), [config, vehicle]);
39-
const position = useMemo(
40-
() => eciKmToScenePosition(vector.position_eci_km),
41-
[vector.position_eci_km],
42-
);
43-
return (
44-
<Spacecraft
45-
{...props}
46-
position={position}
47-
scale={FARFIELD_SPACECRAFT_SCALE}
48-
label={VEHICLE_LABELS[vehicle]}
49-
labelColor={TINTS[vehicle].accent}
50-
labelFontSize={FARFIELD_LABEL_FONT_SIZE}
51-
labelOffsetY={FARFIELD_LABEL_OFFSET_Y}
52-
/>
53-
);
54-
}
55-
5622
export default function FarFieldViewport() {
5723
const { chiefConfig, deputyConfig, chiefState, deputyState } = useConfig(
5824
useShallow((s) => ({
@@ -66,13 +32,19 @@ export default function FarFieldViewport() {
6632
const chiefVector = loadedVector(chiefState);
6733
const deputyVector = loadedVector(deputyState);
6834

35+
const epoch = chiefVector?.epoch ?? deputyVector?.epoch ?? null;
36+
const eraRad = epoch === null ? 0 : computeEraRad(epoch);
37+
const sunScenePos = epoch === null ? FALLBACK_LIGHT_POS : computeSunScenePos(epoch);
38+
// OrbitControls owns the camera after first render; remount to re-apply.
39+
const cameraPos = computeCameraPos(chiefVector, deputyVector);
40+
6941
return (
7042
<div className="h-full w-full">
71-
<Canvas camera={{ position: [0, 0, 6], fov: 50, near: 0.1, far: 100 }} dpr={[1, 2]}>
43+
<Canvas camera={{ position: cameraPos, fov: 50, near: 0.1, far: 100 }} dpr={[1, 2]}>
7244
<ambientLight intensity={0.35} />
7345
<hemisphereLight args={['#8aa0c0', '#1a1f2c', 0.4]} />
74-
<directionalLight position={[10, 5, 10]} intensity={1.0} />
75-
<Earth />
46+
<directionalLight position={sunScenePos} intensity={1.0} />
47+
<Earth rotationY={eraRad} />
7648
{chiefVector && <VehicleMesh vehicle="chief" config={chiefConfig} vector={chiefVector} />}
7749
{deputyVector && (
7850
<VehicleMesh vehicle="deputy" config={deputyConfig} vector={deputyVector} />
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useMemo } from 'react';
2+
3+
import { type Vehicle, VEHICLE_LABELS } from '@/domain/vehicle';
4+
import type { SpacecraftConfig } from '@/schemas/spacecraft';
5+
import type { StateVectorInput } from '@/schemas/stateVector';
6+
import { configToSpacecraftProps, Spacecraft, TINTS } from '@/viewport3d/spacecraft';
7+
8+
import {
9+
FARFIELD_LABEL_FONT_SIZE,
10+
FARFIELD_LABEL_OFFSET_Y,
11+
FARFIELD_SPACECRAFT_SCALE,
12+
} from './constants';
13+
import { eciKmToScenePosition } from './coordinates';
14+
15+
type Props = {
16+
vehicle: Vehicle;
17+
config: SpacecraftConfig;
18+
vector: StateVectorInput;
19+
};
20+
21+
export default function VehicleMesh({ vehicle, config, vector }: Props) {
22+
const props = useMemo(() => configToSpacecraftProps(config, vehicle), [config, vehicle]);
23+
const position = useMemo(
24+
() => eciKmToScenePosition(vector.position_eci_km),
25+
[vector.position_eci_km],
26+
);
27+
return (
28+
<Spacecraft
29+
{...props}
30+
position={position}
31+
scale={FARFIELD_SPACECRAFT_SCALE}
32+
label={VEHICLE_LABELS[vehicle]}
33+
labelColor={TINTS[vehicle].accent}
34+
labelFontSize={FARFIELD_LABEL_FONT_SIZE}
35+
labelOffsetY={FARFIELD_LABEL_OFFSET_Y}
36+
/>
37+
);
38+
}

rpo-app/src/viewport3d/far-field/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,7 @@ export const TEXTURE_PATHS = {
2929
normal: '/textures/earth/earth_normal.jpg',
3030
specular: '/textures/earth/earth_specular.jpg',
3131
} as const;
32+
33+
// Offset to absorb Three.js SphereGeometry UV seam shift if a future texture
34+
// swap introduces one; keep at 0 otherwise.
35+
export const EARTH_TEXTURE_LON_OFFSET_RAD = 0;

rpo-app/src/viewport3d/far-field/coordinates.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,17 @@ export const SCENE_UNITS_PER_KM = EARTH_RADIUS / EARTH_RADIUS_KM;
1414
export function eciKmToScenePosition([x_km, y_km, z_km]: Vec3): Vec3 {
1515
return [x_km * SCENE_UNITS_PER_KM, z_km * SCENE_UNITS_PER_KM, -y_km * SCENE_UNITS_PER_KM];
1616
}
17+
18+
// Same axis swap as eciKmToScenePosition; caller wraps in ERA-rotated group.
19+
export function ecefKmToScenePosition(r_ecef_km: Vec3): Vec3 {
20+
return eciKmToScenePosition(r_ecef_km);
21+
}
22+
23+
// Normalize, apply scene axis swap, and place on a sphere of `distance` scene
24+
// units — used as a parallel-light source point.
25+
export function scaleToSceneFar([x, y, z]: Vec3, distance = 20): Vec3 {
26+
const norm = Math.hypot(x, y, z);
27+
if (norm === 0) return [distance, 0, 0];
28+
const s = distance / norm;
29+
return [x * s, z * s, -y * s];
30+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { match } from '@railway-ts/pipelines/result';
2+
3+
import type { StateVectorInput } from '@/schemas/stateVector';
4+
import type { Vec3 } from '@/viewport3d/types';
5+
import { earthRotationAngleRad, sunPositionEciKm } from '@/wasm/earth';
6+
7+
import { EARTH_TEXTURE_LON_OFFSET_RAD } from './constants';
8+
import { eciKmToScenePosition, scaleToSceneFar } from './coordinates';
9+
10+
export const FALLBACK_LIGHT_POS: Vec3 = scaleToSceneFar([10, 5, 10]);
11+
12+
// ~3× Earth radius, sized to fill ~2/3 of frame.
13+
export const CAMERA_DISTANCE_SCENE = 6;
14+
export const DEFAULT_CAMERA_POS: Vec3 = [0, 0, CAMERA_DISTANCE_SCENE];
15+
16+
export function computeEraRad(epoch: string): number {
17+
return match(earthRotationAngleRad(epoch), {
18+
ok: (v) => v + EARTH_TEXTURE_LON_OFFSET_RAD,
19+
err: () => 0,
20+
});
21+
}
22+
23+
export function computeSunScenePos(epoch: string): Vec3 {
24+
return match(sunPositionEciKm(epoch), {
25+
ok: (eciKm) => scaleToSceneFar(eciKm),
26+
err: () => FALLBACK_LIGHT_POS,
27+
});
28+
}
29+
30+
export function computeCameraPos(
31+
chief: StateVectorInput | null,
32+
deputy: StateVectorInput | null,
33+
): Vec3 {
34+
const sources = [chief, deputy].filter((v): v is StateVectorInput => v !== null);
35+
if (sources.length === 0) return DEFAULT_CAMERA_POS;
36+
const mean: Vec3 = [0, 0, 0];
37+
for (const v of sources) {
38+
const scenePos = eciKmToScenePosition(v.position_eci_km);
39+
mean[0] += scenePos[0];
40+
mean[1] += scenePos[1];
41+
mean[2] += scenePos[2];
42+
}
43+
const norm = Math.hypot(mean[0], mean[1], mean[2]);
44+
if (norm === 0) return DEFAULT_CAMERA_POS;
45+
const s = CAMERA_DISTANCE_SCENE / norm;
46+
return [mean[0] * s, mean[1] * s, mean[2] * s];
47+
}

rpo-app/src/wasm/earth.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { Result } from '@railway-ts/pipelines/result';
2+
3+
import type { EcefState, GeodeticCoord, StateVector, Vec3, WasmError } from 'rpo-wasm';
4+
import {
5+
earth_rotation_angle_rad,
6+
ecef_to_eci_position_km,
7+
ecef_to_geodetic,
8+
eci_to_ecef_position_km,
9+
eci_to_ecef_state,
10+
geodetic_to_ecef_km,
11+
moon_position_eci_km,
12+
sun_position_eci_km,
13+
} from 'rpo-wasm';
14+
15+
import { callWasm } from './error';
16+
17+
export function earthRotationAngleRad(epoch: string): Result<number, WasmError> {
18+
return callWasm(() => earth_rotation_angle_rad(epoch));
19+
}
20+
21+
export function sunPositionEciKm(epoch: string): Result<Vec3, WasmError> {
22+
return callWasm(() => sun_position_eci_km(epoch));
23+
}
24+
25+
export function moonPositionEciKm(epoch: string): Result<Vec3, WasmError> {
26+
return callWasm(() => moon_position_eci_km(epoch));
27+
}
28+
29+
export function eciToEcefPositionKm(rEciKm: Vec3, epoch: string): Result<Vec3, WasmError> {
30+
return callWasm(() => eci_to_ecef_position_km(rEciKm, epoch));
31+
}
32+
33+
export function ecefToEciPositionKm(rEcefKm: Vec3, epoch: string): Result<Vec3, WasmError> {
34+
return callWasm(() => ecef_to_eci_position_km(rEcefKm, epoch));
35+
}
36+
37+
export function eciToEcefState(state: StateVector): Result<EcefState, WasmError> {
38+
return callWasm(() => eci_to_ecef_state(state));
39+
}
40+
41+
export function geodeticToEcefKm(
42+
latitudeRad: number,
43+
longitudeRad: number,
44+
altitudeKm: number,
45+
): Result<Vec3, WasmError> {
46+
return callWasm(() => geodetic_to_ecef_km(latitudeRad, longitudeRad, altitudeKm));
47+
}
48+
49+
export function ecefToGeodetic(rEcefKm: Vec3): Result<GeodeticCoord, WasmError> {
50+
return callWasm(() => ecef_to_geodetic(rEcefKm));
51+
}

0 commit comments

Comments
 (0)