rcnn/web/src/challenges/usePolygonDraw.ts
2026-04-21 16:33:15 +02:00

281 lines
9.4 KiB
TypeScript

import { useEffect, useRef } from 'react'
import * as Cesium from 'cesium'
import { useCesiumViewer } from '../cesium/cesiumContext'
import { useChallengeStore } from '../store/challengeStore'
function vertsToGeoJson(verts: Cesium.Cartesian3[]): GeoJSON.Polygon {
const coords: [number, number][] = verts.map((v) => {
const c = Cesium.Cartographic.fromCartesian(v)
return [Cesium.Math.toDegrees(c.longitude), Cesium.Math.toDegrees(c.latitude)]
})
return { type: 'Polygon', coordinates: [[...coords, coords[0]]] }
}
function geoJsonToVerts(polygon: GeoJSON.Polygon): Cesium.Cartesian3[] {
const ring = polygon.coordinates[0]
// Drop the closing duplicate point
return ring.slice(0, -1).map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat))
}
function pickGlobe(
viewer: Cesium.Viewer,
windowPos: Cesium.Cartesian2,
): Cesium.Cartesian3 | null {
const ray = viewer.camera.getPickRay(windowPos)
if (!ray) return null
return viewer.scene.globe.pick(ray, viewer.scene) ?? null
}
/**
* Manages two phases of polygon interaction:
*
* Drawing (drawingMode=true)
* LEFT_CLICK → place vertex
* MOUSE_MOVE → rubber-band line from last vertex to cursor
* RIGHT_CLICK → close polygon (≥3 verts), enter edit phase
*
* Editing (drawingMode=false, draftPolygon set)
* 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() {
const viewer = useCesiumViewer()
const { drawingMode, setDrawingMode, setDraftPolygon, draftPolygon } =
useChallengeStore()
// Persists vertex positions across edit-phase effect re-runs that are
// triggered by setDraftPolygon being called after each drag.
const editVertsRef = useRef<Cesium.Cartesian3[]>([])
// ── Drawing phase ──────────────────────────────────────────────────────────
useEffect(() => {
if (!drawingMode) return
const verts: Cesium.Cartesian3[] = []
const vertPointEntities: Cesium.Entity[] = []
let outlineEntity: Cesium.Entity | null = null
let rubberBandEntity: Cesium.Entity | null = null
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() {
if (outlineEntity) {
viewer.entities.remove(outlineEntity)
outlineEntity = null
}
if (verts.length < 2) return
outlineEntity = viewer.entities.add({
polyline: {
positions: [...verts, verts[0]],
width: 2,
material: new Cesium.ColorMaterialProperty(
Cesium.Color.YELLOW.withAlpha(0.9),
),
clampToGround: true,
},
})
}
function refreshRubberBand(mousePos: Cesium.Cartesian3) {
if (rubberBandEntity) {
viewer.entities.remove(rubberBandEntity)
rubberBandEntity = null
}
if (verts.length === 0) return
rubberBandEntity = viewer.entities.add({
polyline: {
positions: [verts[verts.length - 1], mousePos],
width: 1.5,
material: new Cesium.ColorMaterialProperty(
Cesium.Color.WHITE.withAlpha(0.5),
),
clampToGround: true,
},
})
}
handler.setInputAction((e: { endPosition: Cesium.Cartesian2 }) => {
const pos = pickGlobe(viewer, e.endPosition)
if (pos) refreshRubberBand(pos)
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
handler.setInputAction((e: { position: Cesium.Cartesian2 }) => {
const pos = pickGlobe(viewer, e.position)
if (!pos) return
verts.push(pos.clone())
vertPointEntities.push(
viewer.entities.add({
position: pos,
point: {
pixelSize: 8,
color: Cesium.Color.YELLOW,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 1,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
}),
)
refreshOutline()
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
handler.setInputAction(() => {
if (verts.length < 3) return
const polygon = vertsToGeoJson(verts)
cleanup()
setDraftPolygon(polygon)
setDrawingMode(false)
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
function cleanup() {
if (viewer.isDestroyed()) return
vertPointEntities.forEach((e) => viewer.entities.remove(e))
vertPointEntities.length = 0
if (outlineEntity) {
viewer.entities.remove(outlineEntity)
outlineEntity = null
}
if (rubberBandEntity) {
viewer.entities.remove(rubberBandEntity)
rubberBandEntity = null
}
}
return () => {
handler.destroy()
canvas.removeEventListener('contextmenu', suppressContextMenu)
cleanup()
}
}, [drawingMode, viewer, setDrawingMode, setDraftPolygon])
// ── Edit phase ─────────────────────────────────────────────────────────────
useEffect(() => {
if (drawingMode || !draftPolygon) {
editVertsRef.current = []
return
}
// 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) {
editVertsRef.current = geoJsonToVerts(draftPolygon)
}
const verts = editVertsRef.current
let draggingIndex = -1
const entities: Cesium.Entity[] = []
const canvas = viewer.scene.canvas
const suppressContextMenu = (e: MouseEvent) => e.preventDefault()
canvas.addEventListener('contextmenu', suppressContextMenu)
// 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,
},
})
entities.push(e)
return e
})
const handler = new Cesium.ScreenSpaceEventHandler(canvas)
handler.setInputAction((e: { endPosition: Cesium.Cartesian2 }) => {
if (draggingIndex !== -1) {
// Update dragged vertex
const pos = pickGlobe(viewer, e.endPosition)
if (pos) verts[draggingIndex] = pos
return
}
// Cursor feedback
const picked = viewer.scene.pick(e.endPosition)
const overVertex =
picked?.id instanceof Cesium.Entity &&
vertEntities.includes(picked.id)
canvas.style.cursor = overVertex ? 'grab' : 'default'
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
handler.setInputAction((e: { position: Cesium.Cartesian2 }) => {
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
draggingIndex = -1
canvas.style.cursor = 'default'
viewer.scene.screenSpaceCameraController.enableRotate = true
viewer.scene.screenSpaceCameraController.enableTranslate = true
// Sync updated geometry back to the store for the submit form.
setDraftPolygon(vertsToGeoJson(verts))
}, Cesium.ScreenSpaceEventType.LEFT_UP)
function cleanup() {
if (viewer.isDestroyed()) return
entities.forEach((e) => viewer.entities.remove(e))
entities.length = 0
canvas.style.cursor = 'default'
viewer.scene.screenSpaceCameraController.enableRotate = true
viewer.scene.screenSpaceCameraController.enableTranslate = true
}
return () => {
handler.destroy()
canvas.removeEventListener('contextmenu', suppressContextMenu)
cleanup()
}
}, [drawingMode, draftPolygon, viewer, setDraftPolygon])
}