import { useState, useEffect, useRef, useCallback } from 'react' import * as Cesium from 'cesium' import type { AnchorPoint } from './bezierUtils' import { effectivePosition, samplePath, computeRails } from './bezierUtils' export type EditorMode = 'add' | 'select' | 'strip' export interface CoasterPathHandle { anchors: AnchorPoint[] pathPts: Cesium.Cartesian3[] selectedId: string | null mode: EditorMode setMode: (m: EditorMode) => void updateAnchorHeight: (id: string, delta: number) => void removeAnchor: (id: string) => void loadAnchors: (anchors: AnchorPoint[]) => void undoLast: () => void clearAll: () => void } let _counter = 0 function genId(): string { return `a${++_counter}-${Math.random().toString(36).slice(2, 6)}` } export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAnchors = true): CoasterPathHandle { const [anchors, setAnchors] = useState([]) const [pathPts, setPathPts] = useState([]) const [selectedId, setSelectedId] = useState(null) const [mode, setMode] = useState('add') // Keep refs in sync so event-handler closures always see current values const anchorsRef = useRef(anchors); anchorsRef.current = anchors const modeRef = useRef(mode); modeRef.current = mode const selectedRef = useRef(selectedId); selectedRef.current = selectedId // Cesium entity refs const sphereMapRef = useRef>(new Map()) const pathEntities = useRef([]) const startLabelRef = useRef(null) // Drag state (refs to avoid triggering re-renders) const isDragging = useRef(false) const dragAnchorId = useRef(null) const dragPos = useRef(null) const didMoveDuringDrag = useRef(false) // ── public callbacks ─────────────────────────────────────────────────────── const updateAnchorHeight = useCallback((id: string, delta: number) => { setAnchors(prev => prev.map(a => a.id === id ? { ...a, heightOffset: Math.max(0, a.heightOffset + delta) } : a)) }, []) const removeAnchor = useCallback((id: string) => { setAnchors(prev => prev.filter(a => a.id !== id)) setSelectedId(prev => prev === id ? null : prev) }, []) const loadAnchors = useCallback((newAnchors: AnchorPoint[]) => { setAnchors(newAnchors) setSelectedId(null) }, []) const undoLast = useCallback(() => { setAnchors(prev => { if (prev.length === 0) return prev const removed = prev[prev.length - 1] setSelectedId(s => s === removed.id ? null : s) return prev.slice(0, -1) }) }, []) const clearAll = useCallback(() => { setAnchors([]) setSelectedId(null) }, []) // ── cursor style ─────────────────────────────────────────────────────────── useEffect(() => { viewer.scene.canvas.style.cursor = mode === 'add' ? 'crosshair' : mode === 'strip' ? 'cell' : 'default' }, [mode, viewer]) // ── entity sync ──────────────────────────────────────────────────────────── useEffect(() => { const existingIds = new Set(anchors.map(a => a.id)) // Remove spheres for deleted anchors sphereMapRef.current.forEach((entity, id) => { if (!existingIds.has(id)) { viewer.entities.remove(entity) sphereMapRef.current.delete(id) } }) // Add or update anchor spheres anchors.forEach((anchor) => { const pos = effectivePosition(anchor) const isSelected = anchor.id === selectedId const color = isSelected ? Cesium.Color.fromCssColorString('#f59e0b') : Cesium.Color.WHITE const size = isSelected ? 15 : 10 if (!sphereMapRef.current.has(anchor.id)) { const entity = viewer.entities.add({ id: `coaster-anchor-${anchor.id}`, position: new Cesium.ConstantPositionProperty(pos), point: { pixelSize: size, color, outlineColor: Cesium.Color.BLACK.withAlpha(0.6), outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY, }, properties: { anchorId: anchor.id }, }) 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) { const pts = samplePath(anchors) setPathPts(pts) const { left, right } = computeRails(pts) const centre = viewer.entities.add({ polyline: { positions: pts, width: 2, material: Cesium.Color.YELLOW.withAlpha(0.55), arcType: Cesium.ArcType.NONE, }, }) const leftRail = viewer.entities.add({ polyline: { positions: left, width: 3, material: Cesium.Color.fromCssColorString('#b0b8c1'), arcType: Cesium.ArcType.NONE, }, }) 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 { setPathPts([]) } // Start label — always at first anchor if (startLabelRef.current) { viewer.entities.remove(startLabelRef.current) startLabelRef.current = null } 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]) // ── path visibility toggle ───────────────────────────────────────────────── useEffect(() => { pathEntities.current.forEach(e => { e.show = showPath }) }, [showPath]) // ── anchor visibility toggle ─────────────────────────────────────────────── useEffect(() => { sphereMapRef.current.forEach(e => { e.show = showAnchors }) 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) const disableCam = () => { 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 const anchorId = pickAnchorId(e.position) if (!anchorId) return isDragging.current = true dragAnchorId.current = anchorId dragPos.current = null didMoveDuringDrag.current = false disableCam() }, Cesium.ScreenSpaceEventType.LEFT_DOWN) // MOUSE_MOVE – live-drag the sphere handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.MotionEvent) => { if (!isDragging.current || !dragAnchorId.current) return const newPos = pickTerrain(e.endPosition) if (!newPos) return didMoveDuringDrag.current = true dragPos.current = newPos // Move the sphere entity directly (no React re-render during drag) const entity = sphereMapRef.current.get(dragAnchorId.current) if (entity) entity.position = new Cesium.ConstantPositionProperty(newPos) }, Cesium.ScreenSpaceEventType.MOUSE_MOVE) // LEFT_UP – commit drag handler.setInputAction(() => { if (isDragging.current) { if (didMoveDuringDrag.current && dragAnchorId.current && dragPos.current) { const id = dragAnchorId.current const pos = dragPos.current setAnchors(prev => prev.map(a => a.id === id ? { ...a, position: pos } : a)) } isDragging.current = false 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 } const anchorId = pickAnchorId(e.position) if (anchorId) { setSelectedId(prev => prev === anchorId ? null : anchorId) return } if (modeRef.current === 'add') { const pos = pickTerrain(e.position) if (!pos) return const id = genId() setAnchors(prev => [...prev, { id, position: pos, heightOffset: 0 }]) setSelectedId(null) } else if (modeRef.current === 'select') { setSelectedId(null) } // 'strip' mode clicks are handled by useAccelerationStrips }, Cesium.ScreenSpaceEventType.LEFT_CLICK) // RIGHT_CLICK – undo / remove selected handler.setInputAction(() => { if (selectedRef.current) { const id = selectedRef.current setAnchors(prev => prev.filter(a => a.id !== id)) setSelectedId(null) } else { setAnchors(prev => prev.slice(0, -1)) } }, Cesium.ScreenSpaceEventType.RIGHT_CLICK) return () => { if (!handler.isDestroyed()) handler.destroy() canvas.removeEventListener('contextmenu', suppressCtx) if (!viewer.isDestroyed()) { enableCam() viewer.scene.canvas.style.cursor = 'default' } } }, [viewer]) // eslint-disable-line react-hooks/exhaustive-deps // ── cleanup on unmount ───────────────────────────────────────────────────── 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 } }