Skip to content

Commit c0392ea

Browse files
committed
feat: implement celestial indicators and enhance proximity viewport with vehicle rendering
1 parent ecda31c commit c0392ea

15 files changed

Lines changed: 482 additions & 103 deletions

File tree

rpo-app/src/features/configuration/ConfigurationStep2.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useShallow } from 'zustand/react/shallow';
22

33
import { ClassificationResult } from '@/features/classification/ClassificationResult';
44
import { ThresholdAdvisories } from '@/features/classification/ThresholdAdvisories';
5-
import { useConfig } from '@/stores/configuration';
5+
import { loadedVector, useConfig } from '@/stores/configuration';
66
import { Callout } from '@/ui/Callout';
77

88
import { ProceedButton } from './ProceedButton';
@@ -14,8 +14,8 @@ export function ConfigurationStep2() {
1414
useShallow((s) => ({ chiefState: s.chiefState, deputyState: s.deputyState })),
1515
);
1616

17-
const chiefVector = chiefState.status === 'loaded' ? chiefState.vector : null;
18-
const deputyVector = deputyState.status === 'loaded' ? deputyState.vector : null;
17+
const chiefVector = loadedVector(chiefState);
18+
const deputyVector = loadedVector(deputyState);
1919
const epochMismatch =
2020
chiefVector !== null && deputyVector !== null && chiefVector.epoch !== deputyVector.epoch;
2121

rpo-app/src/stores/configuration.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ export type VehicleStateSlot =
1616

1717
export const EMPTY_SLOT: VehicleStateSlot = { status: 'empty' };
1818

