fix polygon editor edition + events propagation

This commit is contained in:
wass08
2026-02-09 13:38:12 +09:00
parent f2c1da1655
commit bfe685b011
8 changed files with 369 additions and 264 deletions
+132
View File
@@ -0,0 +1,132 @@
'use client'
import { sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { useMemo, useRef, useState } from 'react'
import { MathUtils, type Mesh } from 'three'
import { color, float, fract, fwidth, mix, positionLocal } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useGridEvents } from '@/hooks/use-grid-events'
export const Grid = ({
cellSize = 0.5,
cellThickness = 0.5,
cellColor = '#888888',
sectionSize = 1,
sectionThickness = 1,
sectionColor = '#000000',
fadeDistance = 100,
fadeStrength = 1,
}: {
cellSize?: number
cellThickness?: number
cellColor?: string
sectionSize?: number
sectionThickness?: number
sectionColor?: string
fadeDistance?: number
fadeStrength?: number
}) => {
const material = useMemo(() => {
// Use xy since plane geometry is in XY space (before rotation)
const pos = positionLocal.xy
// Grid line function using fwidth for anti-aliasing
// Returns 1 on grid lines, 0 elsewhere
const getGrid = (size: number, thickness: number) => {
const r = pos.div(size)
const fw = fwidth(r)
// Distance to nearest grid line for each axis
const grid = fract(r.sub(0.5)).sub(0.5).abs()
// Anti-aliased step: divide by fwidth and clamp
const lineX = float(1).sub(
grid.x
.div(fw.x)
.add(1 - thickness)
.min(1),
)
const lineY = float(1).sub(
grid.y
.div(fw.y)
.add(1 - thickness)
.min(1),
)
// Combine both axes - max gives us lines in both directions
return lineX.max(lineY)
}
const g1 = getGrid(cellSize, cellThickness)
const g2 = getGrid(sectionSize, sectionThickness)
// Distance fade from center
const dist = pos.length()
const fade = float(1).sub(dist.div(fadeDistance).min(1)).pow(fadeStrength)
// Mix colors based on section grid
const gridColor = mix(
color(cellColor),
color(sectionColor),
float(sectionThickness).mul(g2).min(1),
)
// Combined alpha
const alpha = g1.add(g2).mul(fade)
const finalAlpha = mix(alpha.mul(0.75), alpha, g2)
return new MeshBasicNodeMaterial({
transparent: true,
colorNode: gridColor,
opacityNode: finalAlpha,
depthWrite: false,
})
}, [
cellSize,
cellThickness,
cellColor,
sectionSize,
sectionThickness,
sectionColor,
fadeDistance,
fadeStrength,
])
const gridRef = useRef<Mesh>(null!)
const [gridY, setGridY] = useState(0)
// Use custom raycasting for grid events (independent of mesh events)
useGridEvents(gridY)
useFrame((_, delta) => {
const currentLevelId = useViewer.getState().selection.levelId
let targetY = 0
if (currentLevelId) {
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
if (levelMesh) {
targetY = levelMesh.position.y
} else {
// Fallback: compute from level node data when mesh isn't registered yet
const levelNode = useScene.getState().nodes[currentLevelId]
if (levelNode && 'level' in levelNode) {
const levelMode = useViewer.getState().levelMode
const LEVEL_HEIGHT = 2.5
const EXPLODED_GAP = 5
targetY =
((levelNode as any).level || 0) *
(LEVEL_HEIGHT + (levelMode === 'exploded' ? EXPLODED_GAP : 0))
}
}
}
const newY = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)
gridRef.current.position.y = newY
setGridY(newY)
})
return (
<mesh rotation-x={-Math.PI / 2} material={material} ref={gridRef}>
<planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
</mesh>
)
}
+3 -127
View File
@@ -1,19 +1,7 @@
'use client'
import {
initSpaceDetectionSync,
initSpatialGridSync,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useGridEvents, useViewer, Viewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { useMemo, useRef } from 'react'
import { MathUtils, type Mesh } from 'three'
import { color, float, fract, fwidth, mix, positionLocal } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
import { Viewer } from '@pascal-app/viewer'
import { useKeyboard } from '@/hooks/use-keyboard'
import useEditor from '@/store/use-editor'
import { ZoneSystem } from '../systems/zone/zone-system'
@@ -24,6 +12,7 @@ import { SidebarProvider } from '../ui/primitives/sidebar'
import { AppSidebar } from '../ui/sidebar/app-sidebar'
import { CustomCameraControls } from './custom-camera-controls'
import { ExportManager } from './export-manager'
import { Grid } from './grid'
import { SelectionManager } from './selection-manager'
useScene.getState().loadScene()
@@ -55,116 +44,3 @@ export default function Editor() {
</div>
)
}
const Grid = ({
cellSize = 0.5,
cellThickness = 0.5,
cellColor = '#888888',
sectionSize = 1,
sectionThickness = 1,
sectionColor = '#000000',
fadeDistance = 100,
fadeStrength = 1,
}: {
cellSize?: number
cellThickness?: number
cellColor?: string
sectionSize?: number
sectionThickness?: number
sectionColor?: string
fadeDistance?: number
fadeStrength?: number
}) => {
const material = useMemo(() => {
// Use xy since plane geometry is in XY space (before rotation)
const pos = positionLocal.xy
// Grid line function using fwidth for anti-aliasing
// Returns 1 on grid lines, 0 elsewhere
const getGrid = (size: number, thickness: number) => {
const r = pos.div(size)
const fw = fwidth(r)
// Distance to nearest grid line for each axis
const grid = fract(r.sub(0.5)).sub(0.5).abs()
// Anti-aliased step: divide by fwidth and clamp
const lineX = float(1).sub(
grid.x
.div(fw.x)
.add(1 - thickness)
.min(1),
)
const lineY = float(1).sub(
grid.y
.div(fw.y)
.add(1 - thickness)
.min(1),
)
// Combine both axes - max gives us lines in both directions
return lineX.max(lineY)
}
const g1 = getGrid(cellSize, cellThickness)
const g2 = getGrid(sectionSize, sectionThickness)
// Distance fade from center
const dist = pos.length()
const fade = float(1).sub(dist.div(fadeDistance).min(1)).pow(fadeStrength)
// Mix colors based on section grid
const gridColor = mix(
color(cellColor),
color(sectionColor),
float(sectionThickness).mul(g2).min(1),
)
// Combined alpha
const alpha = g1.add(g2).mul(fade)
const finalAlpha = mix(alpha.mul(0.75), alpha, g2)
return new MeshBasicNodeMaterial({
transparent: true,
colorNode: gridColor,
opacityNode: finalAlpha,
depthWrite: false,
})
}, [
cellSize,
cellThickness,
cellColor,
sectionSize,
sectionThickness,
sectionColor,
fadeDistance,
fadeStrength,
])
const handlers = useGridEvents()
const gridRef = useRef<Mesh>(null!)
useFrame((_, delta) => {
const currentLevelId = useViewer.getState().selection.levelId
let targetY = 0
if (currentLevelId) {
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
if (levelMesh) {
targetY = levelMesh.position.y
} else {
// Fallback: compute from level node data when mesh isn't registered yet
const levelNode = useScene.getState().nodes[currentLevelId]
if (levelNode && 'level' in levelNode) {
const levelMode = useViewer.getState().levelMode
const LEVEL_HEIGHT = 2.5
const EXPLODED_GAP = 5
targetY = ((levelNode as any).level || 0) * (LEVEL_HEIGHT + (levelMode === 'exploded' ? EXPLODED_GAP : 0))
}
}
}
gridRef.current.position.y = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)
})
return (
<mesh rotation-x={-Math.PI / 2} material={material} {...handlers} ref={gridRef}>
<planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
</mesh>
)
}
@@ -0,0 +1,42 @@
import { resolveLevelId, type CeilingNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface CeilingBoundaryEditorProps {
ceilingId: CeilingNode['id']
}
/**
* Ceiling boundary editor - allows editing ceiling polygon vertices for a specific ceiling
* Uses the generic PolygonEditor component
*/
export const CeilingBoundaryEditor: React.FC<CeilingBoundaryEditorProps> = ({ ceilingId }) => {
const ceilingNode = useScene((state) => state.nodes[ceilingId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
updateNode(ceilingId, { polygon: newPolygon })
// Re-assert selection so the ceiling stays selected after the edit
setSelection({ selectedIds: [ceilingId] })
},
[ceilingId, updateNode, setSelection],
)
if (!ceiling || !ceiling.polygon || ceiling.polygon.length < 3) return null
return (
<PolygonEditor
polygon={ceiling.polygon}
color="#d4d4d4"
onPolygonChange={handlePolygonChange}
minVertices={3}
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
surfaceHeight={ceiling.height ?? 2.5}
/>
)
}
@@ -1,15 +1,7 @@
import { createPortal, useThree } from '@react-three/fiber'
import { sceneRegistry } from '@pascal-app/core'
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
import { createPortal } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
BufferGeometry,
Float32BufferAttribute,
type Mesh,
Plane,
Raycaster,
Vector2,
Vector3,
} from 'three'
import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
const Y_OFFSET = 0.02
@@ -45,8 +37,6 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
levelId,
surfaceHeight = 0,
}) => {
const { gl, camera } = useThree()
// Get level node from registry if levelId is provided
const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null
@@ -59,11 +49,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
// Refs for raycasting during drag
const dragPlane = useRef(new Plane(new Vector3(0, 1, 0), -editY))
dragPlane.current.constant = -editY
const raycaster = useRef(new Raycaster())
const lineRef = useRef<Mesh>(null!)
// Track the last polygon prop to detect external changes (undo/redo)
@@ -88,30 +75,15 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
})
}, [displayPolygon])
// Handle vertex drag
// Update vertex position using grid cursor position
const handleVertexDrag = useCallback(
(clientX: number, clientY: number, vertexIndex: number) => {
const canvas = gl.domElement
const rect = canvas.getBoundingClientRect()
const x = ((clientX - rect.left) / rect.width) * 2 - 1
const y = -((clientY - rect.top) / rect.height) * 2 + 1
raycaster.current.setFromCamera(new Vector2(x, y), camera)
const intersection = new Vector3()
raycaster.current.ray.intersectPlane(dragPlane.current, intersection)
if (intersection) {
// Snap to 0.5 grid
const gridX = Math.round(intersection.x * 2) / 2
const gridZ = Math.round(intersection.z * 2) / 2
const basePolygon = previewPolygon ?? polygon
const newPolygon = [...basePolygon]
newPolygon[vertexIndex] = [gridX, gridZ]
setPreviewPolygon(newPolygon)
}
(vertexIndex: number) => {
const basePolygon = previewPolygon ?? polygon
const newPolygon = [...basePolygon]
newPolygon[vertexIndex] = cursorPosition
setPreviewPolygon(newPolygon)
},
[gl, camera, previewPolygon, polygon],
[cursorPosition, previewPolygon, polygon],
)
// Commit polygon changes
@@ -152,58 +124,58 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
[polygon, previewPolygon, onPolygonChange, minVertices],
)
// Set up pointer move/up listeners for dragging with pointer capture
// Listen to grid:move events to track cursor position
useEffect(() => {
const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
setCursorPosition([gridX, gridZ])
// Update vertex position during drag
if (dragState?.isDragging) {
handleVertexDrag(dragState.vertexIndex)
}
}
emitter.on('grid:move', onGridMove)
return () => {
emitter.off('grid:move', onGridMove)
}
}, [dragState, handleVertexDrag])
// Set up pointer up listener for ending drag
useEffect(() => {
if (!dragState?.isDragging) return
const canvas = gl.domElement
const pointerId = dragState.pointerId
// Capture pointer to prevent R3F events from firing on other objects (like the grid)
canvas.setPointerCapture(pointerId)
const handlePointerMove = (e: PointerEvent) => {
handleVertexDrag(e.clientX, e.clientY, dragState.vertexIndex)
}
const handlePointerUp = (e: PointerEvent) => {
// Stop the event from reaching R3F's handlers, which would otherwise
// fire a grid:click and deselect the node being edited.
// Only handle the specific pointer that started the drag
if (e.pointerId !== dragState.pointerId) return
// Stop the event from propagating to prevent grid click
e.stopImmediatePropagation()
e.preventDefault()
// Release pointer capture
if (canvas.hasPointerCapture(e.pointerId)) {
canvas.releasePointerCapture(e.pointerId)
}
// Suppress the follow-up click event that browsers fire after pointerup
const suppressClick = (ce: MouseEvent) => {
ce.stopImmediatePropagation()
ce.preventDefault()
canvas.removeEventListener('click', suppressClick, true)
window.removeEventListener('click', suppressClick, true)
}
canvas.addEventListener('click', suppressClick, true)
window.addEventListener('click', suppressClick, true)
// Safety cleanup in case no click fires
requestAnimationFrame(() => {
canvas.removeEventListener('click', suppressClick, true)
window.removeEventListener('click', suppressClick, true)
})
commitPolygonChange()
}
canvas.addEventListener('pointermove', handlePointerMove)
canvas.addEventListener('pointerup', handlePointerUp, true)
window.addEventListener('pointerup', handlePointerUp, true)
return () => {
// Release capture on cleanup
if (canvas.hasPointerCapture(pointerId)) {
canvas.releasePointerCapture(pointerId)
}
canvas.removeEventListener('pointermove', handlePointerMove)
canvas.removeEventListener('pointerup', handlePointerUp, true)
window.removeEventListener('pointerup', handlePointerUp, true)
}
}, [dragState, gl, handleVertexDrag, commitPolygonChange])
}, [dragState, commitPolygonChange])
// Update line geometry when polygon changes
useEffect(() => {
@@ -232,7 +204,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
<group>
{/* Border line */}
{/* @ts-ignore */}
<line ref={lineRef} frustumCulled={false} renderOrder={10}>
<line ref={lineRef} frustumCulled={false} renderOrder={10} raycast={() => {}}>
<bufferGeometry />
<lineBasicNodeMaterial
color={color}
+50 -31
View File
@@ -1,20 +1,21 @@
import useEditor, { type Phase, type Tool } from "@/store/use-editor";
import { useScene, type AnyNodeId, type SlabNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { CeilingTool } from "./ceiling/ceiling-tool";
import { ItemTool } from "./item/item-tool";
import { MoveTool } from "./item/move-tool";
import { RoofTool } from "./roof/roof-tool";
import { SiteBoundaryEditor } from "./site/site-boundary-editor";
import { SlabBoundaryEditor } from "./slab/slab-boundary-editor";
import { SlabTool } from "./slab/slab-tool";
import { WallTool } from "./wall/wall-tool";
import { ZoneBoundaryEditor } from "./zone/zone-boundary-editor";
import { ZoneTool } from "./zone/zone-tool";
import { type AnyNodeId, type CeilingNode, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import useEditor, { type Phase, type Tool } from '@/store/use-editor'
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
import { CeilingTool } from './ceiling/ceiling-tool'
import { ItemTool } from './item/item-tool'
import { MoveTool } from './item/move-tool'
import { RoofTool } from './roof/roof-tool'
import { SiteBoundaryEditor } from './site/site-boundary-editor'
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
import { SlabTool } from './slab/slab-tool'
import { WallTool } from './wall/wall-tool'
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
import { ZoneTool } from './zone/zone-tool'
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
site: {
"property-line": SiteBoundaryEditor,
'property-line': SiteBoundaryEditor,
},
structure: {
wall: WallTool,
@@ -27,44 +28,62 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
furnish: {
item: ItemTool,
},
};
}
export const ToolManager: React.FC = () => {
const phase = useEditor((state) => state.phase);
const mode = useEditor((state) => state.mode);
const tool = useEditor((state) => state.tool);
const movingNode = useEditor((state) => state.movingNode);
const selectedZoneId = useViewer((state) => state.selection.zoneId);
const selectedIds = useViewer((state) => state.selection.selectedIds);
const nodes = useScene((state) => state.nodes);
const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
const movingNode = useEditor((state) => state.movingNode)
const selectedZoneId = useViewer((state) => state.selection.zoneId)
const selectedIds = useViewer((state) => state.selection.selectedIds)
const nodes = useScene((state) => state.nodes)
// Check if a slab is selected
const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === "slab") as SlabNode['id'] | undefined;
const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as
| SlabNode['id']
| undefined
// Check if a ceiling is selected
const selectedCeilingId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'ceiling') as
| CeilingNode['id']
| undefined
// Show site boundary editor when in site phase and edit mode
const showSiteBoundaryEditor = phase === "site" && mode === "edit";
const showSiteBoundaryEditor = phase === 'site' && mode === 'edit'
// Show slab boundary editor when in structure/select mode with a slab selected
const showSlabBoundaryEditor =
phase === "structure" && mode === "select" && selectedSlabId !== undefined;
phase === 'structure' && mode === 'select' && selectedSlabId !== undefined
// Show ceiling boundary editor when in structure/select mode with a ceiling selected
const showCeilingBoundaryEditor =
phase === 'structure' && mode === 'select' && selectedCeilingId !== undefined
// Show zone boundary editor when in structure/select mode with a zone selected
// Hide when editing a slab to avoid overlapping handles
// Hide when editing a slab or ceiling to avoid overlapping handles
const showZoneBoundaryEditor =
phase === "structure" && mode === "select" && selectedZoneId !== null && !showSlabBoundaryEditor;
phase === 'structure' &&
mode === 'select' &&
selectedZoneId !== null &&
!showSlabBoundaryEditor &&
!showCeilingBoundaryEditor
// Show build tools when in build mode
const showBuildTool = mode === "build" && tool !== null;
const showBuildTool = mode === 'build' && tool !== null
const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null;
const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
return (
<>
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
{showCeilingBoundaryEditor && selectedCeilingId && (
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
)}
{movingNode && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />}
</>
);
};
)
}
+99
View File
@@ -0,0 +1,99 @@
import { type EventSuffix, emitter, type GridEvent } from '@pascal-app/core'
import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import { Plane, Raycaster, Vector2, Vector3 } from 'three'
/**
* Custom grid events hook that uses manual raycasting instead of mesh events.
* This ensures grid events work even when other meshes block pointer events with stopPropagation.
*/
export function useGridEvents(gridY: number) {
const { camera, gl } = useThree()
const raycaster = useRef(new Raycaster())
const pointer = useRef(new Vector2())
const groundPlane = useRef(new Plane(new Vector3(0, 1, 0), 0))
const intersectionPoint = useRef(new Vector3())
// Update ground plane when grid Y changes
useEffect(() => {
groundPlane.current.constant = -gridY
}, [gridY])
useEffect(() => {
const canvas = gl.domElement
const getIntersection = (nativeEvent: MouseEvent | PointerEvent): Vector3 | null => {
// Convert mouse position to normalized device coordinates (-1 to +1)
const rect = canvas.getBoundingClientRect()
pointer.current.x = ((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1
pointer.current.y = -((nativeEvent.clientY - rect.top) / rect.height) * 2 + 1
// Update raycaster
raycaster.current.setFromCamera(pointer.current, camera)
// Intersect with ground plane
if (raycaster.current.ray.intersectPlane(groundPlane.current, intersectionPoint.current)) {
return intersectionPoint.current.clone()
}
return null
}
const emit = (suffix: EventSuffix, nativeEvent: MouseEvent | PointerEvent) => {
const point = getIntersection(nativeEvent)
if (!point) return
const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`
const payload: GridEvent = {
position: [point.x, point.y, point.z],
nativeEvent: nativeEvent as any, // Type compatibility with ThreeEvent
}
emitter.emit(eventKey, payload)
}
const handlePointerDown = (e: PointerEvent) => {
if (e.button !== 0) return
emit('pointerdown', e)
}
const handlePointerUp = (e: PointerEvent) => {
if (e.button !== 0) return
emit('pointerup', e)
}
const handleClick = (e: PointerEvent) => {
if (e.button !== 0) return
emit('click', e)
}
const handlePointerMove = (e: PointerEvent) => {
emit('move', e)
}
const handleDoubleClick = (e: MouseEvent) => {
emit('double-click', e)
}
const handleContextMenu = (e: MouseEvent) => {
emit('context-menu', e)
}
// Attach listeners to canvas
canvas.addEventListener('pointerdown', handlePointerDown)
canvas.addEventListener('pointerup', handlePointerUp)
canvas.addEventListener('click', handleClick)
canvas.addEventListener('pointermove', handlePointerMove)
canvas.addEventListener('dblclick', handleDoubleClick)
canvas.addEventListener('contextmenu', handleContextMenu)
return () => {
canvas.removeEventListener('pointerdown', handlePointerDown)
canvas.removeEventListener('pointerup', handlePointerUp)
canvas.removeEventListener('click', handleClick)
canvas.removeEventListener('pointermove', handlePointerMove)
canvas.removeEventListener('dblclick', handleDoubleClick)
canvas.removeEventListener('contextmenu', handleContextMenu)
}
}, [camera, gl, gridY])
}