Support fence endpoint moves and drafting snap
This commit is contained in:
@@ -49,8 +49,10 @@ export function FloatingActionMenu() {
|
|||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
||||||
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
|
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
|
||||||
|
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint)
|
const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint)
|
||||||
|
const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint)
|
||||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const setEditingHole = useEditor((s) => s.setEditingHole)
|
const setEditingHole = useEditor((s) => s.setEditingHole)
|
||||||
@@ -128,12 +130,17 @@ export function FloatingActionMenu() {
|
|||||||
groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
|
groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node?.type === 'wall') {
|
if (node?.type === 'wall' || node?.type === 'fence') {
|
||||||
const wall = node as WallNode
|
const segment = node as WallNode | FenceNode
|
||||||
const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
const segmentLength = Math.hypot(
|
||||||
|
segment.end[0] - segment.start[0],
|
||||||
|
segment.end[1] - segment.start[1],
|
||||||
|
)
|
||||||
const endpointYOffset = 0.35
|
const endpointYOffset = 0.35
|
||||||
const startWorld = obj.localToWorld(new THREE.Vector3(0, 0, 0))
|
const startLocalX = node.type === 'wall' ? 0 : -segmentLength / 2
|
||||||
const endWorld = obj.localToWorld(new THREE.Vector3(wallLength, 0, 0))
|
const endLocalX = node.type === 'wall' ? segmentLength : segmentLength / 2
|
||||||
|
const startWorld = obj.localToWorld(new THREE.Vector3(startLocalX, 0, 0))
|
||||||
|
const endWorld = obj.localToWorld(new THREE.Vector3(endLocalX, 0, 0))
|
||||||
|
|
||||||
if (startEndpointGroupRef.current) {
|
if (startEndpointGroupRef.current) {
|
||||||
startEndpointGroupRef.current.position.set(
|
startEndpointGroupRef.current.position.set(
|
||||||
@@ -190,12 +197,18 @@ export function FloatingActionMenu() {
|
|||||||
const handleEndpointMove = useCallback(
|
const handleEndpointMove = useCallback(
|
||||||
(endpoint: 'start' | 'end', e: React.MouseEvent) => {
|
(endpoint: 'start' | 'end', e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (!(node && node.type === 'wall')) return
|
if (!node) return
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
setMovingWallEndpoint({ wall: node, endpoint })
|
if (node.type === 'wall') {
|
||||||
|
setMovingWallEndpoint({ wall: node, endpoint })
|
||||||
|
} else if (node.type === 'fence') {
|
||||||
|
setMovingFenceEndpoint({ fence: node, endpoint })
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
},
|
},
|
||||||
[node, setMovingWallEndpoint, setSelection],
|
[node, setMovingFenceEndpoint, setMovingWallEndpoint, setSelection],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDuplicate = useCallback(
|
const handleDuplicate = useCallback(
|
||||||
@@ -396,7 +409,8 @@ export function FloatingActionMenu() {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
|
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
|
||||||
movingWallEndpoint
|
movingWallEndpoint ||
|
||||||
|
movingFenceEndpoint
|
||||||
)
|
)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
@@ -426,7 +440,7 @@ export function FloatingActionMenu() {
|
|||||||
/>
|
/>
|
||||||
</Html>
|
</Html>
|
||||||
</group>
|
</group>
|
||||||
{node?.type === 'wall' && (
|
{(node?.type === 'wall' || node?.type === 'fence') && (
|
||||||
<>
|
<>
|
||||||
<group ref={startEndpointGroupRef}>
|
<group ref={startEndpointGroupRef}>
|
||||||
<Html
|
<Html
|
||||||
@@ -435,7 +449,7 @@ export function FloatingActionMenu() {
|
|||||||
zIndexRange={[100, 0]}
|
zIndexRange={[100, 0]}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-label="Move wall start"
|
aria-label={node.type === 'wall' ? 'Move wall start' : 'Move fence start'}
|
||||||
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
|
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
|
||||||
altPressed
|
altPressed
|
||||||
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
|
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
|
||||||
@@ -443,7 +457,11 @@ export function FloatingActionMenu() {
|
|||||||
}`}
|
}`}
|
||||||
onClick={(e) => handleEndpointMove('start', e)}
|
onClick={(e) => handleEndpointMove('start', e)}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
title="Move wall start (Alt to detach)"
|
title={
|
||||||
|
node.type === 'wall'
|
||||||
|
? 'Move wall start (Alt to detach)'
|
||||||
|
: 'Move fence start (Alt to detach)'
|
||||||
|
}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Move className="h-4 w-4" />
|
<Move className="h-4 w-4" />
|
||||||
@@ -457,7 +475,7 @@ export function FloatingActionMenu() {
|
|||||||
zIndexRange={[100, 0]}
|
zIndexRange={[100, 0]}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-label="Move wall end"
|
aria-label={node.type === 'wall' ? 'Move wall end' : 'Move fence end'}
|
||||||
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
|
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
|
||||||
altPressed
|
altPressed
|
||||||
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
|
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
|
||||||
@@ -465,7 +483,11 @@ export function FloatingActionMenu() {
|
|||||||
}`}
|
}`}
|
||||||
onClick={(e) => handleEndpointMove('end', e)}
|
onClick={(e) => handleEndpointMove('end', e)}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
title="Move wall end (Alt to detach)"
|
title={
|
||||||
|
node.type === 'wall'
|
||||||
|
? 'Move wall end (Alt to detach)'
|
||||||
|
: 'Move fence end (Alt to detach)'
|
||||||
|
}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Move className="h-4 w-4" />
|
<Move className="h-4 w-4" />
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { FenceNode, useScene, type WallNode } from '@pascal-app/core'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import {
|
import {
|
||||||
|
getWallAngleSnapStep,
|
||||||
|
getWallGridStep,
|
||||||
type WallPlanPoint,
|
type WallPlanPoint,
|
||||||
findWallSnapTarget,
|
findWallSnapTarget,
|
||||||
isWallLongEnough,
|
isWallLongEnough,
|
||||||
@@ -94,7 +96,12 @@ export function snapFenceDraftPoint(args: {
|
|||||||
ignoreFenceIds?: string[]
|
ignoreFenceIds?: string[]
|
||||||
}): FencePlanPoint {
|
}): FencePlanPoint {
|
||||||
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args
|
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args
|
||||||
const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
|
const gridStep = getWallGridStep()
|
||||||
|
const angleStep = getWallAngleSnapStep(gridStep)
|
||||||
|
const basePoint =
|
||||||
|
start && angleSnap
|
||||||
|
? snapPointTo45Degrees(start, point, gridStep, angleStep)
|
||||||
|
: snapPointToGrid(point, gridStep)
|
||||||
const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
|
const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
|
||||||
|
|
||||||
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
|
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type FenceNode,
|
||||||
|
type WallNode,
|
||||||
|
emitter,
|
||||||
|
type GridEvent,
|
||||||
|
pauseSceneHistory,
|
||||||
|
resumeSceneHistory,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { Html } from '@react-three/drei'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||||
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
|
import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor'
|
||||||
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
import { snapFenceDraftPoint, type FencePlanPoint } from './fence-drafting'
|
||||||
|
import { isWallLongEnough } from '../wall/wall-drafting'
|
||||||
|
|
||||||
|
function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
|
||||||
|
return a[0] === b[0] && a[1] === b[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkedFenceSnapshot = {
|
||||||
|
id: FenceNode['id']
|
||||||
|
start: FencePlanPoint
|
||||||
|
end: FencePlanPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinkedFenceSnapshots(args: {
|
||||||
|
fenceId: FenceNode['id']
|
||||||
|
fenceParentId: string | null
|
||||||
|
originalStart: FencePlanPoint
|
||||||
|
originalEnd: FencePlanPoint
|
||||||
|
}) {
|
||||||
|
const { fenceId, fenceParentId, originalStart, originalEnd } = args
|
||||||
|
const { nodes } = useScene.getState()
|
||||||
|
const snapshots: LinkedFenceSnapshot[] = []
|
||||||
|
|
||||||
|
for (const node of Object.values(nodes)) {
|
||||||
|
if (!(node?.type === 'fence' && node.id !== fenceId)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((node.parentId ?? null) !== fenceParentId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!samePoint(node.start, originalStart) &&
|
||||||
|
!samePoint(node.start, originalEnd) &&
|
||||||
|
!samePoint(node.end, originalStart) &&
|
||||||
|
!samePoint(node.end, originalEnd)
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots.push({
|
||||||
|
id: node.id,
|
||||||
|
start: [...node.start] as FencePlanPoint,
|
||||||
|
end: [...node.end] as FencePlanPoint,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinkedFenceUpdates(
|
||||||
|
linkedFences: LinkedFenceSnapshot[],
|
||||||
|
originalStart: FencePlanPoint,
|
||||||
|
originalEnd: FencePlanPoint,
|
||||||
|
nextStart: FencePlanPoint,
|
||||||
|
nextEnd: FencePlanPoint,
|
||||||
|
) {
|
||||||
|
return linkedFences.map((fence) => ({
|
||||||
|
id: fence.id,
|
||||||
|
start: samePoint(fence.start, originalStart)
|
||||||
|
? nextStart
|
||||||
|
: samePoint(fence.start, originalEnd)
|
||||||
|
? nextEnd
|
||||||
|
: fence.start,
|
||||||
|
end: samePoint(fence.end, originalStart)
|
||||||
|
? nextStart
|
||||||
|
: samePoint(fence.end, originalEnd)
|
||||||
|
? nextEnd
|
||||||
|
: fence.end,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = ({ target }) => {
|
||||||
|
const activatedAtRef = useRef<number>(Date.now())
|
||||||
|
const previousGridPosRef = useRef<FencePlanPoint | null>(null)
|
||||||
|
const shiftPressedRef = useRef(false)
|
||||||
|
const altPressedRef = useRef(false)
|
||||||
|
const nodeIdRef = useRef(target.fence.id)
|
||||||
|
const originalStartRef = useRef<FencePlanPoint>([...target.fence.start] as FencePlanPoint)
|
||||||
|
const originalEndRef = useRef<FencePlanPoint>([...target.fence.end] as FencePlanPoint)
|
||||||
|
const fixedPointRef = useRef<FencePlanPoint>(
|
||||||
|
target.endpoint === 'start'
|
||||||
|
? ([...target.fence.end] as FencePlanPoint)
|
||||||
|
: ([...target.fence.start] as FencePlanPoint),
|
||||||
|
)
|
||||||
|
const linkedOriginalsRef = useRef(
|
||||||
|
getLinkedFenceSnapshots({
|
||||||
|
fenceId: target.fence.id,
|
||||||
|
fenceParentId: target.fence.parentId ?? null,
|
||||||
|
originalStart: target.fence.start,
|
||||||
|
originalEnd: target.fence.end,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
|
||||||
|
|
||||||
|
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
||||||
|
const point = target.endpoint === 'start' ? target.fence.start : target.fence.end
|
||||||
|
return [point[0], 0, point[1]]
|
||||||
|
})
|
||||||
|
const [altPressed, setAltPressed] = useState(false)
|
||||||
|
|
||||||
|
const exitMoveMode = useCallback(() => {
|
||||||
|
useEditor.getState().setMovingFenceEndpoint(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nodeId = nodeIdRef.current
|
||||||
|
const originalStart = originalStartRef.current
|
||||||
|
const originalEnd = originalEndRef.current
|
||||||
|
const fixedPoint = fixedPointRef.current
|
||||||
|
const siblings = Object.values(useScene.getState().nodes)
|
||||||
|
const levelWalls = siblings.filter(
|
||||||
|
(node): node is WallNode =>
|
||||||
|
node?.type === 'wall' && (node.parentId ?? null) === (target.fence.parentId ?? null),
|
||||||
|
)
|
||||||
|
const levelFences = siblings.filter(
|
||||||
|
(node): node is FenceNode =>
|
||||||
|
node?.type === 'fence' && (node.parentId ?? null) === (target.fence.parentId ?? null),
|
||||||
|
)
|
||||||
|
|
||||||
|
pauseSceneHistory(useScene)
|
||||||
|
let wasCommitted = false
|
||||||
|
|
||||||
|
const applyNodePreview = (
|
||||||
|
updates: Array<{ id: FenceNode['id']; start: FencePlanPoint; end: FencePlanPoint }>,
|
||||||
|
) => {
|
||||||
|
useScene.getState().updateNodes(
|
||||||
|
updates.map((entry) => ({
|
||||||
|
id: entry.id as AnyNodeId,
|
||||||
|
data: { start: entry.start, end: entry.end },
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
for (const entry of updates) {
|
||||||
|
useScene.getState().markDirty(entry.id as AnyNodeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => {
|
||||||
|
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
|
||||||
|
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
|
||||||
|
previewRef.current = { start: nextStart, end: nextEnd }
|
||||||
|
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
||||||
|
applyNodePreview([
|
||||||
|
{ id: nodeId, start: nextStart, end: nextEnd },
|
||||||
|
...(detachLinkedFences
|
||||||
|
? []
|
||||||
|
: getLinkedFenceUpdates(
|
||||||
|
linkedOriginalsRef.current,
|
||||||
|
originalStart,
|
||||||
|
originalEnd,
|
||||||
|
nextStart,
|
||||||
|
nextEnd,
|
||||||
|
)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreOriginal = () => {
|
||||||
|
applyNodePreview([
|
||||||
|
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||||
|
...linkedOriginalsRef.current,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGridMove = (event: GridEvent) => {
|
||||||
|
const planPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||||
|
const snappedPoint = snapFenceDraftPoint({
|
||||||
|
point: planPoint,
|
||||||
|
walls: levelWalls,
|
||||||
|
fences: levelFences,
|
||||||
|
start: fixedPoint,
|
||||||
|
angleSnap: !shiftPressedRef.current,
|
||||||
|
ignoreFenceIds: [nodeId],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (
|
||||||
|
previousGridPosRef.current &&
|
||||||
|
(snappedPoint[0] !== previousGridPosRef.current[0] ||
|
||||||
|
snappedPoint[1] !== previousGridPosRef.current[1])
|
||||||
|
) {
|
||||||
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
|
}
|
||||||
|
previousGridPosRef.current = snappedPoint
|
||||||
|
|
||||||
|
applyPreview(snappedPoint, event.nativeEvent.altKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGridClick = (event: GridEvent) => {
|
||||||
|
if (Date.now() - activatedAtRef.current < 150) {
|
||||||
|
event.nativeEvent?.stopPropagation?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
||||||
|
const hasChanged =
|
||||||
|
!samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
|
||||||
|
|
||||||
|
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
|
||||||
|
wasCommitted = true
|
||||||
|
|
||||||
|
applyNodePreview([
|
||||||
|
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||||
|
...linkedOriginalsRef.current,
|
||||||
|
])
|
||||||
|
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
applyNodePreview([
|
||||||
|
{ id: nodeId, start: preview.start, end: preview.end },
|
||||||
|
...(altPressedRef.current
|
||||||
|
? []
|
||||||
|
: getLinkedFenceUpdates(
|
||||||
|
linkedOriginalsRef.current,
|
||||||
|
originalStart,
|
||||||
|
originalEnd,
|
||||||
|
preview.start,
|
||||||
|
preview.end,
|
||||||
|
)),
|
||||||
|
])
|
||||||
|
pauseSceneHistory(useScene)
|
||||||
|
sfxEmitter.emit('sfx:item-place')
|
||||||
|
}
|
||||||
|
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||||
|
exitMoveMode()
|
||||||
|
event.nativeEvent?.stopPropagation?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
restoreOriginal()
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
markToolCancelConsumed()
|
||||||
|
exitMoveMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === 'Shift') {
|
||||||
|
shiftPressedRef.current = true
|
||||||
|
}
|
||||||
|
if (event.key === 'Alt') {
|
||||||
|
altPressedRef.current = true
|
||||||
|
setAltPressed(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyUp = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Shift') {
|
||||||
|
shiftPressedRef.current = false
|
||||||
|
}
|
||||||
|
if (event.key === 'Alt') {
|
||||||
|
altPressedRef.current = false
|
||||||
|
setAltPressed(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWindowBlur = () => {
|
||||||
|
shiftPressedRef.current = false
|
||||||
|
altPressedRef.current = false
|
||||||
|
setAltPressed(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('grid:move', onGridMove)
|
||||||
|
emitter.on('grid:click', onGridClick)
|
||||||
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
window.addEventListener('keyup', onKeyUp)
|
||||||
|
window.addEventListener('blur', onWindowBlur)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (!wasCommitted) {
|
||||||
|
restoreOriginal()
|
||||||
|
}
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
emitter.off('grid:move', onGridMove)
|
||||||
|
emitter.off('grid:click', onGridClick)
|
||||||
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
|
window.removeEventListener('blur', onWindowBlur)
|
||||||
|
}
|
||||||
|
}, [exitMoveMode, target])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||||
|
<Html
|
||||||
|
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
|
||||||
|
style={{ pointerEvents: 'none', touchAction: 'none' }}
|
||||||
|
zIndexRange={[100, 0]}
|
||||||
|
>
|
||||||
|
<div className="translate-y-10">
|
||||||
|
<div
|
||||||
|
className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${
|
||||||
|
altPressed
|
||||||
|
? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
|
||||||
|
: 'border-border/70 bg-background/90 text-foreground/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{altPressed ? 'Detach endpoint' : 'Drag endpoint'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
|
|||||||
import { CeilingTool } from './ceiling/ceiling-tool'
|
import { CeilingTool } from './ceiling/ceiling-tool'
|
||||||
import { DoorTool } from './door/door-tool'
|
import { DoorTool } from './door/door-tool'
|
||||||
import { FenceTool } from './fence/fence-tool'
|
import { FenceTool } from './fence/fence-tool'
|
||||||
|
import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
|
||||||
import { ItemTool } from './item/item-tool'
|
import { ItemTool } from './item/item-tool'
|
||||||
import { MoveTool } from './item/move-tool'
|
import { MoveTool } from './item/move-tool'
|
||||||
import { RoofTool } from './roof/roof-tool'
|
import { RoofTool } from './roof/roof-tool'
|
||||||
@@ -54,6 +55,7 @@ export const ToolManager: React.FC = () => {
|
|||||||
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 movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
|
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
|
||||||
|
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
|
||||||
const curvingWall = useEditor((state) => state.curvingWall)
|
const curvingWall = useEditor((state) => state.curvingWall)
|
||||||
const editingHole = useEditor((state) => state.editingHole)
|
const editingHole = useEditor((state) => state.editingHole)
|
||||||
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
||||||
@@ -145,6 +147,7 @@ export const ToolManager: React.FC = () => {
|
|||||||
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
|
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
|
||||||
)}
|
)}
|
||||||
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
|
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
|
||||||
|
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
|
||||||
{curvingWall && <CurveWallTool node={curvingWall} />}
|
{curvingWall && <CurveWallTool node={curvingWall} />}
|
||||||
{movingNode && movingNode.type !== 'building' && <MoveTool />}
|
{movingNode && movingNode.type !== 'building' && <MoveTool />}
|
||||||
{!movingNode && BuildToolComponent && <BuildToolComponent />}
|
{!movingNode && BuildToolComponent && <BuildToolComponent />}
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ export type MovingWallEndpoint = {
|
|||||||
endpoint: 'start' | 'end'
|
endpoint: 'start' | 'end'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MovingFenceEndpoint = {
|
||||||
|
fence: FenceNode
|
||||||
|
endpoint: 'start' | 'end'
|
||||||
|
}
|
||||||
|
|
||||||
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole
|
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole
|
||||||
|
|
||||||
export type SelectedMaterialTarget = {
|
export type SelectedMaterialTarget = {
|
||||||
@@ -136,6 +141,8 @@ type EditorState = {
|
|||||||
) => void
|
) => void
|
||||||
movingWallEndpoint: MovingWallEndpoint | null
|
movingWallEndpoint: MovingWallEndpoint | null
|
||||||
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
|
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
|
||||||
|
movingFenceEndpoint: MovingFenceEndpoint | null
|
||||||
|
setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void
|
||||||
curvingWall: WallNode | null
|
curvingWall: WallNode | null
|
||||||
setCurvingWall: (wall: WallNode | null) => void
|
setCurvingWall: (wall: WallNode | null) => void
|
||||||
selectedMaterialTarget: SelectedMaterialTarget | null
|
selectedMaterialTarget: SelectedMaterialTarget | null
|
||||||
@@ -513,6 +520,8 @@ const useEditor = create<EditorState>()(
|
|||||||
setMovingNode: (node) => set({ movingNode: node }),
|
setMovingNode: (node) => set({ movingNode: node }),
|
||||||
movingWallEndpoint: null,
|
movingWallEndpoint: null,
|
||||||
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
|
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
|
||||||
|
movingFenceEndpoint: null,
|
||||||
|
setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
|
||||||
curvingWall: null,
|
curvingWall: null,
|
||||||
setCurvingWall: (wall) => set({ curvingWall: wall }),
|
setCurvingWall: (wall) => set({ curvingWall: wall }),
|
||||||
selectedMaterialTarget: null,
|
selectedMaterialTarget: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user