341 lines
13 KiB
TypeScript
341 lines
13 KiB
TypeScript
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<AnchorPoint[]>([])
|
||
const [pathPts, setPathPts] = useState<Cesium.Cartesian3[]>([])
|
||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||
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 modeRef = useRef(mode); modeRef.current = mode
|
||
const selectedRef = useRef(selectedId); selectedRef.current = selectedId
|
||
|
||
// 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 dragAnchorId = useRef<string | null>(null)
|
||
const dragPos = useRef<Cesium.Cartesian3 | null>(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 }
|
||
}
|