Add curved fence support

This commit is contained in:
sudhir
2026-04-21 11:26:49 +05:30
parent 5b5b80be61
commit 8ae217502c
11 changed files with 431 additions and 73 deletions
@@ -50,10 +50,12 @@ export function FloatingActionMenu() {
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
const curvingFence = useEditor((s) => s.curvingFence)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint)
const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint)
const setCurvingWall = useEditor((s) => s.setCurvingWall)
const setCurvingFence = useEditor((s) => s.setCurvingFence)
const setSelection = useViewer((s) => s.setSelection)
const setEditingHole = useEditor((s) => s.setEditingHole)
@@ -132,15 +134,24 @@ export function FloatingActionMenu() {
if (node?.type === 'wall' || node?.type === 'fence') {
const segment = node as WallNode | FenceNode
const segmentLength = Math.hypot(
segment.end[0] - segment.start[0],
segment.end[1] - segment.start[1],
)
const endpointYOffset = 0.35
const startLocalX = node.type === 'wall' ? 0 : -segmentLength / 2
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))
const startWorld =
node.type === 'wall'
? obj.localToWorld(new THREE.Vector3(0, 0, 0))
: obj.localToWorld(new THREE.Vector3(segment.start[0], 0, segment.start[1]))
const endWorld =
node.type === 'wall'
? obj.localToWorld(
new THREE.Vector3(
Math.hypot(
segment.end[0] - segment.start[0],
segment.end[1] - segment.start[1],
),
0,
0,
),
)
: obj.localToWorld(new THREE.Vector3(segment.end[0], 0, segment.end[1]))
if (startEndpointGroupRef.current) {
startEndpointGroupRef.current.position.set(
@@ -187,12 +198,19 @@ export function FloatingActionMenu() {
const handleCurve = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (!canCurveSelectedWall || !node || node.type !== 'wall') return
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setCurvingWall(node)
if (node.type === 'wall') {
if (!canCurveSelectedWall) return
setCurvingWall(node)
} else if (node.type === 'fence') {
setCurvingFence(node)
} else {
return
}
setSelection({ selectedIds: [] })
},
[canCurveSelectedWall, node, setCurvingWall, setSelection],
[canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection],
)
const handleEndpointMove = useCallback(
(endpoint: 'start' | 'end', e: React.MouseEvent) => {
@@ -410,7 +428,8 @@ export function FloatingActionMenu() {
if (
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
movingWallEndpoint ||
movingFenceEndpoint
movingFenceEndpoint ||
curvingFence
)
return null
@@ -427,7 +446,11 @@ export function FloatingActionMenu() {
>
<NodeActionMenu
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
onCurve={canCurveSelectedWall ? handleCurve : undefined}
onCurve={
node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
? handleCurve
: undefined
}
onDelete={handleDelete}
onDuplicate={
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
@@ -4778,8 +4778,9 @@ const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
if (!isFloorplanHovered || movingNode || curvingWall) {
if (!isFloorplanHovered || movingNode || curvingWall || curvingFence) {
return null
}
@@ -4982,6 +4983,7 @@ export function FloorplanPanel() {
const setMode = useEditor((state) => state.setMode)
const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode)
const setPhase = useEditor((state) => state.setPhase)
@@ -5656,6 +5658,7 @@ export function FloorplanPanel() {
const isCeilingMoveActive = movingNode?.type === 'ceiling'
const isWallMoveActive = movingNode?.type === 'wall'
const isWallCurveActive = curvingWall?.type === 'wall'
const isFenceCurveActive = curvingFence?.type === 'fence'
const isItemPlacementPreviewActive =
(mode === 'build' && tool === 'item') || movingNode?.type === 'item'
const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo
@@ -5667,6 +5670,7 @@ export function FloorplanPanel() {
isCeilingMoveActive ||
isWallMoveActive ||
isWallCurveActive ||
isFenceCurveActive ||
isFloorItemBuildActive ||
isFloorItemMoveActive
const floorplanPreviewStairSegment = useMemo(
@@ -472,6 +472,7 @@ export const SelectionManager = () => {
const movingNode = useEditor((s) => s.movingNode)
const curvingWall = useEditor((s) => s.curvingWall)
const curvingFence = useEditor((s) => s.curvingFence)
useEffect(() => {
setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default')
@@ -510,7 +511,7 @@ export const SelectionManager = () => {
useEffect(() => {
if (mode !== 'select') return
if (movingNode || curvingWall) return
if (movingNode || curvingWall || curvingFence) return
const onClick = (event: NodeEvent) => {
// Skip if box-select just completed (drag ended over a node)
@@ -648,12 +649,12 @@ export const SelectionManager = () => {
})
emitter.off('grid:click', onGridClick)
}
}, [curvingWall, mode, movingNode])
}, [curvingFence, curvingWall, mode, movingNode])
// Global double-click handler for auto-switching phases and cross-phase hover
useEffect(() => {
if (mode !== 'select') return
if (movingNode || curvingWall) return
if (movingNode || curvingWall || curvingFence) return
const onEnter = (event: NodeEvent) => {
const node = event.node
@@ -782,7 +783,7 @@ export const SelectionManager = () => {
emitter.off(`${type}:double-click` as any, onDoubleClick as any)
})
}
}, [curvingWall, mode, movingNode])
}, [curvingFence, curvingWall, mode, movingNode])
// Delete mode: click-to-delete (sledgehammer tool)
useEffect(() => {
@@ -0,0 +1,179 @@
'use client'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallChordFrame,
getWallMidpointHandlePoint,
normalizeWallCurveOffset,
pauseSceneHistory,
resumeSceneHistory,
useScene,
} from '@pascal-app/core'
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 from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { getWallGridStep, snapScalarToGrid } from '../wall/wall-drafting'
export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
const previousCurveOffsetRef = useRef<number | null>(null)
const shiftPressedRef = useRef(false)
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
const initialHandle = getWallMidpointHandlePoint(node)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
initialHandle.x,
0,
initialHandle.y,
])
const exitCurveMode = useCallback(() => {
useEditor.getState().setCurvingFence(null)
}, [])
useEffect(() => {
const nodeId = node.id
const originalCurveOffset = originalCurveOffsetRef.current
const chord = getWallChordFrame(node)
const maxCurveOffset = getMaxWallCurveOffset(node)
pauseSceneHistory(useScene)
let wasCommitted = false
const applyPreview = (curveOffset: number) => {
if (previewOffsetRef.current === curveOffset) {
return
}
previewOffsetRef.current = curveOffset
const nextNode = {
...node,
curveOffset,
}
const handlePoint = getWallMidpointHandlePoint(nextNode)
setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const restoreOriginal = () => {
if (previewOffsetRef.current === originalCurveOffset) {
return
}
previewOffsetRef.current = originalCurveOffset
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const snapStep = getWallGridStep()
const localX = shiftPressedRef.current
? event.localPosition[0]
: snapScalarToGrid(event.localPosition[0], snapStep)
const localZ = shiftPressedRef.current
? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep)
const offsetFromMidpoint =
-(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const snappedOffset = shiftPressedRef.current
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
const nextCurveOffset = normalizeWallCurveOffset(
node,
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
)
if (
previousCurveOffsetRef.current !== null &&
nextCurveOffset !== previousCurveOffsetRef.current
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousCurveOffsetRef.current = nextCurveOffset
applyPreview(nextCurveOffset)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const curveOffset = previewOffsetRef.current
wasCommitted = true
if (curveOffset !== originalCurveOffset) {
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
resumeSceneHistory(useScene)
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
pauseSceneHistory(useScene)
}
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitCurveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
markToolCancelConsumed()
exitCurveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
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)
}
}, [exitCurveMode, node])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -1,4 +1,4 @@
import { FenceNode, useScene, type WallNode } from '@pascal-app/core'
import { FenceNode, getWallCurveFrameAt, getWallCurveLength, isCurvedWall, useScene, type WallNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { sfxEmitter } from '../../../lib/sfx-bus'
import {
@@ -60,11 +60,16 @@ function findFenceSnapTarget(
continue
}
const candidates: Array<FencePlanPoint | null> = [
fence.start,
fence.end,
projectPointOntoSegment(point, fence),
]
const candidates: Array<FencePlanPoint | null> = [fence.start, fence.end]
if (isCurvedWall(fence)) {
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3))
for (let index = 0; index <= sampleCount; index += 1) {
const frame = getWallCurveFrameAt(fence, index / sampleCount)
candidates.push([frame.point.x, frame.point.y])
}
} else {
candidates.push(projectPointOntoSegment(point, fence))
}
for (const candidate of candidates) {
if (!candidate) {
@@ -11,6 +11,7 @@ import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
import { CeilingTool } from './ceiling/ceiling-tool'
import { DoorTool } from './door/door-tool'
import { CurveFenceTool } from './fence/curve-fence-tool'
import { FenceTool } from './fence/fence-tool'
import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
import { ItemTool } from './item/item-tool'
@@ -57,6 +58,7 @@ export const ToolManager: React.FC = () => {
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const editingHole = useEditor((state) => state.editingHole)
const selectedZoneId = useViewer((state) => state.selection.zoneId)
const buildingId = useViewer((state) => state.selection.buildingId)
@@ -149,6 +151,7 @@ export const ToolManager: React.FC = () => {
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
{curvingWall && <CurveWallTool node={curvingWall} />}
{curvingFence && <CurveFenceTool node={curvingFence} />}
{movingNode && movingNode.type !== 'building' && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />}
</group>
@@ -1,8 +1,22 @@
'use client'
import { type AnyNode, type AnyNodeId, type FenceNode, type MaterialSchema, useScene } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
type FenceNode,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallCurveLength,
type MaterialSchema,
normalizeWallCurveOffset,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
@@ -28,6 +42,8 @@ export function FencePanel() {
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingFence = useEditor((s) => s.setCurvingFence)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined,
@@ -67,6 +83,20 @@ export function FencePanel() {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleCurve = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setCurvingFence(node)
setSelection({ selectedIds: [] })
}, [node, setCurvingFence, setSelection])
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
@@ -83,9 +113,9 @@ export function FencePanel() {
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
const length = Math.sqrt(dx * dx + dz * dz)
const length = getWallCurveLength(node)
const curveOffset = getClampedWallCurveOffset(node)
const maxCurveOffset = getMaxWallCurveOffset(node)
return (
<PanelWrapper
@@ -119,6 +149,16 @@ export function FencePanel() {
unit="m"
value={length}
/>
<SliderControl
label="Curve"
max={Math.max(0.01, maxCurveOffset)}
min={-Math.max(0.01, maxCurveOffset)}
onChange={(value) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, value) })}
precision={2}
step={0.1}
unit="m"
value={Math.round(curveOffset * 100) / 100}
/>
<SliderControl
label="Height"
max={4}
@@ -213,6 +253,17 @@ export function FencePanel() {
value={node.material}
/>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton
icon={<Spline className="h-3.5 w-3.5" />}
label="Curve"
onClick={handleCurve}
/>
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
+4
View File
@@ -145,6 +145,8 @@ type EditorState = {
setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void
curvingWall: WallNode | null
setCurvingWall: (wall: WallNode | null) => void
curvingFence: FenceNode | null
setCurvingFence: (fence: FenceNode | null) => void
selectedMaterialTarget: SelectedMaterialTarget | null
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
selectedReferenceId: string | null
@@ -524,6 +526,8 @@ const useEditor = create<EditorState>()(
setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
curvingWall: null,
setCurvingWall: (wall) => set({ curvingWall: wall }),
curvingFence: null,
setCurvingFence: (fence) => set({ curvingFence: fence }),
selectedMaterialTarget: null,
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
selectedReferenceId: null,