Compare commits

..

No commits in common. "maplibre" and "main" have entirely different histories.

36 changed files with 2130 additions and 2463 deletions

1041
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",
"maplibre-gl": "^4.7.1", "cesium": "^1.124.0",
"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 { MapLibreViewer } from './maplibre/MapLibreViewer' import { CesiumViewer } from './cesium/CesiumViewer'
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 (
<MapLibreViewer> <CesiumViewer>
{/* Map-layer components — render no DOM, manage MapLibre sources */} {/* Imperative Cesium layers — render no DOM, manage entities */}
<SplatLayer /> <SplatLayer />
<ChallengeLayer /> <ChallengeLayer />
{/* Three.js splat overlay — portalled canvas above the map */} {/* Three.js splat overlay — portalled canvas above Cesium */}
<SplatRenderer /> <SplatRenderer />
{/* React UI — z-indexed above the map canvas */} {/* React UI — z-indexed above Cesium canvas */}
<MapOverlay /> <MapOverlay />
<ChallengePanel /> <ChallengePanel />
<ChallengeCreator /> <ChallengeCreator />
</MapLibreViewer> </CesiumViewer>
) )
} }

View File

@ -0,0 +1,71 @@
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

@ -0,0 +1,14 @@
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

@ -0,0 +1,63 @@
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

