Improve ceiling selection and move preview visibility

This commit is contained in:
sudhir
2026-04-16 15:04:52 +05:30
parent 5010013701
commit 0e5a4d9fea
3 changed files with 332 additions and 2 deletions
@@ -23,6 +23,7 @@ import {
import { initSFXBus } from '../../lib/sfx-bus' import { initSFXBus } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { CeilingSystem } from '../systems/ceiling/ceiling-system' import { CeilingSystem } from '../systems/ceiling/ceiling-system'
import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
import { RoofEditSystem } from '../systems/roof/roof-edit-system' import { RoofEditSystem } from '../systems/roof/roof-edit-system'
import { StairEditSystem } from '../systems/stair/stair-edit-system' import { StairEditSystem } from '../systems/stair/stair-edit-system'
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system' import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
@@ -523,6 +524,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
<ExportManager /> <ExportManager />
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />} {isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
<CeilingSystem /> <CeilingSystem />
<CeilingSelectionAffordanceSystem />
<RoofEditSystem /> <RoofEditSystem />
<StairEditSystem /> <StairEditSystem />
{!isLoading && !isFirstPersonMode && ( {!isLoading && !isFirstPersonMode && (
@@ -0,0 +1,233 @@
'use client'
import {
type CeilingNode,
resolveLevelId,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent } from '@react-three/fiber'
import { useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import useEditor from '../../../store/use-editor'
const BRACKET_THICKNESS = 0.04
const BRACKET_HEIGHT = 0.04
const BRACKET_Y_OFFSET = 0.035
const CORNER_BLOCK_SIZE = 0.085
const HIT_BOX_SIZE: [number, number, number] = [0.24, 0.12, 0.24]
const HIT_INSET = 0.16
type CornerBracketData = {
corner: [number, number]
hitCenter: [number, number]
incomingDirection: [number, number]
outgoingDirection: [number, number]
incomingLength: number
outgoingLength: number
}
export const CeilingSelectionAffordanceSystem = () => {
const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode)
const structureLayer = useEditor((state) => state.structureLayer)
const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall)
const currentLevelId = useViewer((state) => state.selection.levelId)
const ceilings = useScene(
useShallow((state) =>
Object.values(state.nodes).filter((node): node is CeilingNode => {
return (
node.type === 'ceiling' &&
node.visible !== false &&
currentLevelId !== null &&
resolveLevelId(node, state.nodes) === currentLevelId
)
}),
),
)
const shouldRender =
phase === 'structure' &&
mode === 'select' &&
structureLayer === 'elements' &&
!movingNode &&
!curvingWall &&
currentLevelId !== null
if (!shouldRender) return null
return (
<>
{ceilings.map((ceiling) => (
<CeilingSelectionAffordance ceiling={ceiling} key={ceiling.id} levelId={currentLevelId} />
))}
</>
)
}
const CeilingSelectionAffordance = ({
ceiling,
levelId,
}: {
ceiling: CeilingNode
levelId: string
}) => {
const selectedIds = useViewer((state) => state.selection.selectedIds)
const isSelected = selectedIds.includes(ceiling.id)
const levelObject = sceneRegistry.nodes.get(levelId)
const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
if (!levelObject || corners.length === 0 || isSelected) return null
return createPortal(
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
{corners.map((corner, index) => (
<CornerBracket
ceiling={ceiling}
corner={corner}
key={`${ceiling.id}-corner-${index}`}
/>
))}
</group>,
levelObject,
)
}
const CornerBracket = ({
ceiling,
corner,
}: {
ceiling: CeilingNode
corner: CornerBracketData
}) => {
const color = '#d4d4d4'
const opacity = 0.72
const handleClick = (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return
e.stopPropagation()
const nodes = useScene.getState().nodes
const selection = useViewer.getState().selection
const levelId = resolveLevelId(ceiling, nodes)
const buildingId = findBuildingId(levelId, nodes)
useEditor.getState().setMovingNode(null)
useEditor.getState().setMovingWallEndpoint(null)
useEditor.getState().setCurvingWall(null)
useEditor.getState().setEditingHole(null)
useEditor.getState().setMode('select')
useViewer.getState().setSelection({
buildingId: buildingId ?? selection.buildingId,
levelId,
selectedIds: [ceiling.id],
})
}
return (
<group position={[corner.corner[0], 0, corner.corner[1]]}>
<mesh>
<boxGeometry args={[CORNER_BLOCK_SIZE, BRACKET_HEIGHT, CORNER_BLOCK_SIZE]} />
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
</mesh>
<BracketLeg
color={color}
direction={corner.incomingDirection}
length={corner.incomingLength}
opacity={opacity}
/>
<BracketLeg
color={color}
direction={corner.outgoingDirection}
length={corner.outgoingLength}
opacity={opacity}
/>
<mesh
onPointerDown={handleClick}
position={[corner.hitCenter[0] - corner.corner[0], 0, corner.hitCenter[1] - corner.corner[1]]}
>
<boxGeometry args={HIT_BOX_SIZE} />
<meshBasicMaterial opacity={0} transparent />
</mesh>
</group>
)
}
const BracketLeg = ({
direction,
length,
color,
opacity,
}: {
direction: [number, number]
length: number
color: string
opacity: number
}) => {
const angle = Math.atan2(direction[1], direction[0])
const position: [number, number, number] = [
direction[0] * (length / 2),
0,
direction[1] * (length / 2),
]
return (
<mesh position={position} rotation={[0, angle, 0]}>
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
</mesh>
)
}
function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] {
if (polygon.length < 3) return []
return polygon.map((corner, index) => {
const previous = polygon[(index - 1 + polygon.length) % polygon.length]!
const next = polygon[(index + 1) % polygon.length]!
const incomingVector = [previous[0] - corner[0], previous[1] - corner[1]] as [number, number]
const outgoingVector = [next[0] - corner[0], next[1] - corner[1]] as [number, number]
const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
const insetDirection = normalize2D([
normalize2D(incomingVector)[0] + normalize2D(outgoingVector)[0],
normalize2D(incomingVector)[1] + normalize2D(outgoingVector)[1],
])
return {
corner,
hitCenter: [
corner[0] + insetDirection[0] * HIT_INSET,
corner[1] + insetDirection[1] * HIT_INSET,
],
incomingDirection: normalize2D(incomingVector),
outgoingDirection: normalize2D(outgoingVector),
incomingLength: getBracketLength(incomingLength),
outgoingLength: getBracketLength(outgoingLength),
}
})
}
function normalize2D(vector: [number, number]): [number, number] {
const length = Math.hypot(vector[0], vector[1])
if (length < 1e-6) return [1, 0]
return [vector[0] / length, vector[1] / length]
}
function getBracketLength(edgeLength: number): number {
return Math.max(0.14, Math.min(0.38, edgeLength * 0.22))
}
function findBuildingId(levelId: string | null, nodes: Record<string, { parentId: string | null }>): string | null {
if (!levelId) return null
const level = nodes[levelId]
return level?.parentId ?? null
}
@@ -2,11 +2,12 @@
import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core' import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
function snap(value: number) { function snap(value: number) {
return Math.round(value * 2) / 2 return Math.round(value * 2) / 2
@@ -39,6 +40,8 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
) )
const dragAnchorRef = useRef<[number, number] | null>(null) const dragAnchorRef = useRef<[number, number] | null>(null)
const previousGridPosRef = useRef<[number, number] | null>(null) const previousGridPosRef = useRef<[number, number] | null>(null)
const previousCursorPosRef = useRef<[number, number, number] | null>(null)
const previousDeltaRef = useRef<[number, number] | null>(null)
const previewRef = useRef<{ const previewRef = useRef<{
polygon: Array<[number, number]> polygon: Array<[number, number]>
holes: Array<Array<[number, number]>> holes: Array<Array<[number, number]>>
@@ -48,6 +51,8 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const center = getPolygonCenter(node.polygon) const center = getPolygonCenter(node.polygon)
return [center[0], node.height ?? 2.5, center[1]] return [center[0], node.height ?? 2.5, center[1]]
}) })
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]>>(node.polygon)
const [previewHoles, setPreviewHoles] = useState<Array<Array<[number, number]>>>(node.holes ?? [])
const exitMoveMode = useCallback(() => { const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null) useEditor.getState().setMovingNode(null)
@@ -65,13 +70,26 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
holes: Array<Array<[number, number]>>, holes: Array<Array<[number, number]>>,
) => { ) => {
previewRef.current = { polygon, holes } previewRef.current = { polygon, holes }
setPreviewPolygon(polygon)
setPreviewHoles(holes)
const center = getPolygonCenter(polygon) const center = getPolygonCenter(polygon)
setCursorLocalPos([center[0], node.height ?? 2.5, center[1]]) const nextCursorPos: [number, number, number] = [center[0], node.height ?? 2.5, center[1]]
if (
!previousCursorPosRef.current ||
previousCursorPosRef.current[0] !== nextCursorPos[0] ||
previousCursorPosRef.current[1] !== nextCursorPos[1] ||
previousCursorPosRef.current[2] !== nextCursorPos[2]
) {
previousCursorPosRef.current = nextCursorPos
setCursorLocalPos(nextCursorPos)
}
useScene.getState().updateNode(node.id, { polygon, holes }) useScene.getState().updateNode(node.id, { polygon, holes })
useScene.getState().markDirty(node.id as AnyNodeId) useScene.getState().markDirty(node.id as AnyNodeId)
} }
const restoreOriginal = () => { const restoreOriginal = () => {
setPreviewPolygon(originalPolygon)
setPreviewHoles(originalHoles)
useScene.getState().updateNode(node.id, { useScene.getState().updateNode(node.id, {
holes: originalHoles, holes: originalHoles,
polygon: originalPolygon, polygon: originalPolygon,
@@ -97,6 +115,15 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const deltaX = localX - anchor[0] const deltaX = localX - anchor[0]
const deltaZ = localZ - anchor[1] const deltaZ = localZ - anchor[1]
if (
previousDeltaRef.current &&
previousDeltaRef.current[0] === deltaX &&
previousDeltaRef.current[1] === deltaZ
) {
return
}
previousDeltaRef.current = [deltaX, deltaZ]
applyPreview( applyPreview(
translatePolygon(originalPolygon, deltaX, deltaZ), translatePolygon(originalPolygon, deltaX, deltaZ),
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)), originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
@@ -146,9 +173,77 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
} }
}, [exitMoveMode, node.height, node.id]) }, [exitMoveMode, node.height, node.id])
const previewFillGeometry = useMemo(
() => createCeilingPreviewGeometry(previewPolygon, previewHoles),
[previewHoles, previewPolygon],
)
const previewOutlineGeometry = useMemo(
() => createCeilingOutlineGeometry(previewPolygon),
[previewPolygon],
)
return ( return (
<group> <group>
<mesh geometry={previewFillGeometry} position={[0, (node.height ?? 2.5) + 0.012, 0]}>
<meshBasicMaterial
color="#f5f5f4"
depthWrite={false}
opacity={0.3}
side={DoubleSide}
transparent
/>
</mesh>
<line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
<lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
</line>
<CursorSphere position={cursorLocalPos} showTooltip={false} /> <CursorSphere position={cursorLocalPos} showTooltip={false} />
</group> </group>
) )
} }
function createCeilingPreviewGeometry(
polygon: Array<[number, number]>,
holes: Array<Array<[number, number]>>,
): BufferGeometry {
if (polygon.length < 3) return new BufferGeometry()
const shape = new Shape()
const [firstX, firstZ] = polygon[0]!
shape.moveTo(firstX, -firstZ)
for (let i = 1; i < polygon.length; i++) {
const [x, z] = polygon[i]!
shape.lineTo(x, -z)
}
shape.closePath()
for (const holePolygon of holes) {
if (holePolygon.length < 3) continue
const hole = new Path()
const [hx, hz] = holePolygon[0]!
hole.moveTo(hx, -hz)
for (let i = 1; i < holePolygon.length; i++) {
const [x, z] = holePolygon[i]!
hole.lineTo(x, -z)
}
hole.closePath()
shape.holes.push(hole)
}
const geometry = new ShapeGeometry(shape)
geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals()
return geometry
}
function createCeilingOutlineGeometry(polygon: Array<[number, number]>): BufferGeometry {
const geometry = new BufferGeometry()
if (polygon.length < 2) return geometry
const points = polygon.map(([x, z]) => new Vector3(x, 0, z))
const [firstX, firstZ] = polygon[0]!
points.push(new Vector3(firstX, 0, firstZ))
geometry.setFromPoints(points)
return geometry
}