19+
export function loadedVector(slot: VehicleStateSlot): StateVectorInput | null {
20+
return slot.status === 'loaded' ? slot.vector : null;
21+
}
22+
1923
type ConfigState = {
2024
chiefConfig: VehicleState | null;
2125
deputyConfig: VehicleState | null;

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

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1+
import { useMemo } from 'react';
12
import { OrbitControls } from '@react-three/drei';
23
import { Canvas } from '@react-three/fiber';
34
import { useShallow } from 'zustand/react/shallow';
45

56
import { PRESETS, type SpacecraftConfig } from '@/schemas/spacecraft';
6-
import type { StateVectorInput } from '@/schemas/stateVector';
7-
import { useConfig, type VehicleStateSlot } from '@/stores/configuration';
7+
import { loadedVector, useConfig } from '@/stores/configuration';
88

99
import Earth from './Earth';
1010
import { computeCameraPos, computeEraRad, computeSunScenePos, FALLBACK_LIGHT_POS } from './scene';
@@ -15,10 +15,6 @@ const FALLBACK_CONFIG: SpacecraftConfig = {
1515
...PRESETS['Servicer 500kg'],
1616
};
1717

18-
function loadedVector(slot: VehicleStateSlot): StateVectorInput | null {
19-
return slot.status === 'loaded' ? slot.vector : null;
20-
}
21-
2218
export default function FarFieldViewport() {
2319
const { chiefConfig, deputyConfig, chiefState, deputyState } = useConfig(
2420
useShallow((s) => ({
@@ -33,10 +29,16 @@ export default function FarFieldViewport() {
3329
const deputyVector = loadedVector(deputyState);
3430

3531
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);
32+
const eraRad = useMemo(() => (epoch === null ? 0 : computeEraRad(epoch)), [epoch]);
33+
const sunScenePos = useMemo(
34+
() => (epoch === null ? FALLBACK_LIGHT_POS : computeSunScenePos(epoch)),
35+
[epoch],
36+
);
3837
// OrbitControls owns the camera after first render; remount to re-apply.
39-
const cameraPos = computeCameraPos(chiefVector, deputyVector);
38+
const cameraPos = useMemo(
39+
() => computeCameraPos(chiefVector, deputyVector),
40+
[chiefVector, deputyVector],
41+
);
4042

4143
return (
4244
<div className="h-full w-full">

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { match } from '@railway-ts/pipelines/result';
22

33
import type { StateVectorInput } from '@/schemas/stateVector';
44
import type { Vec3 } from '@/viewport3d/types';
5-
import { earthRotationAngleRad, sunPositionEciKm } from '@/wasm/earth';
5+
import { earthRotationAngleRad } from '@/wasm/earth';
6+
import { sunPositionEciKm } from '@/wasm/ephemeris';
67

78
import { EARTH_TEXTURE_LON_OFFSET_RAD } from './constants';
89
import { eciKmToScenePosition, scaleToSceneFar } from './coordinates';
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { useMemo } from 'react';
2+
import { Html, Line } from '@react-three/drei';
3+
import { Earth, Moon, Sun } from 'lucide-react';
4+
5+
import type { Vec3 } from '@/viewport3d/types';
6+
7+
import { CELESTIAL_INDICATOR } from './constants';
8+
import { ricToPosition } from './coordinates';
9+
10+
type CelestialBody = 'earth' | 'sun' | 'moon';
11+
12+
const ICON_BY_BODY = {
13+
earth: Earth,
14+
sun: Sun,
15+
moon: Moon,
16+
} as const;
17+
18+
type Props = {
19+
unitRic: Vec3;
20+
edgeM: number;
21+
body: CelestialBody;
22+
color: string;
23+
};
24+
25+
export default function CelestialIndicator({ unitRic, edgeM, body, color }: Props) {
26+
const { tip, iconPos } = useMemo(() => {
27+
const tipRic: Vec3 = [unitRic[0] * edgeM, unitRic[1] * edgeM, unitRic[2] * edgeM];
28+
const offset = edgeM * CELESTIAL_INDICATOR.labelOffsetRatio;
29+
const iconRic: Vec3 = [
30+
unitRic[0] * (edgeM + offset),
31+
unitRic[1] * (edgeM + offset),
32+
unitRic[2] * (edgeM + offset),
33+
];
34+
return { tip: ricToPosition(tipRic), iconPos: ricToPosition(iconRic) };
35+
}, [unitRic, edgeM]);
36+
37+
const Icon = ICON_BY_BODY[body];
38+
39+
return (
40+
<group>
41+
<Line
42+
points={[[0, 0, 0], tip]}
43+
color={color}
44+
lineWidth={CELESTIAL_INDICATOR.lineWidth}
45+
opacity={CELESTIAL_INDICATOR.lineOpacity}
46+
transparent
47+
depthWrite={false}
48+
/>
49+
<Html
50+
position={iconPos}
51+
center
52+
zIndexRange={[20, 0]}
53+
style={{ pointerEvents: 'none', color }}
54+
>
55+
<Icon
56+
size={CELESTIAL_INDICATOR.iconSizePx}
57+
strokeWidth={CELESTIAL_INDICATOR.iconStrokeWidth}
58+
/>
59+
</Html>
60+
</group>
61+
);
62+
}

rpo-app/src/viewport3d/proximity/Grid.tsx

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,62 +3,72 @@ import { Grid as DreiGrid, Text } from '@react-three/drei';
33

44
import type { Vec3 } from '@/viewport3d/types';
55

6-
import {
7-
GRID_CELL_SIZE_M,
8-
GRID_COLORS,
9-
GRID_LABEL_COLOR,
10-
GRID_LABEL_FONT_SIZE_M,
11-
GRID_LABEL_OFFSET_M,
12-
GRID_SECTION_SIZE_M,
13-
GRID_SIZE_M,
14-
} from './constants';
6+
import { GRID_COLORS, GRID_LABEL_COLOR } from './constants';
7+
8+
// Label font size and axis offset as fractions of the section size, so
9+
// labels stay legible as the grid scales from 1m/section to 500km/section.
10+
// Tuned so the legacy look (section=50m, font=4m, offset=6m) is preserved.
11+
const GRID_LABEL_FONT_RATIO = 0.08;
12+
const GRID_LABEL_OFFSET_RATIO = 0.12;
1513

1614
function formatDistance(meters: number): string {
1715
const abs = Math.abs(meters);
1816
if (abs >= 1000) {
1917
const km = meters / 1000;
2018
return Number.isInteger(km) ? `${km}km` : `${km.toFixed(1)}km`;
2119
}
22-
return `${meters}m`;
20+
return Number.isInteger(meters) ? `${meters}m` : `${meters.toFixed(1)}m`;
2321
}
2422

2523
type Marker = { position: Vec3; label: string };
2624

27-
export default function Grid() {
25+
type Props = {
26+
sizeM: number;
27+
sectionSizeM: number;
28+
};
29+
30+
// DreiGrid draws 5 cells per section; keep the ratio internal so callers
31+
// can't pass an inconsistent cellSize.
32+
const CELLS_PER_SECTION = 5;
33+
34+
export default function Grid({ sizeM, sectionSizeM }: Props) {
35+
const cellSizeM = sectionSizeM / CELLS_PER_SECTION;
36+
const labelFontSizeM = sectionSizeM * GRID_LABEL_FONT_RATIO;
37+
2838
const markers = useMemo<Marker[]>(() => {
29-
const half = GRID_SIZE_M / 2;
30-
const interval = GRID_SECTION_SIZE_M;
39+
const half = sizeM / 2;
40+
const offset = sectionSizeM * GRID_LABEL_OFFSET_RATIO;
3141
const result: Marker[] = [];
32-
for (let d = interval; d <= half; d += interval) {
33-
// I-axis (X) labels — sit just off the R=0 line, in the grid plane
34-
result.push({ position: [d, -GRID_LABEL_OFFSET_M, 0], label: formatDistance(d) });
35-
result.push({ position: [-d, -GRID_LABEL_OFFSET_M, 0], label: formatDistance(-d) });
36-
// R-axis (Y) labels — sit just off the I=0 line, in the grid plane
37-
result.push({ position: [-GRID_LABEL_OFFSET_M, d, 0], label: formatDistance(d) });
38-
result.push({ position: [-GRID_LABEL_OFFSET_M, -d, 0], label: formatDistance(-d) });
42+
for (let d = sectionSizeM; d <= half; d += sectionSizeM) {
43+
// I-axis (X) labels — sit just off the R=0 line, in the grid plane.
44+
result.push({ position: [d, -offset, 0], label: formatDistance(d) });
45+
result.push({ position: [-d, -offset, 0], label: formatDistance(-d) });
46+
// R-axis (Y) labels — sit just off the I=0 line, in the grid plane.
47+
result.push({ position: [-offset, d, 0], label: formatDistance(d) });
48+
result.push({ position: [-offset, -d, 0], label: formatDistance(-d) });
3949
}
4050
return result;
41-
}, []);
51+
}, [sizeM, sectionSizeM]);
4252

4353
return (
4454
<group>
4555
<DreiGrid
46-
args={[GRID_SIZE_M, GRID_SIZE_M]}
56+
args={[sizeM, sizeM]}
4757
rotation={[Math.PI / 2, 0, 0]}
48-
cellSize={GRID_CELL_SIZE_M}
58+
cellSize={cellSizeM}
4959
cellThickness={0.4}
5060
cellColor={GRID_COLORS.cell}
51-
sectionSize={GRID_SECTION_SIZE_M}
61+
sectionSize={sectionSizeM}
5262
sectionThickness={0.8}
5363
sectionColor={GRID_COLORS.section}
54-
fadeDistance={GRID_SIZE_M}
64+
fadeDistance={sizeM}
5565
fadeStrength={1.5}
5666
/>
5767
{markers.map((marker, i) => (
5868
<Text
5969
key={`marker-${i}`}
6070
position={marker.position}
61-
fontSize={GRID_LABEL_FONT_SIZE_M}
71+
fontSize={labelFontSizeM}
6272
color={GRID_LABEL_COLOR}
6373
anchorX="center"
6474
anchorY="middle"

rpo-app/src/viewport3d/proximity/ProximityViewport.tsx

Lines changed: 96 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,117 @@
11
import { useMemo } from 'react';
2-
import { OrbitControls } from '@react-three/drei';
2+
import { OrbitControls, PerspectiveCamera } from '@react-three/drei';
33
import { Canvas } from '@react-three/fiber';
4+
import { useShallow } from 'zustand/react/shallow';
45

5-
import { VEHICLE_LABELS } from '@/domain/vehicle';
6-
import { PRESETS } from '@/schemas/spacecraft';
7-
import { configToSpacecraftProps, Spacecraft, TINTS } from '@/viewport3d/spacecraft';
6+
import { PRESETS, type SpacecraftConfig } from '@/schemas/spacecraft';
7+
import { loadedVector, useConfig } from '@/stores/configuration';
8+
import type { Vec3 } from '@/viewport3d/types';
89

9-
import { CAMERA, DEMO_DEPUTY_OFFSET_RIC_M, ORBIT_CONTROLS } from './constants';
10-
import { ricToPosition } from './coordinates';
10+
import CelestialIndicator from './CelestialIndicator';
11+
import { CELESTIAL_EDGE_RATIO, CELESTIAL_TINTS, ORBIT_CONTROLS } from './constants';
1112
import Grid from './Grid';
1213
import RICAxes from './RICAxes';
14+
import { computeCelestialDirsRic, computeDeputyPositionRicM, computeScale } from './scene';
15+
import VehicleMesh from './VehicleMesh';
1316

14-
const DEFAULT_CHIEF = { preset: 'Servicer 500kg' as const, ...PRESETS['Servicer 500kg'] };
15-
const DEFAULT_DEPUTY = { preset: 'Servicer 500kg' as const, ...PRESETS['Servicer 500kg'] };
17+
const CHIEF_ORIGIN_RIC_M: Vec3 = [0, 0, 0];
18+
19+
const FALLBACK_CONFIG: SpacecraftConfig = {
20+
preset: 'Servicer 500kg',
21+
...PRESETS['Servicer 500kg'],
22+
};
1623

1724
export default function ProximityViewport() {
18-
const chiefProps = useMemo(() => configToSpacecraftProps(DEFAULT_CHIEF, 'chief'), []);
19-
const deputyProps = useMemo(() => configToSpacecraftProps(DEFAULT_DEPUTY, 'deputy'), []);
20-
const deputyPosition = useMemo(() => ricToPosition(DEMO_DEPUTY_OFFSET_RIC_M), []);
25+
const { chiefConfig, deputyConfig, chiefState, deputyState } = useConfig(
26+
useShallow((s) => ({
27+
chiefConfig: s.chiefConfig?.values ?? FALLBACK_CONFIG,
28+
deputyConfig: s.deputyConfig?.values ?? FALLBACK_CONFIG,
29+
chiefState: s.chiefState,
30+
deputyState: s.deputyState,
31+
})),
32+
);
33+
34+
const chiefVector = loadedVector(chiefState);
35+
const deputyVector = loadedVector(deputyState);
36+
37+
const deputyRicM = useMemo(
38+
() => computeDeputyPositionRicM(chiefVector, deputyVector),
39+
[chiefVector, deputyVector],
40+
);
41+
const scale = useMemo(() => computeScale(deputyRicM), [deputyRicM]);
42+
const celestials = useMemo(() => computeCelestialDirsRic(chiefVector), [chiefVector]);
43+
const celestialEdgeM = scale.extentM * CELESTIAL_EDGE_RATIO;
2144

2245
return (
2346
<div className="h-full w-full">
24-
<Canvas
25-
camera={{
26-
position: CAMERA.position,
27-
fov: CAMERA.fov,
28-
near: CAMERA.near,
29-
far: CAMERA.far,
30-
}}
31-
dpr={[1, 2]}
32-
>
47+
<Canvas dpr={[1, 2]}>
48+
<PerspectiveCamera
49+
makeDefault
50+
fov={50}
51+
position={[0, 0, scale.cameraDistanceM]}
52+
near={scale.nearPlaneM}
53+
far={scale.farPlaneM}
54+
/>
55+
3356
<ambientLight intensity={0.35} />
3457
<hemisphereLight args={['#8aa0c0', '#1a1f2c', 0.4]} />
35-
<directionalLight position={[10, 5, 10]} intensity={1.0} />
36-
<Grid />
37-
<RICAxes />
38-
<Spacecraft {...chiefProps} label={VEHICLE_LABELS.chief} labelColor={TINTS.chief.accent} />
39-
<Spacecraft
40-
{...deputyProps}
41-
position={deputyPosition}
42-
label={VEHICLE_LABELS.deputy}
43-
labelColor={TINTS.deputy.accent}
58+
<directionalLight
59+
position={[scale.extentM, scale.extentM / 2, scale.extentM]}
60+
intensity={1.0}
4461
/>
62+
63+
<Grid sizeM={scale.extentM} sectionSizeM={scale.sectionM} />
64+
<RICAxes />
65+
66+
{chiefVector && (
67+
<VehicleMesh
68+
vehicle="chief"
69+
config={chiefConfig}
70+
positionRicM={CHIEF_ORIGIN_RIC_M}
71+
scale={scale.spacecraftScale}
72+
labelFontSizeM={scale.labelFontSizeM}
73+
/>
74+
)}
75+
{chiefVector && deputyVector && deputyRicM && (
76+
<VehicleMesh
77+
vehicle="deputy"
78+
config={deputyConfig}
79+
positionRicM={deputyRicM}
80+
scale={scale.spacecraftScale}
81+
labelFontSizeM={scale.labelFontSizeM}
82+
/>
83+
)}
84+
85+
{celestials.earth && (
86+
<CelestialIndicator
87+
unitRic={celestials.earth}
88+
edgeM={celestialEdgeM}
89+
body="earth"
90+
color={CELESTIAL_TINTS.earth}
91+
/>
92+
)}
93+
{celestials.sun && (
94+
<CelestialIndicator
95+
unitRic={celestials.sun}
96+
edgeM={celestialEdgeM}
97+
body="sun"
98+
color={CELESTIAL_TINTS.sun}
99+
/>
100+
)}
101+
{celestials.moon && (
102+
<CelestialIndicator
103+
unitRic={celestials.moon}
104+
edgeM={celestialEdgeM}
105+
body="moon"
106+
color={CELESTIAL_TINTS.moon}
107+
/>
108+
)}
109+
45110
<OrbitControls
46111
enablePan
47112
enableDamping={ORBIT_CONTROLS.enableDamping}
48-
minDistance={ORBIT_CONTROLS.minDistance}
49-
maxDistance={ORBIT_CONTROLS.maxDistance}
113+
minDistance={scale.orbitMinM}
114+
maxDistance={scale.orbitMaxM}
50115
/>
51116
</Canvas>
52117
</div>

0 commit comments

Comments
 (0)