Compare commits

..

3 Commits

Author SHA1 Message Date
munsel
81a6f1fa37 fix path to terrain alignment 2026-04-26 00:28:26 +02:00
munsel
b836d9c01b now with mapLibre instead of cesium 2026-04-25 23:15:46 +02:00
munsel
3aeff0a7e7 ui tweaks and performance tweaks 2026-04-24 03:39:20 +02:00
36 changed files with 2465 additions and 2132 deletions

1031
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@
"dependencies": { "dependencies": {
"@mkkellogg/gaussian-splats-3d": "^0.4.6", "@mkkellogg/gaussian-splats-3d": "^0.4.6",
"axios": "^1.7.9", "axios": "^1.7.9",
"cesium": "^1.124.0", "maplibre-gl": "^4.7.1",
"oidc-client-ts": "^3.1.0", "oidc-client-ts": "^3.1.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@ -22,12 +22,12 @@
}, },
"devDependencies": { "devDependencies": {
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",
"@types/maplibre-gl": "^1.0.0",
"@types/react": "^19.0.2", "@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2", "@types/react-dom": "^19.0.2",
"@types/three": "^0.171.0", "@types/three": "^0.171.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^5.4.11", "vite": "^5.4.11"
"vite-plugin-cesium": "^1.2.22"
} }
} }

View File

@ -1,7 +1,7 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom' import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { AuthProvider } from './auth/AuthProvider' import { AuthProvider } from './auth/AuthProvider'
import { CallbackPage } from './auth/CallbackPage' import { CallbackPage } from './auth/CallbackPage'
import { CesiumViewer } from './cesium/CesiumViewer' import { MapLibreViewer } from './maplibre/MapLibreViewer'
import { SplatLayer } from './splat/SplatLayer' import { SplatLayer } from './splat/SplatLayer'
import { SplatRenderer } from './splat/SplatRenderer' import { SplatRenderer } from './splat/SplatRenderer'
import { ChallengeLayer } from './challenges/ChallengeLayer' import { ChallengeLayer } from './challenges/ChallengeLayer'
@ -17,17 +17,17 @@ import { UserProfilePage } from './users/UserProfilePage'
function MapPage() { function MapPage() {
return ( return (
<CesiumViewer> <MapLibreViewer>
{/* Imperative Cesium layers — render no DOM, manage entities */} {/* Map-layer components — render no DOM, manage MapLibre sources */}
<SplatLayer /> <SplatLayer />
<ChallengeLayer /> <ChallengeLayer />
{/* Three.js splat overlay — portalled canvas above Cesium */} {/* Three.js splat overlay — portalled canvas above the map */}
<SplatRenderer /> <SplatRenderer />
{/* React UI — z-indexed above Cesium canvas */} {/* React UI — z-indexed above the map canvas */}
<MapOverlay /> <MapOverlay />
<ChallengePanel /> <ChallengePanel />
<ChallengeCreator /> <ChallengeCreator />
</CesiumViewer> </MapLibreViewer>
) )
} }

View File

