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'
|
||||
|
||||
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}
|
||||
|
||||
@@ -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 />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 { useGridEvents } from './hooks/use-grid-events'
|
||||
export { default as useViewer } from './store/use-viewer'
|
||||
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
|
||||
Reference in New Issue
Block a user