fix polygon editor edition + events propagation
This commit is contained in:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,19 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
|
||||||
initSpaceDetectionSync,
|
import { Viewer } from '@pascal-app/viewer'
|
||||||
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 { useKeyboard } from '@/hooks/use-keyboard'
|
import { useKeyboard } from '@/hooks/use-keyboard'
|
||||||
import useEditor from '@/store/use-editor'
|
import useEditor from '@/store/use-editor'
|
||||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
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 { AppSidebar } from '../ui/sidebar/app-sidebar'
|
||||||
import { CustomCameraControls } from './custom-camera-controls'
|
import { CustomCameraControls } from './custom-camera-controls'
|
||||||
import { ExportManager } from './export-manager'
|
import { ExportManager } from './export-manager'
|
||||||
|
import { Grid } from './grid'
|
||||||
import { SelectionManager } from './selection-manager'
|
import { SelectionManager } from './selection-manager'
|
||||||
|
|
||||||
useScene.getState().loadScene()
|
useScene.getState().loadScene()
|
||||||
@@ -55,116 +44,3 @@ export default function Editor() {
|
|||||||
</div>
|
</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 { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
||||||
import { sceneRegistry } from '@pascal-app/core'
|
import { createPortal } from '@react-three/fiber'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
|
||||||
BufferGeometry,
|
|
||||||
Float32BufferAttribute,
|
|
||||||
type Mesh,
|
|
||||||
Plane,
|
|
||||||
Raycaster,
|
|
||||||
Vector2,
|
|
||||||
Vector3,
|
|
||||||
} from 'three'
|
|
||||||
|
|
||||||
const Y_OFFSET = 0.02
|
const Y_OFFSET = 0.02
|
||||||
|
|
||||||
@@ -45,8 +37,6 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
levelId,
|
levelId,
|
||||||
surfaceHeight = 0,
|
surfaceHeight = 0,
|
||||||
}) => {
|
}) => {
|
||||||
const { gl, camera } = useThree()
|
|
||||||
|
|
||||||
// Get level node from registry if levelId is provided
|
// Get level node from registry if levelId is provided
|
||||||
const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null
|
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 [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
|
||||||
const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
|
const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
|
||||||
const [hoveredMidpoint, setHoveredMidpoint] = 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!)
|
const lineRef = useRef<Mesh>(null!)
|
||||||
|
|
||||||
// Track the last polygon prop to detect external changes (undo/redo)
|
// Track the last polygon prop to detect external changes (undo/redo)
|
||||||
@@ -88,30 +75,15 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
})
|
})
|
||||||
}, [displayPolygon])
|
}, [displayPolygon])
|
||||||
|
|
||||||
// Handle vertex drag
|
// Update vertex position using grid cursor position
|
||||||
const handleVertexDrag = useCallback(
|
const handleVertexDrag = useCallback(
|
||||||
(clientX: number, clientY: number, vertexIndex: number) => {
|
(vertexIndex: number) => {
|
||||||
const canvas = gl.domElement
|
const basePolygon = previewPolygon ?? polygon
|
||||||
const rect = canvas.getBoundingClientRect()
|
const newPolygon = [...basePolygon]
|
||||||
const x = ((clientX - rect.left) / rect.width) * 2 - 1
|
newPolygon[vertexIndex] = cursorPosition
|
||||||
const y = -((clientY - rect.top) / rect.height) * 2 + 1
|
setPreviewPolygon(newPolygon)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[gl, camera, previewPolygon, polygon],
|
[cursorPosition, previewPolygon, polygon],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Commit polygon changes
|
// Commit polygon changes
|
||||||
@@ -152,58 +124,58 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
[polygon, previewPolygon, onPolygonChange, minVertices],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!dragState?.isDragging) return
|
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) => {
|
const handlePointerUp = (e: PointerEvent) => {
|
||||||
// Stop the event from reaching R3F's handlers, which would otherwise
|
// Only handle the specific pointer that started the drag
|
||||||
// fire a grid:click and deselect the node being edited.
|
if (e.pointerId !== dragState.pointerId) return
|
||||||
|
|
||||||
|
// Stop the event from propagating to prevent grid click
|
||||||
e.stopImmediatePropagation()
|
e.stopImmediatePropagation()
|
||||||
e.preventDefault()
|
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
|
// Suppress the follow-up click event that browsers fire after pointerup
|
||||||
const suppressClick = (ce: MouseEvent) => {
|
const suppressClick = (ce: MouseEvent) => {
|
||||||
ce.stopImmediatePropagation()
|
ce.stopImmediatePropagation()
|
||||||
ce.preventDefault()
|
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
|
// Safety cleanup in case no click fires
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
canvas.removeEventListener('click', suppressClick, true)
|
window.removeEventListener('click', suppressClick, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
commitPolygonChange()
|
commitPolygonChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas.addEventListener('pointermove', handlePointerMove)
|
window.addEventListener('pointerup', handlePointerUp, true)
|
||||||
canvas.addEventListener('pointerup', handlePointerUp, true)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Release capture on cleanup
|
window.removeEventListener('pointerup', handlePointerUp, true)
|
||||||
if (canvas.hasPointerCapture(pointerId)) {
|
|
||||||
canvas.releasePointerCapture(pointerId)
|
|
||||||
}
|
|
||||||
canvas.removeEventListener('pointermove', handlePointerMove)
|
|
||||||
canvas.removeEventListener('pointerup', handlePointerUp, true)
|
|
||||||
}
|
}
|
||||||
}, [dragState, gl, handleVertexDrag, commitPolygonChange])
|
}, [dragState, commitPolygonChange])
|
||||||
|
|
||||||
// Update line geometry when polygon changes
|
// Update line geometry when polygon changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -232,7 +204,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
<group>
|
<group>
|
||||||
{/* Border line */}
|
{/* Border line */}
|
||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
<line ref={lineRef} frustumCulled={false} renderOrder={10}>
|
<line ref={lineRef} frustumCulled={false} renderOrder={10} raycast={() => {}}>
|
||||||
<bufferGeometry />
|
<bufferGeometry />
|
||||||
<lineBasicNodeMaterial
|
<lineBasicNodeMaterial
|
||||||
color={color}
|
color={color}
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import useEditor, { type Phase, type Tool } from "@/store/use-editor";
|
import { type AnyNodeId, type CeilingNode, type SlabNode, useScene } from '@pascal-app/core'
|
||||||
import { useScene, type AnyNodeId, type SlabNode } from "@pascal-app/core";
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
import useEditor, { type Phase, type Tool } from '@/store/use-editor'
|
||||||
import { CeilingTool } from "./ceiling/ceiling-tool";
|
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
|
||||||
import { ItemTool } from "./item/item-tool";
|
import { CeilingTool } from './ceiling/ceiling-tool'
|
||||||
import { MoveTool } from "./item/move-tool";
|
import { ItemTool } from './item/item-tool'
|
||||||
import { RoofTool } from "./roof/roof-tool";
|
import { MoveTool } from './item/move-tool'
|
||||||
import { SiteBoundaryEditor } from "./site/site-boundary-editor";
|
import { RoofTool } from './roof/roof-tool'
|
||||||
import { SlabBoundaryEditor } from "./slab/slab-boundary-editor";
|
import { SiteBoundaryEditor } from './site/site-boundary-editor'
|
||||||
import { SlabTool } from "./slab/slab-tool";
|
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
|
||||||
import { WallTool } from "./wall/wall-tool";
|
import { SlabTool } from './slab/slab-tool'
|
||||||
import { ZoneBoundaryEditor } from "./zone/zone-boundary-editor";
|
import { WallTool } from './wall/wall-tool'
|
||||||
import { ZoneTool } from "./zone/zone-tool";
|
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
|
||||||
|
import { ZoneTool } from './zone/zone-tool'
|
||||||
|
|
||||||
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
||||||
site: {
|
site: {
|
||||||
"property-line": SiteBoundaryEditor,
|
'property-line': SiteBoundaryEditor,
|
||||||
},
|
},
|
||||||
structure: {
|
structure: {
|
||||||
wall: WallTool,
|
wall: WallTool,
|
||||||
@@ -27,44 +28,62 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
|||||||
furnish: {
|
furnish: {
|
||||||
item: ItemTool,
|
item: ItemTool,
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
export const ToolManager: React.FC = () => {
|
export const ToolManager: React.FC = () => {
|
||||||
const phase = useEditor((state) => state.phase);
|
const phase = useEditor((state) => state.phase)
|
||||||
const mode = useEditor((state) => state.mode);
|
const mode = useEditor((state) => state.mode)
|
||||||
const tool = useEditor((state) => state.tool);
|
const tool = useEditor((state) => state.tool)
|
||||||
const movingNode = useEditor((state) => state.movingNode);
|
const movingNode = useEditor((state) => state.movingNode)
|
||||||
const selectedZoneId = useViewer((state) => state.selection.zoneId);
|
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
||||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||||
const nodes = useScene((state) => state.nodes);
|
const nodes = useScene((state) => state.nodes)
|
||||||
|
|
||||||
// Check if a slab is selected
|
// 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
|
// 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
|
// Show slab boundary editor when in structure/select mode with a slab selected
|
||||||
const showSlabBoundaryEditor =
|
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
|
// 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 =
|
const showZoneBoundaryEditor =
|
||||||
phase === "structure" && mode === "select" && selectedZoneId !== null && !showSlabBoundaryEditor;
|
phase === 'structure' &&
|
||||||
|
mode === 'select' &&
|
||||||
|
selectedZoneId !== null &&
|
||||||
|
!showSlabBoundaryEditor &&
|
||||||
|
!showCeilingBoundaryEditor
|
||||||
|
|
||||||
// Show build tools when in build mode
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
||||||
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
|
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
|
||||||
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
|
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
|
||||||
|
{showCeilingBoundaryEditor && selectedCeilingId && (
|
||||||
|
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
|
||||||
|
)}
|
||||||
{movingNode && <MoveTool />}
|
{movingNode && <MoveTool />}
|
||||||
{!movingNode && BuildToolComponent && <BuildToolComponent />}
|
{!movingNode && BuildToolComponent && <BuildToolComponent />}
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { type EventSuffix, emitter, type GridEvent } from "@pascal-app/core";
|
|
||||||
import type { ThreeEvent } from "@react-three/fiber";
|
|
||||||
|
|
||||||
export function useGridEvents() {
|
|
||||||
const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => {
|
|
||||||
const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`;
|
|
||||||
const payload: GridEvent = {
|
|
||||||
position: [e.point.x, e.point.y, e.point.z],
|
|
||||||
nativeEvent: e,
|
|
||||||
};
|
|
||||||
|
|
||||||
emitter.emit(eventKey, payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
onPointerDown: (e: ThreeEvent<PointerEvent>) => {
|
|
||||||
if (e.button !== 0) return;
|
|
||||||
emit("pointerdown", e);
|
|
||||||
},
|
|
||||||
onPointerUp: (e: ThreeEvent<PointerEvent>) => {
|
|
||||||
if (e.button !== 0) return;
|
|
||||||
emit("pointerup", e);
|
|
||||||
},
|
|
||||||
onClick: (e: ThreeEvent<PointerEvent>) => {
|
|
||||||
if (e.button !== 0) return;
|
|
||||||
emit("click", e);
|
|
||||||
},
|
|
||||||
onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit("enter", e),
|
|
||||||
onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit("leave", e),
|
|
||||||
onPointerMove: (e: ThreeEvent<PointerEvent>) => emit("move", e),
|
|
||||||
onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit("double-click", e),
|
|
||||||
onContextMenu: (e: ThreeEvent<PointerEvent>) => emit("context-menu", e),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
export { default as Viewer } from './components/viewer'
|
export { default as Viewer } from './components/viewer'
|
||||||
export { useGridEvents } from './hooks/use-grid-events'
|
|
||||||
export { default as useViewer } from './store/use-viewer'
|
export { default as useViewer } from './store/use-viewer'
|
||||||
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
|
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
|
||||||
Reference in New Issue
Block a user