@ -1,71 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import * as Cesium from 'cesium'
import 'cesium/Build/Cesium/Widgets/widgets.css'
import { CesiumContext } from './cesiumContext'
interface Props {
children?: React.ReactNode
}
export function CesiumViewer({ children }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const [viewer, setViewer] = useState<Cesium.Viewer | null>(null)
useEffect(() => {
// Guard: only create if the container is mounted and no viewer yet
if (!containerRef.current || viewer) return
Cesium.Ion.defaultAccessToken = import.meta.env.VITE_CESIUM_ION_TOKEN ?? ''
const v = new Cesium.Viewer(containerRef.current, {
terrainProvider: new Cesium.EllipsoidTerrainProvider(),
homeButton: false,
baseLayerPicker: false,
navigationHelpButton: false,
animation: false,
timeline: false,
geocoder: false,
sceneModePicker: false,
fullscreenButton: false,
infoBox: false,
selectionIndicator: false,
})
// Async: upgrade to world terrain after initial load
Cesium.createWorldTerrainAsync()
.then((tp) => {
if (!v.isDestroyed()) v.terrainProvider = tp
})
.catch(() => {/* non-fatal: fall back to ellipsoid */})
// Async: switch base imagery to Bing Aerial with Labels
Cesium.createWorldImageryAsync({ style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS })
.then((ip) => {
if (!v.isDestroyed()) v.imageryLayers.get(0).imageryProvider = ip
})
.catch(() => {/* non-fatal: keep default imagery */})
setViewer(v)
return () => {
if (!v.isDestroyed()) v.destroy()
setViewer(null)
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return (
<>
{/* Cesium mounts itself into this div and fills it completely */}
<div
ref={containerRef}
style={{ position: 'fixed', inset: 0 }}
/>
{/* Provide viewer to all children; only render children once viewer is ready */}
{viewer && (
<CesiumContext.Provider value={viewer}>
{children}
</CesiumContext.Provider>
)}
</>
)
}

View File

@ -1,14 +0,0 @@
import { createContext, useContext } from 'react'
import type * as CesiumType from 'cesium'
type Viewer = InstanceType<typeof CesiumType.Viewer>
export const CesiumContext = createContext<Viewer | null>(null)
export function useCesiumViewer(): Viewer {
const viewer = useContext(CesiumContext)
if (!viewer) {
throw new Error('useCesiumViewer must be used inside <CesiumViewer>')
}
return viewer
}

View File

@ -1,63 +0,0 @@
import * as Cesium from 'cesium'
import * as THREE from 'three'
/**
* Build a Three.js Matrix4 that positions and orients a local scene
* at the given geographic coordinate.
*
* The returned matrix transforms from local ENU space (metres from the
* anchor point, X=East, Y=North, Z=Up) to Cesium ECEF space (metres
* from Earth centre). Apply it to a Three.js Object3D.matrixWorld and
* set matrixAutoUpdate = false.
*
* @param lon Longitude in degrees
* @param lat Latitude in degrees
* @param alt Altitude in metres above WGS-84 ellipsoid
* @param headingDeg Clockwise heading in degrees (0 = North, 90 = East)
*/
export function buildSplatWorldMatrix(
lon: number,
lat: number,
alt: number,
headingDeg: number,
): THREE.Matrix4 {
const position = Cesium.Cartesian3.fromDegrees(lon, lat, alt)
// 4×4 column-major matrix: local ENU → ECEF
const enuToEcef = Cesium.Transforms.eastNorthUpToFixedFrame(position)
// Apply a rotation around local Up (Z in ENU) for heading.
// Cesium heading is clockwise from North, which is Z rotation in ENU.
const headingRad = Cesium.Math.toRadians(-headingDeg)
const headingRotation = Cesium.Matrix4.fromRotationTranslation(
Cesium.Matrix3.fromRotationZ(headingRad),
)
const finalCesiumMatrix = new Cesium.Matrix4()
Cesium.Matrix4.multiply(enuToEcef, headingRotation, finalCesiumMatrix)
// Cesium Matrix4 is a Float64Array in column-major order.
// Three.js Matrix4 uses Float32Array, also column-major.
// Direct cast works since both use the same element layout.
const threeMatrix = new THREE.Matrix4()
threeMatrix.set(
finalCesiumMatrix[0], finalCesiumMatrix[4], finalCesiumMatrix[8], finalCesiumMatrix[12],
finalCesiumMatrix[1], finalCesiumMatrix[5], finalCesiumMatrix[9], finalCesiumMatrix[13],
finalCesiumMatrix[2], finalCesiumMatrix[6], finalCesiumMatrix[10], finalCesiumMatrix[14],
finalCesiumMatrix[3], finalCesiumMatrix[7], finalCesiumMatrix[11], finalCesiumMatrix[15],
)
return threeMatrix
}
/**
* Convert a Cesium Rectangle (radians) to a bbox tuple (degrees).
*/
export function rectangleToBbox(
rect: Cesium.Rectangle,
): [number, number, number, number] {
return [
Cesium.Math.toDegrees(rect.west),
Cesium.Math.toDegrees(rect.south),
Cesium.Math.toDegrees(rect.east),
Cesium.Math.toDegrees(rect.north),
]
}

View File

@ -1,37 +0,0 @@
import { useEffect } from 'react'
import { useCesiumViewer } from './cesiumContext'
import { useMapStore } from '../store/mapStore'
import { rectangleToBbox } from './geoUtils'
/**
* Attaches a scene.preUpdate listener that writes camera height and
* the current view bbox to mapStore on every frame.
*
* Throttled so the store update fires at most once per 200 ms to avoid
* triggering expensive API queries on every rendered frame.
*/
export function useCesiumCamera() {
const viewer = useCesiumViewer()
const setCameraState = useMapStore((s) => s.setCameraState)
useEffect(() => {
let lastFired = 0
const THROTTLE_MS = 200
const removeListener = viewer.scene.preUpdate.addEventListener(() => {
const now = Date.now()
if (now - lastFired < THROTTLE_MS) return
lastFired = now
const height = viewer.camera.positionCartographic.height
const rect = viewer.camera.computeViewRectangle()
if (rect) {
setCameraState(height, rectangleToBbox(rect))
}
})
return () => {
removeListener()
}
}, [viewer, setCameraState])
}

View File

@ -1,31 +1,53 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import * as Cesium from 'cesium' import maplibregl, { type GeoJSONSource } from 'maplibre-gl'
import { useCesiumViewer } from '../cesium/cesiumContext' import { useMapLibreMap } from '../maplibre/maplibreContext'
import { safeRemoveLayers } from '../maplibre/geoUtils'
import { useMapStore } from '../store/mapStore' import { useMapStore } from '../store/mapStore'
import { useChallengeStore } from '../store/challengeStore' import { useChallengeStore } from '../store/challengeStore'
import { usePolygonDraw } from './usePolygonDraw' import { usePolygonDraw } from './usePolygonDraw'
import { fetchChallenges } from '../api/challenges' import { fetchChallenges, fetchChallengeDetail } from '../api/challenges'
import type { BBox } from '../types/geo' import type { BBox } from '../types/geo'
import type { ChallengeMapProperties } from '../types/api' import type { ChallengeMapProperties } from '../types/api'
const CHALLENGE_VISIBLE_HEIGHT = 200_000 const CHALLENGE_VISIBLE_HEIGHT = 200_000
const SRC_REGION = 'challenge-region'
const LYR_REGION_FILL = 'challenge-region-fill'
const LYR_REGION_LINE = 'challenge-region-line'
function emptyFC(): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', features: [] } }
export function ChallengeLayer() { export function ChallengeLayer() {
const viewer = useCesiumViewer() const map = useMapLibreMap()
usePolygonDraw() usePolygonDraw()
const { bbox, cameraHeight, setLoadedChallenges } = useMapStore() const { bbox, cameraHeight, setLoadedChallenges } = useMapStore()
const { selectedChallengeId, setSelectedChallengeId } = useChallengeStore() const { selectedChallengeId, setSelectedChallengeId } = useChallengeStore()
const entityMapRef = useRef<Map<string, Cesium.Entity>>(new Map()) const markersRef = useRef<Map<string, maplibregl.Marker>>(new Map())
const regionEntityRef = useRef<Cesium.Entity | null>(null)
const lastBboxRef = useRef<BBox | null>(null) const lastBboxRef = useRef<BBox | null>(null)
// Fetch and render challenge pins // ── Set up region polygon sources + layers ─────────────────────────────────
useEffect(() => {
map.addSource(SRC_REGION, { type: 'geojson', data: emptyFC() })
map.addLayer({ id: LYR_REGION_FILL, type: 'fill', source: SRC_REGION,
paint: { 'fill-color': '#fbbf24', 'fill-opacity': 0.15 } })
map.addLayer({ id: LYR_REGION_LINE, type: 'line', source: SRC_REGION,
paint: { 'line-color': '#fbbf24', 'line-width': 2 } })
return () => {
safeRemoveLayers(map,
[LYR_REGION_FILL, LYR_REGION_LINE],
[SRC_REGION],
)
}
}, [map])
// ── Fetch and render challenge markers ─────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!bbox || cameraHeight > CHALLENGE_VISIBLE_HEIGHT) { if (!bbox || cameraHeight > CHALLENGE_VISIBLE_HEIGHT) {
entityMapRef.current.forEach((e) => viewer.entities.remove(e)) markersRef.current.forEach(m => m.remove())
entityMapRef.current.clear() markersRef.current.clear()
setLoadedChallenges([]) setLoadedChallenges([])
return return
} }
@ -43,101 +65,68 @@ export function ChallengeLayer() {
fetchChallenges({ bbox }).then((fc) => { fetchChallenges({ bbox }).then((fc) => {
const incoming = new Set(fc.features.map((f: GeoJSON.Feature<GeoJSON.Point, ChallengeMapProperties>) => String(f.id))) const incoming = new Set(fc.features.map((f: GeoJSON.Feature<GeoJSON.Point, ChallengeMapProperties>) => String(f.id)))
entityMapRef.current.forEach((entity, id) => { markersRef.current.forEach((marker, id) => {
if (!incoming.has(id)) { if (!incoming.has(id)) { marker.remove(); markersRef.current.delete(id) }
viewer.entities.remove(entity)
entityMapRef.current.delete(id)
}
}) })
fc.features.forEach((feature: GeoJSON.Feature<GeoJSON.Point, ChallengeMapProperties>) => { fc.features.forEach((feature: GeoJSON.Feature<GeoJSON.Point, ChallengeMapProperties>) => {
const id = String(feature.id) const id = String(feature.id)
if (entityMapRef.current.has(id)) return if (markersRef.current.has(id)) return
const [lon, lat] = feature.geometry.coordinates const [lon, lat] = feature.geometry.coordinates
const el = createChallengePinElement()
el.addEventListener('click', () => setSelectedChallengeId(id))
const entity = viewer.entities.add({ const marker = new maplibregl.Marker({ element: el, anchor: 'bottom' })
id: `challenge-${id}`, .setLngLat([lon, lat])
position: Cesium.Cartesian3.fromDegrees(lon, lat), .addTo(map)
billboard: { markersRef.current.set(id, marker)
image: createChallengePinSvg(),
width: 36,
height: 36,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
properties: { challengeId: id },
})
entityMapRef.current.set(id, entity)
}) })
setLoadedChallenges(fc.features) setLoadedChallenges(fc.features)
}).catch(console.error) }).catch(console.error)
}, [bbox, cameraHeight]) // eslint-disable-line react-hooks/exhaustive-deps }, [bbox, cameraHeight]) // eslint-disable-line react-hooks/exhaustive-deps
// Show region polygon for selected challenge // ── Show region polygon for selected challenge ─────────────────────────────
useEffect(() => { useEffect(() => {
if (regionEntityRef.current) { const src = map.getSource(SRC_REGION) as GeoJSONSource | undefined
viewer.entities.remove(regionEntityRef.current) if (!src) return
regionEntityRef.current = null
if (!selectedChallengeId) {
src.setData(emptyFC())
return
} }
if (!selectedChallengeId) return fetchChallengeDetail(selectedChallengeId).then((detail) => {
// We need the full detail to get the region polygon — ChallengePanel fetches
// it; we read from the DOM or re-fetch. For simplicity, re-fetch here.
import('../api/challenges').then(({ fetchChallengeDetail }) =>
fetchChallengeDetail(selectedChallengeId),
).then((detail) => {
if (!detail.region) return if (!detail.region) return
const coords = detail.region.coordinates[0] src.setData({
const positions = coords.map((c) => type: 'Feature',
Cesium.Cartesian3.fromDegrees(c[0], c[1]), geometry: detail.region,
) properties: {},
regionEntityRef.current = viewer.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(positions),
material: Cesium.Color.YELLOW.withAlpha(0.15),
outline: true,
outlineColor: Cesium.Color.YELLOW,
outlineWidth: 2,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
},
}) })
}).catch(console.error) }).catch(console.error)
}, [selectedChallengeId, viewer]) }, [selectedChallengeId, map])
// Wire up entity selection // ── Cleanup on unmount ─────────────────────────────────────────────────────
useEffect(() => {
const remove = viewer.selectedEntityChanged.addEventListener((entity) => {
if (!entity) return
const challengeId = entity.properties?.challengeId?.getValue()
if (challengeId) setSelectedChallengeId(challengeId)
})
return () => remove()
}, [viewer, setSelectedChallengeId])
// Cleanup on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
if (viewer.isDestroyed()) return markersRef.current.forEach(m => m.remove())
entityMapRef.current.forEach((e) => viewer.entities.remove(e)) markersRef.current.clear()
entityMapRef.current.clear()
if (regionEntityRef.current) viewer.entities.remove(regionEntityRef.current)
} }
}, [viewer]) }, [])
return null return null
} }
function createChallengePinSvg(): string { function createChallengePinElement(): HTMLElement {
const svg = ` const el = document.createElement('div')
el.style.cssText = 'width:36px;height:36px;cursor:pointer'
el.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36"> <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36">
<circle cx="18" cy="16" r="12" fill="#f59e0b" stroke="#fff" stroke-width="2"/> <circle cx="18" cy="16" r="12" fill="#f59e0b" stroke="#fff" stroke-width="2"/>
<polygon points="18,32 11,21 25,21" fill="#f59e0b"/> <polygon points="18,32 11,21 25,21" fill="#f59e0b"/>
<text x="18" y="20" text-anchor="middle" fill="#fff" font-size="11" font-weight="bold">!</text> <text x="18" y="20" text-anchor="middle" fill="#fff" font-size="11" font-weight="bold">!</text>
</svg> </svg>
` `
return `data:image/svg+xml;base64,${btoa(svg)}` return el
} }

View File

@ -1,15 +1,14 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import * as Cesium from 'cesium'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Panel } from '../ui/Panel' import { Panel } from '../ui/Panel'
import { useCesiumViewer } from '../cesium/cesiumContext' import { useMapLibreMap } from '../maplibre/maplibreContext'
import { useChallengeStore } from '../store/challengeStore' import { useChallengeStore } from '../store/challengeStore'
import { fetchChallengeDetail, participateInChallenge } from '../api/challenges' import { fetchChallengeDetail, participateInChallenge } from '../api/challenges'
import type { ChallengeDetail } from '../types/api' import type { ChallengeDetail } from '../types/api'
import styles from './ChallengePanel.module.css' import styles from './ChallengePanel.module.css'
export function ChallengePanel() { export function ChallengePanel() {
const viewer = useCesiumViewer() const map = useMapLibreMap()
const navigate = useNavigate() const navigate = useNavigate()
const { selectedChallengeId, setSelectedChallengeId } = useChallengeStore() const { selectedChallengeId, setSelectedChallengeId } = useChallengeStore()
const [detail, setDetail] = useState<ChallengeDetail | null>(null) const [detail, setDetail] = useState<ChallengeDetail | null>(null)
@ -31,10 +30,7 @@ export function ChallengePanel() {
function handleCenterMap() { function handleCenterMap() {
if (!detail?.region_centroid) return if (!detail?.region_centroid) return
const [lon, lat] = detail.region_centroid.coordinates const [lon, lat] = detail.region_centroid.coordinates
viewer.camera.flyTo({ map.flyTo({ center: [lon, lat], zoom: 14, pitch: 50, duration: 1500 })
destination: Cesium.Cartesian3.fromDegrees(lon, lat, 2000),
duration: 1.5,
})
} }
async function handleParticipate() { async function handleParticipate() {

View File

@ -1,158 +1,157 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import * as Cesium from 'cesium' import type { Map, MapMouseEvent, GeoJSONSource, MapLayerMouseEvent } from 'maplibre-gl'
import { useCesiumViewer } from '../cesium/cesiumContext' import { useMapLibreMap } from '../maplibre/maplibreContext'
import { safeRemoveLayers } from '../maplibre/geoUtils'
import { useChallengeStore } from '../store/challengeStore' import { useChallengeStore } from '../store/challengeStore'
function vertsToGeoJson(verts: Cesium.Cartesian3[]): GeoJSON.Polygon { // ── Layer / source IDs ────────────────────────────────────────────────────────
const coords: [number, number][] = verts.map((v) => {
const c = Cesium.Cartographic.fromCartesian(v) const SRC_FILL = 'pd-fill'
return [Cesium.Math.toDegrees(c.longitude), Cesium.Math.toDegrees(c.latitude)] const SRC_OUTLINE = 'pd-outline'
}) const SRC_RUBBER = 'pd-rubber'
return { type: 'Polygon', coordinates: [[...coords, coords[0]]] } const SRC_VERTICES = 'pd-vertices'
const LYR_FILL = 'pd-fill-lyr'
const LYR_OUTLINE = 'pd-outline-lyr'
const LYR_RUBBER = 'pd-rubber-lyr'
const LYR_VERTICES = 'pd-vertices-lyr'
function emptyFC(): GeoJSON.FeatureCollection {
return { type: 'FeatureCollection', features: [] }
} }
function geoJsonToVerts(polygon: GeoJSON.Polygon): Cesium.Cartesian3[] { function addDrawLayers(map: Map) {
const ring = polygon.coordinates[0] map.addSource(SRC_FILL, { type: 'geojson', data: emptyFC() })
// Drop the closing duplicate point map.addSource(SRC_OUTLINE, { type: 'geojson', data: emptyFC() })
return ring.slice(0, -1).map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat)) map.addSource(SRC_RUBBER, { type: 'geojson', data: emptyFC() })
map.addSource(SRC_VERTICES, { type: 'geojson', data: emptyFC() })
map.addLayer({ id: LYR_FILL, type: 'fill', source: SRC_FILL,
paint: { 'fill-color': '#fbbf24', 'fill-opacity': 0.15 } })
map.addLayer({ id: LYR_OUTLINE, type: 'line', source: SRC_OUTLINE,
paint: { 'line-color': '#fbbf24', 'line-width': 2 } })
map.addLayer({ id: LYR_RUBBER, type: 'line', source: SRC_RUBBER,
paint: { 'line-color': '#ffffff', 'line-width': 1.5, 'line-dasharray': [4, 4] } })
map.addLayer({ id: LYR_VERTICES, type: 'circle', source: SRC_VERTICES,
paint: {
'circle-radius': 6,
'circle-color': '#ffffff',
'circle-stroke-color': '#000000',
'circle-stroke-width': 2,
} })
} }
function pickGlobe( function removeDrawLayers(map: Map) {
viewer: Cesium.Viewer, safeRemoveLayers(map,
windowPos: Cesium.Cartesian2, [LYR_FILL, LYR_OUTLINE, LYR_RUBBER, LYR_VERTICES],
): Cesium.Cartesian3 | null { [SRC_FILL, SRC_OUTLINE, SRC_RUBBER, SRC_VERTICES],
const ray = viewer.camera.getPickRay(windowPos) )
if (!ray) return null }
return viewer.scene.globe.pick(ray, viewer.scene) ?? null
function setSource(map: Map, id: string, data: GeoJSON.GeoJSON) {
(map.getSource(id) as GeoJSONSource | undefined)?.setData(data)
} }
/** /**
* Manages two phases of polygon interaction: * Manages two phases of polygon interaction:
* *
* Drawing (drawingMode=true) * Drawing (drawingMode=true)
* LEFT_CLICK place vertex * click place vertex
* MOUSE_MOVE rubber-band line from last vertex to cursor * mousemove rubber-band line from last vertex to cursor
* RIGHT_CLICK close polygon (3 verts), enter edit phase * contextmenu close polygon (3 verts), enter edit phase
* *
* Editing (drawingMode=false, draftPolygon set) * Editing (drawingMode=false, draftPolygon set)
* Drag vertex handles to reposition them. * Drag vertex handles to reposition them.
* Changes are written back to the store on mouse-up so the submit
* form always reads the latest geometry.
*/ */
export function usePolygonDraw() { export function usePolygonDraw() {
const viewer = useCesiumViewer() const map = useMapLibreMap()
const { drawingMode, setDrawingMode, setDraftPolygon, draftPolygon } = const { drawingMode, setDrawingMode, setDraftPolygon, draftPolygon } =
useChallengeStore() useChallengeStore()
// Persists vertex positions across edit-phase effect re-runs that are const editVertsRef = useRef<[number, number][]>([])
// triggered by setDraftPolygon being called after each drag.
const editVertsRef = useRef<Cesium.Cartesian3[]>([]) // ── Set up / tear down MapLibre sources + layers ──────────────────────────
useEffect(() => {
addDrawLayers(map)
return () => { removeDrawLayers(map) }
}, [map])
// ── Drawing phase ────────────────────────────────────────────────────────── // ── Drawing phase ──────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!drawingMode) return if (!drawingMode) return
const verts: Cesium.Cartesian3[] = [] const verts: [number, number][] = []
const vertPointEntities: Cesium.Entity[] = [] const canvas = map.getCanvas()
let outlineEntity: Cesium.Entity | null = null
let rubberBandEntity: Cesium.Entity | null = null
const canvas = viewer.scene.canvas canvas.style.cursor = 'crosshair'
// Prevent the browser context menu so right-click can close the polygon.
const suppressContextMenu = (e: MouseEvent) => e.preventDefault()
canvas.addEventListener('contextmenu', suppressContextMenu)
const handler = new Cesium.ScreenSpaceEventHandler(canvas)
function refreshOutline() { function refreshOutline() {
if (outlineEntity) { if (verts.length < 2) {
viewer.entities.remove(outlineEntity) setSource(map, SRC_OUTLINE, emptyFC())
outlineEntity = null return
} }
if (verts.length < 2) return setSource(map, SRC_OUTLINE, {
outlineEntity = viewer.entities.add({ type: 'Feature',
polyline: { geometry: { type: 'LineString', coordinates: [...verts, verts[0]] },
positions: [...verts, verts[0]], properties: {},
width: 2,
material: new Cesium.ColorMaterialProperty(
Cesium.Color.YELLOW.withAlpha(0.9),
),
clampToGround: true,
},
}) })
} }
function refreshRubberBand(mousePos: Cesium.Cartesian3) { function refreshVertices() {
if (rubberBandEntity) { setSource(map, SRC_VERTICES, {
viewer.entities.remove(rubberBandEntity) type: 'FeatureCollection',
rubberBandEntity = null features: verts.map((v, i) => ({
type: 'Feature',
id: i,
geometry: { type: 'Point', coordinates: v },
properties: {},
})),
})
} }
const onMouseMove = (e: MapMouseEvent) => {
if (verts.length === 0) return if (verts.length === 0) return
rubberBandEntity = viewer.entities.add({ setSource(map, SRC_RUBBER, {
polyline: { type: 'Feature',
positions: [verts[verts.length - 1], mousePos], geometry: { type: 'LineString', coordinates: [verts[verts.length - 1], [e.lngLat.lng, e.lngLat.lat]] },
width: 1.5, properties: {},
material: new Cesium.ColorMaterialProperty(
Cesium.Color.WHITE.withAlpha(0.5),
),
clampToGround: true,
},
}) })
} }
handler.setInputAction((e: { endPosition: Cesium.Cartesian2 }) => { const onClick = (e: MapMouseEvent) => {
const pos = pickGlobe(viewer, e.endPosition) verts.push([e.lngLat.lng, e.lngLat.lat])
if (pos) refreshRubberBand(pos)
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
handler.setInputAction((e: { position: Cesium.Cartesian2 }) => {
const pos = pickGlobe(viewer, e.position)
if (!pos) return
verts.push(pos.clone())
vertPointEntities.push(
viewer.entities.add({
position: pos,
point: {
pixelSize: 8,
color: Cesium.Color.YELLOW,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 1,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
}),
)
refreshOutline() refreshOutline()
}, Cesium.ScreenSpaceEventType.LEFT_CLICK) refreshVertices()
}
handler.setInputAction(() => { const onContextMenu = (e: MouseEvent) => {
e.preventDefault()
if (verts.length < 3) return if (verts.length < 3) return
const polygon = vertsToGeoJson(verts) const polygon: GeoJSON.Polygon = {
type: 'Polygon',
coordinates: [[...verts, verts[0]]],
}
cleanup() cleanup()
setDraftPolygon(polygon) setDraftPolygon(polygon)
setDrawingMode(false) setDrawingMode(false)
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK) }
canvas.addEventListener('contextmenu', onContextMenu)
map.on('click', onClick)
map.on('mousemove', onMouseMove)
function cleanup() { function cleanup() {
if (viewer.isDestroyed()) return canvas.style.cursor = ''
vertPointEntities.forEach((e) => viewer.entities.remove(e)) map.off('click', onClick)
vertPointEntities.length = 0 map.off('mousemove', onMouseMove)
if (outlineEntity) { canvas.removeEventListener('contextmenu', onContextMenu)
viewer.entities.remove(outlineEntity) setSource(map, SRC_FILL, emptyFC())
outlineEntity = null setSource(map, SRC_OUTLINE, emptyFC())
} setSource(map, SRC_RUBBER, emptyFC())
if (rubberBandEntity) { setSource(map, SRC_VERTICES, emptyFC())
viewer.entities.remove(rubberBandEntity)
rubberBandEntity = null
}
} }
return () => { return cleanup
handler.destroy() }, [drawingMode, map, setDrawingMode, setDraftPolygon])
canvas.removeEventListener('contextmenu', suppressContextMenu)
cleanup()
}
}, [drawingMode, viewer, setDrawingMode, setDraftPolygon])
// ── Edit phase ───────────────────────────────────────────────────────────── // ── Edit phase ─────────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
@ -161,120 +160,84 @@ export function usePolygonDraw() {
return return
} }
// Only initialise from the store on the first entry into edit mode. // Only initialise from the store on first entry into edit mode.
// Subsequent runs (triggered by setDraftPolygon after each drag) reuse
// the already-mutated ref so vertex positions are not reset.
if (editVertsRef.current.length === 0) { if (editVertsRef.current.length === 0) {
editVertsRef.current = geoJsonToVerts(draftPolygon) const ring = draftPolygon.coordinates[0]
editVertsRef.current = ring.slice(0, -1) as [number, number][]
} }
const verts = editVertsRef.current const verts = editVertsRef.current
let draggingIndex = -1 let draggingIndex = -1
const entities: Cesium.Entity[] = []
const canvas = viewer.scene.canvas function refreshAll() {
const suppressContextMenu = (e: MouseEvent) => e.preventDefault() setSource(map, SRC_FILL, {
canvas.addEventListener('contextmenu', suppressContextMenu) type: 'Feature',
geometry: { type: 'Polygon', coordinates: [[...verts, verts[0]]] },
// Filled polygon properties: {},
entities.push(
viewer.entities.add({
polygon: {
hierarchy: new Cesium.CallbackProperty(
() => new Cesium.PolygonHierarchy(verts),
false,
),
material: Cesium.Color.YELLOW.withAlpha(0.15),
},
}),
)
// Outline
entities.push(
viewer.entities.add({
polyline: {
positions: new Cesium.CallbackProperty(
() => [...verts, verts[0]],
false,
),
width: 2,
material: new Cesium.ColorMaterialProperty(
Cesium.Color.YELLOW.withAlpha(0.9),
),
clampToGround: true,
},
}),
)
// Vertex handles — one point entity per vertex, driven by CallbackProperty
// so they track the mutable verts array without entity recreation.
const vertEntities: Cesium.Entity[] = verts.map((_, i) => {
const e = viewer.entities.add({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
position: new Cesium.CallbackProperty(() => verts[i], false) as any,
point: {
pixelSize: 10,
color: Cesium.Color.WHITE,
outlineColor: Cesium.Color.YELLOW,
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
}) })
entities.push(e) setSource(map, SRC_OUTLINE, {
return e type: 'Feature',
geometry: { type: 'LineString', coordinates: [...verts, verts[0]] },
properties: {},
})
setSource(map, SRC_VERTICES, {
type: 'FeatureCollection',
features: verts.map((v, i) => ({
type: 'Feature',
id: i,
geometry: { type: 'Point', coordinates: v },
properties: { idx: i },
})),
}) })
const handler = new Cesium.ScreenSpaceEventHandler(canvas)
handler.setInputAction((e: { endPosition: Cesium.Cartesian2 }) => {
if (draggingIndex !== -1) {
// Update dragged vertex
const pos = pickGlobe(viewer, e.endPosition)
if (pos) verts[draggingIndex] = pos
return
} }
// Cursor feedback
const picked = viewer.scene.pick(e.endPosition)
const overVertex =
picked?.id instanceof Cesium.Entity &&
vertEntities.includes(picked.id)
canvas.style.cursor = overVertex ? 'grab' : 'default'
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
handler.setInputAction((e: { position: Cesium.Cartesian2 }) => { refreshAll()
const picked = viewer.scene.pick(e.position)
if (!(picked?.id instanceof Cesium.Entity)) return const canvas = map.getCanvas()
const idx = vertEntities.indexOf(picked.id)
if (idx === -1) return const onVertexMouseDown = (e: MapLayerMouseEvent) => {
const idx = e.features?.[0]?.properties?.idx as number | undefined
if (idx == null) return
e.preventDefault()
draggingIndex = idx draggingIndex = idx
canvas.style.cursor = 'grabbing' canvas.style.cursor = 'grabbing'
viewer.scene.screenSpaceCameraController.enableRotate = false map.dragPan.disable()
viewer.scene.screenSpaceCameraController.enableTranslate = false }
}, Cesium.ScreenSpaceEventType.LEFT_DOWN)
handler.setInputAction(() => { const onMouseMove = (e: MapMouseEvent) => {
if (draggingIndex !== -1) {
verts[draggingIndex] = [e.lngLat.lng, e.lngLat.lat]
refreshAll()
return
}
const features = map.queryRenderedFeatures(e.point, { layers: [LYR_VERTICES] })
canvas.style.cursor = features.length ? 'grab' : ''
}
const onMouseUp = () => {
if (draggingIndex === -1) return if (draggingIndex === -1) return
draggingIndex = -1 draggingIndex = -1
canvas.style.cursor = 'default' canvas.style.cursor = ''
viewer.scene.screenSpaceCameraController.enableRotate = true map.dragPan.enable()
viewer.scene.screenSpaceCameraController.enableTranslate = true setDraftPolygon({
// Sync updated geometry back to the store for the submit form. type: 'Polygon',
setDraftPolygon(vertsToGeoJson(verts)) coordinates: [[...verts, verts[0]]],
}, Cesium.ScreenSpaceEventType.LEFT_UP) })
function cleanup() {
if (viewer.isDestroyed()) return
entities.forEach((e) => viewer.entities.remove(e))
entities.length = 0
canvas.style.cursor = 'default'
viewer.scene.screenSpaceCameraController.enableRotate = true
viewer.scene.screenSpaceCameraController.enableTranslate = true
} }
map.on('mousedown', LYR_VERTICES, onVertexMouseDown)
map.on('mousemove', onMouseMove)
map.on('mouseup', onMouseUp)
return () => { return () => {
handler.destroy() map.off('mousedown', LYR_VERTICES, onVertexMouseDown)
canvas.removeEventListener('contextmenu', suppressContextMenu) map.off('mousemove', onMouseMove)
cleanup() map.off('mouseup', onMouseUp)
map.dragPan.enable()
canvas.style.cursor = ''
setSource(map, SRC_FILL, emptyFC())
setSource(map, SRC_OUTLINE, emptyFC())
setSource(map, SRC_VERTICES, emptyFC())
} }
}, [drawingMode, draftPolygon, viewer, setDraftPolygon]) }, [drawingMode, draftPolygon, map, setDraftPolygon])
} }

View File

@ -1,9 +1,5 @@
.panel { .panel {
position: fixed; width: 340px;
right: 16px;
top: 60px;
z-index: 600;
width: 280px;
background: rgba(8, 8, 12, 0.84); background: rgba(8, 8, 12, 0.84);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
@ -57,7 +53,7 @@
.stripRow { .stripRow {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 5px;
margin-bottom: 6px; margin-bottom: 6px;
font-size: 12px; font-size: 12px;
} }
@ -66,37 +62,17 @@
color: rgba(245, 158, 11, 0.9); color: rgba(245, 158, 11, 0.9);
font-weight: 600; font-weight: 600;
min-width: 44px; min-width: 44px;
flex-shrink: 0;
} }
.stripRange { .rangeSep {
color: rgba(255, 255, 255, 0.38);
font-size: 11px;
min-width: 56px;
}
.accelInput {
width: 50px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
color: #fff;
font-size: 13px;
font-weight: 600;
padding: 2px 5px;
text-align: right;
}
.accelInput:focus {
outline: none;
border-color: rgba(245, 158, 11, 0.5);
}
.stripUnit {
color: rgba(255, 255, 255, 0.28); color: rgba(255, 255, 255, 0.28);
font-size: 11px; font-size: 11px;
} }
.stripDelete { .stripDelete {
margin-left: auto; margin-left: auto;
flex-shrink: 0;
width: 20px; width: 20px;
height: 20px; height: 20px;
border-radius: 4px; border-radius: 4px;

View File

@ -1,16 +1,16 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import type { AccelerationStrip } from '../types/api' import type { AccelerationStrip } from '../types/api'
import { BlenderInput } from '../ui/BlenderInput'
import styles from './AccelerationStripsPanel.module.css' import styles from './AccelerationStripsPanel.module.css'
interface Props { interface Props {
strips: AccelerationStrip[] strips: AccelerationStrip[]
onRemoveStrip: (id: string) => void onRemoveStrip: (id: string) => void
onUpdateStrip: (id: string, accel_ms2: number) => void onUpdateStrip: (id: string, accel_ms2: number) => void
onUpdateStripFrac: (id: string, startFrac: number, endFrac: number) => void
} }
function pct(v: number) { return `${(v * 100).toFixed(0)}%` } export function AccelerationStripsPanel({ strips, onRemoveStrip, onUpdateStrip, onUpdateStripFrac }: Props) {
export function AccelerationStripsPanel({ strips, onRemoveStrip, onUpdateStrip }: Props) {
const [open, setOpen] = useState(true) const [open, setOpen] = useState(true)
// Auto-open when a strip is added // Auto-open when a strip is added
@ -31,19 +31,45 @@ export function AccelerationStripsPanel({ strips, onRemoveStrip, onUpdateStrip }
: strips.map((s, i) => ( : strips.map((s, i) => (
<div key={s.id} className={styles.stripRow}> <div key={s.id} className={styles.stripRow}>
<span className={styles.stripIndex}>Strip {i + 1}</span> <span className={styles.stripIndex}>Strip {i + 1}</span>
<span className={styles.stripRange}>
{pct(s.startFrac)}{pct(s.endFrac)} <BlenderInput
</span> value={s.startFrac * 100}
<input onChange={v => {
type="number" const sf = Math.max(0, Math.min(v / 100, s.endFrac - 0.001))
step={0.5} onUpdateStripFrac(s.id, sf, s.endFrac)
}}
min={0}
max={s.endFrac * 100 - 0.1}
step={0.2}
decimals={1}
suffix="%"
/>
<span className={styles.rangeSep}></span>
<BlenderInput
value={s.endFrac * 100}
onChange={v => {
const ef = Math.min(100, Math.max(v / 100, s.startFrac + 0.001))
onUpdateStripFrac(s.id, s.startFrac, ef)
}}
min={s.startFrac * 100 + 0.1}
max={100}
step={0.2}
decimals={1}
suffix="%"
/>
<BlenderInput
value={s.accel_ms2}
onChange={v => onUpdateStrip(s.id, v)}
min={-50} min={-50}
max={50} max={50}
value={s.accel_ms2} step={0.1}
onChange={e => onUpdateStrip(s.id, Number(e.target.value))} decimals={1}
className={styles.accelInput} suffix="m/s²"
/> />
<span className={styles.stripUnit}>m/s²</span>
<button <button
className={styles.stripDelete} className={styles.stripDelete}
onClick={() => onRemoveStrip(s.id)} onClick={() => onRemoveStrip(s.id)}

View File

@ -516,6 +516,38 @@
margin: 0 4px; margin: 0 4px;
} }
/* ── Right panel column ──────────────────────────────────────────────────────── */
.rightColumn {
position: fixed;
right: 16px;
top: 116px;
z-index: 150;
display: flex;
flex-direction: column;
gap: 8px;
max-height: calc(100vh - 132px);
overflow-y: auto;
}
/* ── Path hover tooltip ──────────────────────────────────────────────────────── */
.pathTooltip {
position: fixed;
z-index: 300;
pointer-events: none;
padding: 3px 8px;
background: rgba(8, 8, 12, 0.85);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
font-size: 11px;
font-weight: 600;
color: #fbbf24;
white-space: nowrap;
}
/* ── Loading overlay ─────────────────────────────────────────────────────────── */ /* ── Loading overlay ─────────────────────────────────────────────────────────── */
.loading { .loading {

View File

@ -1,20 +1,24 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import * as Cesium from 'cesium' import type { Map, GeoJSONSource } from 'maplibre-gl'
import { CesiumViewer } from '../cesium/CesiumViewer' import { MapLibreViewer } from '../maplibre/MapLibreViewer'
import { useCesiumViewer } from '../cesium/cesiumContext' import { useMapLibreMap } from '../maplibre/maplibreContext'
import { safeRemoveLayers } from '../maplibre/geoUtils'
import { createCustom3DLayer } from '../maplibre/custom3DLayer'
import type { Layer3DHandle } from '../maplibre/custom3DLayer'
import { fetchChallengeDetail } from '../api/challenges' import { fetchChallengeDetail } from '../api/challenges'
import { simulateCoaster, saveCoaster, listCoasters } from '../api/coaster' import { simulateCoaster, saveCoaster, listCoasters } from '../api/coaster'
import { useCoasterPath } from './useCoasterPath' import { useCoasterPath } from './useCoasterPath'
import { useAccelerationStrips } from './useAccelerationStrips' import { useAccelerationStrips, computeArcLengths, snapToPath } from './useAccelerationStrips'
import { useTerrainCapture } from './useTerrainCapture' import { useTerrainCapture } from './useTerrainCapture'
import { RideRenderer } from './RideRenderer' import { RideRenderer } from './RideRenderer'
import { SimulationPlots } from './SimulationPlots' import { SimulationPlots } from './SimulationPlots'
import { AccelerationStripsPanel } from './AccelerationStripsPanel' import { AccelerationStripsPanel } from './AccelerationStripsPanel'
import { CoasterListPanel } from './CoasterListPanel' import { CoasterListPanel } from './CoasterListPanel'
import { effectivePosition } from './bezierUtils' import { effectiveLngLatAlt } from './bezierUtils'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
import type { ChallengeDetail, CoasterSimulationResult, SavedCoaster } from '../types/api' import type { ChallengeDetail, CoasterSimulationResult, SavedCoaster } from '../types/api'
import type { AnchorPoint } from './bezierUtils'
import styles from './CoasterEditorPage.module.css' import styles from './CoasterEditorPage.module.css'
// ── Route pages ─────────────────────────────────────────────────────────────── // ── Route pages ───────────────────────────────────────────────────────────────
@ -29,9 +33,9 @@ export function CoasterEditorPage() {
}, [id]) }, [id])
return ( return (
<CesiumViewer> <MapLibreViewer>
<CoasterEditorScene challengeId={id} challenge={challenge} /> <CoasterEditorScene challengeId={id} challenge={challenge} />
</CesiumViewer> </MapLibreViewer>
) )
} }
@ -52,32 +56,30 @@ export function CoasterViewerPage() {
}, [challengeId, coasterId]) }, [challengeId, coasterId])
return ( return (
<CesiumViewer> <MapLibreViewer>
<CoasterEditorScene <CoasterEditorScene
challengeId={challengeId} challengeId={challengeId}
challenge={challenge} challenge={challenge}
readonly readonly
preloadCoaster={preloadCoaster ?? undefined} preloadCoaster={preloadCoaster ?? undefined}
/> />
</CesiumViewer> </MapLibreViewer>
) )
} }
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Source / layer IDs for editor-specific overlays ───────────────────────────
/** Build a circle cross-section shape for PolylineVolumeGraphics. */ const SRC_REGION = 'editor-region'
function buildCircleShape(radius: number, segments = 8): Cesium.Cartesian2[] { const LYR_REGION_F = 'editor-region-fill'
const pts: Cesium.Cartesian2[] = [] const LYR_REGION_L = 'editor-region-line'
for (let i = 0; i < segments; i++) { const LYR_SIM_RAILS = 'sim-rails-3d'
const angle = (2 * Math.PI * i) / segments
pts.push(new Cesium.Cartesian2(Math.cos(angle) * radius, Math.sin(angle) * radius)) function emptyFC(): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', features: [] } }
} function setData(map: Map, src: string, data: GeoJSON.GeoJSON) {
return pts (map.getSource(src) as GeoJSONSource | undefined)?.setData(data)
} }
const RAIL_SHAPE = buildCircleShape(0.075, 8) // 7.5 cm radius = 15 cm diameter // ── Inner scene (needs map context) ──────────────────────────────────────────
// ── Inner scene (needs viewer context) ────────────────────────────────────────
interface SceneProps { interface SceneProps {
challengeId: string | undefined challengeId: string | undefined
@ -87,9 +89,11 @@ interface SceneProps {
} }
function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadCoaster }: SceneProps) { function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadCoaster }: SceneProps) {
const viewer = useCesiumViewer() const map = useMapLibreMap()
const navigate = useNavigate() const navigate = useNavigate()
const simRailLayerRef = useRef<Layer3DHandle | null>(null)
const authUser = useAuthStore(s => s.user) const authUser = useAuthStore(s => s.user)
const currentUsername = authUser?.profile?.preferred_username as string | undefined const currentUsername = authUser?.profile?.preferred_username as string | undefined
@ -104,144 +108,107 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
const [coasterListKey, setCoasterListKey] = useState(0) const [coasterListKey, setCoasterListKey] = useState(0)
const [isRideMode, setIsRideMode] = useState(false) const [isRideMode, setIsRideMode] = useState(false)
const [rideCursor, setRideCursor] = useState<number | null>(null) const [rideCursor, setRideCursor] = useState<number | null>(null)
const [pathHover, setPathHover] = useState<{ x: number; y: number; pct: number } | null>(null)
const path = useCoasterPath(viewer, showPath, showAnchors) const path = useCoasterPath(map, showPath, showAnchors)
const accel = useAccelerationStrips(viewer, path.pathPts, path.mode === 'strip', showStrips) const accel = useAccelerationStrips(map, path.pathPts, path.mode === 'strip', showStrips)
const terrain = useTerrainCapture(viewer, simResult) const terrain = useTerrainCapture(map, simResult)
// Auto-load a preloaded coaster (viewer mode) // Auto-load preloaded coaster (viewer mode)
useEffect(() => { useEffect(() => {
if (preloadCoaster) handleLoad(preloadCoaster) if (preloadCoaster) handleLoad(preloadCoaster)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [preloadCoaster]) }, [preloadCoaster])
// Exit ride mode whenever the sim result changes; clear cursor on exit // Exit ride mode when sim result changes
useEffect(() => { setIsRideMode(false); setRideCursor(null) }, [simResult]) useEffect(() => { setIsRideMode(false); setRideCursor(null) }, [simResult])
// Suspend Cesium's render loop while Three.js ride view is active so both // ── Set up region + sim-rail sources ──────────────────────────────────────
// renderers don't compete for the GPU simultaneously.
useEffect(() => {
viewer.useDefaultRenderLoop = !isRideMode
}, [isRideMode, viewer])
// Refs for simulation result entities (cleared on each new run / unmount)
const simEntitiesRef = useRef<Cesium.Entity[]>([])
const simPrimitivesRef = useRef<Cesium.Primitive[]>([])
// ── Fly to challenge region ───────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!challenge) return map.addSource(SRC_REGION, { type: 'geojson', data: emptyFC() })
const coords = challenge.region.coordinates[0] map.addLayer({ id: LYR_REGION_F, type: 'fill', source: SRC_REGION,
const positions = coords.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat)) paint: { 'fill-color': '#06b6d4', 'fill-opacity': 0.04 } })
const sphere = Cesium.BoundingSphere.fromPoints(positions, new Cesium.BoundingSphere()) map.addLayer({ id: LYR_REGION_L, type: 'line', source: SRC_REGION,
sphere.radius = Math.max(sphere.radius + 20, 50) paint: { 'line-color': '#06b6d4', 'line-opacity': 0.45, 'line-width': 2 } })
viewer.camera.flyToBoundingSphere(sphere, { const handle = createCustom3DLayer(LYR_SIM_RAILS, map)
offset: new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-78), sphere.radius * 3), simRailLayerRef.current = handle
duration: 1.2,
})
}, [challenge, viewer])
// ── Challenge boundary polygon ────────────────────────────────────────────
const regionEntityRef = useRef<Cesium.Entity | null>(null)
useEffect(() => {
if (regionEntityRef.current) {
viewer.entities.remove(regionEntityRef.current)
regionEntityRef.current = null
}
if (!challenge) return
const coords = challenge.region.coordinates[0]
const positions = coords.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat))
regionEntityRef.current = viewer.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(positions),
material: Cesium.Color.CYAN.withAlpha(0.04),
outline: true,
outlineColor: Cesium.Color.CYAN.withAlpha(0.45),
outlineWidth: 2,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
},
})
return () => { return () => {
if (regionEntityRef.current && !viewer.isDestroyed()) { handle.destroy()
viewer.entities.remove(regionEntityRef.current) simRailLayerRef.current = null
regionEntityRef.current = null safeRemoveLayers(map, [LYR_REGION_F, LYR_REGION_L], [SRC_REGION])
} }
} }, [map])
}, [challenge, viewer])
// ── Render simulation result rails ──────────────────────────────────────── // ── Fly to challenge region ────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
// Clear previous simulation entities if (!challenge) return
if (!viewer.isDestroyed()) {
simEntitiesRef.current.forEach(e => viewer.entities.remove(e))
simPrimitivesRef.current.forEach(p => viewer.scene.primitives.remove(p))
}
simEntitiesRef.current = []
simPrimitivesRef.current = []
if (!simResult) return const coords = challenge.region.coordinates[0] as [number, number][]
const lons = coords.map(c => c[0])
const toC3 = ([lon, lat, alt]: [number, number, number]) => const lats = coords.map(c => c[1])
Cesium.Cartesian3.fromDegrees(lon, lat, alt) map.fitBounds(
[[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]],
const r1Pts = simResult.rail_1.map(toC3) { padding: 60, pitch: 55, bearing: 0, duration: 1200 },
const r2Pts = simResult.rail_2.map(toC3)
const rail1 = viewer.entities.add({
polylineVolume: {
positions: r1Pts,
shape: RAIL_SHAPE,
material: Cesium.Color.fromCssColorString('#ef4444'),
cornerType: Cesium.CornerType.ROUNDED,
},
})
const rail2 = viewer.entities.add({
polylineVolume: {
positions: r2Pts,
shape: RAIL_SHAPE,
material: Cesium.Color.fromCssColorString('#ef4444'),
cornerType: Cesium.CornerType.ROUNDED,
},
})
simEntitiesRef.current = [rail1, rail2]
// Load GLB model if available
if (simResult.model_url) {
const [lon0, lat0, alt0] = simResult.origin
const modelMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(
Cesium.Cartesian3.fromDegrees(lon0, lat0, alt0),
) )
Cesium.Model.fromGltfAsync({ url: simResult.model_url, modelMatrix })
.then(model => { setData(map, SRC_REGION, { type: 'Feature', geometry: challenge.region, properties: {} })
if (viewer.isDestroyed()) return }, [challenge, map])
viewer.scene.primitives.add(model)
simPrimitivesRef.current.push(model as unknown as Cesium.Primitive) // ── Simulation result rails (custom 3D layer at absolute altitude) ────────
})
.catch(err => console.error('GLB model load failed:', err)) useEffect(() => {
if (!simResult) {
simRailLayerRef.current?.update([])
return
} }
return () => { simRailLayerRef.current?.update([
if (viewer.isDestroyed()) return {
simEntitiesRef.current.forEach(e => viewer.entities.remove(e)) pts: simResult.rail_1 as [number, number, number][],
simPrimitivesRef.current.forEach(p => viewer.scene.primitives.remove(p)) color: 0xef4444,
simEntitiesRef.current = [] radiusMeters: 0.12,
simPrimitivesRef.current = [] },
{
pts: simResult.rail_2 as [number, number, number][],
color: 0xef4444,
radiusMeters: 0.12,
},
])
}, [simResult])
// ── Path hover tooltip ────────────────────────────────────────────────────
useEffect(() => {
if (path.pathPts.length < 2) { setPathHover(null); return }
const arcs = computeArcLengths(path.pathPts)
function onMove(e: { point: { x: number; y: number }; lngLat: { lng: number; lat: number } }) {
const { x, y } = e.point
const hits = map.queryRenderedFeatures(
[{ x: x - 4, y: y - 4 }, { x: x + 4, y: y + 4 }],
{ layers: ['coaster-path-lyr'] },
)
if (hits.length === 0) { setPathHover(null); return }
const { frac } = snapToPath([e.lngLat.lng, e.lngLat.lat], path.pathPts, arcs)
setPathHover({ x, y, pct: frac * 100 })
} }
}, [simResult, viewer])
map.on('mousemove', onMove)
return () => { map.off('mousemove', onMove) }
}, [map, path.pathPts])
// ── Load / Save handlers ───────────────────────────────────────────────── // ── Load / Save handlers ─────────────────────────────────────────────────
function handleLoad(coaster: SavedCoaster) { function handleLoad(coaster: SavedCoaster) {
const anchorPoints = coaster.anchors.map(a => ({ const anchorPoints: AnchorPoint[] = coaster.anchors.map(a => ({
id: a.id, id: a.id,
position: Cesium.Cartesian3.fromDegrees(a.lon, a.lat, a.terrainAlt), lngLat: [a.lon, a.lat] as [number, number],
terrainHeight: a.terrainAlt,
heightOffset: a.heightOffset, heightOffset: a.heightOffset,
})) }))
path.loadAnchors(anchorPoints) path.loadAnchors(anchorPoints)
@ -252,16 +219,13 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
async function handleSave() { async function handleSave() {
if (!challengeId || path.anchors.length < 2) return if (!challengeId || path.anchors.length < 2) return
const storedAnchors = path.anchors.map(a => { const storedAnchors = path.anchors.map(a => ({
const carto = Cesium.Cartographic.fromCartesian(effectivePosition(a))
return {
id: a.id, id: a.id,
lon: Cesium.Math.toDegrees(carto.longitude), lon: a.lngLat[0],
lat: Cesium.Math.toDegrees(carto.latitude), lat: a.lngLat[1],
terrainAlt: carto.height, terrainAlt: a.terrainHeight,
heightOffset: a.heightOffset, heightOffset: a.heightOffset,
} }))
})
await saveCoaster(challengeId, { await saveCoaster(challengeId, {
name: coasterName.trim(), name: coasterName.trim(),
anchors: storedAnchors, anchors: storedAnchors,
@ -278,19 +242,7 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
setSimError(null) setSimError(null)
try { try {
// Send only the anchor control points (not the pre-sampled Bezier const geoPath = path.anchors.map(anchor => effectiveLngLatAlt(anchor))
// polyline). The backend fits its own smooth B-spline with analytical
// derivatives, which avoids the curvature spikes that arise from
// finite-differencing a piecewise-linear polyline approximation.
const geoPath = path.anchors.map(anchor => {
const pos = effectivePosition(anchor)
const carto = Cesium.Cartographic.fromCartesian(pos)
return [
Cesium.Math.toDegrees(carto.longitude),
Cesium.Math.toDegrees(carto.latitude),
carto.height,
] as [number, number, number]
})
const result = await simulateCoaster({ const result = await simulateCoaster({
path: geoPath, path: geoPath,
@ -318,13 +270,12 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
return ( return (
<> <>
{/* ── Three.js ride renderer (fullscreen, mounts over Cesium) ─────── */} {/* ── Three.js ride renderer (fullscreen, mounts over map) ──────────── */}
{isRideMode && simResult && terrain.captureData && ( {isRideMode && simResult && terrain.captureData && (
<RideRenderer <RideRenderer
simResult={simResult} simResult={simResult}
captureData={terrain.captureData} captureData={terrain.captureData}
onStop={() => { setIsRideMode(false); setRideCursor(null) }} onStop={() => { setIsRideMode(false); setRideCursor(null) }}
onRideProgress={setRideCursor}
/> />
)} )}
@ -347,6 +298,15 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
{challenge ? challenge.title : 'Loading…'} {challenge ? challenge.title : 'Loading…'}
</h1> </h1>
<span className={styles.badge}>{readonly ? 'Viewer' : 'Coaster Editor'}</span> <span className={styles.badge}>{readonly ? 'Viewer' : 'Coaster Editor'}</span>
{challengeId && (
<CoasterListPanel
challengeId={challengeId}
currentUsername={currentUsername}
onLoad={handleLoad}
refreshKey={coasterListKey}
menuMode
/>
)}
</div> </div>
{/* ── Mode toolbar (hidden while riding) ──────────────────────────── */} {/* ── Mode toolbar (hidden while riding) ──────────────────────────── */}
@ -431,9 +391,7 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
)} )}
{/* ── Simulation error ─────────────────────────────────────────────── */} {/* ── Simulation error ─────────────────────────────────────────────── */}
{simError && ( {simError && <div className={styles.simError}>{simError}</div>}
<div className={styles.simError}>{simError}</div>
)}
{/* ── Diagnostics strip ────────────────────────────────────────────── */} {/* ── Diagnostics strip ────────────────────────────────────────────── */}
{diag && !simError && ( {diag && !simError && (
@ -496,7 +454,7 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
)} )}
{/* ── Simulation profile charts (left panel) ──────────────────────── */} {/* ── Simulation profile charts (left panel) ──────────────────────── */}
{simResult?.profile && ( {!isRideMode && simResult?.profile && (
<SimulationPlots <SimulationPlots
profile={simResult.profile} profile={simResult.profile}
strips={accel.strips} strips={accel.strips}
@ -504,23 +462,26 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
/> />
)} )}
{/* ── Acceleration strips (right panel) ───────────────────────────── */} {/* ── Right panel column (acceleration strips) ─────────────────────── */}
{accel.strips.length > 0 && ( {!isRideMode && accel.strips.length > 0 && (
<div className={styles.rightColumn}>
<AccelerationStripsPanel <AccelerationStripsPanel
strips={accel.strips} strips={accel.strips}
onRemoveStrip={accel.removeStrip} onRemoveStrip={accel.removeStrip}
onUpdateStrip={accel.updateStrip} onUpdateStrip={accel.updateStrip}
onUpdateStripFrac={accel.updateStripFrac}
/> />
</div>
)} )}
{/* ── Coaster list panel (right) ───────────────────────────────────── */} {/* ── Path hover tooltip ──────────────────────────────────────────── */}
{challengeId && ( {!isRideMode && pathHover && (
<CoasterListPanel <div
challengeId={challengeId} className={styles.pathTooltip}
currentUsername={currentUsername} style={{ left: pathHover.x + 14, top: pathHover.y - 10 }}
onLoad={handleLoad} >
refreshKey={coasterListKey} {pathHover.pct.toFixed(1)}%
/> </div>
)} )}
{/* ── Loading overlay ──────────────────────────────────────────────── */} {/* ── Loading overlay ──────────────────────────────────────────────── */}

View File

@ -1,8 +1,4 @@
.panel { .panel {
position: fixed;
right: 16px;
top: 112px; /* 52px header + ~44px topBar + 16px gap */
z-index: 200;
width: 240px; width: 240px;
background: rgba(8, 8, 12, 0.84); background: rgba(8, 8, 12, 0.84);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
@ -122,3 +118,45 @@
color: rgba(255, 255, 255, 0.25); color: rgba(255, 255, 255, 0.25);
padding: 6px 0 2px; padding: 6px 0 2px;
} }
/* ── Top-bar menu mode ───────────────────────────────────────────────────────── */
.menuWrapper {
position: relative;
flex-shrink: 0;
}
.menuToggle {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
color: rgba(255, 255, 255, 0.75);
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.menuToggle:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.menuDropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 400;
width: 260px;
background: rgba(8, 8, 12, 0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
padding: 6px 12px 10px;
overflow: hidden;
}

View File

@ -8,9 +8,11 @@ interface Props {
currentUsername: string | undefined currentUsername: string | undefined
onLoad: (coaster: SavedCoaster) => void onLoad: (coaster: SavedCoaster) => void
refreshKey: number refreshKey: number
/** Render as a top-bar dropdown instead of a sidebar panel */
menuMode?: boolean
} }
export function CoasterListPanel({ challengeId, currentUsername, onLoad, refreshKey }: Props) { export function CoasterListPanel({ challengeId, currentUsername, onLoad, refreshKey, menuMode }: Props) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [coasters, setCoasters] = useState<SavedCoaster[]>([]) const [coasters, setCoasters] = useState<SavedCoaster[]>([])
@ -23,6 +25,46 @@ export function CoasterListPanel({ challengeId, currentUsername, onLoad, refresh
setCoasters(prev => prev.filter(c => c.id !== id)) setCoasters(prev => prev.filter(c => c.id !== id))
} }
if (menuMode) {
return (
<div className={styles.menuWrapper}>
<button className={styles.menuToggle} onClick={() => setOpen(o => !o)}>
Coasters ({coasters.length})
<span className={`${styles.toggleArrow}${open ? ` ${styles.open}` : ''}`}></span>
</button>
{open && (
<div className={styles.menuDropdown}>
{coasters.length === 0 ? (
<p className={styles.empty}>No coasters yet.</p>
) : (
coasters.map(c => {
const isOwn = c.creator_username === currentUsername
return (
<div key={c.id} className={styles.row}>
<div className={styles.rowInfo}>
<span className={styles.rowName}>{c.name || 'Unnamed coaster'}</span>
<span className={`${styles.username}${isOwn ? ` ${styles.you}` : ''}`}>
@{c.creator_username}
</span>
</div>
<button className={styles.loadBtn} onClick={() => { onLoad(c); setOpen(false) }}>
Load
</button>
{isOwn && (
<button className={styles.deleteBtn} onClick={() => handleDelete(c.id)}>
Del
</button>
)}
</div>
)
})
)}
</div>
)}
</div>
)
}
return ( return (
<div className={styles.panel}> <div className={styles.panel}>
<button className={styles.toggle} onClick={() => setOpen(o => !o)}> <button className={styles.toggle} onClick={() => setOpen(o => !o)}>

View File

@ -154,8 +154,8 @@ function computeCameraTarget(
function buildTerrainMesh( function buildTerrainMesh(
patch: TerrainCaptureData, patch: TerrainCaptureData,
patchIdx: number,
renderer: THREE.WebGLRenderer, renderer: THREE.WebGLRenderer,
visible: boolean,
): { mesh: THREE.Mesh; geo: THREE.BufferGeometry; mat: THREE.Material; tex: THREE.Texture } { ): { mesh: THREE.Mesh; geo: THREE.BufferGeometry; mat: THREE.Material; tex: THREE.Texture } {
const GRID = patch.gridSize const GRID = patch.gridSize
const verts = patch.terrainVertices const verts = patch.terrainVertices
@ -168,7 +168,7 @@ function buildTerrainMesh(
const idx = j * GRID + i const idx = j * GRID + i
const v = verts[idx] const v = verts[idx]
posArr[idx * 3] = v.x posArr[idx * 3] = v.x
posArr[idx * 3 + 1] = v.y - 0.5 posArr[idx * 3 + 1] = v.y
posArr[idx * 3 + 2] = v.z posArr[idx * 3 + 2] = v.z
uvArr[idx * 2] = i / (GRID - 1) uvArr[idx * 2] = i / (GRID - 1)
uvArr[idx * 2 + 1] = j / (GRID - 1) uvArr[idx * 2 + 1] = j / (GRID - 1)
@ -197,9 +197,16 @@ function buildTerrainMesh(
tex.magFilter = THREE.LinearFilter tex.magFilter = THREE.LinearFilter
tex.needsUpdate = true tex.needsUpdate = true
const mat = new THREE.MeshLambertMaterial({ map: tex, side: THREE.FrontSide }) // Polygon offset: higher-indexed patches render on top of lower-indexed ones in
// overlap zones so depth-fighting never causes flickering under the camera.
const mat = new THREE.MeshLambertMaterial({
map: tex,
side: THREE.FrontSide,
polygonOffset: true,
polygonOffsetFactor: -(patchIdx + 1),
polygonOffsetUnits: -(patchIdx + 1),
})
const mesh = new THREE.Mesh(geo, mat) const mesh = new THREE.Mesh(geo, mat)
mesh.visible = visible
return { mesh, geo, mat, tex } return { mesh, geo, mat, tex }
} }
@ -218,23 +225,17 @@ function buildScene(captureData: TerrainCaptureData[], rideData: RideData, rende
const mats: THREE.Material[] = [] const mats: THREE.Material[] = []
const texes: THREE.Texture[] = [] const texes: THREE.Texture[] = []
// ── Terrain patches — one mesh per patch, only the active one visible ──── // ── Terrain patches — all meshes permanently visible ─────────────────────
const terrainMeshes = captureData.map((patch, i) => { // Polygon offset (set per-mesh in buildTerrainMesh) ensures higher-indexed
const { mesh, geo, mat, tex } = buildTerrainMesh(patch, renderer, i === 0) // patches win the depth test in overlap zones without flickering.
captureData.forEach((patch, i) => {
const { mesh, geo, mat, tex } = buildTerrainMesh(patch, i, renderer)
geos.push(geo) geos.push(geo)
mats.push(mat) mats.push(mat)
texes.push(tex) texes.push(tex)
scene.add(mesh) scene.add(mesh)
// Pre-upload texture to GPU so visibility swaps have zero hitch
renderer.initTexture(tex)
return mesh
}) })
function setActivePatch(idx: number) {
const clamped = Math.max(0, Math.min(idx, terrainMeshes.length - 1))
terrainMeshes.forEach((m, i) => { m.visible = i === clamped })
}
// ── Coaster rails ────────────────────────────────────────────────────────── // ── Coaster rails ──────────────────────────────────────────────────────────
function addRail(pts: THREE.Vector3[]) { function addRail(pts: THREE.Vector3[]) {
const step = Math.max(1, Math.floor(pts.length / 200)) const step = Math.max(1, Math.floor(pts.length / 200))
@ -251,7 +252,6 @@ function buildScene(captureData: TerrainCaptureData[], rideData: RideData, rende
return { return {
scene, scene,
setActivePatch,
disposeAll: () => { disposeAll: () => {
geos.forEach(g => g.dispose()) geos.forEach(g => g.dispose())
mats.forEach(m => m.dispose()) mats.forEach(m => m.dispose())
@ -288,10 +288,6 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
const smoothQuatRef = useRef(new THREE.Quaternion()) const smoothQuatRef = useRef(new THREE.Quaternion())
const needsInitRef = useRef(true) const needsInitRef = useRef(true)
// Active terrain patch switching
const setActivePatchRef = useRef<((idx: number) => void) | null>(null)
const activePatchIdxRef = useRef(0)
// First-person drag rotation state // First-person drag rotation state
const userYawRef = useRef(0) // radians — current yaw offset from track forward const userYawRef = useRef(0) // radians — current yaw offset from track forward
const userPitchRef = useRef(0) // radians — current pitch offset const userPitchRef = useRef(0) // radians — current pitch offset
@ -322,10 +318,8 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
rideDataRef.current = rideData rideDataRef.current = rideData
setTotalDuration(rideData.totalDuration) setTotalDuration(rideData.totalDuration)
const { scene, setActivePatch, disposeAll } = buildScene(captureData, rideData, renderer) const { scene, disposeAll } = buildScene(captureData, rideData, renderer)
sceneRef.current = scene sceneRef.current = scene
setActivePatchRef.current = setActivePatch
activePatchIdxRef.current = 0
function onResize() { function onResize() {
const w = window.innerWidth, h = window.innerHeight const w = window.innerWidth, h = window.innerHeight
@ -345,6 +339,7 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
cameraRef.current.updateMatrixWorld() cameraRef.current.updateMatrixWorld()
} }
// Initial render — all patches are permanently visible so no warm-up needed.
renderer.render(scene, cameraRef.current) renderer.render(scene, cameraRef.current)
return () => { return () => {
@ -419,24 +414,6 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
} }
} }
// ── Active terrain patch ──────────────────────────────────────────────
if (captureData.length > 1 && setActivePatchRef.current) {
const d = rideDataRef.current
if (d) {
const pi = bisect(d.timeArray, rideT)
const pi1 = Math.min(pi + 1, d.count - 1)
const pa = d.timeArray[pi1] > d.timeArray[pi]
? (rideT - d.timeArray[pi]) / (d.timeArray[pi1] - d.timeArray[pi])
: 0
const frac = d.sFrac[pi] + (d.sFrac[pi1] - d.sFrac[pi]) * pa
const target = Math.round(frac * (captureData.length - 1))
if (target !== activePatchIdxRef.current) {
activePatchIdxRef.current = target
setActivePatchRef.current(target)
}
}
}
// ── Camera ──────────────────────────────────────────────────────────── // ── Camera ────────────────────────────────────────────────────────────
const data = rideDataRef.current const data = rideDataRef.current
if (data) { if (data) {

View File

@ -1,7 +1,7 @@
.plotsPanel { .plotsPanel {
position: fixed; position: fixed;
left: 16px; left: 16px;
top: 60px; top: 116px;
z-index: 600; /* above Three.js ride canvas (z-index 500) */ z-index: 600; /* above Three.js ride canvas (z-index 500) */
width: 340px; width: 340px;
background: rgba(8, 8, 12, 0.84); background: rgba(8, 8, 12, 0.84);

View File

@ -94,8 +94,10 @@ function ProfileChart({ data, dataKey, color, unit, strips, showZero, rideCursor
/> />
<Tooltip <Tooltip
{...tooltipStyle} {...tooltipStyle}
formatter={(v: number) => [`${v.toFixed(2)} ${unit}`, dataKey]} // eslint-disable-next-line @typescript-eslint/no-explicit-any
labelFormatter={(l: number) => `s = ${pct(l)}`} formatter={(v: any) => [`${Number(v).toFixed(2)} ${unit}`, dataKey]}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
labelFormatter={(l: any) => `s = ${pct(Number(l))}`}
/> />
{strips && <StripAreas strips={strips} />} {strips && <StripAreas strips={strips} />}
{showZero && ( {showZero && (

View File

@ -1,54 +1,46 @@
import * as Cesium from 'cesium' import * as THREE from 'three'
import { lngLatAltToECEF, ecefToLngLatAlt } from '../maplibre/geoUtils'
export interface AnchorPoint { export interface AnchorPoint {
id: string id: string
position: Cesium.Cartesian3 // base position on terrain lngLat: [number, number] // [longitude, latitude]
heightOffset: number // meters above terrain surface terrainHeight: number // metres above WGS-84 ellipsoid (from terrain query)
heightOffset: number // metres above terrain surface (user-adjustable)
} }
// ── helpers ────────────────────────────────────────────────────────────────── // ── helpers ──────────────────────────────────────────────────────────────────
function v3add(a: Cesium.Cartesian3, b: Cesium.Cartesian3): Cesium.Cartesian3 { /** Return the effective 3-D position of an anchor in ECEF metres. */
return Cesium.Cartesian3.add(a, b, new Cesium.Cartesian3()) export function effectivePosition(anchor: AnchorPoint): THREE.Vector3 {
} const [lon, lat] = anchor.lngLat
function v3sub(a: Cesium.Cartesian3, b: Cesium.Cartesian3): Cesium.Cartesian3 { const alt = anchor.terrainHeight + anchor.heightOffset
return Cesium.Cartesian3.subtract(a, b, new Cesium.Cartesian3()) return lngLatAltToECEF(lon, lat, alt)
}
function v3scale(v: Cesium.Cartesian3, s: number): Cesium.Cartesian3 {
return Cesium.Cartesian3.multiplyByScalar(v, s, new Cesium.Cartesian3())
}
function v3norm(v: Cesium.Cartesian3): Cesium.Cartesian3 {
return Cesium.Cartesian3.normalize(v, new Cesium.Cartesian3())
}
function v3cross(a: Cesium.Cartesian3, b: Cesium.Cartesian3): Cesium.Cartesian3 {
return Cesium.Cartesian3.cross(a, b, new Cesium.Cartesian3())
} }
/** Return the effective 3-D position of an anchor including its height offset. */ /** Return effective position as [lon, lat, alt]. */
export function effectivePosition(anchor: AnchorPoint): Cesium.Cartesian3 { export function effectiveLngLatAlt(anchor: AnchorPoint): [number, number, number] {
if (anchor.heightOffset === 0) return anchor.position.clone() return [anchor.lngLat[0], anchor.lngLat[1], anchor.terrainHeight + anchor.heightOffset]
const up = v3norm(anchor.position)
return v3add(anchor.position, v3scale(up, anchor.heightOffset))
} }
// ── Catmull-Rom → cubic Bézier ─────────────────────────────────────────────── // ── Catmull-Rom → cubic Bézier ───────────────────────────────────────────────
interface Segment { interface Segment {
p0: Cesium.Cartesian3 p0: THREE.Vector3
c1: Cesium.Cartesian3 c1: THREE.Vector3
c2: Cesium.Cartesian3 c2: THREE.Vector3
p1: Cesium.Cartesian3 p1: THREE.Vector3
} }
/** /**
* Convert anchor points to cubic Bézier segments via Catmull-Rom. * Convert anchor points to cubic Bézier segments via Catmull-Rom.
* The curve passes through every anchor point with C1 continuity. * The curve passes through every anchor point with C1 continuity.
* All coordinates are in ECEF metres.
*/ */
export function computeSegments(anchors: AnchorPoint[]): Segment[] { export function computeSegments(anchors: AnchorPoint[]): Segment[] {
if (anchors.length < 2) return [] if (anchors.length < 2) return []
const pts = anchors.map(effectivePosition) const pts = anchors.map(effectivePosition)
const n = pts.length const n = pts.length
// phantom end-points so the curve starts/ends at the first/last anchor // phantom end-points so curve starts/ends at first/last anchor
const ext = [pts[0], ...pts, pts[n - 1]] const ext = [pts[0], ...pts, pts[n - 1]]
const segments: Segment[] = [] const segments: Segment[] = []
@ -57,33 +49,36 @@ export function computeSegments(anchors: AnchorPoint[]): Segment[] {
const p0 = ext[i + 1] const p0 = ext[i + 1]
const p1 = ext[i + 2] const p1 = ext[i + 2]
const p2 = ext[i + 3] const p2 = ext[i + 3]
// Catmull-Rom tangent handles converted to cubic Bézier control points // Catmull-Rom tangent handles cubic Bézier control points
const c1 = v3add(p0, v3scale(v3sub(p1, pm1), 1 / 6)) const c1 = p0.clone().addScaledVector(p1.clone().sub(pm1), 1 / 6)
const c2 = v3add(p1, v3scale(v3sub(p0, p2), 1 / 6)) const c2 = p1.clone().addScaledVector(p0.clone().sub(p2), 1 / 6)
segments.push({ p0, c1, c2, p1 }) segments.push({ p0, c1, c2, p1 })
} }
return segments return segments
} }
function evalBezier(seg: Segment, t: number): Cesium.Cartesian3 { function evalBezier(seg: Segment, t: number): THREE.Vector3 {
const { p0, c1, c2, p1 } = seg const { p0, c1, c2, p1 } = seg
const mt = 1 - t const mt = 1 - t
return new Cesium.Cartesian3( return new THREE.Vector3(
mt ** 3 * p0.x + 3 * mt ** 2 * t * c1.x + 3 * mt * t ** 2 * c2.x + t ** 3 * p1.x, mt ** 3 * p0.x + 3 * mt ** 2 * t * c1.x + 3 * mt * t ** 2 * c2.x + t ** 3 * p1.x,
mt ** 3 * p0.y + 3 * mt ** 2 * t * c1.y + 3 * mt * t ** 2 * c2.y + t ** 3 * p1.y, mt ** 3 * p0.y + 3 * mt ** 2 * t * c1.y + 3 * mt * t ** 2 * c2.y + t ** 3 * p1.y,
mt ** 3 * p0.z + 3 * mt ** 2 * t * c1.z + 3 * mt * t ** 2 * c2.z + t ** 3 * p1.z, mt ** 3 * p0.z + 3 * mt ** 2 * t * c1.z + 3 * mt * t ** 2 * c2.z + t ** 3 * p1.z,
) )
} }
/** Sample the full spline as a polyline (all segments concatenated). */ /**
* Sample the full spline as a polyline (all segments concatenated).
* Returns ECEF THREE.Vector3 positions.
*/
export function samplePath( export function samplePath(
anchors: AnchorPoint[], anchors: AnchorPoint[],
samplesPerSegment = 40, samplesPerSegment = 40,
): Cesium.Cartesian3[] { ): THREE.Vector3[] {
const segs = computeSegments(anchors) const segs = computeSegments(anchors)
if (segs.length === 0) return anchors.map(effectivePosition) if (segs.length === 0) return anchors.map(effectivePosition)
const pts: Cesium.Cartesian3[] = [] const pts: THREE.Vector3[] = []
segs.forEach((seg, i) => { segs.forEach((seg, i) => {
const from = i === 0 ? 0 : 1 const from = i === 0 ? 0 : 1
for (let s = from; s <= samplesPerSegment; s++) { for (let s = from; s <= samplesPerSegment; s++) {
@ -93,23 +88,33 @@ export function samplePath(
return pts return pts
} }
/**
* Sample the full spline as geographic [lon, lat, alt] tuples.
*/
export function samplePathGeo(
anchors: AnchorPoint[],
samplesPerSegment = 40,
): [number, number, number][] {
return samplePath(anchors, samplesPerSegment).map(v => ecefToLngLatAlt(v))
}
// ── Rail geometry ───────────────────────────────────────────────────────────── // ── Rail geometry ─────────────────────────────────────────────────────────────
export interface RailPositions { export interface RailPositions {
left: Cesium.Cartesian3[] left: THREE.Vector3[]
right: Cesium.Cartesian3[] right: THREE.Vector3[]
} }
/** /**
* Given a centre-line path compute parallel left/right rail positions. * Given a centre-line path (ECEF) compute parallel left/right rail positions.
* @param gauge distance between rails in metres (default 2.5 for visual clarity) * @param gauge distance between rails in metres (default 2.5 for visual clarity)
*/ */
export function computeRails( export function computeRails(
path: Cesium.Cartesian3[], path: THREE.Vector3[],
gauge = 2.5, gauge = 2.5,
): RailPositions { ): RailPositions {
const left: Cesium.Cartesian3[] = [] const left: THREE.Vector3[] = []
const right: Cesium.Cartesian3[] = [] const right: THREE.Vector3[] = []
const half = gauge / 2 const half = gauge / 2
const n = path.length const n = path.length
@ -117,19 +122,19 @@ export function computeRails(
const pt = path[i] const pt = path[i]
// Finite-difference tangent along the curve // Finite-difference tangent along the curve
let tangent: Cesium.Cartesian3 let tangent: THREE.Vector3
if (i === 0) tangent = v3sub(path[1], path[0]) if (i === 0) tangent = path[1].clone().sub(path[0])
else if (i === n - 1) tangent = v3sub(path[n - 1], path[n - 2]) else if (i === n - 1) tangent = path[n - 1].clone().sub(path[n - 2])
else tangent = v3sub(path[i + 1], path[i - 1]) else tangent = path[i + 1].clone().sub(path[i - 1])
tangent = v3norm(tangent) tangent.normalize()
// Local up = radially outward from Earth centre // Local up = radially outward from Earth centre (normalise ECEF position)
const up = v3norm(pt) const up = pt.clone().normalize()
// Track-right = tangent × up (right-hand rule → points right when facing forward) // Track-right = tangent × up (right-hand rule)
const rightDir = v3norm(v3cross(tangent, up)) const rightDir = new THREE.Vector3().crossVectors(tangent, up).normalize()
left.push(v3add(pt, v3scale(rightDir, -half))) left.push(pt.clone().addScaledVector(rightDir, -half))
right.push(v3add(pt, v3scale(rightDir, half))) right.push(pt.clone().addScaledVector(rightDir, half))
} }
return { left, right } return { left, right }

View File

@ -1,26 +1,40 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import * as Cesium from 'cesium' import type { Map, MapMouseEvent, GeoJSONSource } from 'maplibre-gl'
import maplibregl from 'maplibre-gl'
import type { AccelerationStrip } from '../types/api' import type { AccelerationStrip } from '../types/api'
import { safeRemoveLayers } from '../maplibre/geoUtils'
import { createCustom3DLayer } from '../maplibre/custom3DLayer'
import type { Layer3DHandle } from '../maplibre/custom3DLayer'
// ── Arc-length utilities ─────────────────────────────────────────────────────── // ── Arc-length utilities (geographic [lon, lat, alt] inputs) ──────────────────
function computeArcLengths(pts: Cesium.Cartesian3[]): number[] { function geoDist(a: [number, number, number], b: [number, number, number]): number {
const midLat = (a[1] + b[1]) / 2
const dlat = (b[1] - a[1]) * 111320
const dlon = (b[0] - a[0]) * 111320 * Math.cos(midLat * Math.PI / 180)
return Math.sqrt(dlat * dlat + dlon * dlon)
}
export function computeArcLengths(pts: [number, number, number][]): number[] {
const s = [0] const s = [0]
for (let i = 1; i < pts.length; i++) { for (let i = 1; i < pts.length; i++) {
s.push(s[i - 1] + Cesium.Cartesian3.distance(pts[i - 1], pts[i])) s.push(s[i - 1] + geoDist(pts[i - 1], pts[i]))
} }
return s return s
} }
function snapToPath( export function snapToPath(
pos: Cesium.Cartesian3, lngLat: [number, number],
pts: Cesium.Cartesian3[], pts: [number, number, number][],
arcs: number[], arcs: number[],
): { frac: number; pt: Cesium.Cartesian3 } { ): { frac: number; pt: [number, number, number] } {
let minDist = Infinity let minDist = Infinity
let best = 0 let best = 0
const refLat = lngLat[1]
for (let i = 0; i < pts.length; i++) { for (let i = 0; i < pts.length; i++) {
const d = Cesium.Cartesian3.distance(pos, pts[i]) const dlat = (pts[i][1] - lngLat[1]) * 111320
const dlon = (pts[i][0] - lngLat[0]) * 111320 * Math.cos(refLat * Math.PI / 180)
const d = Math.sqrt(dlat * dlat + dlon * dlon)
if (d < minDist) { minDist = d; best = i } if (d < minDist) { minDist = d; best = i }
} }
const total = arcs[arcs.length - 1] const total = arcs[arcs.length - 1]
@ -37,33 +51,46 @@ function fracToIndex(frac: number, arcs: number[]): number {
return best return best
} }
// ── Source / layer IDs ────────────────────────────────────────────────────────
const SRC_STRIPS = 'accel-strips-src'
const LYR_STRIPS = 'accel-strips-lyr' // invisible draped line for right-click hit test
const SRC_PENDING = 'accel-pending-src'
const LYR_PENDING = 'accel-pending-lyr' // draped circle for pending start point
const LYR_STRIPS_3D = 'accel-strips-3d' // custom 3D layer for visual rendering
function emptyFC(): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', features: [] } }
function setData(map: Map, src: string, data: GeoJSON.GeoJSON) {
(map.getSource(src) as GeoJSONSource | undefined)?.setData(data)
}
// ── Hook ────────────────────────────────────────────────────────────────────── // ── Hook ──────────────────────────────────────────────────────────────────────
export interface AccelerationStripsHandle { export interface AccelerationStripsHandle {
strips: AccelerationStrip[] strips: AccelerationStrip[]
removeStrip: (id: string) => void removeStrip: (id: string) => void
updateStrip: (id: string, accel_ms2: number) => void updateStrip: (id: string, accel_ms2: number) => void
updateStripFrac: (id: string, startFrac: number, endFrac: number) => void
clearStrips: () => void clearStrips: () => void
loadStrips: (strips: AccelerationStrip[]) => void loadStrips: (strips: AccelerationStrip[]) => void
} }
interface PendingStrip { interface PendingStrip { startFrac: number; pt: [number, number, number] }
startFrac: number
marker: Cesium.Entity
}
export function useAccelerationStrips( export function useAccelerationStrips(
viewer: Cesium.Viewer, map: Map,
pathPts: Cesium.Cartesian3[], pathPts: [number, number, number][],
isActive: boolean, isActive: boolean,
showStrips = true, showStrips = true,
): AccelerationStripsHandle { ): AccelerationStripsHandle {
const [strips, setStrips] = useState<AccelerationStrip[]>([]) const [strips, setStrips] = useState<AccelerationStrip[]>([])
const pendingRef = useRef<PendingStrip | null>(null) const pendingRef = useRef<PendingStrip | null>(null)
const stripEntities = useRef<Map<string, Cesium.Entity>>(new Map())
const pathPtsRef = useRef(pathPts) const pathPtsRef = useRef(pathPts)
pathPtsRef.current = pathPts pathPtsRef.current = pathPts
const isActiveRef = useRef(isActive)
isActiveRef.current = isActive
const layer3DRef = useRef<Layer3DHandle | null>(null)
// ── Public callbacks ─────────────────────────────────────────────────────── // ── Public callbacks ───────────────────────────────────────────────────────
@ -75,166 +102,167 @@ export function useAccelerationStrips(
setStrips(prev => prev.map(s => s.id === id ? { ...s, accel_ms2 } : s)) setStrips(prev => prev.map(s => s.id === id ? { ...s, accel_ms2 } : s))
}, []) }, [])
const clearStrips = useCallback(() => { const updateStripFrac = useCallback((id: string, startFrac: number, endFrac: number) => {
setStrips([]) setStrips(prev => prev.map(s => s.id === id ? { ...s, startFrac, endFrac } : s))
}, []) }, [])
const clearStrips = useCallback(() => setStrips([]), [])
const loadStrips = useCallback((newStrips: AccelerationStrip[]) => { const loadStrips = useCallback((newStrips: AccelerationStrip[]) => {
setStrips(newStrips) setStrips(newStrips)
}, []) }, [])
// ── Set up MapLibre sources + layers ───────────────────────────────────────
useEffect(() => {
map.addSource(SRC_STRIPS, { type: 'geojson', data: emptyFC() })
map.addSource(SRC_PENDING, { type: 'geojson', data: emptyFC() })
// Invisible line kept only for right-click hit-testing (queryRenderedFeatures).
map.addLayer({ id: LYR_STRIPS, type: 'line', source: SRC_STRIPS,
paint: { 'line-color': '#f59e0b', 'line-width': 8, 'line-opacity': 0.01 } })
// Draped circle showing the pending strip start point.
map.addLayer({ id: LYR_PENDING, type: 'circle', source: SRC_PENDING,
paint: {
'circle-radius': 8,
'circle-color': '#f59e0b',
'circle-stroke-color': 'rgba(0,0,0,0.6)',
'circle-stroke-width': 2,
} })
return () => {
safeRemoveLayers(map,
[LYR_STRIPS, LYR_PENDING],
[SRC_STRIPS, SRC_PENDING],
)
}
}, [map])
// ── Custom 3D layer for strip visual rendering ─────────────────────────────
useEffect(() => {
const handle = createCustom3DLayer(LYR_STRIPS_3D, map)
layer3DRef.current = handle
return () => {
handle.destroy()
layer3DRef.current = null
}
}, [map])
// ── Click handler for strip placement ───────────────────────────────────── // ── Click handler for strip placement ─────────────────────────────────────
useEffect(() => { useEffect(() => {
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas) const onClick = (e: MapMouseEvent) => {
if (!isActiveRef.current) return
function pickTerrain(pos: Cesium.Cartesian2): Cesium.Cartesian3 | null {
const ray = viewer.camera.getPickRay(pos)
if (!ray) return null
return viewer.scene.globe.pick(ray, viewer.scene) ?? null
}
handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => {
if (!isActive) return
const pts = pathPtsRef.current const pts = pathPtsRef.current
if (pts.length < 2) return if (pts.length < 2) return
const worldPos = pickTerrain(e.position)
if (!worldPos) return
const arcs = computeArcLengths(pts) const arcs = computeArcLengths(pts)
const { frac, pt } = snapToPath(worldPos, pts, arcs) const { frac, pt } = snapToPath([e.lngLat.lng, e.lngLat.lat], pts, arcs)
if (!pendingRef.current) { if (!pendingRef.current) {
// First click — place start marker pendingRef.current = { startFrac: frac, pt }
const marker = viewer.entities.add({ setData(map, SRC_PENDING, {
position: new Cesium.ConstantPositionProperty(pt), type: 'Feature',
point: { geometry: { type: 'Point', coordinates: pt },
pixelSize: 14, properties: {},
color: Cesium.Color.fromCssColorString('#f59e0b'),
outlineColor: Cesium.Color.BLACK.withAlpha(0.6),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
label: {
text: 'Strip start',
font: '12px sans-serif',
fillColor: Cesium.Color.fromCssColorString('#f59e0b'),
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(0, -22),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
}) })
pendingRef.current = { startFrac: frac, marker }
} else { } else {
// Second click — complete the strip const { startFrac } = pendingRef.current
const { startFrac, marker } = pendingRef.current
viewer.entities.remove(marker)
pendingRef.current = null pendingRef.current = null
setData(map, SRC_PENDING, emptyFC())
let sf = startFrac, ef = frac let sf = startFrac, ef = frac
if (sf > ef) [sf, ef] = [ef, sf] if (sf > ef) [sf, ef] = [ef, sf]
if (sf === ef) return // degenerate — same point if (sf === ef) return
const id = crypto.randomUUID() const id = crypto.randomUUID()
setStrips(prev => [...prev, { id, startFrac: sf, endFrac: ef, accel_ms2: 5.0 }]) setStrips(prev => [...prev, { id, startFrac: sf, endFrac: ef, accel_ms2: 5.0 }])
} }
}, Cesium.ScreenSpaceEventType.LEFT_CLICK) }
// Right-click: cancel pending const onContextMenu = () => {
handler.setInputAction(() => { if (!isActiveRef.current || !pendingRef.current) return
if (!isActive || !pendingRef.current) return
viewer.entities.remove(pendingRef.current.marker)
pendingRef.current = null pendingRef.current = null
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK) setData(map, SRC_PENDING, emptyFC())
}
map.on('click', onClick)
map.getCanvas().addEventListener('contextmenu', onContextMenu)
return () => { return () => {
if (!handler.isDestroyed()) handler.destroy() map.off('click', onClick)
map.getCanvas().removeEventListener('contextmenu', onContextMenu)
} }
}, [viewer, isActive]) // pathPts via ref — intentional }, [map])
// ── Cesium polyline entity sync ──────────────────────────────────────────── // ── Right-click on strip to delete ────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (viewer.isDestroyed()) return const onContextMenuStrip = (e: MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
if (isActiveRef.current) return
const currentIds = new Set(strips.map(s => s.id)) const features = map.queryRenderedFeatures(e.point, { layers: [LYR_STRIPS] })
if (!features.length) return
// Remove entities for deleted strips const stripId = features[0].properties?.stripId as string | undefined
stripEntities.current.forEach((entity, id) => { if (stripId) removeStrip(stripId)
if (!currentIds.has(id)) {
viewer.entities.remove(entity)
stripEntities.current.delete(id)
} }
})
if (pathPts.length < 2) return map.on('contextmenu', LYR_STRIPS, onContextMenuStrip as Parameters<typeof map.on>[1])
return () => {
map.off('contextmenu', LYR_STRIPS, onContextMenuStrip as Parameters<typeof map.on>[1])
}
}, [map, removeStrip])
const arcs = computeArcLengths(pathPts) // ── Sync strip GeoJSON + 3D tubes ─────────────────────────────────────────
// Remove all existing strip entities and rebuild — pathPts may have changed useEffect(() => {
stripEntities.current.forEach(entity => viewer.entities.remove(entity)) if (!showStrips) {
stripEntities.current.clear() layer3DRef.current?.update([])
return
}
const pts = pathPts
if (pts.length < 2) {
setData(map, SRC_STRIPS, emptyFC())
layer3DRef.current?.update([])
return
}
const arcs = computeArcLengths(pts)
for (const strip of strips) { const segments: { coords: [number, number, number][]; id: string }[] = []
const features: GeoJSON.Feature<GeoJSON.Geometry, GeoJSON.GeoJsonProperties>[] = strips.map(strip => {
const si = fracToIndex(strip.startFrac, arcs) const si = fracToIndex(strip.startFrac, arcs)
const ei = fracToIndex(strip.endFrac, arcs) const ei = fracToIndex(strip.endFrac, arcs)
const sliced = pathPts.slice(Math.min(si, ei), Math.max(si, ei) + 1) const lo = Math.min(si, ei)
if (sliced.length < 2) continue const hi = Math.max(si, ei)
const sliced = pts.slice(lo, hi + 1) as [number, number, number][]
const entity = viewer.entities.add({ if (sliced.length >= 2) segments.push({ coords: sliced, id: strip.id })
id: `accel-strip-${strip.id}`, return {
polyline: { type: 'Feature' as const,
positions: sliced, geometry: { type: 'LineString' as const, coordinates: sliced },
width: 7,
material: Cesium.Color.fromCssColorString('#f59e0b'),
arcType: Cesium.ArcType.NONE,
clampToGround: false,
},
properties: { stripId: strip.id }, properties: { stripId: strip.id },
})
stripEntities.current.set(strip.id, entity)
} }
}, [strips, pathPts, viewer]) }).filter(f => (f.geometry as GeoJSON.LineString).coordinates.length >= 2)
// ── Right-click on strip entity to delete ───────────────────────────────── // Invisible draped geometry for right-click hit-testing
setData(map, SRC_STRIPS, { type: 'FeatureCollection', features })
// 3D tube rendering on the path
layer3DRef.current?.update(
segments.map(s => ({ pts: s.coords, color: 0xf59e0b, radiusMeters: 0.4 }))
)
}, [strips, pathPts, showStrips, map])
// ── Visibility toggle ─────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas) const vis = showStrips ? 'visible' : 'none'
if (map.getLayer(LYR_STRIPS)) map.setLayoutProperty(LYR_STRIPS, 'visibility', vis)
if (map.getLayer(LYR_PENDING)) map.setLayoutProperty(LYR_PENDING, 'visibility', vis)
// 3D layer: update with empty when hidden, restore on show
// (handled by the strip sync effect which runs on showStrips via the deps below)
}, [showStrips, map])
handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => { return { strips, removeStrip, updateStrip, updateStripFrac, clearStrips, loadStrips }
if (isActive) return // handled by placement handler above
const picked = viewer.scene.pick(e.position)
if (!Cesium.defined(picked)) return
const entity = picked.id as Cesium.Entity | undefined
if (!(entity instanceof Cesium.Entity)) return
const stripId = entity.properties?.stripId?.getValue() as string | undefined
if (stripId) removeStrip(stripId)
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
return () => {
if (!handler.isDestroyed()) handler.destroy()
}
}, [viewer, isActive, removeStrip])
// ── Strip visibility toggle ────────────────────────────────────────────────
useEffect(() => {
stripEntities.current.forEach(e => { e.show = showStrips })
}, [showStrips])
// ── Cleanup on unmount ─────────────────────────────────────────────────────
useEffect(() => {
return () => {
if (viewer.isDestroyed()) return
if (pendingRef.current) viewer.entities.remove(pendingRef.current.marker)
stripEntities.current.forEach(e => viewer.entities.remove(e))
stripEntities.current.clear()
}
}, [viewer])
return { strips, removeStrip, updateStrip, clearStrips, loadStrips }
} }

View File

@ -1,13 +1,35 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import * as Cesium from 'cesium' import type { Map, MapMouseEvent, GeoJSONSource } from 'maplibre-gl'
import type { AnchorPoint } from './bezierUtils' import type { AnchorPoint } from './bezierUtils'
import { effectivePosition, samplePath, computeRails } from './bezierUtils' import { effectivePosition, effectiveLngLatAlt, samplePath, computeRails } from './bezierUtils'
import { ecefToLngLatAlt, safeRemoveLayers } from '../maplibre/geoUtils'
import { createCustom3DLayer } from '../maplibre/custom3DLayer'
import type { Layer3DHandle, Point3D } from '../maplibre/custom3DLayer'
export type EditorMode = 'add' | 'select' | 'strip' export type EditorMode = 'add' | 'select' | 'strip'
// ── Altitude helper ───────────────────────────────────────────────────────────
//
// map.queryTerrainElevation() returns a RELATIVE value:
// (DEM_at_point DEM_at_center) × exaggeration
//
// To get absolute MSL elevation in metres (matching Terrarium tile encoding and
// MercatorCoordinate.fromLngLat's altitude parameter), we add back the centre
// elevation stored in the transform and divide out the exaggeration factor.
//
// The exaggeration constant must match the value in MapLibreViewer.tsx.
const TERRAIN_EXAGGERATION = 1.5
function getAbsoluteTerrainElevation(map: Map, lngLat: { lng: number; lat: number }): number {
const relative = map.queryTerrainElevation(lngLat) ?? 0
const centerElev = (map as unknown as { transform: { elevation: number } }).transform?.elevation ?? 0
return (relative + centerElev) / TERRAIN_EXAGGERATION
}
export interface CoasterPathHandle { export interface CoasterPathHandle {
anchors: AnchorPoint[] anchors: AnchorPoint[]
pathPts: Cesium.Cartesian3[] /** Sampled path as geographic [lon, lat, alt] tuples */
pathPts: [number, number, number][]
selectedId: string | null selectedId: string | null
mode: EditorMode mode: EditorMode
setMode: (m: EditorMode) => void setMode: (m: EditorMode) => void
@ -18,37 +40,54 @@ export interface CoasterPathHandle {
clearAll: () => void clearAll: () => void
} }
let _counter = 0 // ── Source / layer IDs ────────────────────────────────────────────────────────
function genId(): string {
return `a${++_counter}-${Math.random().toString(36).slice(2, 6)}` const SRC_PATH = 'coaster-path-src' // thin draped line for queryRenderedFeatures hover
const SRC_LABEL = 'coaster-label-src'
const LYR_PATH = 'coaster-path-lyr'
const LYR_LABEL = 'coaster-label-lyr'
const LYR_PATH_3D = 'coaster-path-3d' // custom 3D layer id
function emptyFC(): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', features: [] } }
function setData(map: Map, src: string, data: GeoJSON.GeoJSON) {
(map.getSource(src) as GeoJSONSource | undefined)?.setData(data)
} }
export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAnchors = true): CoasterPathHandle { let _counter = 0
function genId(): string { return `a${++_counter}-${Math.random().toString(36).slice(2, 6)}` }
// Sphere radius in metres for anchor markers at each selection state.
const ANCHOR_RADIUS_NORMAL = 4
const ANCHOR_RADIUS_SELECTED = 6
export function useCoasterPath(
map: Map,
showPath = true,
showAnchors = true,
): CoasterPathHandle {
const [anchors, setAnchors] = useState<AnchorPoint[]>([]) const [anchors, setAnchors] = useState<AnchorPoint[]>([])
const [pathPts, setPathPts] = useState<Cesium.Cartesian3[]>([]) const [pathPts, setPathPts] = useState<[number, number, number][]>([])
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
const [mode, setMode] = useState<EditorMode>('add') const [mode, setMode] = useState<EditorMode>('add')
// Keep refs in sync so event-handler closures always see current values
const anchorsRef = useRef(anchors); anchorsRef.current = anchors const anchorsRef = useRef(anchors); anchorsRef.current = anchors
const modeRef = useRef(mode); modeRef.current = mode const modeRef = useRef(mode); modeRef.current = mode
const selectedRef = useRef(selectedId); selectedRef.current = selectedId const selectedRef = useRef(selectedId); selectedRef.current = selectedId
const layer3DRef = useRef<Layer3DHandle | null>(null)
// Cesium entity refs // Drag state (no React re-renders during drag)
const sphereMapRef = useRef<Map<string, Cesium.Entity>>(new Map())
const pathEntities = useRef<Cesium.Entity[]>([])
const startLabelRef = useRef<Cesium.Entity | null>(null)
// Drag state (refs to avoid triggering re-renders)
const isDragging = useRef(false) const isDragging = useRef(false)
const dragAnchorId = useRef<string | null>(null) const dragAnchorId = useRef<string | null>(null)
const dragPos = useRef<Cesium.Cartesian3 | null>(null) const dragLngLat = useRef<[number, number] | null>(null)
const didMoveDuringDrag = useRef(false) const didMoveDuringDrag = useRef(false)
// ── public callbacks ─────────────────────────────────────────────────────── // ── Public callbacks ───────────────────────────────────────────────────────
const updateAnchorHeight = useCallback((id: string, delta: number) => { const updateAnchorHeight = useCallback((id: string, delta: number) => {
setAnchors(prev => prev.map(a => a.id === id ? { ...a, heightOffset: Math.max(0, a.heightOffset + delta) } : a)) setAnchors(prev => prev.map(a =>
a.id === id ? { ...a, heightOffset: Math.max(0, a.heightOffset + delta) } : a,
))
}, []) }, [])
const removeAnchor = useCallback((id: string) => { const removeAnchor = useCallback((id: string) => {
@ -70,237 +109,229 @@ export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAncho
}) })
}, []) }, [])
const clearAll = useCallback(() => { const clearAll = useCallback(() => { setAnchors([]); setSelectedId(null) }, [])
setAnchors([])
setSelectedId(null)
}, [])
// ── cursor style ─────────────────────────────────────────────────────────── // ── Set up MapLibre sources + layers ───────────────────────────────────────
useEffect(() => { useEffect(() => {
viewer.scene.canvas.style.cursor = map.addSource(SRC_PATH, { type: 'geojson', data: emptyFC() })
mode === 'add' ? 'crosshair' : map.addSource(SRC_LABEL, { type: 'geojson', data: emptyFC() })
mode === 'strip' ? 'cell' : 'default'
}, [mode, viewer])
// ── entity sync ──────────────────────────────────────────────────────────── // Thin draped line — used only so queryRenderedFeatures can pick up hover events
map.addLayer({ id: LYR_PATH, type: 'line', source: SRC_PATH,
layout: { 'line-cap': 'round' },
paint: { 'line-color': '#fbbf24', 'line-width': 2, 'line-opacity': 0.7 } })
useEffect(() => { map.addLayer({ id: LYR_LABEL, type: 'symbol', source: SRC_LABEL,
const existingIds = new Set(anchors.map(a => a.id)) layout: {
'text-field': ['get', 'label'],
// Remove spheres for deleted anchors 'text-size': 13,
sphereMapRef.current.forEach((entity, id) => { 'text-anchor': 'bottom',
if (!existingIds.has(id)) { 'text-offset': [0, -1.2],
viewer.entities.remove(entity)
sphereMapRef.current.delete(id)
}
})
// Add or update anchor spheres
anchors.forEach((anchor) => {
const pos = effectivePosition(anchor)
const isSelected = anchor.id === selectedId
const color = isSelected ? Cesium.Color.fromCssColorString('#f59e0b') : Cesium.Color.WHITE
const size = isSelected ? 15 : 10
if (!sphereMapRef.current.has(anchor.id)) {
const entity = viewer.entities.add({
id: `coaster-anchor-${anchor.id}`,
position: new Cesium.ConstantPositionProperty(pos),
point: {
pixelSize: size,
color,
outlineColor: Cesium.Color.BLACK.withAlpha(0.6),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
}, },
properties: { anchorId: anchor.id }, paint: {
}) 'text-color': '#4ade80',
sphereMapRef.current.set(anchor.id, entity) 'text-halo-color': '#000000',
} else { 'text-halo-width': 1.5,
const entity = sphereMapRef.current.get(anchor.id)! } })
entity.position = new Cesium.ConstantPositionProperty(pos)
entity.point!.color = new Cesium.ConstantProperty(color)
entity.point!.pixelSize = new Cesium.ConstantProperty(size)
}
})
// Rebuild path + rails return () => {
pathEntities.current.forEach(e => viewer.entities.remove(e)) safeRemoveLayers(map,
pathEntities.current = [] [LYR_PATH, LYR_LABEL],
[SRC_PATH, SRC_LABEL],
)
}
}, [map])
// ── Custom 3D layer for path, rails, and anchor spheres ───────────────────
useEffect(() => {
const handle = createCustom3DLayer(LYR_PATH_3D, map)
layer3DRef.current = handle
return () => {
handle.destroy()
layer3DRef.current = null
}
}, [map])
// ── Cursor style ───────────────────────────────────────────────────────────
useEffect(() => {
const canvas = map.getCanvas()
canvas.style.cursor =
mode === 'add' ? 'crosshair' :
mode === 'strip' ? 'cell' : ''
}, [mode, map])
// ── Sync anchors + path to GeoJSON sources and 3D layer ───────────────────
useEffect(() => {
// Draped label for the start point
if (anchors.length > 0) {
const [lon, lat, alt] = ecefToLngLatAlt(effectivePosition(anchors[0]))
setData(map, SRC_LABEL, {
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat, alt] },
properties: { label: '▶ Start' },
}],
})
} else {
setData(map, SRC_LABEL, emptyFC())
}
if (anchors.length >= 2) { if (anchors.length >= 2) {
const pts = samplePath(anchors) const ecefPts = samplePath(anchors)
setPathPts(pts) const geoPts = ecefPts.map(v => ecefToLngLatAlt(v)) as [number, number, number][]
const { left, right } = computeRails(pts) setPathPts(geoPts)
const centre = viewer.entities.add({ // Thin draped line for hover hit-testing (queryRenderedFeatures)
polyline: { setData(map, SRC_PATH, {
positions: pts, type: 'FeatureCollection',
width: 2, features: [{
material: Cesium.Color.YELLOW.withAlpha(0.55), type: 'Feature',
arcType: Cesium.ArcType.NONE, geometry: { type: 'LineString', coordinates: geoPts },
}, properties: {},
}],
}) })
const leftRail = viewer.entities.add({
polyline: { const { left, right } = computeRails(ecefPts)
positions: left, const leftPts = left.map(v => ecefToLngLatAlt(v)) as [number, number, number][]
width: 3, const rightPts = right.map(v => ecefToLngLatAlt(v)) as [number, number, number][]
material: Cesium.Color.fromCssColorString('#b0b8c1'),
arcType: Cesium.ArcType.NONE, layer3DRef.current?.update([
}, { pts: geoPts, color: 0xfbbf24, radiusMeters: 0.15 },
}) { pts: leftPts, color: 0xb0b8c1, radiusMeters: 0.08 },
const rightRail = viewer.entities.add({ { pts: rightPts, color: 0xb0b8c1, radiusMeters: 0.08 },
polyline: { ])
positions: right,
width: 3,
material: Cesium.Color.fromCssColorString('#b0b8c1'),
arcType: Cesium.ArcType.NONE,
},
})
pathEntities.current = [centre, leftRail, rightRail]
} else { } else {
setData(map, SRC_PATH, emptyFC())
layer3DRef.current?.update([])
setPathPts([]) setPathPts([])
} }
// Start label — always at first anchor // Anchor sphere markers rendered in 3D at path altitude
if (startLabelRef.current) { if (showAnchors && anchors.length > 0) {
viewer.entities.remove(startLabelRef.current) const spheres: Point3D[] = anchors.map(a => ({
startLabelRef.current = null id: a.id,
pt: effectiveLngLatAlt(a),
color: a.id === selectedId ? 0xf59e0b : 0xffffff,
radiusMeters: a.id === selectedId ? ANCHOR_RADIUS_SELECTED : ANCHOR_RADIUS_NORMAL,
}))
layer3DRef.current?.updatePoints(spheres)
} else {
layer3DRef.current?.updatePoints([])
} }
if (anchors.length > 0) { }, [anchors, selectedId, showAnchors, map])
startLabelRef.current = viewer.entities.add({
position: new Cesium.ConstantPositionProperty(effectivePosition(anchors[0])),
label: {
text: '\u25B6 Start',
font: '13px sans-serif',
fillColor: Cesium.Color.fromCssColorString('#4ade80'),
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(0, -22),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
showBackground: true,
backgroundColor: Cesium.Color.BLACK.withAlpha(0.45),
backgroundPadding: new Cesium.Cartesian2(6, 4),
},
})
}
}, [anchors, selectedId, viewer])
// ── path visibility toggle ───────────────────────────────────────────────── // ── Visibility toggles ─────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
pathEntities.current.forEach(e => { e.show = showPath }) const vis = showPath ? 'visible' : 'none'
}, [showPath]) if (map.getLayer(LYR_PATH)) map.setLayoutProperty(LYR_PATH, 'visibility', vis)
}, [showPath, map])
// ── anchor visibility toggle ─────────────────────────────────────────────── // ── Input handling ─────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
sphereMapRef.current.forEach(e => { e.show = showAnchors }) const canvas = map.getCanvas()
if (startLabelRef.current) startLabelRef.current.show = showAnchors const suppressCtx = (e: Event) => { e.preventDefault() }
}, [showAnchors])
// ── input handling ─────────────────────────────────────────────────────────
useEffect(() => {
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
const canvas = viewer.scene.canvas
// Prevent browser context-menu in the viewer
const suppressCtx = (e: Event) => e.preventDefault()
canvas.addEventListener('contextmenu', suppressCtx) canvas.addEventListener('contextmenu', suppressCtx)
const disableCam = () => { // Mousedown — begin drag on anchor sphere (select mode only)
const c = viewer.scene.screenSpaceCameraController const onMouseDown = (e: MapMouseEvent) => {
c.enableRotate = c.enableZoom = c.enableTranslate = c.enableTilt = false
}
const enableCam = () => {
const c = viewer.scene.screenSpaceCameraController
c.enableRotate = c.enableZoom = c.enableTranslate = c.enableTilt = true
}
function pickAnchorId(pos: Cesium.Cartesian2): string | null {
const picked = viewer.scene.pick(pos)
if (!Cesium.defined(picked)) return null
const entity = picked.id as Cesium.Entity | undefined
if (!(entity instanceof Cesium.Entity)) return null
return entity.properties?.anchorId?.getValue() ?? null
}
function pickTerrain(pos: Cesium.Cartesian2): Cesium.Cartesian3 | null {
const ray = viewer.camera.getPickRay(pos)
if (!ray) return null
return viewer.scene.globe.pick(ray, viewer.scene) ?? null
}
// LEFT_DOWN start drag in select mode
handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => {
if (modeRef.current !== 'select') return if (modeRef.current !== 'select') return
const anchorId = pickAnchorId(e.position) const { clientWidth: w, clientHeight: h } = canvas
const anchorId = layer3DRef.current?.hitTestPoints(e.point.x, e.point.y, w, h) ?? null
if (!anchorId) return if (!anchorId) return
e.preventDefault()
isDragging.current = true isDragging.current = true
dragAnchorId.current = anchorId dragAnchorId.current = anchorId
dragPos.current = null dragLngLat.current = null
didMoveDuringDrag.current = false didMoveDuringDrag.current = false
disableCam() map.dragPan.disable()
}, Cesium.ScreenSpaceEventType.LEFT_DOWN) }
// MOUSE_MOVE live-drag the sphere // Mousemove → drag anchor live, or update cursor
handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.MotionEvent) => { const onMouseMove = (e: MapMouseEvent) => {
if (!isDragging.current || !dragAnchorId.current) return if (isDragging.current && dragAnchorId.current) {
const newPos = pickTerrain(e.endPosition)
if (!newPos) return
didMoveDuringDrag.current = true didMoveDuringDrag.current = true
dragPos.current = newPos const ll: [number, number] = [e.lngLat.lng, e.lngLat.lat]
// Move the sphere entity directly (no React re-render during drag) dragLngLat.current = ll
const entity = sphereMapRef.current.get(dragAnchorId.current) // Live-update anchor sphere position without React re-render
if (entity) entity.position = new Cesium.ConstantPositionProperty(newPos)
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
// LEFT_UP commit drag
handler.setInputAction(() => {
if (isDragging.current) {
if (didMoveDuringDrag.current && dragAnchorId.current && dragPos.current) {
const id = dragAnchorId.current const id = dragAnchorId.current
const pos = dragPos.current const terrainAlt = getAbsoluteTerrainElevation(map, e.lngLat)
setAnchors(prev => prev.map(a => a.id === id ? { ...a, position: pos } : a)) const current = anchorsRef.current
const spheres: Point3D[] = current.map(a => {
const pt: [number, number, number] = a.id === id
? [ll[0], ll[1], terrainAlt + a.heightOffset]
: effectiveLngLatAlt(a)
return {
id: a.id,
pt,
color: a.id === selectedRef.current ? 0xf59e0b : 0xffffff,
radiusMeters: a.id === selectedRef.current ? ANCHOR_RADIUS_SELECTED : ANCHOR_RADIUS_NORMAL,
}
})
layer3DRef.current?.updatePoints(spheres)
return
}
if (modeRef.current === 'select') {
const { clientWidth: w, clientHeight: h } = canvas
const hit = layer3DRef.current?.hitTestPoints(e.point.x, e.point.y, w, h)
canvas.style.cursor = hit ? 'grab' : ''
}
}
// Mouseup → commit drag to React state
const onMouseUp = (e: MapMouseEvent) => {
if (!isDragging.current) return
if (didMoveDuringDrag.current && dragAnchorId.current && dragLngLat.current) {
const id = dragAnchorId.current
const [lng, lat] = dragLngLat.current
const terrainAlt = getAbsoluteTerrainElevation(map, e.lngLat)
setAnchors(prev => prev.map(a =>
a.id === id ? { ...a, lngLat: [lng, lat], terrainHeight: terrainAlt } : a,
))
} }
isDragging.current = false isDragging.current = false
dragAnchorId.current = null dragAnchorId.current = null
dragPos.current = null dragLngLat.current = null
enableCam() map.dragPan.enable()
} if (modeRef.current === 'select') canvas.style.cursor = ''
}, Cesium.ScreenSpaceEventType.LEFT_UP)
// LEFT_CLICK add point or select
handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => {
// Suppress click that immediately follows a completed drag
if (didMoveDuringDrag.current) {
didMoveDuringDrag.current = false
return
} }
const anchorId = pickAnchorId(e.position) // Click → add anchor or select/deselect
const onClick = (e: MapMouseEvent) => {
if (didMoveDuringDrag.current) { didMoveDuringDrag.current = false; return }
// 3D sphere hit test for anchor selection
const { clientWidth: w, clientHeight: h } = canvas
const anchorId = layer3DRef.current?.hitTestPoints(e.point.x, e.point.y, w, h) ?? null
if (anchorId) { if (anchorId) {
setSelectedId(prev => prev === anchorId ? null : anchorId) setSelectedId(prev => prev === anchorId ? null : anchorId)
return return
} }
if (modeRef.current === 'add') { if (modeRef.current === 'add') {
const pos = pickTerrain(e.position) const terrainAlt = getAbsoluteTerrainElevation(map, e.lngLat)
if (!pos) return
const id = genId() const id = genId()
setAnchors(prev => [...prev, { id, position: pos, heightOffset: 1 }]) setAnchors(prev => [...prev, {
id,
lngLat: [e.lngLat.lng, e.lngLat.lat],
terrainHeight: terrainAlt,
heightOffset: 1,
}])
setSelectedId(null) setSelectedId(null)
} else if (modeRef.current === 'select') { } else if (modeRef.current === 'select') {
setSelectedId(null) setSelectedId(null)
} }
// 'strip' mode clicks are handled by useAccelerationStrips // 'strip' mode clicks handled by useAccelerationStrips
}, Cesium.ScreenSpaceEventType.LEFT_CLICK) }
// RIGHT_CLICK undo / remove selected // Right-click on canvas → undo / delete selected
handler.setInputAction(() => { const onContextMenu = () => {
if (selectedRef.current) { if (selectedRef.current) {
const id = selectedRef.current const id = selectedRef.current
setAnchors(prev => prev.filter(a => a.id !== id)) setAnchors(prev => prev.filter(a => a.id !== id))
@ -308,33 +339,26 @@ export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAncho
} else { } else {
setAnchors(prev => prev.slice(0, -1)) setAnchors(prev => prev.slice(0, -1))
} }
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK) }
canvas.addEventListener('contextmenu', onContextMenu)
map.on('mousedown', onMouseDown)
map.on('mousemove', onMouseMove)
map.on('mouseup', onMouseUp)
map.on('click', onClick)
return () => { return () => {
if (!handler.isDestroyed()) handler.destroy()
canvas.removeEventListener('contextmenu', suppressCtx) canvas.removeEventListener('contextmenu', suppressCtx)
if (!viewer.isDestroyed()) { canvas.removeEventListener('contextmenu', onContextMenu)
enableCam() map.off('mousedown', onMouseDown)
viewer.scene.canvas.style.cursor = 'default' map.off('mousemove', onMouseMove)
map.off('mouseup', onMouseUp)
map.off('click', onClick)
map.dragPan.enable()
canvas.style.cursor = ''
} }
} }, [map]) // eslint-disable-line react-hooks/exhaustive-deps
}, [viewer]) // eslint-disable-line react-hooks/exhaustive-deps
// ── cleanup on unmount ───────────────────────────────────────────────────── return { anchors, pathPts, selectedId, mode, setMode,
updateAnchorHeight, removeAnchor, loadAnchors, undoLast, clearAll }
useEffect(() => {
return () => {
if (viewer.isDestroyed()) return
sphereMapRef.current.forEach(e => viewer.entities.remove(e))
sphereMapRef.current.clear()
pathEntities.current.forEach(e => viewer.entities.remove(e))
pathEntities.current = []
if (startLabelRef.current) {
viewer.entities.remove(startLabelRef.current)
startLabelRef.current = null
}
}
}, [viewer])
return { anchors, pathPts, selectedId, mode, setMode, updateAnchorHeight, removeAnchor, loadAnchors, undoLast, clearAll }
} }

View File

@ -1,7 +1,8 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import * as Cesium from 'cesium' import type { Map } from 'maplibre-gl'
import * as THREE from 'three' import * as THREE from 'three'
import type { CoasterSimulationResult } from '../types/api' import type { CoasterSimulationResult } from '../types/api'
import { lngLatAltToECEF, ecefToEnu } from '../maplibre/geoUtils'
// ── Public types ─────────────────────────────────────────────────────────────── // ── Public types ───────────────────────────────────────────────────────────────
@ -10,7 +11,7 @@ export interface TerrainCaptureData {
tileBbox: [number, number, number, number] tileBbox: [number, number, number, number]
/** Stitched satellite imagery */ /** Stitched satellite imagery */
imageBitmap: ImageBitmap imageBitmap: ImageBitmap
/** 64×64 grid of ENU positions (X=East, Y=Up, Z=North) with terrain heights */ /** 64×64 grid of ENU positions (X=East, Y=North, Z=Up → remapped to Three.js) */
terrainVertices: THREE.Vector3[] terrainVertices: THREE.Vector3[]
gridSize: 64 gridSize: 64
/** Geodetic origin used for ENU conversions (shared across all patches) */ /** Geodetic origin used for ENU conversions (shared across all patches) */
@ -22,34 +23,126 @@ export interface TerrainCaptureData {
export type CaptureStatus = 'idle' | 'loading' | 'ready' | 'error' export type CaptureStatus = 'idle' | 'loading' | 'ready' | 'error'
// ── Patch sizing ─────────────────────────────────────────────────────────────── // ── Patch sizing ───────────────────────────────────────────────────────────────
// Each patch covers PATCH_RADIUS_M metres in each direction from the track centre.
// Patches are spaced PATCH_INTERVAL_M apart along the track arc-length.
// At z19 a 1 km × 1 km bbox fits in 4×4 tiles → ~0.85 m/px (vs ~9 m/px for an
// 8 km bbox at the z15 forced by the old single-capture approach).
// Patches overlap by ~150 m at the midpoint between centres so swaps are seamless.
const PATCH_RADIUS_M = 500 // ±500 m → 1 km × 1 km per patch const PATCH_RADIUS_M = 500
const PATCH_INTERVAL_M = 700 // one patch centre every 700 m of track const PATCH_INTERVAL_M = 700
// ── Tile math ─────────────────────────────────────────────────────────────────
function lngLatToTile(lon: number, lat: number, z: number): { x: number; y: number } {
const n = Math.pow(2, z)
const x = Math.floor((lon + 180) / 360 * n)
const latR = lat * Math.PI / 180
const y = Math.floor((1 - Math.log(Math.tan(latR) + 1 / Math.cos(latR)) / Math.PI) / 2 * n)
return { x, y }
}
function tileToLng(x: number, z: number): number {
return x / Math.pow(2, z) * 360 - 180
}
function tileToLat(y: number, z: number): number {
const n = Math.PI - 2 * Math.PI * y / Math.pow(2, z)
return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)))
}
// ── Geo → Three.js ENU (X=East, Y=Up, Z=North) ─────────────────────────────── // ── Geo → Three.js ENU (X=East, Y=Up, Z=North) ───────────────────────────────
// Note: RideRenderer expects Y=Up, Z=North (Three.js-friendly ENU).
export function geoToEnu( export function geoToEnu(
lon: number, lat: number, alt: number, lon: number, lat: number, alt: number,
origin: [number, number, number], origin: [number, number, number],
): THREE.Vector3 { ): THREE.Vector3 {
const pt = Cesium.Cartesian3.fromDegrees(lon, lat, alt) const pt = lngLatAltToECEF(lon, lat, alt)
const org = Cesium.Cartesian3.fromDegrees(origin[0], origin[1], origin[2]) const enu = ecefToEnu(pt, origin) // X=East, Y=North, Z=Up
const enuFrame = Cesium.Transforms.eastNorthUpToFixedFrame(org) // Remap to Three.js convention used by RideRenderer: X=East, Y=Up, Z=North
const inv = Cesium.Matrix4.inverseTransformation(enuFrame, new Cesium.Matrix4())
const enu = Cesium.Matrix4.multiplyByPoint(inv, pt, new Cesium.Cartesian3())
// ENU: x=East, y=North, z=Up → Three.js: x=East, y=Up, z=North
return new THREE.Vector3(enu.x, enu.z, -enu.y) return new THREE.Vector3(enu.x, enu.z, -enu.y)
} }
// ── Terrarium tile elevation decoding ─────────────────────────────────────────
async function fetchTerrariumTile(z: number, x: number, y: number): Promise<ImageBitmap | null> {
const url = `https://s3.amazonaws.com/elevation-tiles-prod/terrarium/${z}/${x}/${y}.png`
try {
const resp = await fetch(url)
if (!resp.ok) return null
return createImageBitmap(await resp.blob())
} catch {
return null
}
}
function decodeTerrarium(r: number, g: number, b: number): number {
return r * 256 + g + b / 256 - 32768
}
async function sampleElevationGrid(
tileBbox: [number, number, number, number],
gridSize: number,
): Promise<number[]> {
const [west, south, east, north] = tileBbox
// Use zoom 14 for terrain sampling — 4× better resolution than z13 (~5 m/px),
// much closer to MapLibre's z15 queryTerrainElevation samples, so track altitudes
// and mesh heights agree within 1-3 m instead of 10+ m on varied terrain.
const ELEV_ZOOM = 14
const swTile = lngLatToTile(west, south, ELEV_ZOOM)
const neTile = lngLatToTile(east, north, ELEV_ZOOM)
const tileXMin = swTile.x
const tileXMax = neTile.x
const tileYMin = neTile.y
const tileYMax = swTile.y
const nx = tileXMax - tileXMin + 1
const ny = tileYMax - tileYMin + 1
// Fetch and stitch terrain tiles
const tileImages = await Promise.all(
Array.from({ length: ny }, (_, tj) =>
Array.from({ length: nx }, async (_, ti) => {
const img = await fetchTerrariumTile(ELEV_ZOOM, tileXMin + ti, tileYMin + tj)
return { ti, tj, img }
}),
).flat(),
)
const TILE_PX = 256
const canvas = new OffscreenCanvas(nx * TILE_PX, ny * TILE_PX)
const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D
for (const { ti, tj, img } of tileImages) {
if (img) ctx.drawImage(img as CanvasImageSource, ti * TILE_PX, tj * TILE_PX)
}
// Actual geographic extent covered by stitched tiles
const tileWest = tileToLng(tileXMin, ELEV_ZOOM)
const tileEast = tileToLng(tileXMax + 1, ELEV_ZOOM)
const tileNorth = tileToLat(tileYMin, ELEV_ZOOM)
const tileSouth = tileToLat(tileYMax + 1, ELEV_ZOOM)
const totalW = nx * TILE_PX
const totalH = ny * TILE_PX
const elevations: number[] = []
for (let j = 0; j < gridSize; j++) {
for (let i = 0; i < gridSize; i++) {
const lon = west + (east - west) * i / (gridSize - 1)
const lat = south + (north - south) * j / (gridSize - 1)
const px = Math.max(0, Math.min(totalW - 1, Math.round(
(lon - tileWest) / (tileEast - tileWest) * totalW,
)))
// Lat is top-to-bottom (north=0, south=height)
const py = Math.max(0, Math.min(totalH - 1, Math.round(
(tileNorth - lat) / (tileNorth - tileSouth) * totalH,
)))
const [r, g, b] = ctx.getImageData(px, py, 1, 1).data
elevations.push(decodeTerrarium(r, g, b))
}
}
return elevations
}
// ── Single-patch capture ─────────────────────────────────────────────────────── // ── Single-patch capture ───────────────────────────────────────────────────────
async function captureTerrainPatch( async function captureTerrainPatch(
viewer: Cesium.Viewer,
origin: [number, number, number], origin: [number, number, number],
minLon: number, minLon: number,
maxLon: number, maxLon: number,
@ -57,91 +150,72 @@ async function captureTerrainPatch(
maxLat: number, maxLat: number,
trackFrac: number, trackFrac: number,
): Promise<TerrainCaptureData> { ): Promise<TerrainCaptureData> {
// ── Use Cesium's imagery provider (same tiles the viewer already shows) ─── // ── Satellite imagery (ESRI World Imagery, {z}/{y}/{x} order) ─────────────
const provider = viewer.imageryLayers.get(0).imageryProvider // Use z19 max; find highest zoom where tile count ≤ 25
const tilingScheme = provider.tilingScheme let level = 19
const maxLevel = Math.min((provider as { maximumLevel?: number }).maximumLevel ?? 19, 21)
// ── Find the highest zoom where tile count stays ≤ 25 ────────────────────
let level = maxLevel
while (level > 5) { while (level > 5) {
const sw = tilingScheme.positionToTileXY( const sw = lngLatToTile(minLon, minLat, level)
Cesium.Cartographic.fromDegrees(minLon, minLat), level, new Cesium.Cartesian2(), const ne = lngLatToTile(maxLon, maxLat, level)
)
const ne = tilingScheme.positionToTileXY(
Cesium.Cartographic.fromDegrees(maxLon, maxLat), level, new Cesium.Cartesian2(),
)
if (!sw || !ne) { level--; continue }
const nx = ne.x - sw.x + 1 const nx = ne.x - sw.x + 1
const ny = sw.y - ne.y + 1 const ny = sw.y - ne.y + 1
if (nx >= 1 && ny >= 1 && nx * ny <= 25) break if (nx >= 1 && ny >= 1 && nx * ny <= 25) break
level-- level--
} }
const swTile = tilingScheme.positionToTileXY( const swTile = lngLatToTile(minLon, minLat, level)
Cesium.Cartographic.fromDegrees(minLon, minLat), level, new Cesium.Cartesian2(), const neTile = lngLatToTile(maxLon, maxLat, level)
)!
const neTile = tilingScheme.positionToTileXY(
Cesium.Cartographic.fromDegrees(maxLon, maxLat), level, new Cesium.Cartesian2(),
)!
const tileXMin = swTile.x const tileXMin = swTile.x
const tileXMax = neTile.x const tileXMax = neTile.x
const tileYMin = neTile.y const tileYMin = neTile.y // north tiles have smaller y
const tileYMax = swTile.y const tileYMax = swTile.y
const nx = tileXMax - tileXMin + 1 const nx = tileXMax - tileXMin + 1
const ny = tileYMax - tileYMin + 1 const ny = tileYMax - tileYMin + 1
const TILE_PX = provider.tileWidth const TILE_PX = 256
// ── Fetch tiles via the provider ───────────────────────────────────────── // ESRI uses {z}/{y}/{x}
const tileImages = await Promise.all( const satImages = await Promise.all(
Array.from({ length: ny }, (_, tj) => Array.from({ length: ny }, (_, tj) =>
Array.from({ length: nx }, (_, ti) => { Array.from({ length: nx }, async (_, ti) => {
const x = tileXMin + ti const x = tileXMin + ti
const y = tileYMin + tj const y = tileYMin + tj
const result = provider.requestImage(x, y, level) const url = `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${level}/${y}/${x}`
const p: Promise<Cesium.ImageryTypes | undefined> = try {
result instanceof Promise ? result : Promise.resolve(result ?? undefined) const resp = await fetch(url)
return p.then(img => ({ ti, tj, img: img ?? null })) if (!resp.ok) return { ti, tj, img: null }
const img = await createImageBitmap(await resp.blob())
return { ti, tj, img }
} catch {
return { ti, tj, img: null }
}
}), }),
).flat(), ).flat(),
) )
// ── Stitch into a single OffscreenCanvas ─────────────────────────────────
const canvas = new OffscreenCanvas(nx * TILE_PX, ny * TILE_PX) const canvas = new OffscreenCanvas(nx * TILE_PX, ny * TILE_PX)
const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D
for (const { ti, tj, img } of tileImages) { for (const { ti, tj, img } of satImages) {
if (img) ctx.drawImage(img as CanvasImageSource, ti * TILE_PX, tj * TILE_PX) if (img) ctx.drawImage(img as CanvasImageSource, ti * TILE_PX, tj * TILE_PX)
} }
const imageBitmap = await createImageBitmap(canvas) // Three.js ignores `flipY` for ImageBitmap; flip at creation time so UV v=0 (South row)
// maps to the bottom of the satellite image (South), not top (North).
const imageBitmap = await createImageBitmap(canvas, { imageOrientation: 'flipY' })
// ── Derive geographic bbox from actual tile edges ───────────────────────── // Geographic bbox from actual tile edges
const nwRect = tilingScheme.tileXYToRectangle(tileXMin, tileYMin, level, new Cesium.Rectangle())
const seRect = tilingScheme.tileXYToRectangle(tileXMax, tileYMax, level, new Cesium.Rectangle())
const tileBbox: [number, number, number, number] = [ const tileBbox: [number, number, number, number] = [
Cesium.Math.toDegrees(nwRect.west), tileToLng(tileXMin, level),
Cesium.Math.toDegrees(seRect.south), tileToLat(tileYMax + 1, level),
Cesium.Math.toDegrees(seRect.east), tileToLng(tileXMax + 1, level),
Cesium.Math.toDegrees(nwRect.north), tileToLat(tileYMin, level),
] ]
// ── Sample terrain heights on a 64×64 grid ─────────────────────────────── // ── Terrain elevation grid ────────────────────────────────────────────────
const GRID = 64 const GRID = 64 as const
const cartographics: Cesium.Cartographic[] = [] const elevations = await sampleElevationGrid(tileBbox, GRID)
for (let j = 0; j < GRID; j++) {
for (let i = 0; i < GRID; i++) {
const lon = tileBbox[0] + (tileBbox[2] - tileBbox[0]) * i / (GRID - 1)
const lat = tileBbox[1] + (tileBbox[3] - tileBbox[1]) * j / (GRID - 1)
cartographics.push(Cesium.Cartographic.fromDegrees(lon, lat))
}
}
const sampled = await Cesium.sampleTerrainMostDetailed(viewer.terrainProvider, cartographics)
// ── Convert to ENU Three.js vectors ────────────────────────────────────── const terrainVertices: THREE.Vector3[] = elevations.map((elev, idx) => {
const terrainVertices: THREE.Vector3[] = sampled.map((c, idx) => {
const lon = tileBbox[0] + (tileBbox[2] - tileBbox[0]) * (idx % GRID) / (GRID - 1) const lon = tileBbox[0] + (tileBbox[2] - tileBbox[0]) * (idx % GRID) / (GRID - 1)
const lat = tileBbox[1] + (tileBbox[3] - tileBbox[1]) * Math.floor(idx / GRID) / (GRID - 1) const lat = tileBbox[1] + (tileBbox[3] - tileBbox[1]) * Math.floor(idx / GRID) / (GRID - 1)
return geoToEnu(lon, lat, c.height ?? 0, origin) return geoToEnu(lon, lat, elev, origin)
}) })
return { tileBbox, imageBitmap, terrainVertices, gridSize: 64, origin, trackFrac } return { tileBbox, imageBitmap, terrainVertices, gridSize: 64, origin, trackFrac }
@ -150,7 +224,6 @@ async function captureTerrainPatch(
// ── Multi-patch prefetch ─────────────────────────────────────────────────────── // ── Multi-patch prefetch ───────────────────────────────────────────────────────
async function captureAllPatches( async function captureAllPatches(
viewer: Cesium.Viewer,
simResult: CoasterSimulationResult, simResult: CoasterSimulationResult,
): Promise<TerrainCaptureData[]> { ): Promise<TerrainCaptureData[]> {
const origin = simResult.origin const origin = simResult.origin
@ -159,13 +232,11 @@ async function captureAllPatches(
const r1 = simResult.rail_1 as [number, number, number][] const r1 = simResult.rail_1 as [number, number, number][]
const r2 = simResult.rail_2 as [number, number, number][] const r2 = simResult.rail_2 as [number, number, number][]
// ── Sample N patch centres evenly along arc-length ────────────────────────
const N = Math.max(1, Math.round(totalLength / PATCH_INTERVAL_M) + 1) const N = Math.max(1, Math.round(totalLength / PATCH_INTERVAL_M) + 1)
const patchFracs = Array.from({ length: N }, (_, i) => const patchFracs = Array.from({ length: N }, (_, i) =>
N === 1 ? 0.5 : i / (N - 1), N === 1 ? 0.5 : i / (N - 1),
) )
// Geographic midpoint between both rails at a given s_frac
function geoAt(frac: number): [number, number] { function geoAt(frac: number): [number, number] {
let idx = sFracs.length - 2 let idx = sFracs.length - 2
for (let j = 0; j < sFracs.length - 1; j++) { for (let j = 0; j < sFracs.length - 1; j++) {
@ -175,14 +246,13 @@ async function captureAllPatches(
return [(r1[idx][0] + r2[idx][0]) / 2, (r1[idx][1] + r2[idx][1]) / 2] return [(r1[idx][0] + r2[idx][0]) / 2, (r1[idx][1] + r2[idx][1]) / 2]
} }
// ── Fetch all patches in parallel ─────────────────────────────────────────
return Promise.all( return Promise.all(
patchFracs.map(frac => { patchFracs.map(frac => {
const [lon, lat] = geoAt(frac) const [lon, lat] = geoAt(frac)
const rLat = PATCH_RADIUS_M / 111320 const rLat = PATCH_RADIUS_M / 111320
const rLon = PATCH_RADIUS_M / (111320 * Math.cos(lat * Math.PI / 180)) const rLon = PATCH_RADIUS_M / (111320 * Math.cos(lat * Math.PI / 180))
return captureTerrainPatch( return captureTerrainPatch(
viewer, origin, origin,
lon - rLon, lon + rLon, lon - rLon, lon + rLon,
lat - rLat, lat + rLat, lat - rLat, lat + rLat,
frac, frac,
@ -191,10 +261,10 @@ async function captureAllPatches(
) )
} }
// ── Hook ───────────────────────────────────────────────────────────────────── // ── Hook ─────────────────────────────────────────────────────────────────────
export function useTerrainCapture( export function useTerrainCapture(
viewer: Cesium.Viewer, _map: Map, // kept for API compatibility; tile fetching is now direct
simResult: CoasterSimulationResult | null, simResult: CoasterSimulationResult | null,
) { ) {
const [status, setStatus] = useState<CaptureStatus>('idle') const [status, setStatus] = useState<CaptureStatus>('idle')
@ -207,13 +277,13 @@ export function useTerrainCapture(
if (!simResult) { if (!simResult) {
setStatus('idle') setStatus('idle')
return return () => { abortRef.current = true }
} }
abortRef.current = false abortRef.current = false
setStatus('loading') setStatus('loading')
captureAllPatches(viewer, simResult) captureAllPatches(simResult)
.then(patches => { .then(patches => {
if (abortRef.current) return if (abortRef.current) return
setCaptureData(patches) setCaptureData(patches)
@ -224,7 +294,9 @@ export function useTerrainCapture(
console.error('Terrain capture failed:', err) console.error('Terrain capture failed:', err)
setStatus('error') setStatus('error')
}) })
}, [simResult, viewer])
return () => { abortRef.current = true }
}, [simResult]) // eslint-disable-line react-hooks/exhaustive-deps
return { status, captureData } return { status, captureData }
} }

View File

@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from 'react'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { MapLibreContext } from './maplibreContext'
interface Props {
children?: React.ReactNode
}
/**
* Satellite imagery: ESRI World Imagery (free, no key needed).
* 3-D terrain DEM: AWS Open Terrain in Terrarium format (public domain).
* OSM labels/roads overlay: OpenStreetMap raster at reduced opacity.
*/
function buildMapStyle(): maplibregl.StyleSpecification {
return {
version: 8,
glyphs: 'https://fonts.openmaptiles.org/{fontstack}/{range}.pbf',
sources: {
satellite: {
type: 'raster',
// ESRI uses {z}/{y}/{x} tile order
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
tileSize: 256,
maxzoom: 19,
attribution: '© Esri, Maxar, Earthstar Geographics',
},
'terrain-dem': {
type: 'raster-dem',
encoding: 'terrarium',
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 15,
attribution: '© Mapzen, USGS, SRTM',
},
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 19,
attribution: '© OpenStreetMap contributors',
},
},
layers: [
{
id: 'satellite-layer',
type: 'raster',
source: 'satellite',
},
{
id: 'osm-overlay',
type: 'raster',
source: 'osm',
paint: { 'raster-opacity': 0.25 },
},
],
// sky is set programmatically after load
}
}
export function MapLibreViewer({ children }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const [map, setMap] = useState<maplibregl.Map | null>(null)
useEffect(() => {
if (!containerRef.current || map) return
const m = new maplibregl.Map({
container: containerRef.current,
style: buildMapStyle(),
center: [10.0, 51.0],
zoom: 7,
pitch: 50,
bearing: 0,
antialias: true,
maxPitch: 80,
})
m.on('load', () => {
// Enable 3D terrain
m.setTerrain({ source: 'terrain-dem', exaggeration: 1.5 })
// Sky atmosphere
m.setSky({
'sky-color': '#199EF3',
'sky-horizon-blend': 0.5,
'horizon-color': '#ffffff',
'horizon-fog-blend': 0.5,
'fog-color': '#0000ff',
'fog-ground-blend': 0.9,
'atmosphere-blend': ['interpolate', ['linear'], ['zoom'], 0, 1, 10, 0] as maplibregl.ExpressionSpecification,
})
// Navigation control (zoom + compass)
m.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-right')
setMap(m)
})
return () => {
m.remove()
setMap(null)
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return (
<>
<div ref={containerRef} style={{ position: 'fixed', top: 52, left: 0, right: 0, bottom: 0 }} />
{map && (
<MapLibreContext.Provider value={map}>
{children}
</MapLibreContext.Provider>
)}
</>
)
}

View File

@ -0,0 +1,279 @@
/**
* Custom MapLibre layer that renders 3D tube geometry above terrain.
*
* MapLibre 4.x drapes all built-in layer types (line, circle, fill) on the terrain
* surface regardless of their GeoJSON Z coordinate. The only escape hatch is a
* `type: 'custom'` layer whose `render()` callback receives the Mercator projection
* matrix and can draw arbitrary WebGL. We use Three.js for the geometry, reusing
* MapLibre's WebGL context so the output lands in the same frame.
*
* Altitude alignment: MapLibre passes the `mercatorMatrix` to custom layers, which
* does NOT include the center-elevation offset baked into the standard tile matrix.
* To align with the visual terrain surface (rendered at DEM × exaggeration), we must:
* 1. Multiply stored raw-MSL altitudes by TERRAIN_EXAGGERATION when building geometry.
* 2. Subtract `transform.elevation` (the exaggerated center elevation) from the
* origin Z each frame so objects track the camera-relative terrain position.
*/
import * as THREE from 'three'
import { MercatorCoordinate } from 'maplibre-gl'
import type { CustomLayerInterface, Map } from 'maplibre-gl'
// Must match the exaggeration value in MapLibreViewer.tsx.
const TERRAIN_EXAGGERATION = 1.5
// ── Public types ───────────────────────────────────────────────────────────────
export interface Line3D {
/** Geographic [lon, lat, altMSL] triples, in order. */
pts: [number, number, number][]
/** Three.js colour hex (e.g. 0xfbbf24). */
color: number
/** Physical tube radius in metres. */
radiusMeters: number
}
export interface Point3D {
/** Stable ID returned by hitTestPoints(). */
id: string
/** Geographic [lon, lat, altMSL] position. */
pt: [number, number, number]
/** Three.js colour hex. */
color: number
/** Physical sphere radius in metres. */
radiusMeters: number
}
/** Handle returned by createCustom3DLayer. */
export interface Layer3DHandle {
/** Replace all rendered tube lines and trigger a repaint. */
update: (lines: Line3D[]) => void
/** Replace all rendered sphere points and trigger a repaint. */
updatePoints: (points: Point3D[]) => void
/**
* Screen-space hit test against rendered spheres.
* @param x CSS-pixel x coordinate
* @param y CSS-pixel y coordinate
* @param w Canvas CSS-pixel width
* @param h Canvas CSS-pixel height
* @returns The `id` of the closest intersected sphere, or null.
*/
hitTestPoints: (x: number, y: number, w: number, h: number) => string | null
/** Remove the layer from the map and dispose GPU resources. */
destroy: () => void
}
// ── Factory ───────────────────────────────────────────────────────────────────
export function createCustom3DLayer(id: string, map: Map): Layer3DHandle {
const threeCamera = new THREE.Camera()
const scene = new THREE.Scene()
let renderer: THREE.WebGLRenderer | null = null
let origin: MercatorCoordinate | null = null
let originLngLat: [number, number] | null = null
let dirty = true
let currentLines: Line3D[] = []
let currentPoints: Point3D[] = []
// GPU resource tracking (lines)
const geos: THREE.BufferGeometry[] = []
const mats: THREE.Material[] = []
const meshes: THREE.Mesh[] = []
// Sphere meshes for hit testing (parallel arrays)
const sphereMeshes: THREE.Mesh[] = []
const sphereIds: string[] = []
// ── Geometry helpers ────────────────────────────────────────────────────────
function disposeGeometry() {
meshes.forEach(m => scene.remove(m))
geos.forEach(g => g.dispose())
mats.forEach(m => m.dispose())
geos.length = 0
mats.length = 0
meshes.length = 0
sphereMeshes.length = 0
sphereIds.length = 0
origin = null
originLngLat = null
}
function rebuildGeometry() {
disposeGeometry()
dirty = false
// Determine origin from the first available point (line or sphere).
const firstLinePt = currentLines.find(l => l.pts.length >= 2)?.pts[0]
const firstSphPt = currentPoints[0]?.pt
const firstPt = firstLinePt ?? firstSphPt
if (!firstPt) return
const [lon0, lat0, alt0] = firstPt
originLngLat = [lon0, lat0]
// Use exaggerated altitude so geometry aligns with visual terrain.
origin = MercatorCoordinate.fromLngLat([lon0, lat0], alt0 * TERRAIN_EXAGGERATION)
const mpu = origin.meterInMercatorCoordinateUnits()
// ── Tube geometry for lines ─────────────────────────────────────────────
for (const { pts, color, radiusMeters } of currentLines) {
if (pts.length < 2) continue
const step = Math.max(1, Math.floor(pts.length / 400))
const sampled = pts.filter((_, i) => i % step === 0 || i === pts.length - 1)
if (sampled.length < 2) continue
const threePts = sampled.map(([lon, lat, alt]) => {
const mc = MercatorCoordinate.fromLngLat([lon, lat], alt * TERRAIN_EXAGGERATION)
return new THREE.Vector3(mc.x - origin!.x, mc.y - origin!.y, mc.z - origin!.z)
})
const curve = new THREE.CatmullRomCurve3(threePts)
const segments = Math.min(threePts.length * 4, 800)
const geo = new THREE.TubeGeometry(curve, segments, radiusMeters * mpu, 8, false)
const mat = new THREE.MeshBasicMaterial({ color, depthTest: false, depthWrite: false })
const mesh = new THREE.Mesh(geo, mat)
mesh.frustumCulled = false
scene.add(mesh)
meshes.push(mesh)
geos.push(geo)
mats.push(mat)
}
// ── Sphere geometry for points ──────────────────────────────────────────
for (const { id, pt, color, radiusMeters } of currentPoints) {
const [lon, lat, alt] = pt
const mc = MercatorCoordinate.fromLngLat([lon, lat], alt * TERRAIN_EXAGGERATION)
const pos = new THREE.Vector3(mc.x - origin!.x, mc.y - origin!.y, mc.z - origin!.z)
const geo = new THREE.SphereGeometry(radiusMeters * mpu, 14, 10)
const mat = new THREE.MeshBasicMaterial({ color, depthTest: false, depthWrite: false })
const mesh = new THREE.Mesh(geo, mat)
mesh.position.copy(pos)
mesh.frustumCulled = false
scene.add(mesh)
meshes.push(mesh)
geos.push(geo)
mats.push(mat)
sphereMeshes.push(mesh)
sphereIds.push(id)
}
}
// ── MapLibre custom layer ───────────────────────────────────────────────────
const layer: CustomLayerInterface = {
id,
type: 'custom',
renderingMode: '3d',
onAdd(_m: Map, gl: WebGLRenderingContext) {
renderer = new THREE.WebGLRenderer({
canvas: map.getCanvas(),
context: gl as unknown as WebGL2RenderingContext,
antialias: false,
})
renderer.autoClear = false
},
onRemove() {
disposeGeometry()
renderer?.dispose()
renderer = null
},
render(_gl: WebGLRenderingContext, matrix: number[]) {
if (!renderer) return
if (dirty) rebuildGeometry()
if (!origin || meshes.length === 0) {
renderer.resetState()
return
}
// Compensate for the center-elevation offset that is present in
// modelViewProjectionMatrix (used by terrain tiles) but absent from
// mercatorMatrix (passed to custom layers). Subtracting it here makes
// our exaggerated-altitude objects align with the visual terrain surface.
//
// Use MercatorCoordinate.fromLngLat (not origin.meterInMercatorCoordinateUnits)
// because altitude z uses 1/(circumference*cos(lat)) while mpu gives
// cos(lat)/circumference — they differ by cos²(lat), causing ~2× error at lat 50°.
const centerElevM = (map as unknown as { transform: { elevation: number } }).transform?.elevation ?? 0
const centerElevMerc = originLngLat
? MercatorCoordinate.fromLngLat(originLngLat, centerElevM).z
: centerElevM * origin.meterInMercatorCoordinateUnits()
const m = new THREE.Matrix4().fromArray(matrix)
const t = new THREE.Matrix4().makeTranslation(
origin.x,
origin.y,
origin.z - centerElevMerc,
)
threeCamera.projectionMatrix = m.multiply(t)
// Keep inverse in sync so Raycaster can unproject correctly.
threeCamera.projectionMatrixInverse.copy(threeCamera.projectionMatrix).invert()
renderer.resetState()
renderer.render(scene, threeCamera)
},
}
map.addLayer(layer)
// ── Public handle ───────────────────────────────────────────────────────────
return {
update(lines: Line3D[]) {
currentLines = lines
dirty = true
map.triggerRepaint()
},
updatePoints(points: Point3D[]) {
currentPoints = points
dirty = true
map.triggerRepaint()
},
hitTestPoints(x: number, y: number, w: number, h: number): string | null {
if (sphereMeshes.length === 0) return null
const ndcX = (2 * x) / w - 1
const ndcY = 1 - (2 * y) / h
// Unproject screen point into local scene space.
const projInv = threeCamera.projectionMatrix.clone().invert()
const near = new THREE.Vector3(ndcX, ndcY, -1).applyMatrix4(projInv)
const far = new THREE.Vector3(ndcX, ndcY, 1).applyMatrix4(projInv)
const dir = far.clone().sub(near).normalize()
const raycaster = new THREE.Raycaster(near, dir)
let closestDist = Infinity
let closestIdx = -1
for (let i = 0; i < sphereMeshes.length; i++) {
const hits = raycaster.intersectObject(sphereMeshes[i])
if (hits.length > 0 && hits[0].distance < closestDist) {
closestDist = hits[0].distance
closestIdx = i
}
}
return closestIdx >= 0 ? sphereIds[closestIdx] : null
},
destroy() {
try {
if (map.getLayer(id)) map.removeLayer(id)
} catch { /* map may already be destroyed */ }
},
}
}

View File

@ -0,0 +1,162 @@
import * as THREE from 'three'
// WGS-84 ellipsoid constants
const A = 6378137.0 // semi-major axis (m)
const E2 = 0.00669437999014 // first eccentricity squared
/**
* Convert geographic coordinates to ECEF (Earth-Centred Earth-Fixed) metres.
*/
export function lngLatAltToECEF(lon: number, lat: number, alt: number): THREE.Vector3 {
const lonR = lon * Math.PI / 180
const latR = lat * Math.PI / 180
const sinLat = Math.sin(latR)
const cosLat = Math.cos(latR)
const N = A / Math.sqrt(1 - E2 * sinLat * sinLat)
return new THREE.Vector3(
(N + alt) * cosLat * Math.cos(lonR),
(N + alt) * cosLat * Math.sin(lonR),
(N * (1 - E2) + alt) * sinLat,
)
}
/**
* Convert ECEF metres back to [longitude, latitude, altitude].
* Uses Bowring's iterative method (5 iterations sub-mm accuracy).
*/
export function ecefToLngLatAlt(v: THREE.Vector3): [number, number, number] {
const p = Math.sqrt(v.x * v.x + v.y * v.y)
const lon = Math.atan2(v.y, v.x) * 180 / Math.PI
let lat = Math.atan2(v.z, p * (1 - E2))
for (let i = 0; i < 5; i++) {
const sinLat = Math.sin(lat)
const N = A / Math.sqrt(1 - E2 * sinLat * sinLat)
lat = Math.atan2(v.z + E2 * N * sinLat, p)
}
const sinLat = Math.sin(lat)
const N = A / Math.sqrt(1 - E2 * sinLat * sinLat)
const alt = p / Math.cos(lat) - N
return [lon, lat * 180 / Math.PI, alt]
}
/**
* Convert a point from ECEF to ENU coordinates relative to an origin.
* ENU: X=East, Y=North, Z=Up
*/
export function ecefToEnu(
point: THREE.Vector3,
origin: [number, number, number],
): THREE.Vector3 {
const [oLon, oLat, oAlt] = origin
const org = lngLatAltToECEF(oLon, oLat, oAlt)
const diff = point.clone().sub(org)
const oLonR = oLon * Math.PI / 180
const oLatR = oLat * Math.PI / 180
// ENU basis vectors in ECEF
const east = new THREE.Vector3(-Math.sin(oLonR), Math.cos(oLonR), 0)
const north = new THREE.Vector3(
-Math.sin(oLatR) * Math.cos(oLonR),
-Math.sin(oLatR) * Math.sin(oLonR),
Math.cos(oLatR),
)
const up = new THREE.Vector3(
Math.cos(oLatR) * Math.cos(oLonR),
Math.cos(oLatR) * Math.sin(oLonR),
Math.sin(oLatR),
)
return new THREE.Vector3(diff.dot(east), diff.dot(north), diff.dot(up))
}
/**
* Build a Three.js Matrix4 that positions and orients a local scene
* at the given geographic coordinate (ENU ECEF transform + heading rotation).
*
* @param lon Longitude in degrees
* @param lat Latitude in degrees
* @param alt Altitude in metres above WGS-84 ellipsoid
* @param headingDeg Clockwise heading in degrees (0 = North)
*/
export function buildSplatWorldMatrix(
lon: number,
lat: number,
alt: number,
headingDeg: number,
): THREE.Matrix4 {
const lonR = lon * Math.PI / 180
const latR = lat * Math.PI / 180
// ENU basis vectors in ECEF
const east = new THREE.Vector3(-Math.sin(lonR), Math.cos(lonR), 0)
const north = new THREE.Vector3(
-Math.sin(latR) * Math.cos(lonR),
-Math.sin(latR) * Math.sin(lonR),
Math.cos(latR),
)
const up = new THREE.Vector3(
Math.cos(latR) * Math.cos(lonR),
Math.cos(latR) * Math.sin(lonR),
Math.sin(latR),
)
// Apply heading rotation (clockwise from North = negative rotation around Up/Z-ENU)
const hRad = -headingDeg * Math.PI / 180
const cosH = Math.cos(hRad)
const sinH = Math.sin(hRad)
// Rotated ENU: X' = cosH*East + sinH*North, Y' = -sinH*East + cosH*North, Z' = Up
const xAxis = east.clone().multiplyScalar(cosH).addScaledVector(north, sinH)
const yAxis = east.clone().multiplyScalar(-sinH).addScaledVector(north, cosH)
const zAxis = up.clone()
const pos = lngLatAltToECEF(lon, lat, alt)
// Column-major Matrix4 (Three.js): each column is [x, y, z, 0], last col is translation
return new THREE.Matrix4().set(
xAxis.x, yAxis.x, zAxis.x, pos.x,
xAxis.y, yAxis.y, zAxis.y, pos.y,
xAxis.z, yAxis.z, zAxis.z, pos.z,
0, 0, 0, 1,
)
}
/**
* Estimate MapLibre camera altitude above terrain in metres.
* Falls back to zoom-based approximation when transform internals are unavailable.
*/
export function getMapCameraAltitude(map: { getZoom(): number; getCenter(): { lat: number }; transform?: unknown }): number {
const t = (map as { transform?: { cameraToCenterDistance?: number; pixelsPerMeter?: number; altitude?: number } }).transform
if (t) {
if (typeof t.altitude === 'number') return t.altitude
if (t.cameraToCenterDistance && t.pixelsPerMeter) {
return t.cameraToCenterDistance / t.pixelsPerMeter
}
}
// Fallback: approximate from zoom (rough but covers our visibility thresholds)
const zoom = map.getZoom()
const lat = map.getCenter().lat
return (40075016.7 * Math.cos(lat * Math.PI / 180)) / Math.pow(2, zoom + 8) * 600
}
/**
* Remove MapLibre layers and sources without throwing if the map was already
* destroyed (map.remove() sets this.style to undefined internally).
*/
export function safeRemoveLayers(
map: {
getLayer: (id: string) => unknown
removeLayer: (id: string) => void
getSource: (id: string) => unknown
removeSource:(id: string) => void
},
layerIds: string[],
sourceIds: string[],
) {
try {
for (const id of layerIds) { if (map.getLayer(id)) map.removeLayer(id) }
for (const id of sourceIds) { if (map.getSource(id)) map.removeSource(id) }
} catch {
// Map was already destroyed before this cleanup ran — safe to ignore.
}
}

View File

@ -0,0 +1,12 @@
import { createContext, useContext } from 'react'
import type { Map } from 'maplibre-gl'
export const MapLibreContext = createContext<Map | null>(null)
export function useMapLibreMap(): Map {
const map = useContext(MapLibreContext)
if (!map) {
throw new Error('useMapLibreMap must be used inside <MapLibreViewer>')
}
return map
}

View File

@ -0,0 +1,36 @@
import { useEffect } from 'react'
import { useMapLibreMap } from './maplibreContext'
import { useMapStore } from '../store/mapStore'
import { getMapCameraAltitude } from './geoUtils'
/**
* Tracks MapLibre camera state (altitude + view bbox) and writes it to mapStore.
* Throttled to at most once per 200 ms so expensive API queries are not spammed.
*/
export function useMapLibreCamera() {
const map = useMapLibreMap()
const setCameraState = useMapStore((s) => s.setCameraState)
useEffect(() => {
let lastFired = 0
const THROTTLE_MS = 200
function onMove() {
const now = Date.now()
if (now - lastFired < THROTTLE_MS) return
lastFired = now
const b = map.getBounds()
const bbox: [number, number, number, number] = [
b.getWest(), b.getSouth(), b.getEast(), b.getNorth(),
]
const altitude = getMapCameraAltitude(map)
setCameraState(altitude, bbox)
}
map.on('move', onMove)
onMove() // fire once immediately so store is populated before first render
return () => { map.off('move', onMove) }
}, [map, setCameraState])
}

View File

@ -1,41 +1,40 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import * as Cesium from 'cesium' import maplibregl from 'maplibre-gl'
import { useCesiumViewer } from '../cesium/cesiumContext' import { useMapLibreMap } from '../maplibre/maplibreContext'
import { useCesiumCamera } from '../cesium/useCesiumCamera' import { useMapLibreCamera } from '../maplibre/useMapLibreCamera'
import { useMapStore } from '../store/mapStore' import { useMapStore } from '../store/mapStore'
import { fetchSplats } from '../api/splats' import { fetchSplats } from '../api/splats'
import type { BBox } from '../types/geo' import type { BBox } from '../types/geo'
import type { SplatMapProperties } from '../types/api' import type { SplatMapProperties } from '../types/api'
// Show splat pins when camera is below this altitude (metres)
const SPLAT_VISIBLE_HEIGHT = 50_000 const SPLAT_VISIBLE_HEIGHT = 50_000
const MAX_BBOX_DEGREES = 1.0
export function SplatLayer() { export function SplatLayer() {
const viewer = useCesiumViewer() const map = useMapLibreMap()
useCesiumCamera() useMapLibreCamera()
const { bbox, cameraHeight, setLoadedSplats, setActiveSplatId } = useMapStore() const { bbox, cameraHeight, setLoadedSplats, setActiveSplatId } = useMapStore()
const entityMapRef = useRef<Map<string, Cesium.Entity>>(new Map()) const markersRef = useRef<Map<string, maplibregl.Marker>>(new Map())
const lastBboxRef = useRef<BBox | null>(null) const lastBboxRef = useRef<BBox | null>(null)
// Fetch and sync splat entities whenever the bbox changes meaningfully // Fetch and sync splat markers whenever the bbox changes meaningfully
useEffect(() => { useEffect(() => {
if (!bbox || cameraHeight > SPLAT_VISIBLE_HEIGHT) { const bboxTooLarge = bbox && (
// Clear all splat pins when zoomed out (bbox[2] - bbox[0]) > MAX_BBOX_DEGREES || (bbox[3] - bbox[1]) > MAX_BBOX_DEGREES
entityMapRef.current.forEach((e) => viewer.entities.remove(e)) )
entityMapRef.current.clear() if (!bbox || cameraHeight > SPLAT_VISIBLE_HEIGHT || bboxTooLarge) {
markersRef.current.forEach(m => m.remove())
markersRef.current.clear()
setLoadedSplats([]) setLoadedSplats([])
return return
} }
// Avoid re-fetching if bbox moved less than 0.01° (noise reduction)
const last = lastBboxRef.current const last = lastBboxRef.current
if (last) { if (last) {
const delta = Math.max( const delta = Math.max(
Math.abs(bbox[0] - last[0]), Math.abs(bbox[0] - last[0]), Math.abs(bbox[1] - last[1]),
Math.abs(bbox[1] - last[1]), Math.abs(bbox[2] - last[2]), Math.abs(bbox[3] - last[3]),
Math.abs(bbox[2] - last[2]),
Math.abs(bbox[3] - last[3]),
) )
if (delta < 0.01) return if (delta < 0.01) return
} }
@ -44,75 +43,48 @@ export function SplatLayer() {
fetchSplats(bbox).then((fc) => { fetchSplats(bbox).then((fc) => {
const incoming = new Set(fc.features.map((f: GeoJSON.Feature<GeoJSON.Point, SplatMapProperties>) => f.properties.id)) const incoming = new Set(fc.features.map((f: GeoJSON.Feature<GeoJSON.Point, SplatMapProperties>) => f.properties.id))
// Remove entities that are no longer in view markersRef.current.forEach((marker, id) => {
entityMapRef.current.forEach((entity, id) => { if (!incoming.has(id)) { marker.remove(); markersRef.current.delete(id) }
if (!incoming.has(id)) {
viewer.entities.remove(entity)
entityMapRef.current.delete(id)
}
}) })
// Add new entities
fc.features.forEach((feature: GeoJSON.Feature<GeoJSON.Point, SplatMapProperties>) => { fc.features.forEach((feature: GeoJSON.Feature<GeoJSON.Point, SplatMapProperties>) => {
const id = feature.properties.id const id = feature.properties.id
if (entityMapRef.current.has(id)) return if (markersRef.current.has(id)) return
const [lon, lat] = feature.geometry.coordinates const [lon, lat] = feature.geometry.coordinates
const alt = feature.properties.altitude ?? 0 const el = createSplatPinElement()
el.addEventListener('click', () => setActiveSplatId(id))
const entity = viewer.entities.add({ const marker = new maplibregl.Marker({ element: el, anchor: 'bottom' })
id: `splat-${id}`, .setLngLat([lon, lat])
position: Cesium.Cartesian3.fromDegrees(lon, lat, alt), .addTo(map)
billboard: { markersRef.current.set(id, marker)
image: createSplatPinSvg(),
width: 32,
height: 32,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
properties: { splatId: id },
})
entityMapRef.current.set(id, entity)
}) })
setLoadedSplats(fc.features) setLoadedSplats(fc.features)
}).catch(console.error) }).catch(console.error)
}, [bbox, cameraHeight]) // eslint-disable-line react-hooks/exhaustive-deps }, [bbox, cameraHeight]) // eslint-disable-line react-hooks/exhaustive-deps
// Wire up entity selection → activeSplatId in store // Clean up on unmount
useEffect(() => {
const remove = viewer.selectedEntityChanged.addEventListener((entity) => {
if (!entity) {
setActiveSplatId(null)
return
}
const splatId = entity.properties?.splatId?.getValue()
if (splatId) {
setActiveSplatId(splatId)
}
})
return () => remove()
}, [viewer, setActiveSplatId])
// Clean up all entities on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
if (viewer.isDestroyed()) return markersRef.current.forEach(m => m.remove())
entityMapRef.current.forEach((e) => viewer.entities.remove(e)) markersRef.current.clear()
entityMapRef.current.clear()
} }
}, [viewer]) }, [])
return null // no DOM — everything is imperative Cesium entities return null
} }
function createSplatPinSvg(): string { function createSplatPinElement(): HTMLElement {
const svg = ` const el = document.createElement('div')
el.style.cssText = 'width:32px;height:32px;cursor:pointer'
el.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"> <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<circle cx="16" cy="14" r="10" fill="#6366f1" stroke="#fff" stroke-width="2"/> <circle cx="16" cy="14" r="10" fill="#6366f1" stroke="#fff" stroke-width="2"/>
<polygon points="16,28 10,18 22,18" fill="#6366f1"/> <polygon points="16,28 10,18 22,18" fill="#6366f1"/>
<text x="16" y="18" text-anchor="middle" fill="#fff" font-size="10" font-weight="bold">3D</text> <text x="16" y="18" text-anchor="middle" fill="#fff" font-size="10" font-weight="bold">3D</text>
</svg> </svg>
` `
return `data:image/svg+xml;base64,${btoa(svg)}` return el
} }

View File

@ -1,44 +1,44 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import * as THREE from 'three' import * as THREE from 'three'
import { useCesiumViewer } from '../cesium/cesiumContext' import { useMapLibreMap } from '../maplibre/maplibreContext'
import { useMapStore } from '../store/mapStore' import { useMapStore } from '../store/mapStore'
import { useSplatStore } from '../store/splatStore' import { useSplatStore } from '../store/splatStore'
import { syncSplatCamera } from './useSplatCamera' import { syncSplatCamera } from './useSplatCamera'
import { getSplatDownloadUrl } from './splatLoader' import { getSplatDownloadUrl } from './splatLoader'
import { buildSplatWorldMatrix } from '../cesium/geoUtils' import { buildSplatWorldMatrix } from '../maplibre/geoUtils'
import { fetchSplatDetail } from '../api/splats' import { fetchSplatDetail } from '../api/splats'
// Only render the splat when the camera is below this altitude // Only render the splat when the camera is below this altitude (metres)
const RENDER_HEIGHT = 500 const RENDER_HEIGHT = 500
export function SplatRenderer() { export function SplatRenderer() {
const viewer = useCesiumViewer() const map = useMapLibreMap()
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
const splatViewerRef = useRef<unknown>(null) const splatViewerRef = useRef<unknown>(null)
const camerRef = useRef<THREE.PerspectiveCamera>(new THREE.PerspectiveCamera()) const cameraRef = useRef<THREE.PerspectiveCamera>(new THREE.PerspectiveCamera())
const activeSplatId = useMapStore((s) => s.activeSplatId) const activeSplatId = useMapStore((s) => s.activeSplatId)
const cameraHeight = useMapStore((s) => s.cameraHeight) const cameraHeight = useMapStore((s) => s.cameraHeight)
const { setSplatDetail, splatCache } = useSplatStore() const { setSplatDetail, splatCache } = useSplatStore()
// Keep canvas dimensions in sync with Cesium canvas // Keep overlay canvas dimensions in sync with the MapLibre canvas
useEffect(() => { useEffect(() => {
const cesiumCanvas = viewer.canvas const mapCanvas = map.getCanvas()
const overlayCanvas = canvasRef.current const overlayCanvas = canvasRef.current
if (!overlayCanvas) return if (!overlayCanvas) return
function syncSize() { function syncSize() {
overlayCanvas!.width = cesiumCanvas.width overlayCanvas!.width = mapCanvas.width
overlayCanvas!.height = cesiumCanvas.height overlayCanvas!.height = mapCanvas.height
camerRef.current.aspect = cesiumCanvas.width / cesiumCanvas.height cameraRef.current.aspect = mapCanvas.width / mapCanvas.height
camerRef.current.updateProjectionMatrix() cameraRef.current.updateProjectionMatrix()
} }
syncSize() syncSize()
const observer = new ResizeObserver(syncSize) const observer = new ResizeObserver(syncSize)
observer.observe(cesiumCanvas) observer.observe(mapCanvas)
return () => observer.disconnect() return () => observer.disconnect()
}, [viewer]) }, [map])
// Load / unload splat when activeSplatId changes // Load / unload splat when activeSplatId changes
useEffect(() => { useEffect(() => {
@ -52,7 +52,6 @@ export function SplatRenderer() {
async function loadSplat() { async function loadSplat() {
if (!activeSplatId) return if (!activeSplatId) return
// Fetch detail if not cached
let detail = splatCache.get(activeSplatId) let detail = splatCache.get(activeSplatId)
if (!detail) { if (!detail) {
detail = await fetchSplatDetail(activeSplatId) detail = await fetchSplatDetail(activeSplatId)
@ -65,7 +64,6 @@ export function SplatRenderer() {
const url = await getSplatDownloadUrl(activeSplatId) const url = await getSplatDownloadUrl(activeSplatId)
if (cancelled) return if (cancelled) return
// Dynamically import the library to keep initial bundle lean
const { Viewer: GaussianViewer } = await import('@mkkellogg/gaussian-splats-3d') const { Viewer: GaussianViewer } = await import('@mkkellogg/gaussian-splats-3d')
if (cancelled) return if (cancelled) return
@ -78,10 +76,9 @@ export function SplatRenderer() {
selfDrivenMode: false, selfDrivenMode: false,
useBuiltInControls: false, useBuiltInControls: false,
renderer: new THREE.WebGLRenderer({ canvas, alpha: true }), renderer: new THREE.WebGLRenderer({ canvas, alpha: true }),
camera: camerRef.current, camera: cameraRef.current,
}) })
// Geo-anchor the splat
const [lon, lat] = detail.location.coordinates const [lon, lat] = detail.location.coordinates
const alt = detail.altitude ?? 0 const alt = detail.altitude ?? 0
const heading = detail.heading ?? 0 const heading = detail.heading ?? 0
@ -89,15 +86,11 @@ export function SplatRenderer() {
await gViewer.addSplatScene(url, { await gViewer.addSplatScene(url, {
progressiveLoad: true, progressiveLoad: true,
onProgress: () => { /* optional: update progress UI */ }, onProgress: () => { /* optional progress UI */ },
}) })
if (cancelled) { if (cancelled) { gViewer.dispose(); return }
gViewer.dispose()
return
}
// Apply geo-anchor transform to the loaded scene
const scene = gViewer.splatMesh const scene = gViewer.splatMesh
if (scene) { if (scene) {
scene.matrixAutoUpdate = false scene.matrixAutoUpdate = false
@ -109,34 +102,32 @@ export function SplatRenderer() {
} }
loadSplat().catch(console.error) loadSplat().catch(console.error)
return () => { cancelled = true }
return () => {
cancelled = true
}
}, [activeSplatId]) // eslint-disable-line react-hooks/exhaustive-deps }, [activeSplatId]) // eslint-disable-line react-hooks/exhaustive-deps
// Drive the splat render loop from Cesium's postRender event // Drive the splat render loop from MapLibre's 'render' event
useEffect(() => { useEffect(() => {
const remove = viewer.scene.postRender.addEventListener(() => { function onRender() {
const gViewer = splatViewerRef.current as import('@mkkellogg/gaussian-splats-3d').Viewer | null const gViewer = splatViewerRef.current as import('@mkkellogg/gaussian-splats-3d').Viewer | null
if (!gViewer || cameraHeight > RENDER_HEIGHT) return if (!gViewer || cameraHeight > RENDER_HEIGHT) return
const canvas = canvasRef.current const canvas = canvasRef.current
if (!canvas) return if (!canvas) return
syncSplatCamera(viewer, camerRef.current, canvas) syncSplatCamera(map, cameraRef.current, canvas)
gViewer.update() gViewer.update()
gViewer.render() gViewer.render()
}) }
return () => remove()
}, [viewer, cameraHeight]) map.on('render', onRender)
return () => { map.off('render', onRender) }
}, [map, cameraHeight])
function disposeSplatViewer() { function disposeSplatViewer() {
const gViewer = splatViewerRef.current as { dispose?: () => void } | null const gViewer = splatViewerRef.current as { dispose?: () => void } | null
if (gViewer?.dispose) gViewer.dispose() if (gViewer?.dispose) gViewer.dispose()
splatViewerRef.current = null splatViewerRef.current = null
// Clear the overlay canvas
const canvas = canvasRef.current const canvas = canvasRef.current
if (canvas) { if (canvas) {
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
@ -144,7 +135,6 @@ export function SplatRenderer() {
} }
} }
// Overlay canvas portal — sits above the Cesium canvas, no pointer events
const overlayCanvas = ( const overlayCanvas = (
<canvas <canvas
ref={canvasRef} ref={canvasRef}

View File

@ -1,41 +1,78 @@
import * as Cesium from 'cesium' import type { Map } from 'maplibre-gl'
import * as THREE from 'three' import * as THREE from 'three'
import { lngLatAltToECEF } from '../maplibre/geoUtils'
/** /**
* Synchronise a Three.js PerspectiveCamera to the current Cesium camera. * Synchronise a Three.js PerspectiveCamera to the current MapLibre camera.
* *
* Both cameras work in ECEF space (metres from Earth centre). * MapLibre camera state (center, pitch, bearing, altitude) is converted to
* The splat scene's Object3D has its matrixWorld set to the ECEF transform * an ECEF position + orientation that matches the splat scene, which is also
* of the capture point (see geoUtils.buildSplatWorldMatrix), so the camera * positioned in ECEF via buildSplatWorldMatrix.
* and the scene are in the same coordinate space.
* *
* Call this inside a Cesium scene.postRender listener, before splatViewer.render(). * Call this inside a MapLibre 'render' or requestAnimationFrame listener,
* before splatViewer.render().
*/ */
export function syncSplatCamera( export function syncSplatCamera(
cesiumViewer: Cesium.Viewer, map: Map,
threeCamera: THREE.PerspectiveCamera, threeCamera: THREE.PerspectiveCamera,
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
) { ) {
const cam = cesiumViewer.camera const center = map.getCenter()
const lon0 = center.lng
const lat0 = center.lat
const lon0r = lon0 * Math.PI / 180
const lat0r = lat0 * Math.PI / 180
// Position (ECEF, metres) const pitchRad = map.getPitch() * Math.PI / 180
const pos = cam.positionWC const bearingRad = map.getBearing() * Math.PI / 180
threeCamera.position.set(pos.x, pos.y, pos.z)
// LookAt target: position + direction // Camera-to-ground distance in metres
const dir = cam.directionWC const t = (map as { transform?: { cameraToCenterDistance?: number; pixelsPerMeter?: number } }).transform
const target = new THREE.Vector3(pos.x + dir.x, pos.y + dir.y, pos.z + dir.z) const D = (t?.cameraToCenterDistance && t?.pixelsPerMeter)
? t.cameraToCenterDistance / t.pixelsPerMeter
: (40075016.7 * Math.cos(lat0r)) / Math.pow(2, map.getZoom() + 8) * 600
// Up vector // Ground altitude at center from terrain (0 if not available)
const up = cam.upWC const groundAlt = map.queryTerrainElevation(center) ?? 0
threeCamera.up.set(up.x, up.y, up.z)
threeCamera.lookAt(target)
// FOV and aspect // ENU basis vectors in ECEF at the center point
const frustum = cam.frustum as Cesium.PerspectiveFrustum const east = new THREE.Vector3(-Math.sin(lon0r), Math.cos(lon0r), 0)
if (frustum.fovy != null) { const north = new THREE.Vector3(
threeCamera.fov = Cesium.Math.toDegrees(frustum.fovy) -Math.sin(lat0r) * Math.cos(lon0r),
} -Math.sin(lat0r) * Math.sin(lon0r),
Math.cos(lat0r),
)
const up = new THREE.Vector3(
Math.cos(lat0r) * Math.cos(lon0r),
Math.cos(lat0r) * Math.sin(lon0r),
Math.sin(lat0r),
)
// Camera position in ENU relative to the center ground point:
// East = D·sin(pitch)·sin(bearing)
// North = D·sin(pitch)·cos(bearing)
// Up = D·cos(pitch)
// (camera looks *toward* center, so it is on the opposite side)
const eastOff = -D * Math.sin(pitchRad) * Math.sin(bearingRad)
const northOff = -D * Math.sin(pitchRad) * Math.cos(bearingRad)
const upOff = D * Math.cos(pitchRad)
const groundECEF = lngLatAltToECEF(lon0, lat0, groundAlt)
const camPos = groundECEF.clone()
.addScaledVector(east, eastOff)
.addScaledVector(north, northOff)
.addScaledVector(up, upOff)
threeCamera.position.copy(camPos)
threeCamera.up.copy(up)
threeCamera.lookAt(groundECEF)
// FOV: read from MapLibre transform if available, fall back to 45°
const fovDeg = (() => {
const fov = (map as { transform?: { fov?: number } }).transform?.fov
return fov ? fov * 180 / Math.PI : 45
})()
threeCamera.fov = fovDeg
threeCamera.aspect = canvas.width / canvas.height threeCamera.aspect = canvas.width / canvas.height
threeCamera.updateProjectionMatrix() threeCamera.updateProjectionMatrix()
} }

View File

@ -0,0 +1,50 @@
.root {
display: inline-flex;
align-items: center;
cursor: ew-resize;
user-select: none;
}
.display {
display: inline-flex;
align-items: center;
gap: 2px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 5px;
color: #fff;
font-size: 12px;
font-weight: 600;
padding: 2px 6px;
min-width: 40px;
text-align: right;
justify-content: flex-end;
white-space: nowrap;
transition: border-color 0.12s, background 0.12s;
}
.root:hover .display {
background: rgba(255, 255, 255, 0.11);
border-color: rgba(245, 158, 11, 0.45);
}
.suffix {
font-size: 10px;
font-weight: 400;
color: rgba(255, 255, 255, 0.35);
margin-left: 2px;
}
.input {
width: 64px;
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.6);
border-radius: 5px;
color: #fff;
font-size: 12px;
font-weight: 600;
padding: 2px 6px;
outline: none;
cursor: text;
text-align: right;
}

112
web/src/ui/BlenderInput.tsx Normal file
View File

@ -0,0 +1,112 @@
import { useRef, useState, useCallback, useEffect } from 'react'
import styles from './BlenderInput.module.css'
interface Props {
value: number
onChange: (v: number) => void
min?: number
max?: number
/** How much the value changes per pixel dragged. Default 0.1 */
step?: number
/** Decimal places shown. Default 1 */
decimals?: number
/** Optional suffix shown after the number, e.g. "%" or "m/s²" */
suffix?: string
}
const DRAG_THRESHOLD = 4 // px before we treat motion as a drag
function clamp(v: number, min: number | undefined, max: number | undefined) {
if (min !== undefined && v < min) return min
if (max !== undefined && v > max) return max
return v
}
export function BlenderInput({ value, onChange, min, max, step = 0.1, decimals = 1, suffix }: Props) {
const [editing, setEditing] = useState(false)
const [text, setText] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const dragState = useRef<{
startX: number
startValue: number
moved: boolean
} | null>(null)
// ── Focus the real input when we enter edit mode ────────────────────────────
useEffect(() => {
if (editing) {
inputRef.current?.select()
}
}, [editing])
// ── Mouse-down: begin drag tracking ────────────────────────────────────────
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (editing) return
e.preventDefault()
dragState.current = { startX: e.clientX, startValue: value, moved: false }
function onMove(me: MouseEvent) {
if (!dragState.current) return
const dx = me.clientX - dragState.current.startX
if (Math.abs(dx) >= DRAG_THRESHOLD) {
dragState.current.moved = true
const next = clamp(
dragState.current.startValue + dx * step,
min,
max,
)
onChange(next)
}
}
function onUp() {
if (!dragState.current) return
if (!dragState.current.moved) {
// treat as a click → enter edit mode
setText(value.toFixed(decimals))
setEditing(true)
}
dragState.current = null
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}, [editing, value, step, min, max, decimals, onChange])
// ── Commit text input ───────────────────────────────────────────────────────
const commit = useCallback(() => {
const parsed = parseFloat(text)
if (!isNaN(parsed)) {
onChange(clamp(parsed, min, max))
}
setEditing(false)
}, [text, min, max, onChange])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') commit()
if (e.key === 'Escape') setEditing(false)
}, [commit])
return (
<span className={styles.root} onMouseDown={handleMouseDown}>
{editing ? (
<input
ref={inputRef}
className={styles.input}
value={text}
onChange={e => setText(e.target.value)}
onBlur={commit}
onKeyDown={handleKeyDown}
onClick={e => e.stopPropagation()}
/>
) : (
<span className={styles.display}>
{value.toFixed(decimals)}
{suffix && <span className={styles.suffix}>{suffix}</span>}
</span>
)}
</span>
)
}

View File

@ -1,78 +1,85 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import * as Cesium from 'cesium' import type { Map } from 'maplibre-gl'
import { useCesiumViewer } from '../cesium/cesiumContext' import { useMapLibreMap } from '../maplibre/maplibreContext'
import { safeRemoveLayers } from '../maplibre/geoUtils'
import styles from './OverlayControls.module.css' import styles from './OverlayControls.module.css'
interface OverlayDef { interface OverlayDef {
id: string id: string
label: string label: string
// UrlTemplateImageryProvider url — uses {z}/{x}/{y} OR {z}/{y}/{x} for ESRI tiles: string[]
url: string tileSize: 256 | 512
alpha: number opacity: number
} }
const OVERLAYS: OverlayDef[] = [ const OVERLAYS: OverlayDef[] = [
{ {
id: 'streets', id: 'streets',
label: 'Streets', label: 'Streets',
// ESRI World Transportation — roads, highways, rail; no labels tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}'],
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}', tileSize: 256,
alpha: 0.75, opacity: 0.75,
}, },
{ {
id: 'labels', id: 'labels',
label: 'City names', label: 'City names',
// CartoDB Voyager labels-only — place/city/country labels tiles: ['https://a.basemaps.cartocdn.com/rastertiles/voyager_only_labels/{z}/{x}/{y}.png'],
url: 'https://a.basemaps.cartocdn.com/rastertiles/voyager_only_labels/{z}/{x}/{y}.png', tileSize: 256,
alpha: 1.0, opacity: 1.0,
}, },
{ {
id: 'borders', id: 'borders',
label: 'Borders', label: 'Borders',
// ESRI World Boundaries & Places — country + admin borders + labels tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}'],
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', tileSize: 256,
alpha: 0.85, opacity: 0.85,
}, },
] ]
function addOverlay(map: Map, overlay: OverlayDef) {
const srcId = `overlay-src-${overlay.id}`
const lyrId = `overlay-lyr-${overlay.id}`
map.addSource(srcId, { type: 'raster', tiles: overlay.tiles, tileSize: overlay.tileSize })
map.addLayer({ id: lyrId, type: 'raster', source: srcId,
paint: { 'raster-opacity': overlay.opacity } })
}
function removeOverlay(map: Map, overlay: OverlayDef) {
safeRemoveLayers(map,
[`overlay-lyr-${overlay.id}`],
[`overlay-src-${overlay.id}`],
)
}
export function OverlayControls() { export function OverlayControls() {
const viewer = useCesiumViewer() const map = useMapLibreMap()
const [active, setActive] = useState<Set<string>>(new Set()) const [active, setActive] = useState<Set<string>>(new Set())
// Keep a ref map from overlay id → the live ImageryLayer so we can remove it const activeRef = useRef(active)
const layerRefs = useRef<Map<string, Cesium.ImageryLayer>>(new Map()) activeRef.current = active
function toggle(overlay: OverlayDef) { function toggle(overlay: OverlayDef) {
setActive((prev) => { setActive((prev) => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(overlay.id)) { if (next.has(overlay.id)) {
// Remove layer removeOverlay(map, overlay)
const layer = layerRefs.current.get(overlay.id)
if (layer && !viewer.isDestroyed()) {
viewer.imageryLayers.remove(layer, true)
}
layerRefs.current.delete(overlay.id)
next.delete(overlay.id) next.delete(overlay.id)
} else { } else {
// Add layer addOverlay(map, overlay)
const provider = new Cesium.UrlTemplateImageryProvider({ url: overlay.url })
const layer = viewer.imageryLayers.addImageryProvider(provider)
layer.alpha = overlay.alpha
layerRefs.current.set(overlay.id, layer)
next.add(overlay.id) next.add(overlay.id)
} }
return next return next
}) })
} }
// Clean up all layers if the component unmounts // Clean up all active overlays on unmount
useEffect(() => { useEffect(() => {
const refs = layerRefs.current
return () => { return () => {
refs.forEach((layer) => { for (const id of activeRef.current) {
if (!viewer.isDestroyed()) viewer.imageryLayers.remove(layer, true) const overlay = OVERLAYS.find(o => o.id === id)
}) if (overlay) removeOverlay(map, overlay)
} }
}, [viewer]) }
}, [map])
return ( return (
<div className={styles.group}> <div className={styles.group}>

View File

@ -1,6 +1,5 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import * as Cesium from 'cesium' import { useMapLibreMap } from '../maplibre/maplibreContext'
import { useCesiumViewer } from '../cesium/cesiumContext'
import styles from './SearchBar.module.css' import styles from './SearchBar.module.css'
interface NominatimResult { interface NominatimResult {
@ -12,7 +11,7 @@ interface NominatimResult {
} }
export function SearchBar() { export function SearchBar() {
const viewer = useCesiumViewer() const map = useMapLibreMap()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [results, setResults] = useState<NominatimResult[]>([]) const [results, setResults] = useState<NominatimResult[]>([])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@ -57,11 +56,7 @@ export function SearchBar() {
function flyTo(result: NominatimResult) { function flyTo(result: NominatimResult) {
const [minLat, maxLat, minLon, maxLon] = result.boundingbox.map(Number) const [minLat, maxLat, minLon, maxLon] = result.boundingbox.map(Number)
const rect = Cesium.Rectangle.fromDegrees(minLon, minLat, maxLon, maxLat) map.fitBounds([[minLon, minLat], [maxLon, maxLat]], { duration: 1500, padding: 40 })
viewer.camera.flyTo({
destination: rect,
duration: 1.5,
})
setQuery(result.display_name.split(',')[0]) setQuery(result.display_name.split(',')[0])
setOpen(false) setOpen(false)
setResults([]) setResults([])

View File

@ -1,21 +1,10 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import cesium from 'vite-plugin-cesium'
export default defineConfig({ export default defineConfig({
plugins: [react(), cesium()], plugins: [react()],
optimizeDeps: { optimizeDeps: {
// Prevent Vite from pre-bundling Cesium — it ships its own worker scripts
// that must be loaded from a specific public path, not inlined.
exclude: ['cesium'],
// Force pre-bundling of @mkkellogg/gaussian-splats-3d and all its CJS
// transitive dependencies. The 'pkg > dep' syntax tells Vite's esbuild
// step to convert each dep to ESM when reached through that package.
// All CJS transitive dependencies of cesium and @mkkellogg/gaussian-splats-3d.
// Because cesium is in exclude, Vite won't crawl its deps automatically —
// every CJS package it touches must be listed here for ESM conversion.
// List generated via: node -e "..." (see vite.config.ts comments above)
include: [ include: [
'@mkkellogg/gaussian-splats-3d', '@mkkellogg/gaussian-splats-3d',
'@mkkellogg/gaussian-splats-3d > mersenne-twister', '@mkkellogg/gaussian-splats-3d > mersenne-twister',
@ -41,8 +30,7 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: { manualChunks: {
// cesium is handled by vite-plugin-cesium (served from /Cesium/ public path) maplibre: ['maplibre-gl'],
// and must NOT appear here.
splat: ['@mkkellogg/gaussian-splats-3d', 'three'], splat: ['@mkkellogg/gaussian-splats-3d', 'three'],
}, },
}, },
@ -52,8 +40,6 @@ export default defineConfig({
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
// Proxy API calls to Django — avoids CORS in development.
// Django dev server runs on the host at :8000.
'/api': { '/api': {
target: 'http://localhost:8000', target: 'http://localhost:8000',
changeOrigin: true, changeOrigin: true,