Sync monorepo PRs #243 and #248 to the open-source editor. Move/Rotate Building: - New move-building-tool with grid snapping, R/T rotation, and Esc cancel - Floating building action menu with move button - Building helper with keyboard shortcut hints - BuildingRenderer now applies position and rotation from node data Building-Relative Coordinate System: - GridEvent gains localPosition (building-local coords) - use-grid-events computes building-local intersection from building mesh - ToolManager wraps building-relative tools in a building-local <group> - All tools (wall, slab, ceiling, stair, roof, zone, polygon-editor, placement coordinator) updated to use event.localPosition - Placement coordinator adds worldToBuildingLocal helper for cursor position conversion 2D Floorplan Fix: - SVG layers wrapped in <g transform=rotate(...)> for building rotation - Pointer-to-plan conversion un-rotates when building is rotated - Grid events from 2D include localPosition Store Changes: - useEditor movingNode union includes BuildingNode - NodeActionMenu onDelete is now optional (buildings can move but not delete)
119 lines
4.2 KiB
TypeScript
119 lines
4.2 KiB
TypeScript
import {
|
|
type AnyNodeId,
|
|
type EventSuffix,
|
|
emitter,
|
|
type GridEvent,
|
|
sceneRegistry,
|
|
} from '@pascal-app/core'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
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
|
|
|
|
// Convert world-space point to building-local for tools that live inside a building.
|
|
const buildingId = useViewer.getState().selection.buildingId
|
|
const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
const localPoint = buildingMesh ? buildingMesh.worldToLocal(point.clone()) : point
|
|
|
|
const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`
|
|
const payload: GridEvent = {
|
|
position: [point.x, point.y, point.z],
|
|
localPosition: [localPoint.x, localPoint.y, localPoint.z],
|
|
nativeEvent: nativeEvent as any, // Type compatibility with ThreeEvent
|
|
}
|
|
|
|
emitter.emit(eventKey, payload)
|
|
}
|
|
|
|
const handlePointerDown = (e: PointerEvent) => {
|
|
if (useViewer.getState().cameraDragging) return
|
|
if (e.button !== 0) return
|
|
emit('pointerdown', e)
|
|
}
|
|
|
|
const handlePointerUp = (e: PointerEvent) => {
|
|
if (useViewer.getState().cameraDragging) return
|
|
if (e.button !== 0) return
|
|
emit('pointerup', e)
|
|
}
|
|
|
|
const handleClick = (e: PointerEvent) => {
|
|
if (useViewer.getState().cameraDragging) return
|
|
if (e.button !== 0) return
|
|
emit('click', e)
|
|
}
|
|
|
|
const handlePointerMove = (e: PointerEvent) => {
|
|
// Emit move even if camera is dragging, so tools like PolygonEditor still work
|
|
emit('move', e)
|
|
}
|
|
|
|
const handleDoubleClick = (e: MouseEvent) => {
|
|
if (useViewer.getState().cameraDragging) return
|
|
emit('double-click', e)
|
|
}
|
|
|
|
const handleContextMenu = (e: MouseEvent) => {
|
|
if (useViewer.getState().cameraDragging) return
|
|
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])
|
|
}
|