@ -0,0 +1,37 @@
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,53 +1,31 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import maplibregl, { type GeoJSONSource } from 'maplibre-gl' import * as Cesium from 'cesium'
import { useMapLibreMap } from '../maplibre/maplibreContext' import { useCesiumViewer } from '../cesium/cesiumContext'
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, fetchChallengeDetail } from '../api/challenges' import { fetchChallenges } 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 map = useMapLibreMap() const viewer = useCesiumViewer()
usePolygonDraw() usePolygonDraw()
const { bbox, cameraHeight, setLoadedChallenges } = useMapStore() const { bbox, cameraHeight, setLoadedChallenges } = useMapStore()
const { selectedChallengeId, setSelectedChallengeId } = useChallengeStore() const { selectedChallengeId, setSelectedChallengeId } = useChallengeStore()
const markersRef = useRef<Map<string, maplibregl.Marker>>(new Map()) const entityMapRef = useRef<Map<string, Cesium.Entity>>(new Map())
const regionEntityRef = useRef<Cesium.Entity | null>(null)
const lastBboxRef = useRef<BBox | null>(null) const lastBboxRef = useRef<BBox | null>(null)
// ── Set up region polygon sources + layers ───────────────────────────────── // Fetch and render challenge pins
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) {
markersRef.current.forEach(m => m.remove()) entityMapRef.current.forEach((e) => viewer.entities.remove(e))
markersRef.current.clear() entityMapRef.current.clear()
setLoadedChallenges([]) setLoadedChallenges([])
return return
} }
@ -65,68 +43,101 @@ 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)))
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)
}
}) })
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 (markersRef.current.has(id)) return if (entityMapRef.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 marker = new maplibregl.Marker({ element: el, anchor: 'bottom' }) const entity = viewer.entities.add({
.setLngLat([lon, lat]) id: `challenge-${id}`,
.addTo(map) position: Cesium.Cartesian3.fromDegrees(lon, lat),
markersRef.current.set(id, marker) billboard: {
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(() => {
const src = map.getSource(SRC_REGION) as GeoJSONSource | undefined if (regionEntityRef.current) {
if (!src) return viewer.entities.remove(regionEntityRef.current)
regionEntityRef.current = null
if (!selectedChallengeId) {
src.setData(emptyFC())
return
} }
fetchChallengeDetail(selectedChallengeId).then((detail) => { if (!selectedChallengeId) return
// 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
src.setData({ const coords = detail.region.coordinates[0]
type: 'Feature', const positions = coords.map((c) =>
geometry: detail.region, Cesium.Cartesian3.fromDegrees(c[0], c[1]),
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, map]) }, [selectedChallengeId, viewer])
// ── Cleanup on unmount ───────────────────────────────────────────────────── // Wire up entity selection
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 () => {
markersRef.current.forEach(m => m.remove()) if (viewer.isDestroyed()) return
markersRef.current.clear() entityMapRef.current.forEach((e) => viewer.entities.remove(e))
entityMapRef.current.clear()
if (regionEntityRef.current) viewer.entities.remove(regionEntityRef.current)
} }
}, []) }, [viewer])
return null return null
} }
function createChallengePinElement(): HTMLElement { function createChallengePinSvg(): string {
const el = document.createElement('div') const svg = `
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 el return `data:image/svg+xml;base64,${btoa(svg)}`
} }

View File

@ -1,14 +1,15 @@
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 { useMapLibreMap } from '../maplibre/maplibreContext' import { useCesiumViewer } from '../cesium/cesiumContext'
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 map = useMapLibreMap() const viewer = useCesiumViewer()
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)
@ -30,7 +31,10 @@ 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
map.flyTo({ center: [lon, lat], zoom: 14, pitch: 50, duration: 1500 }) viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(lon, lat, 2000),
duration: 1.5,
})
} }
async function handleParticipate() { async function handleParticipate() {

View File

@ -1,157 +1,158 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import type { Map, MapMouseEvent, GeoJSONSource, MapLayerMouseEvent } from 'maplibre-gl' import * as Cesium from 'cesium'
import { useMapLibreMap } from '../maplibre/maplibreContext' import { useCesiumViewer } from '../cesium/cesiumContext'
import { safeRemoveLayers } from '../maplibre/geoUtils'
import { useChallengeStore } from '../store/challengeStore' import { useChallengeStore } from '../store/challengeStore'
// ── Layer / source IDs ──────────────────────────────────────────────────────── function vertsToGeoJson(verts: Cesium.Cartesian3[]): GeoJSON.Polygon {
const coords: [number, number][] = verts.map((v) => {
const SRC_FILL = 'pd-fill' const c = Cesium.Cartographic.fromCartesian(v)
const SRC_OUTLINE = 'pd-outline' return [Cesium.Math.toDegrees(c.longitude), Cesium.Math.toDegrees(c.latitude)]
const SRC_RUBBER = 'pd-rubber' })
const SRC_VERTICES = 'pd-vertices' return { type: 'Polygon', coordinates: [[...coords, coords[0]]] }
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 addDrawLayers(map: Map) { function geoJsonToVerts(polygon: GeoJSON.Polygon): Cesium.Cartesian3[] {
map.addSource(SRC_FILL, { type: 'geojson', data: emptyFC() }) const ring = polygon.coordinates[0]
map.addSource(SRC_OUTLINE, { type: 'geojson', data: emptyFC() }) // Drop the closing duplicate point
map.addSource(SRC_RUBBER, { type: 'geojson', data: emptyFC() }) return ring.slice(0, -1).map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat))
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 removeDrawLayers(map: Map) { function pickGlobe(
safeRemoveLayers(map, viewer: Cesium.Viewer,
[LYR_FILL, LYR_OUTLINE, LYR_RUBBER, LYR_VERTICES], windowPos: Cesium.Cartesian2,
[SRC_FILL, SRC_OUTLINE, SRC_RUBBER, SRC_VERTICES], ): Cesium.Cartesian3 | null {
) 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)
* click place vertex * LEFT_CLICK place vertex
* mousemove rubber-band line from last vertex to cursor * MOUSE_MOVE rubber-band line from last vertex to cursor
* contextmenu close polygon (3 verts), enter edit phase * RIGHT_CLICK 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 map = useMapLibreMap() const viewer = useCesiumViewer()
const { drawingMode, setDrawingMode, setDraftPolygon, draftPolygon } = const { drawingMode, setDrawingMode, setDraftPolygon, draftPolygon } =
useChallengeStore() useChallengeStore()
const editVertsRef = useRef<[number, number][]>([]) // Persists vertex positions across edit-phase effect re-runs that are
// triggered by setDraftPolygon being called after each drag.
// ── Set up / tear down MapLibre sources + layers ────────────────────────── const editVertsRef = useRef<Cesium.Cartesian3[]>([])
useEffect(() => {
addDrawLayers(map)
return () => { removeDrawLayers(map) }
}, [map])
// ── Drawing phase ────────────────────────────────────────────────────────── // ── Drawing phase ──────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!drawingMode) return if (!drawingMode) return
const verts: [number, number][] = [] const verts: Cesium.Cartesian3[] = []
const canvas = map.getCanvas() const vertPointEntities: Cesium.Entity[] = []
let outlineEntity: Cesium.Entity | null = null
let rubberBandEntity: Cesium.Entity | null = null
canvas.style.cursor = 'crosshair' const canvas = viewer.scene.canvas
// 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 (verts.length < 2) { if (outlineEntity) {
setSource(map, SRC_OUTLINE, emptyFC()) viewer.entities.remove(outlineEntity)
return outlineEntity = null
} }
setSource(map, SRC_OUTLINE, { if (verts.length < 2) return
type: 'Feature', outlineEntity = viewer.entities.add({
geometry: { type: 'LineString', coordinates: [...verts, verts[0]] }, polyline: {
properties: {}, positions: [...verts, verts[0]],
width: 2,
material: new Cesium.ColorMaterialProperty(
Cesium.Color.YELLOW.withAlpha(0.9),
),
clampToGround: true,
},
}) })
} }
function refreshVertices() { function refreshRubberBand(mousePos: Cesium.Cartesian3) {
setSource(map, SRC_VERTICES, { if (rubberBandEntity) {
type: 'FeatureCollection', viewer.entities.remove(rubberBandEntity)
features: verts.map((v, i) => ({ rubberBandEntity = null
type: 'Feature', }
id: i,
geometry: { type: 'Point', coordinates: v },
properties: {},
})),
})
}
const onMouseMove = (e: MapMouseEvent) => {
if (verts.length === 0) return if (verts.length === 0) return
setSource(map, SRC_RUBBER, { rubberBandEntity = viewer.entities.add({
type: 'Feature', polyline: {
geometry: { type: 'LineString', coordinates: [verts[verts.length - 1], [e.lngLat.lng, e.lngLat.lat]] }, positions: [verts[verts.length - 1], mousePos],
properties: {}, width: 1.5,
material: new Cesium.ColorMaterialProperty(
Cesium.Color.WHITE.withAlpha(0.5),
),
clampToGround: true,
},
}) })
} }
const onClick = (e: MapMouseEvent) => { handler.setInputAction((e: { endPosition: Cesium.Cartesian2 }) => {
verts.push([e.lngLat.lng, e.lngLat.lat]) const pos = pickGlobe(viewer, e.endPosition)
refreshOutline() if (pos) refreshRubberBand(pos)
refreshVertices() }, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
}
const onContextMenu = (e: MouseEvent) => { handler.setInputAction((e: { position: Cesium.Cartesian2 }) => {
e.preventDefault() 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()
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
handler.setInputAction(() => {
if (verts.length < 3) return if (verts.length < 3) return
const polygon: GeoJSON.Polygon = { const polygon = vertsToGeoJson(verts)
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() {
canvas.style.cursor = '' if (viewer.isDestroyed()) return
map.off('click', onClick) vertPointEntities.forEach((e) => viewer.entities.remove(e))
map.off('mousemove', onMouseMove) vertPointEntities.length = 0
canvas.removeEventListener('contextmenu', onContextMenu) if (outlineEntity) {
setSource(map, SRC_FILL, emptyFC()) viewer.entities.remove(outlineEntity)
setSource(map, SRC_OUTLINE, emptyFC()) outlineEntity = null
setSource(map, SRC_RUBBER, emptyFC()) }
setSource(map, SRC_VERTICES, emptyFC()) if (rubberBandEntity) {
viewer.entities.remove(rubberBandEntity)
rubberBandEntity = null
}
} }
return cleanup return () => {
}, [drawingMode, map, setDrawingMode, setDraftPolygon]) handler.destroy()
canvas.removeEventListener('contextmenu', suppressContextMenu)
cleanup()
}
}, [drawingMode, viewer, setDrawingMode, setDraftPolygon])
// ── Edit phase ───────────────────────────────────────────────────────────── // ── Edit phase ─────────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
@ -160,84 +161,120 @@ export function usePolygonDraw() {
return return
} }
// Only initialise from the store on first entry into edit mode. // Only initialise from the store on the 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) {
const ring = draftPolygon.coordinates[0] editVertsRef.current = geoJsonToVerts(draftPolygon)
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[] = []
function refreshAll() { const canvas = viewer.scene.canvas
setSource(map, SRC_FILL, { const suppressContextMenu = (e: MouseEvent) => e.preventDefault()
type: 'Feature', canvas.addEventListener('contextmenu', suppressContextMenu)
geometry: { type: 'Polygon', coordinates: [[...verts, verts[0]]] },
properties: {}, // Filled polygon
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,
},
}) })
setSource(map, SRC_OUTLINE, { entities.push(e)
type: 'Feature', return e
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 },
})),
})
}
refreshAll() const handler = new Cesium.ScreenSpaceEventHandler(canvas)
const canvas = map.getCanvas() handler.setInputAction((e: { endPosition: Cesium.Cartesian2 }) => {
const onVertexMouseDown = (e: MapLayerMouseEvent) => {
const idx = e.features?.[0]?.properties?.idx as number | undefined
if (idx == null) return
e.preventDefault()
draggingIndex = idx
canvas.style.cursor = 'grabbing'
map.dragPan.disable()
}
const onMouseMove = (e: MapMouseEvent) => {
if (draggingIndex !== -1) { if (draggingIndex !== -1) {
verts[draggingIndex] = [e.lngLat.lng, e.lngLat.lat] // Update dragged vertex
refreshAll() const pos = pickGlobe(viewer, e.endPosition)
if (pos) verts[draggingIndex] = pos
return return
} }
const features = map.queryRenderedFeatures(e.point, { layers: [LYR_VERTICES] }) // Cursor feedback
canvas.style.cursor = features.length ? 'grab' : '' 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)
const onMouseUp = () => { handler.setInputAction((e: { position: Cesium.Cartesian2 }) => {
const picked = viewer.scene.pick(e.position)
if (!(picked?.id instanceof Cesium.Entity)) return
const idx = vertEntities.indexOf(picked.id)
if (idx === -1) return
draggingIndex = idx
canvas.style.cursor = 'grabbing'
viewer.scene.screenSpaceCameraController.enableRotate = false
viewer.scene.screenSpaceCameraController.enableTranslate = false
}, Cesium.ScreenSpaceEventType.LEFT_DOWN)
handler.setInputAction(() => {
if (draggingIndex === -1) return if (draggingIndex === -1) return
draggingIndex = -1 draggingIndex = -1
canvas.style.cursor = '' canvas.style.cursor = 'default'
map.dragPan.enable() viewer.scene.screenSpaceCameraController.enableRotate = true
setDraftPolygon({ viewer.scene.screenSpaceCameraController.enableTranslate = true
type: 'Polygon', // Sync updated geometry back to the store for the submit form.
coordinates: [[...verts, verts[0]]], setDraftPolygon(vertsToGeoJson(verts))
}) }, Cesium.ScreenSpaceEventType.LEFT_UP)
}
map.on('mousedown', LYR_VERTICES, onVertexMouseDown) function cleanup() {
map.on('mousemove', onMouseMove) if (viewer.isDestroyed()) return
map.on('mouseup', onMouseUp) entities.forEach((e) => viewer.entities.remove(e))
entities.length = 0
canvas.style.cursor = 'default'
viewer.scene.screenSpaceCameraController.enableRotate = true
viewer.scene.screenSpaceCameraController.enableTranslate = true
}
return () => { return () => {
map.off('mousedown', LYR_VERTICES, onVertexMouseDown) handler.destroy()
map.off('mousemove', onMouseMove) canvas.removeEventListener('contextmenu', suppressContextMenu)
map.off('mouseup', onMouseUp) cleanup()
map.dragPan.enable()
canvas.style.cursor = ''
setSource(map, SRC_FILL, emptyFC())
setSource(map, SRC_OUTLINE, emptyFC())
setSource(map, SRC_VERTICES, emptyFC())
} }
}, [drawingMode, draftPolygon, map, setDraftPolygon]) }, [drawingMode, draftPolygon, viewer, setDraftPolygon])
} }

View File

@ -1,5 +1,9 @@
.panel { .panel {
width: 340px; position: fixed;
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);
@ -53,7 +57,7 @@
.stripRow { .stripRow {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 6px;
margin-bottom: 6px; margin-bottom: 6px;
font-size: 12px; font-size: 12px;
} }
@ -62,17 +66,37 @@
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;
} }
.rangeSep { .stripRange {
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
} }
export function AccelerationStripsPanel({ strips, onRemoveStrip, onUpdateStrip, onUpdateStripFrac }: Props) { function pct(v: number) { return `${(v * 100).toFixed(0)}%` }
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,45 +31,19 @@ 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}>
<BlenderInput {pct(s.startFrac)}{pct(s.endFrac)}
value={s.startFrac * 100} </span>
onChange={v => { <input
const sf = Math.max(0, Math.min(v / 100, s.endFrac - 0.001)) type="number"
onUpdateStripFrac(s.id, sf, s.endFrac) step={0.5}
}}
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}
step={0.1} value={s.accel_ms2}
decimals={1} onChange={e => onUpdateStrip(s.id, Number(e.target.value))}
suffix="m/s²" className={styles.accelInput}
/> />
<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,38 +516,6 @@
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,24 +1,20 @@
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 type { Map, GeoJSONSource } from 'maplibre-gl' import * as Cesium from 'cesium'
import { MapLibreViewer } from '../maplibre/MapLibreViewer' import { CesiumViewer } from '../cesium/CesiumViewer'
import { useMapLibreMap } from '../maplibre/maplibreContext' import { useCesiumViewer } from '../cesium/cesiumContext'
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, computeArcLengths, snapToPath } from './useAccelerationStrips' import { useAccelerationStrips } 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 { effectiveLngLatAlt } from './bezierUtils' import { effectivePosition } 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 ───────────────────────────────────────────────────────────────
@ -33,9 +29,9 @@ export function CoasterEditorPage() {
}, [id]) }, [id])
return ( return (
<MapLibreViewer> <CesiumViewer>
<CoasterEditorScene challengeId={id} challenge={challenge} /> <CoasterEditorScene challengeId={id} challenge={challenge} />
</MapLibreViewer> </CesiumViewer>
) )
} }
@ -56,30 +52,32 @@ export function CoasterViewerPage() {
}, [challengeId, coasterId]) }, [challengeId, coasterId])
return ( return (
<MapLibreViewer> <CesiumViewer>
<CoasterEditorScene <CoasterEditorScene
challengeId={challengeId} challengeId={challengeId}
challenge={challenge} challenge={challenge}
readonly readonly
preloadCoaster={preloadCoaster ?? undefined} preloadCoaster={preloadCoaster ?? undefined}
/> />
</MapLibreViewer> </CesiumViewer>
) )
} }
// ── Source / layer IDs for editor-specific overlays ─────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
const SRC_REGION = 'editor-region' /** Build a circle cross-section shape for PolylineVolumeGraphics. */
const LYR_REGION_F = 'editor-region-fill' function buildCircleShape(radius: number, segments = 8): Cesium.Cartesian2[] {
const LYR_REGION_L = 'editor-region-line' const pts: Cesium.Cartesian2[] = []
const LYR_SIM_RAILS = 'sim-rails-3d' for (let i = 0; i < segments; i++) {
const angle = (2 * Math.PI * i) / segments
function emptyFC(): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', features: [] } } pts.push(new Cesium.Cartesian2(Math.cos(angle) * radius, Math.sin(angle) * radius))
function setData(map: Map, src: string, data: GeoJSON.GeoJSON) { }
(map.getSource(src) as GeoJSONSource | undefined)?.setData(data) return pts
} }
// ── Inner scene (needs map context) ────────────────────────────────────────── const RAIL_SHAPE = buildCircleShape(0.075, 8) // 7.5 cm radius = 15 cm diameter
// ── Inner scene (needs viewer context) ────────────────────────────────────────
interface SceneProps { interface SceneProps {
challengeId: string | undefined challengeId: string | undefined
@ -89,11 +87,9 @@ interface SceneProps {
} }
function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadCoaster }: SceneProps) { function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadCoaster }: SceneProps) {
const map = useMapLibreMap() const viewer = useCesiumViewer()
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
@ -108,107 +104,144 @@ 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(map, showPath, showAnchors) const path = useCoasterPath(viewer, showPath, showAnchors)
const accel = useAccelerationStrips(map, path.pathPts, path.mode === 'strip', showStrips) const accel = useAccelerationStrips(viewer, path.pathPts, path.mode === 'strip', showStrips)
const terrain = useTerrainCapture(map, simResult) const terrain = useTerrainCapture(viewer, simResult)
// Auto-load preloaded coaster (viewer mode) // Auto-load a 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 when sim result changes // Exit ride mode whenever the sim result changes; clear cursor on exit
useEffect(() => { setIsRideMode(false); setRideCursor(null) }, [simResult]) useEffect(() => { setIsRideMode(false); setRideCursor(null) }, [simResult])
// ── Set up region + sim-rail sources ────────────────────────────────────── // Suspend Cesium's render loop while Three.js ride view is active so both
// renderers don't compete for the GPU simultaneously.
useEffect(() => { useEffect(() => {
map.addSource(SRC_REGION, { type: 'geojson', data: emptyFC() }) viewer.useDefaultRenderLoop = !isRideMode
map.addLayer({ id: LYR_REGION_F, type: 'fill', source: SRC_REGION, }, [isRideMode, viewer])
paint: { 'fill-color': '#06b6d4', 'fill-opacity': 0.04 } })
map.addLayer({ id: LYR_REGION_L, type: 'line', source: SRC_REGION,
paint: { 'line-color': '#06b6d4', 'line-opacity': 0.45, 'line-width': 2 } })
const handle = createCustom3DLayer(LYR_SIM_RAILS, map) // Refs for simulation result entities (cleared on each new run / unmount)
simRailLayerRef.current = handle const simEntitiesRef = useRef<Cesium.Entity[]>([])
const simPrimitivesRef = useRef<Cesium.Primitive[]>([])
return () => { // ── Fly to challenge region ───────────────────────────────────────────────
handle.destroy()
simRailLayerRef.current = null
safeRemoveLayers(map, [LYR_REGION_F, LYR_REGION_L], [SRC_REGION])
}
}, [map])
// ── Fly to challenge region ────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!challenge) return if (!challenge) return
const coords = challenge.region.coordinates[0]
const positions = coords.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat))
const sphere = Cesium.BoundingSphere.fromPoints(positions, new Cesium.BoundingSphere())
sphere.radius = Math.max(sphere.radius + 20, 50)
const coords = challenge.region.coordinates[0] as [number, number][] viewer.camera.flyToBoundingSphere(sphere, {
const lons = coords.map(c => c[0]) offset: new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-78), sphere.radius * 3),
const lats = coords.map(c => c[1]) duration: 1.2,
map.fitBounds( })
[[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]], }, [challenge, viewer])
{ padding: 60, pitch: 55, bearing: 0, duration: 1200 },
)
setData(map, SRC_REGION, { type: 'Feature', geometry: challenge.region, properties: {} }) // ── Challenge boundary polygon ────────────────────────────────────────────
}, [challenge, map])
// ── Simulation result rails (custom 3D layer at absolute altitude) ────────
const regionEntityRef = useRef<Cesium.Entity | null>(null)
useEffect(() => { useEffect(() => {
if (!simResult) { if (regionEntityRef.current) {
simRailLayerRef.current?.update([]) viewer.entities.remove(regionEntityRef.current)
return regionEntityRef.current = null
} }
if (!challenge) return
simRailLayerRef.current?.update([ const coords = challenge.region.coordinates[0]
{ const positions = coords.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat))
pts: simResult.rail_1 as [number, number, number][],
color: 0xef4444,
radiusMeters: 0.12,
},
{
pts: simResult.rail_2 as [number, number, number][],
color: 0xef4444,
radiusMeters: 0.12,
},
])
}, [simResult])
// ── Path hover tooltip ──────────────────────────────────────────────────── 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 () => {
if (regionEntityRef.current && !viewer.isDestroyed()) {
viewer.entities.remove(regionEntityRef.current)
regionEntityRef.current = null
}
}
}, [challenge, viewer])
// ── Render simulation result rails ────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (path.pathPts.length < 2) { setPathHover(null); return } // Clear previous simulation entities
if (!viewer.isDestroyed()) {
simEntitiesRef.current.forEach(e => viewer.entities.remove(e))
simPrimitivesRef.current.forEach(p => viewer.scene.primitives.remove(p))
}
simEntitiesRef.current = []
simPrimitivesRef.current = []
const arcs = computeArcLengths(path.pathPts) if (!simResult) return
function onMove(e: { point: { x: number; y: number }; lngLat: { lng: number; lat: number } }) { const toC3 = ([lon, lat, alt]: [number, number, number]) =>
const { x, y } = e.point Cesium.Cartesian3.fromDegrees(lon, lat, alt)
const hits = map.queryRenderedFeatures(
[{ x: x - 4, y: y - 4 }, { x: x + 4, y: y + 4 }], const r1Pts = simResult.rail_1.map(toC3)
{ layers: ['coaster-path-lyr'] }, 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),
) )
if (hits.length === 0) { setPathHover(null); return } Cesium.Model.fromGltfAsync({ url: simResult.model_url, modelMatrix })
const { frac } = snapToPath([e.lngLat.lng, e.lngLat.lat], path.pathPts, arcs) .then(model => {
setPathHover({ x, y, pct: frac * 100 }) if (viewer.isDestroyed()) return
viewer.scene.primitives.add(model)
simPrimitivesRef.current.push(model as unknown as Cesium.Primitive)
})
.catch(err => console.error('GLB model load failed:', err))
} }
map.on('mousemove', onMove) return () => {
return () => { map.off('mousemove', onMove) } if (viewer.isDestroyed()) return
}, [map, path.pathPts]) simEntitiesRef.current.forEach(e => viewer.entities.remove(e))
simPrimitivesRef.current.forEach(p => viewer.scene.primitives.remove(p))
simEntitiesRef.current = []
simPrimitivesRef.current = []
}
}, [simResult, viewer])
// ── Load / Save handlers ───────────────────────────────────────────────── // ── Load / Save handlers ─────────────────────────────────────────────────
function handleLoad(coaster: SavedCoaster) { function handleLoad(coaster: SavedCoaster) {
const anchorPoints: AnchorPoint[] = coaster.anchors.map(a => ({ const anchorPoints = coaster.anchors.map(a => ({
id: a.id, id: a.id,
lngLat: [a.lon, a.lat] as [number, number], position: Cesium.Cartesian3.fromDegrees(a.lon, a.lat, a.terrainAlt),
terrainHeight: a.terrainAlt,
heightOffset: a.heightOffset, heightOffset: a.heightOffset,
})) }))
path.loadAnchors(anchorPoints) path.loadAnchors(anchorPoints)
@ -219,13 +252,16 @@ 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 => {
id: a.id, const carto = Cesium.Cartographic.fromCartesian(effectivePosition(a))
lon: a.lngLat[0], return {
lat: a.lngLat[1], id: a.id,
terrainAlt: a.terrainHeight, lon: Cesium.Math.toDegrees(carto.longitude),
heightOffset: a.heightOffset, lat: Cesium.Math.toDegrees(carto.latitude),
})) terrainAlt: carto.height,
heightOffset: a.heightOffset,
}
})
await saveCoaster(challengeId, { await saveCoaster(challengeId, {
name: coasterName.trim(), name: coasterName.trim(),
anchors: storedAnchors, anchors: storedAnchors,
@ -242,7 +278,19 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
setSimError(null) setSimError(null)
try { try {
const geoPath = path.anchors.map(anchor => effectiveLngLatAlt(anchor)) // Send only the anchor control points (not the pre-sampled Bezier
// 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,
@ -270,12 +318,13 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
return ( return (
<> <>
{/* ── Three.js ride renderer (fullscreen, mounts over map) ──────────── */} {/* ── Three.js ride renderer (fullscreen, mounts over Cesium) ─────── */}
{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}
/> />
)} )}
@ -298,15 +347,6 @@ 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) ──────────────────────────── */}
@ -391,7 +431,9 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
)} )}
{/* ── Simulation error ─────────────────────────────────────────────── */} {/* ── Simulation error ─────────────────────────────────────────────── */}
{simError && <div className={styles.simError}>{simError}</div>} {simError && (
<div className={styles.simError}>{simError}</div>
)}
{/* ── Diagnostics strip ────────────────────────────────────────────── */} {/* ── Diagnostics strip ────────────────────────────────────────────── */}
{diag && !simError && ( {diag && !simError && (
@ -454,7 +496,7 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
)} )}
{/* ── Simulation profile charts (left panel) ──────────────────────── */} {/* ── Simulation profile charts (left panel) ──────────────────────── */}
{!isRideMode && simResult?.profile && ( {simResult?.profile && (
<SimulationPlots <SimulationPlots
profile={simResult.profile} profile={simResult.profile}
strips={accel.strips} strips={accel.strips}
@ -462,26 +504,23 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
/> />
)} )}
{/* ── Right panel column (acceleration strips) ─────────────────────── */} {/* ── Acceleration strips (right panel) ───────────────────────────── */}
{!isRideMode && accel.strips.length > 0 && ( {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>
)} )}
{/* ── Path hover tooltip ──────────────────────────────────────────── */} {/* ── Coaster list panel (right) ───────────────────────────────────── */}
{!isRideMode && pathHover && ( {challengeId && (
<div <CoasterListPanel
className={styles.pathTooltip} challengeId={challengeId}
style={{ left: pathHover.x + 14, top: pathHover.y - 10 }} currentUsername={currentUsername}
> onLoad={handleLoad}
{pathHover.pct.toFixed(1)}% refreshKey={coasterListKey}
</div> />
)} )}
{/* ── Loading overlay ──────────────────────────────────────────────── */} {/* ── Loading overlay ──────────────────────────────────────────────── */}

View File

@ -1,4 +1,8 @@
.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);
@ -118,45 +122,3 @@
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,11 +8,9 @@ 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, menuMode }: Props) { export function CoasterListPanel({ challengeId, currentUsername, onLoad, refreshKey }: Props) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [coasters, setCoasters] = useState<SavedCoaster[]>([]) const [coasters, setCoasters] = useState<SavedCoaster[]>([])
@ -25,46 +23,6 @@ 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 posArr[idx * 3 + 1] = v.y - 0.5
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,16 +197,9 @@ function buildTerrainMesh(
tex.magFilter = THREE.LinearFilter tex.magFilter = THREE.LinearFilter
tex.needsUpdate = true tex.needsUpdate = true
// Polygon offset: higher-indexed patches render on top of lower-indexed ones in const mat = new THREE.MeshLambertMaterial({ map: tex, side: THREE.FrontSide })
// 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 }
} }
@ -225,17 +218,23 @@ 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 — all meshes permanently visible ───────────────────── // ── Terrain patches — one mesh per patch, only the active one visible ────
// Polygon offset (set per-mesh in buildTerrainMesh) ensures higher-indexed const terrainMeshes = captureData.map((patch, i) => {
// patches win the depth test in overlap zones without flickering. const { mesh, geo, mat, tex } = buildTerrainMesh(patch, renderer, i === 0)
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))
@ -252,6 +251,7 @@ 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,6 +288,10 @@ 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
@ -318,8 +322,10 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
rideDataRef.current = rideData rideDataRef.current = rideData
setTotalDuration(rideData.totalDuration) setTotalDuration(rideData.totalDuration)
const { scene, disposeAll } = buildScene(captureData, rideData, renderer) const { scene, setActivePatch, 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
@ -339,7 +345,6 @@ 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 () => {
@ -414,6 +419,24 @@ 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: 116px; top: 60px;
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,10 +94,8 @@ function ProfileChart({ data, dataKey, color, unit, strips, showZero, rideCursor
/> />
<Tooltip <Tooltip
{...tooltipStyle} {...tooltipStyle}
// eslint-disable-next-line @typescript-eslint/no-explicit-any formatter={(v: number) => [`${v.toFixed(2)} ${unit}`, dataKey]}
formatter={(v: any) => [`${Number(v).toFixed(2)} ${unit}`, dataKey]} labelFormatter={(l: number) => `s = ${pct(l)}`}
// 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,46 +1,54 @@
import * as THREE from 'three' import * as Cesium from 'cesium'
import { lngLatAltToECEF, ecefToLngLatAlt } from '../maplibre/geoUtils'
export interface AnchorPoint { export interface AnchorPoint {
id: string id: string
lngLat: [number, number] // [longitude, latitude] position: Cesium.Cartesian3 // base position on terrain
terrainHeight: number // metres above WGS-84 ellipsoid (from terrain query) heightOffset: number // meters above terrain surface
heightOffset: number // metres above terrain surface (user-adjustable)
} }
// ── helpers ────────────────────────────────────────────────────────────────── // ── helpers ──────────────────────────────────────────────────────────────────
/** Return the effective 3-D position of an anchor in ECEF metres. */ function v3add(a: Cesium.Cartesian3, b: Cesium.Cartesian3): Cesium.Cartesian3 {
export function effectivePosition(anchor: AnchorPoint): THREE.Vector3 { return Cesium.Cartesian3.add(a, b, new Cesium.Cartesian3())
const [lon, lat] = anchor.lngLat }
const alt = anchor.terrainHeight + anchor.heightOffset function v3sub(a: Cesium.Cartesian3, b: Cesium.Cartesian3): Cesium.Cartesian3 {
return lngLatAltToECEF(lon, lat, alt) return Cesium.Cartesian3.subtract(a, b, new Cesium.Cartesian3())
}
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 effective position as [lon, lat, alt]. */ /** Return the effective 3-D position of an anchor including its height offset. */
export function effectiveLngLatAlt(anchor: AnchorPoint): [number, number, number] { export function effectivePosition(anchor: AnchorPoint): Cesium.Cartesian3 {
return [anchor.lngLat[0], anchor.lngLat[1], anchor.terrainHeight + anchor.heightOffset] if (anchor.heightOffset === 0) return anchor.position.clone()
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: THREE.Vector3 p0: Cesium.Cartesian3
c1: THREE.Vector3 c1: Cesium.Cartesian3
c2: THREE.Vector3 c2: Cesium.Cartesian3
p1: THREE.Vector3 p1: Cesium.Cartesian3
} }
/** /**
* 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 curve starts/ends at first/last anchor // phantom end-points so the curve starts/ends at the 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[] = []
@ -49,36 +57,33 @@ 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 cubic Bézier control points // Catmull-Rom tangent handles converted to cubic Bézier control points
const c1 = p0.clone().addScaledVector(p1.clone().sub(pm1), 1 / 6) const c1 = v3add(p0, v3scale(v3sub(p1, pm1), 1 / 6))
const c2 = p1.clone().addScaledVector(p0.clone().sub(p2), 1 / 6) const c2 = v3add(p1, v3scale(v3sub(p0, 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): THREE.Vector3 { function evalBezier(seg: Segment, t: number): Cesium.Cartesian3 {
const { p0, c1, c2, p1 } = seg const { p0, c1, c2, p1 } = seg
const mt = 1 - t const mt = 1 - t
return new THREE.Vector3( return new Cesium.Cartesian3(
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,
): THREE.Vector3[] { ): Cesium.Cartesian3[] {
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: THREE.Vector3[] = [] const pts: Cesium.Cartesian3[] = []
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++) {
@ -88,33 +93,23 @@ 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: THREE.Vector3[] left: Cesium.Cartesian3[]
right: THREE.Vector3[] right: Cesium.Cartesian3[]
} }
/** /**
* Given a centre-line path (ECEF) compute parallel left/right rail positions. * Given a centre-line path 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: THREE.Vector3[], path: Cesium.Cartesian3[],
gauge = 2.5, gauge = 2.5,
): RailPositions { ): RailPositions {
const left: THREE.Vector3[] = [] const left: Cesium.Cartesian3[] = []
const right: THREE.Vector3[] = [] const right: Cesium.Cartesian3[] = []
const half = gauge / 2 const half = gauge / 2
const n = path.length const n = path.length
@ -122,19 +117,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: THREE.Vector3 let tangent: Cesium.Cartesian3
if (i === 0) tangent = path[1].clone().sub(path[0]) if (i === 0) tangent = v3sub(path[1], path[0])
else if (i === n - 1) tangent = path[n - 1].clone().sub(path[n - 2]) else if (i === n - 1) tangent = v3sub(path[n - 1], path[n - 2])
else tangent = path[i + 1].clone().sub(path[i - 1]) else tangent = v3sub(path[i + 1], path[i - 1])
tangent.normalize() tangent = v3norm(tangent)
// Local up = radially outward from Earth centre (normalise ECEF position) // Local up = radially outward from Earth centre
const up = pt.clone().normalize() const up = v3norm(pt)
// Track-right = tangent × up (right-hand rule) // Track-right = tangent × up (right-hand rule → points right when facing forward)
const rightDir = new THREE.Vector3().crossVectors(tangent, up).normalize() const rightDir = v3norm(v3cross(tangent, up))
left.push(pt.clone().addScaledVector(rightDir, -half)) left.push(v3add(pt, v3scale(rightDir, -half)))
right.push(pt.clone().addScaledVector(rightDir, half)) right.push(v3add(pt, v3scale(rightDir, half)))
} }
return { left, right } return { left, right }

View File

@ -1,40 +1,26 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import type { Map, MapMouseEvent, GeoJSONSource } from 'maplibre-gl' import * as Cesium from 'cesium'
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 (geographic [lon, lat, alt] inputs) ────────────────── // ── Arc-length utilities ───────────────────────────────────────────────────────
function geoDist(a: [number, number, number], b: [number, number, number]): number { function computeArcLengths(pts: Cesium.Cartesian3[]): 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] + geoDist(pts[i - 1], pts[i])) s.push(s[i - 1] + Cesium.Cartesian3.distance(pts[i - 1], pts[i]))
} }
return s return s
} }
export function snapToPath( function snapToPath(
lngLat: [number, number], pos: Cesium.Cartesian3,
pts: [number, number, number][], pts: Cesium.Cartesian3[],
arcs: number[], arcs: number[],
): { frac: number; pt: [number, number, number] } { ): { frac: number; pt: Cesium.Cartesian3 } {
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 dlat = (pts[i][1] - lngLat[1]) * 111320 const d = Cesium.Cartesian3.distance(pos, pts[i])
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]
@ -51,46 +37,33 @@ 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 { startFrac: number; pt: [number, number, number] } interface PendingStrip {
startFrac: number
marker: Cesium.Entity
}
export function useAccelerationStrips( export function useAccelerationStrips(
map: Map, viewer: Cesium.Viewer,
pathPts: [number, number, number][], pathPts: Cesium.Cartesian3[],
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 pathPtsRef = useRef(pathPts) const stripEntities = useRef<Map<string, Cesium.Entity>>(new Map())
pathPtsRef.current = pathPts const pathPtsRef = useRef(pathPts)
const isActiveRef = useRef(isActive) pathPtsRef.current = pathPts
isActiveRef.current = isActive
const layer3DRef = useRef<Layer3DHandle | null>(null)
// ── Public callbacks ─────────────────────────────────────────────────────── // ── Public callbacks ───────────────────────────────────────────────────────
@ -102,167 +75,166 @@ 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 updateStripFrac = useCallback((id: string, startFrac: number, endFrac: number) => { const clearStrips = useCallback(() => {
setStrips(prev => prev.map(s => s.id === id ? { ...s, startFrac, endFrac } : s)) setStrips([])
}, []) }, [])
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 onClick = (e: MapMouseEvent) => { const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
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([e.lngLat.lng, e.lngLat.lat], pts, arcs) const { frac, pt } = snapToPath(worldPos, pts, arcs)
if (!pendingRef.current) { if (!pendingRef.current) {
pendingRef.current = { startFrac: frac, pt } // First click — place start marker
setData(map, SRC_PENDING, { const marker = viewer.entities.add({
type: 'Feature', position: new Cesium.ConstantPositionProperty(pt),
geometry: { type: 'Point', coordinates: pt }, point: {
properties: {}, pixelSize: 14,
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 {
const { startFrac } = pendingRef.current // Second click — complete the strip
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 if (sf === ef) return // degenerate — same point
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)
const onContextMenu = () => { // Right-click: cancel pending
if (!isActiveRef.current || !pendingRef.current) return handler.setInputAction(() => {
if (!isActive || !pendingRef.current) return
viewer.entities.remove(pendingRef.current.marker)
pendingRef.current = null pendingRef.current = null
setData(map, SRC_PENDING, emptyFC()) }, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
}
map.on('click', onClick)
map.getCanvas().addEventListener('contextmenu', onContextMenu)
return () => { return () => {
map.off('click', onClick) if (!handler.isDestroyed()) handler.destroy()
map.getCanvas().removeEventListener('contextmenu', onContextMenu)
} }
}, [map]) }, [viewer, isActive]) // pathPts via ref — intentional
// ── Right-click on strip to delete ──────────────────────────────────────── // ── Cesium polyline entity sync ────────────────────────────────────────────
useEffect(() => { useEffect(() => {
const onContextMenuStrip = (e: MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { if (viewer.isDestroyed()) return
if (isActiveRef.current) return
const features = map.queryRenderedFeatures(e.point, { layers: [LYR_STRIPS] })
if (!features.length) return
const stripId = features[0].properties?.stripId as string | undefined
if (stripId) removeStrip(stripId)
}
map.on('contextmenu', LYR_STRIPS, onContextMenuStrip as Parameters<typeof map.on>[1]) const currentIds = new Set(strips.map(s => s.id))
return () => {
map.off('contextmenu', LYR_STRIPS, onContextMenuStrip as Parameters<typeof map.on>[1])
}
}, [map, removeStrip])
// ── Sync strip GeoJSON + 3D tubes ───────────────────────────────────────── // Remove entities for deleted strips
stripEntities.current.forEach((entity, id) => {
useEffect(() => { if (!currentIds.has(id)) {
if (!showStrips) { viewer.entities.remove(entity)
layer3DRef.current?.update([]) stripEntities.current.delete(id)
return
}
const pts = pathPts
if (pts.length < 2) {
setData(map, SRC_STRIPS, emptyFC())
layer3DRef.current?.update([])
return
}
const arcs = computeArcLengths(pts)
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 ei = fracToIndex(strip.endFrac, arcs)
const lo = Math.min(si, ei)
const hi = Math.max(si, ei)
const sliced = pts.slice(lo, hi + 1) as [number, number, number][]
if (sliced.length >= 2) segments.push({ coords: sliced, id: strip.id })
return {
type: 'Feature' as const,
geometry: { type: 'LineString' as const, coordinates: sliced },
properties: { stripId: strip.id },
} }
}).filter(f => (f.geometry as GeoJSON.LineString).coordinates.length >= 2) })
// Invisible draped geometry for right-click hit-testing if (pathPts.length < 2) return
setData(map, SRC_STRIPS, { type: 'FeatureCollection', features })
// 3D tube rendering on the path const arcs = computeArcLengths(pathPts)
layer3DRef.current?.update(
segments.map(s => ({ pts: s.coords, color: 0xf59e0b, radiusMeters: 0.4 }))
)
}, [strips, pathPts, showStrips, map])
// ── Visibility toggle ───────────────────────────────────────────────────── // Remove all existing strip entities and rebuild — pathPts may have changed
stripEntities.current.forEach(entity => viewer.entities.remove(entity))
stripEntities.current.clear()
for (const strip of strips) {
const si = fracToIndex(strip.startFrac, arcs)
const ei = fracToIndex(strip.endFrac, arcs)
const sliced = pathPts.slice(Math.min(si, ei), Math.max(si, ei) + 1)
if (sliced.length < 2) continue
const entity = viewer.entities.add({
id: `accel-strip-${strip.id}`,
polyline: {
positions: sliced,
width: 7,
material: Cesium.Color.fromCssColorString('#f59e0b'),
arcType: Cesium.ArcType.NONE,
clampToGround: false,
},
properties: { stripId: strip.id },
})
stripEntities.current.set(strip.id, entity)
}
}, [strips, pathPts, viewer])
// ── Right-click on strip entity to delete ─────────────────────────────────
useEffect(() => { useEffect(() => {
const vis = showStrips ? 'visible' : 'none' const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
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])
return { strips, removeStrip, updateStrip, updateStripFrac, clearStrips, loadStrips } handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => {
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,35 +1,13 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import type { Map, MapMouseEvent, GeoJSONSource } from 'maplibre-gl' import * as Cesium from 'cesium'
import type { AnchorPoint } from './bezierUtils' import type { AnchorPoint } from './bezierUtils'
import { effectivePosition, effectiveLngLatAlt, samplePath, computeRails } from './bezierUtils' import { effectivePosition, 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[]
/** Sampled path as geographic [lon, lat, alt] tuples */ pathPts: Cesium.Cartesian3[]
pathPts: [number, number, number][]
selectedId: string | null selectedId: string | null
mode: EditorMode mode: EditorMode
setMode: (m: EditorMode) => void setMode: (m: EditorMode) => void
@ -40,54 +18,37 @@ export interface CoasterPathHandle {
clearAll: () => void clearAll: () => void
} }
// ── Source / layer IDs ──────────────────────────────────────────────────────── let _counter = 0
function genId(): string {
const SRC_PATH = 'coaster-path-src' // thin draped line for queryRenderedFeatures hover return `a${++_counter}-${Math.random().toString(36).slice(2, 6)}`
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)
} }
let _counter = 0 export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAnchors = true): CoasterPathHandle {
function genId(): string { return `a${++_counter}-${Math.random().toString(36).slice(2, 6)}` } const [anchors, setAnchors] = useState<AnchorPoint[]>([])
const [pathPts, setPathPts] = useState<Cesium.Cartesian3[]>([])
// 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 [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)
// Drag state (no React re-renders during drag) // Cesium entity refs
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 dragLngLat = useRef<[number, number] | null>(null) const dragPos = useRef<Cesium.Cartesian3 | 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 => setAnchors(prev => prev.map(a => a.id === id ? { ...a, heightOffset: Math.max(0, a.heightOffset + delta) } : a))
a.id === id ? { ...a, heightOffset: Math.max(0, a.heightOffset + delta) } : a,
))
}, []) }, [])
const removeAnchor = useCallback((id: string) => { const removeAnchor = useCallback((id: string) => {
@ -109,229 +70,237 @@ export function useCoasterPath(
}) })
}, []) }, [])
const clearAll = useCallback(() => { setAnchors([]); setSelectedId(null) }, []) const clearAll = useCallback(() => {
setAnchors([])
setSelectedId(null)
}, [])
// ── Set up MapLibre sources + layers ─────────────────────────────────────── // ── cursor style ───────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
map.addSource(SRC_PATH, { type: 'geojson', data: emptyFC() }) viewer.scene.canvas.style.cursor =
map.addSource(SRC_LABEL, { type: 'geojson', data: emptyFC() })
// 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 } })
map.addLayer({ id: LYR_LABEL, type: 'symbol', source: SRC_LABEL,
layout: {
'text-field': ['get', 'label'],
'text-size': 13,
'text-anchor': 'bottom',
'text-offset': [0, -1.2],
},
paint: {
'text-color': '#4ade80',
'text-halo-color': '#000000',
'text-halo-width': 1.5,
} })
return () => {
safeRemoveLayers(map,
[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 === 'add' ? 'crosshair' :
mode === 'strip' ? 'cell' : '' mode === 'strip' ? 'cell' : 'default'
}, [mode, map]) }, [mode, viewer])
// ── Sync anchors + path to GeoJSON sources and 3D layer ─────────────────── // ── entity sync ────────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
// Draped label for the start point const existingIds = new Set(anchors.map(a => a.id))
if (anchors.length > 0) {
const [lon, lat, alt] = ecefToLngLatAlt(effectivePosition(anchors[0])) // Remove spheres for deleted anchors
setData(map, SRC_LABEL, { sphereMapRef.current.forEach((entity, id) => {
type: 'FeatureCollection', if (!existingIds.has(id)) {
features: [{ viewer.entities.remove(entity)
type: 'Feature', sphereMapRef.current.delete(id)
geometry: { type: 'Point', coordinates: [lon, lat, alt] }, }
properties: { label: '▶ Start' }, })
}],
}) // Add or update anchor spheres
} else { anchors.forEach((anchor) => {
setData(map, SRC_LABEL, emptyFC()) 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 },
})
sphereMapRef.current.set(anchor.id, entity)
} else {
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
pathEntities.current.forEach(e => viewer.entities.remove(e))
pathEntities.current = []
if (anchors.length >= 2) { if (anchors.length >= 2) {
const ecefPts = samplePath(anchors) const pts = samplePath(anchors)
const geoPts = ecefPts.map(v => ecefToLngLatAlt(v)) as [number, number, number][] setPathPts(pts)
setPathPts(geoPts) const { left, right } = computeRails(pts)
// Thin draped line for hover hit-testing (queryRenderedFeatures) const centre = viewer.entities.add({
setData(map, SRC_PATH, { polyline: {
type: 'FeatureCollection', positions: pts,
features: [{ width: 2,
type: 'Feature', material: Cesium.Color.YELLOW.withAlpha(0.55),
geometry: { type: 'LineString', coordinates: geoPts }, arcType: Cesium.ArcType.NONE,
properties: {}, },
}],
}) })
const leftRail = viewer.entities.add({
const { left, right } = computeRails(ecefPts) polyline: {
const leftPts = left.map(v => ecefToLngLatAlt(v)) as [number, number, number][] positions: left,
const rightPts = right.map(v => ecefToLngLatAlt(v)) as [number, number, number][] width: 3,
material: Cesium.Color.fromCssColorString('#b0b8c1'),
layer3DRef.current?.update([ arcType: Cesium.ArcType.NONE,
{ pts: geoPts, color: 0xfbbf24, radiusMeters: 0.15 }, },
{ pts: leftPts, color: 0xb0b8c1, radiusMeters: 0.08 }, })
{ pts: rightPts, color: 0xb0b8c1, radiusMeters: 0.08 }, const rightRail = viewer.entities.add({
]) 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([])
} }
// Anchor sphere markers rendered in 3D at path altitude // Start label — always at first anchor
if (showAnchors && anchors.length > 0) { if (startLabelRef.current) {
const spheres: Point3D[] = anchors.map(a => ({ viewer.entities.remove(startLabelRef.current)
id: a.id, startLabelRef.current = null
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([])
} }
}, [anchors, selectedId, showAnchors, map]) if (anchors.length > 0) {
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])
// ── Visibility toggles ───────────────────────────────────────────────────── // ── path visibility toggle ─────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
const vis = showPath ? 'visible' : 'none' pathEntities.current.forEach(e => { e.show = showPath })
if (map.getLayer(LYR_PATH)) map.setLayoutProperty(LYR_PATH, 'visibility', vis) }, [showPath])
}, [showPath, map])
// ── Input handling ───────────────────────────────────────────────────────── // ── anchor visibility toggle ───────────────────────────────────────────────
useEffect(() => { useEffect(() => {
const canvas = map.getCanvas() sphereMapRef.current.forEach(e => { e.show = showAnchors })
const suppressCtx = (e: Event) => { e.preventDefault() } if (startLabelRef.current) startLabelRef.current.show = showAnchors
}, [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)
// Mousedown — begin drag on anchor sphere (select mode only) const disableCam = () => {
const onMouseDown = (e: MapMouseEvent) => { const c = viewer.scene.screenSpaceCameraController
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 { clientWidth: w, clientHeight: h } = canvas const anchorId = pickAnchorId(e.position)
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
dragLngLat.current = null dragPos.current = null
didMoveDuringDrag.current = false didMoveDuringDrag.current = false
map.dragPan.disable() disableCam()
} }, Cesium.ScreenSpaceEventType.LEFT_DOWN)
// Mousemove → drag anchor live, or update cursor // MOUSE_MOVE live-drag the sphere
const onMouseMove = (e: MapMouseEvent) => { handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.MotionEvent) => {
if (isDragging.current && dragAnchorId.current) { if (!isDragging.current || !dragAnchorId.current) return
didMoveDuringDrag.current = true const newPos = pickTerrain(e.endPosition)
const ll: [number, number] = [e.lngLat.lng, e.lngLat.lat] if (!newPos) return
dragLngLat.current = ll didMoveDuringDrag.current = true
// Live-update anchor sphere position without React re-render dragPos.current = newPos
const id = dragAnchorId.current // Move the sphere entity directly (no React re-render during drag)
const terrainAlt = getAbsoluteTerrainElevation(map, e.lngLat) const entity = sphereMapRef.current.get(dragAnchorId.current)
const current = anchorsRef.current if (entity) entity.position = new Cesium.ConstantPositionProperty(newPos)
const spheres: Point3D[] = current.map(a => { }, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
const pt: [number, number, number] = a.id === id
? [ll[0], ll[1], terrainAlt + a.heightOffset] // LEFT_UP commit drag
: effectiveLngLatAlt(a) handler.setInputAction(() => {
return { if (isDragging.current) {
id: a.id, if (didMoveDuringDrag.current && dragAnchorId.current && dragPos.current) {
pt, const id = dragAnchorId.current
color: a.id === selectedRef.current ? 0xf59e0b : 0xffffff, const pos = dragPos.current
radiusMeters: a.id === selectedRef.current ? ANCHOR_RADIUS_SELECTED : ANCHOR_RADIUS_NORMAL, setAnchors(prev => prev.map(a => a.id === id ? { ...a, position: pos } : a))
} }
}) isDragging.current = false
layer3DRef.current?.updatePoints(spheres) dragAnchorId.current = null
dragPos.current = null
enableCam()
}
}, 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 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 anchorId = pickAnchorId(e.position)
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
dragAnchorId.current = null
dragLngLat.current = null
map.dragPan.enable()
if (modeRef.current === 'select') canvas.style.cursor = ''
}
// 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 terrainAlt = getAbsoluteTerrainElevation(map, e.lngLat) const pos = pickTerrain(e.position)
if (!pos) return
const id = genId() const id = genId()
setAnchors(prev => [...prev, { setAnchors(prev => [...prev, { id, position: pos, heightOffset: 1 }])
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 handled by useAccelerationStrips // 'strip' mode clicks are handled by useAccelerationStrips
} }, Cesium.ScreenSpaceEventType.LEFT_CLICK)
// Right-click on canvas → undo / delete selected // RIGHT_CLICK undo / remove selected
const onContextMenu = () => { handler.setInputAction(() => {
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))
@ -339,26 +308,33 @@ export function useCoasterPath(
} 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)
canvas.removeEventListener('contextmenu', onContextMenu) if (!viewer.isDestroyed()) {
map.off('mousedown', onMouseDown) enableCam()
map.off('mousemove', onMouseMove) viewer.scene.canvas.style.cursor = 'default'
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
return { anchors, pathPts, selectedId, mode, setMode, // ── cleanup on unmount ─────────────────────────────────────────────────────
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,8 +1,7 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import type { Map } from 'maplibre-gl' import * as Cesium from 'cesium'
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 ───────────────────────────────────────────────────────────────
@ -11,7 +10,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=North, Z=Up → remapped to Three.js) */ /** 64×64 grid of ENU positions (X=East, Y=Up, Z=North) with terrain heights */
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) */
@ -23,126 +22,34 @@ 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 const PATCH_RADIUS_M = 500 // ±500 m → 1 km × 1 km per patch
const PATCH_INTERVAL_M = 700 const PATCH_INTERVAL_M = 700 // one patch centre every 700 m of track
// ── 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 = lngLatAltToECEF(lon, lat, alt) const pt = Cesium.Cartesian3.fromDegrees(lon, lat, alt)
const enu = ecefToEnu(pt, origin) // X=East, Y=North, Z=Up const org = Cesium.Cartesian3.fromDegrees(origin[0], origin[1], origin[2])
// Remap to Three.js convention used by RideRenderer: X=East, Y=Up, Z=North const enuFrame = Cesium.Transforms.eastNorthUpToFixedFrame(org)
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,
@ -150,72 +57,91 @@ async function captureTerrainPatch(
maxLat: number, maxLat: number,
trackFrac: number, trackFrac: number,
): Promise<TerrainCaptureData> { ): Promise<TerrainCaptureData> {
// ── Satellite imagery (ESRI World Imagery, {z}/{y}/{x} order) ───────────── // ── Use Cesium's imagery provider (same tiles the viewer already shows) ───
// Use z19 max; find highest zoom where tile count ≤ 25 const provider = viewer.imageryLayers.get(0).imageryProvider
let level = 19 const tilingScheme = provider.tilingScheme
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 = lngLatToTile(minLon, minLat, level) const sw = tilingScheme.positionToTileXY(
const ne = lngLatToTile(maxLon, maxLat, level) Cesium.Cartographic.fromDegrees(minLon, minLat), level, new Cesium.Cartesian2(),
)
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 = lngLatToTile(minLon, minLat, level) const swTile = tilingScheme.positionToTileXY(
const neTile = lngLatToTile(maxLon, maxLat, level) Cesium.Cartographic.fromDegrees(minLon, minLat), level, new Cesium.Cartesian2(),
)!
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 // north tiles have smaller y const tileYMin = neTile.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 = 256 const TILE_PX = provider.tileWidth
// ESRI uses {z}/{y}/{x} // ── Fetch tiles via the provider ─────────────────────────────────────────
const satImages = await Promise.all( const tileImages = await Promise.all(
Array.from({ length: ny }, (_, tj) => Array.from({ length: ny }, (_, tj) =>
Array.from({ length: nx }, async (_, ti) => { Array.from({ length: nx }, (_, ti) => {
const x = tileXMin + ti const x = tileXMin + ti
const y = tileYMin + tj const y = tileYMin + tj
const url = `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${level}/${y}/${x}` const result = provider.requestImage(x, y, level)
try { const p: Promise<Cesium.ImageryTypes | undefined> =
const resp = await fetch(url) result instanceof Promise ? result : Promise.resolve(result ?? undefined)
if (!resp.ok) return { ti, tj, img: null } return p.then(img => ({ ti, tj, img: 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 satImages) { for (const { ti, tj, img } of tileImages) {
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)
} }
// Three.js ignores `flipY` for ImageBitmap; flip at creation time so UV v=0 (South row) const imageBitmap = await createImageBitmap(canvas)
// maps to the bottom of the satellite image (South), not top (North).
const imageBitmap = await createImageBitmap(canvas, { imageOrientation: 'flipY' })
// Geographic bbox from actual tile edges // ── Derive 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] = [
tileToLng(tileXMin, level), Cesium.Math.toDegrees(nwRect.west),
tileToLat(tileYMax + 1, level), Cesium.Math.toDegrees(seRect.south),
tileToLng(tileXMax + 1, level), Cesium.Math.toDegrees(seRect.east),
tileToLat(tileYMin, level), Cesium.Math.toDegrees(nwRect.north),
] ]
// ── Terrain elevation grid ──────────────────────────────────────────────── // ── Sample terrain heights on a 64×64 grid ───────────────────────────────
const GRID = 64 as const const GRID = 64
const elevations = await sampleElevationGrid(tileBbox, GRID) const cartographics: Cesium.Cartographic[] = []
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)
const terrainVertices: THREE.Vector3[] = elevations.map((elev, idx) => { // ── Convert to ENU Three.js vectors ──────────────────────────────────────
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, elev, origin) return geoToEnu(lon, lat, c.height ?? 0, origin)
}) })
return { tileBbox, imageBitmap, terrainVertices, gridSize: 64, origin, trackFrac } return { tileBbox, imageBitmap, terrainVertices, gridSize: 64, origin, trackFrac }
@ -224,6 +150,7 @@ 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
@ -232,11 +159,13 @@ 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++) {
@ -246,13 +175,14 @@ 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(
origin, viewer, origin,
lon - rLon, lon + rLon, lon - rLon, lon + rLon,
lat - rLat, lat + rLat, lat - rLat, lat + rLat,
frac, frac,
@ -261,10 +191,10 @@ async function captureAllPatches(
) )
} }
// ── Hook ───────────────────────────────────────────────────────────────────── // ── Hook ─────────────────────────────────────────────────────────────────────
export function useTerrainCapture( export function useTerrainCapture(
_map: Map, // kept for API compatibility; tile fetching is now direct viewer: Cesium.Viewer,
simResult: CoasterSimulationResult | null, simResult: CoasterSimulationResult | null,
) { ) {
const [status, setStatus] = useState<CaptureStatus>('idle') const [status, setStatus] = useState<CaptureStatus>('idle')
@ -277,13 +207,13 @@ export function useTerrainCapture(
if (!simResult) { if (!simResult) {
setStatus('idle') setStatus('idle')
return () => { abortRef.current = true } return
} }
abortRef.current = false abortRef.current = false
setStatus('loading') setStatus('loading')
captureAllPatches(simResult) captureAllPatches(viewer, simResult)
.then(patches => { .then(patches => {
if (abortRef.current) return if (abortRef.current) return
setCaptureData(patches) setCaptureData(patches)
@ -294,9 +224,7 @@ 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

@ -1,116 +0,0 @@
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

@ -1,279 +0,0 @@
/**
* 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

@ -1,162 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,36 +0,0 @@
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,40 +1,41 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import maplibregl from 'maplibre-gl' import * as Cesium from 'cesium'
import { useMapLibreMap } from '../maplibre/maplibreContext' import { useCesiumViewer } from '../cesium/cesiumContext'
import { useMapLibreCamera } from '../maplibre/useMapLibreCamera' import { useCesiumCamera } from '../cesium/useCesiumCamera'
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 map = useMapLibreMap() const viewer = useCesiumViewer()
useMapLibreCamera() useCesiumCamera()
const { bbox, cameraHeight, setLoadedSplats, setActiveSplatId } = useMapStore() const { bbox, cameraHeight, setLoadedSplats, setActiveSplatId } = useMapStore()
const markersRef = useRef<Map<string, maplibregl.Marker>>(new Map()) const entityMapRef = useRef<Map<string, Cesium.Entity>>(new Map())
const lastBboxRef = useRef<BBox | null>(null) const lastBboxRef = useRef<BBox | null>(null)
// Fetch and sync splat markers whenever the bbox changes meaningfully // Fetch and sync splat entities whenever the bbox changes meaningfully
useEffect(() => { useEffect(() => {
const bboxTooLarge = bbox && ( if (!bbox || cameraHeight > SPLAT_VISIBLE_HEIGHT) {
(bbox[2] - bbox[0]) > MAX_BBOX_DEGREES || (bbox[3] - bbox[1]) > MAX_BBOX_DEGREES // Clear all splat pins when zoomed out
) entityMapRef.current.forEach((e) => viewer.entities.remove(e))
if (!bbox || cameraHeight > SPLAT_VISIBLE_HEIGHT || bboxTooLarge) { entityMapRef.current.clear()
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[1] - last[1]), Math.abs(bbox[0] - last[0]),
Math.abs(bbox[2] - last[2]), Math.abs(bbox[3] - last[3]), Math.abs(bbox[1] - last[1]),
Math.abs(bbox[2] - last[2]),
Math.abs(bbox[3] - last[3]),
) )
if (delta < 0.01) return if (delta < 0.01) return
} }
@ -43,48 +44,75 @@ 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))
markersRef.current.forEach((marker, id) => { // Remove entities that are no longer in view
if (!incoming.has(id)) { marker.remove(); markersRef.current.delete(id) } entityMapRef.current.forEach((entity, 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 (markersRef.current.has(id)) return if (entityMapRef.current.has(id)) return
const [lon, lat] = feature.geometry.coordinates const [lon, lat] = feature.geometry.coordinates
const el = createSplatPinElement() const alt = feature.properties.altitude ?? 0
el.addEventListener('click', () => setActiveSplatId(id))
const marker = new maplibregl.Marker({ element: el, anchor: 'bottom' }) const entity = viewer.entities.add({
.setLngLat([lon, lat]) id: `splat-${id}`,
.addTo(map) position: Cesium.Cartesian3.fromDegrees(lon, lat, alt),
markersRef.current.set(id, marker) billboard: {
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
// Clean up on unmount // Wire up entity selection → activeSplatId in store
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 () => {
markersRef.current.forEach(m => m.remove()) if (viewer.isDestroyed()) return
markersRef.current.clear() entityMapRef.current.forEach((e) => viewer.entities.remove(e))
entityMapRef.current.clear()
} }
}, []) }, [viewer])
return null return null // no DOM — everything is imperative Cesium entities
} }
function createSplatPinElement(): HTMLElement { function createSplatPinSvg(): string {
const el = document.createElement('div') const svg = `
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 el return `data:image/svg+xml;base64,${btoa(svg)}`
} }

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 { useMapLibreMap } from '../maplibre/maplibreContext' import { useCesiumViewer } from '../cesium/cesiumContext'
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 '../maplibre/geoUtils' import { buildSplatWorldMatrix } from '../cesium/geoUtils'
import { fetchSplatDetail } from '../api/splats' import { fetchSplatDetail } from '../api/splats'
// Only render the splat when the camera is below this altitude (metres) // Only render the splat when the camera is below this altitude
const RENDER_HEIGHT = 500 const RENDER_HEIGHT = 500
export function SplatRenderer() { export function SplatRenderer() {
const map = useMapLibreMap() const viewer = useCesiumViewer()
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
const splatViewerRef = useRef<unknown>(null) const splatViewerRef = useRef<unknown>(null)
const cameraRef = useRef<THREE.PerspectiveCamera>(new THREE.PerspectiveCamera()) const camerRef = 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 overlay canvas dimensions in sync with the MapLibre canvas // Keep canvas dimensions in sync with Cesium canvas
useEffect(() => { useEffect(() => {
const mapCanvas = map.getCanvas() const cesiumCanvas = viewer.canvas
const overlayCanvas = canvasRef.current const overlayCanvas = canvasRef.current
if (!overlayCanvas) return if (!overlayCanvas) return
function syncSize() { function syncSize() {
overlayCanvas!.width = mapCanvas.width overlayCanvas!.width = cesiumCanvas.width
overlayCanvas!.height = mapCanvas.height overlayCanvas!.height = cesiumCanvas.height
cameraRef.current.aspect = mapCanvas.width / mapCanvas.height camerRef.current.aspect = cesiumCanvas.width / cesiumCanvas.height
cameraRef.current.updateProjectionMatrix() camerRef.current.updateProjectionMatrix()
} }
syncSize() syncSize()
const observer = new ResizeObserver(syncSize) const observer = new ResizeObserver(syncSize)
observer.observe(mapCanvas) observer.observe(cesiumCanvas)
return () => observer.disconnect() return () => observer.disconnect()
}, [map]) }, [viewer])
// Load / unload splat when activeSplatId changes // Load / unload splat when activeSplatId changes
useEffect(() => { useEffect(() => {
@ -52,6 +52,7 @@ 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)
@ -64,6 +65,7 @@ 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
@ -76,21 +78,26 @@ 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: cameraRef.current, camera: camerRef.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
const worldMatrix = buildSplatWorldMatrix(lon, lat, alt, heading) const worldMatrix = buildSplatWorldMatrix(lon, lat, alt, heading)
await gViewer.addSplatScene(url, { await gViewer.addSplatScene(url, {
progressiveLoad: true, progressiveLoad: true,
onProgress: () => { /* optional progress UI */ }, onProgress: () => { /* optional: update progress UI */ },
}) })
if (cancelled) { gViewer.dispose(); return } if (cancelled) {
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
@ -102,32 +109,34 @@ 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 MapLibre's 'render' event // Drive the splat render loop from Cesium's postRender event
useEffect(() => { useEffect(() => {
function onRender() { const remove = viewer.scene.postRender.addEventListener(() => {
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(map, cameraRef.current, canvas) syncSplatCamera(viewer, camerRef.current, canvas)
gViewer.update() gViewer.update()
gViewer.render() gViewer.render()
} })
return () => remove()
map.on('render', onRender) }, [viewer, cameraHeight])
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')
@ -135,6 +144,7 @@ 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,78 +1,41 @@
import type { Map } from 'maplibre-gl' import * as Cesium from 'cesium'
import * as THREE from 'three' import * as THREE from 'three'
import { lngLatAltToECEF } from '../maplibre/geoUtils'
/** /**
* Synchronise a Three.js PerspectiveCamera to the current MapLibre camera. * Synchronise a Three.js PerspectiveCamera to the current Cesium camera.
* *
* MapLibre camera state (center, pitch, bearing, altitude) is converted to * Both cameras work in ECEF space (metres from Earth centre).
* an ECEF position + orientation that matches the splat scene, which is also * The splat scene's Object3D has its matrixWorld set to the ECEF transform
* positioned in ECEF via buildSplatWorldMatrix. * of the capture point (see geoUtils.buildSplatWorldMatrix), so the camera
* and the scene are in the same coordinate space.
* *
* Call this inside a MapLibre 'render' or requestAnimationFrame listener, * Call this inside a Cesium scene.postRender listener, before splatViewer.render().
* before splatViewer.render().
*/ */
export function syncSplatCamera( export function syncSplatCamera(
map: Map, cesiumViewer: Cesium.Viewer,
threeCamera: THREE.PerspectiveCamera, threeCamera: THREE.PerspectiveCamera,
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
) { ) {
const center = map.getCenter() const cam = cesiumViewer.camera
const lon0 = center.lng
const lat0 = center.lat
const lon0r = lon0 * Math.PI / 180
const lat0r = lat0 * Math.PI / 180
const pitchRad = map.getPitch() * Math.PI / 180 // Position (ECEF, metres)
const bearingRad = map.getBearing() * Math.PI / 180 const pos = cam.positionWC
threeCamera.position.set(pos.x, pos.y, pos.z)
// Camera-to-ground distance in metres // LookAt target: position + direction
const t = (map as { transform?: { cameraToCenterDistance?: number; pixelsPerMeter?: number } }).transform const dir = cam.directionWC
const D = (t?.cameraToCenterDistance && t?.pixelsPerMeter) const target = new THREE.Vector3(pos.x + dir.x, pos.y + dir.y, pos.z + dir.z)
? t.cameraToCenterDistance / t.pixelsPerMeter
: (40075016.7 * Math.cos(lat0r)) / Math.pow(2, map.getZoom() + 8) * 600
// Ground altitude at center from terrain (0 if not available) // Up vector
const groundAlt = map.queryTerrainElevation(center) ?? 0 const up = cam.upWC
threeCamera.up.set(up.x, up.y, up.z)
threeCamera.lookAt(target)
// ENU basis vectors in ECEF at the center point // FOV and aspect
const east = new THREE.Vector3(-Math.sin(lon0r), Math.cos(lon0r), 0) const frustum = cam.frustum as Cesium.PerspectiveFrustum
const north = new THREE.Vector3( if (frustum.fovy != null) {
-Math.sin(lat0r) * Math.cos(lon0r), threeCamera.fov = Cesium.Math.toDegrees(frustum.fovy)
-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

@ -1,50 +0,0 @@
.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;
}

View File

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

View File

@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import { useMapLibreMap } from '../maplibre/maplibreContext' import * as Cesium from 'cesium'
import { useCesiumViewer } from '../cesium/cesiumContext'
import styles from './SearchBar.module.css' import styles from './SearchBar.module.css'
interface NominatimResult { interface NominatimResult {
@ -11,7 +12,7 @@ interface NominatimResult {
} }
export function SearchBar() { export function SearchBar() {
const map = useMapLibreMap() const viewer = useCesiumViewer()
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)
@ -56,7 +57,11 @@ 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)
map.fitBounds([[minLon, minLat], [maxLon, maxLat]], { duration: 1500, padding: 40 }) const rect = Cesium.Rectangle.fromDegrees(minLon, minLat, maxLon, maxLat)
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,10 +1,21 @@
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()], plugins: [react(), cesium()],
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',
@ -30,7 +41,8 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: { manualChunks: {
maplibre: ['maplibre-gl'], // cesium is handled by vite-plugin-cesium (served from /Cesium/ public path)
// and must NOT appear here.
splat: ['@mkkellogg/gaussian-splats-3d', 'three'], splat: ['@mkkellogg/gaussian-splats-3d', 'three'],
}, },
}, },
@ -40,6 +52,8 @@ 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,